Upload folder using huggingface_hub
Browse files- ragsolver/__init__.py +0 -0
- ragsolver/agents/agents.py +209 -0
- ragsolver/agents/schemas.py +122 -0
- ragsolver/cli.py +33 -0
- ragsolver/lean/verify.py +491 -0
- ragsolver/llm.py +265 -0
- ragsolver/logging/__init__.py +0 -0
- ragsolver/logging/jsonl_writer.py +40 -0
- ragsolver/orchestrator/graph.py +715 -0
- ragsolver/orchestrator/run.py +331 -0
- ragsolver/prover.py +302 -0
- ragsolver/rag/__init__.py +0 -0
- ragsolver/rag/embed.py +128 -0
- ragsolver/rag/embed_corpus.py +74 -0
- ragsolver/rag/enrich.py +261 -0
- ragsolver/rag/evaluate.py +98 -0
- ragsolver/rag/figures.py +110 -0
- ragsolver/rag/index.py +148 -0
- ragsolver/rag/ingest_informal.py +325 -0
- ragsolver/rag/ingest_mathlib.py +123 -0
- ragsolver/rag/ingest_pdf.py +111 -0
- ragsolver/rag/metrics.py +39 -0
- ragsolver/rag/mineru_blocks.py +265 -0
- ragsolver/rag/parse_structure.py +168 -0
- ragsolver/rag/premises.py +128 -0
- ragsolver/rag/proofs.py +118 -0
- ragsolver/rag/retrieve.py +321 -0
- ragsolver/rag/schema.py +246 -0
ragsolver/__init__.py
ADDED
|
File without changes
|
ragsolver/agents/agents.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agentes (Rol A) — Planificador, Generador, Autoformalizador, Crítico y las dos
|
| 2 |
+
líneas base de la propuesta §8.2. Cada uno: arma un prompt (configs/agents.yaml),
|
| 3 |
+
hace UNA llamada LLM con salida JSON validada por Pydantic y devuelve el modelo.
|
| 4 |
+
|
| 5 |
+
El contexto de recuperación llega ya formateado (docs/PLAN_RAG.md Fase 4, tarea 4):
|
| 6 |
+
lemas como `[M1] name : signature — description (module)`; ejemplares como
|
| 7 |
+
`[I1] statement — key idea — source, page`.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
import textwrap
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
import yaml
|
| 18 |
+
|
| 19 |
+
from ragsolver.agents.schemas import BaselineAOutput, BaselineBOutput, CriticOutput, FillOutput, FormalizerOutput, GeneratorOutput, PlannerOutput, Problem
|
| 20 |
+
from ragsolver.llm import LLM, LLMResponse
|
| 21 |
+
from ragsolver.logging.jsonl_writer import append_event
|
| 22 |
+
from ragsolver.rag.retrieve import mathlib_table_name
|
| 23 |
+
from ragsolver.rag.schema import Hit, TraceEvent, VersionsPin
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def load_agents_config(path: str = "configs/agents.yaml") -> dict:
|
| 27 |
+
return yaml.safe_load(open(path, encoding="utf-8"))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def format_lemmas(hits: list[Hit]) -> str:
|
| 31 |
+
lines = []
|
| 32 |
+
for h in hits:
|
| 33 |
+
m = h.metadata
|
| 34 |
+
desc = (m.get("informal_description") or "")[:300].replace("\n", " ")
|
| 35 |
+
line = f"[{h.citation.citation_id}] {m.get('decl_name')} : {(m.get('signature') or '')[:400]}"
|
| 36 |
+
lines.append(line + (f" — {desc}" if desc else "") + (f" ({m.get('module_path')})" if m.get("module_path") else ""))
|
| 37 |
+
return "\n".join(lines) or "(none)"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def format_exemplars(hits: list[Hit]) -> str:
|
| 41 |
+
lines = []
|
| 42 |
+
for h in hits:
|
| 43 |
+
m = h.metadata
|
| 44 |
+
stmt = (h.content_md or h.text).split("## Solución")[0].strip().replace("\n", " ")[:500]
|
| 45 |
+
idea = (m.get("context_summary") or "")[:400].replace("\n", " ")
|
| 46 |
+
figs = "; ".join((f.get("description") or "")[:200] for f in h.figures) if h.figures else ""
|
| 47 |
+
lines.append(f"[{h.citation.citation_id}] {stmt} — {idea} — {h.citation.label}" + (f" — figure: {figs}" if figs else ""))
|
| 48 |
+
return "\n".join(lines) or "(none)"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class InvalidLLMOutput(Exception):
|
| 52 |
+
"""El modelo no devolvió JSON válido ni en el reintento (p.ej. generación degenerada hasta
|
| 53 |
+
`max_tokens`): el problema cuenta como fallo del sistema, no como error de infraestructura."""
|
| 54 |
+
|
| 55 |
+
def __init__(self, agent: str, tokens_in: int, tokens_out: int, calls: int, model: str):
|
| 56 |
+
super().__init__(f"{agent}: salida JSON inválida tras {calls} llamadas")
|
| 57 |
+
self.agent, self.tokens_in, self.tokens_out, self.calls, self.model = agent, tokens_in, tokens_out, calls, model
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Agents:
|
| 61 |
+
def __init__(self, llm: LLM, config: dict | None = None, trace_dir: str = "data/eval/traces", versions: dict | None = None):
|
| 62 |
+
self.llm = llm
|
| 63 |
+
self.cfg = config or load_agents_config()
|
| 64 |
+
self.trace_dir = trace_dir
|
| 65 |
+
self.versions = versions or yaml.safe_load(open("configs/versions.yaml", encoding="utf-8"))
|
| 66 |
+
|
| 67 |
+
def _versions_pin(self) -> VersionsPin:
|
| 68 |
+
return VersionsPin(
|
| 69 |
+
embedding_model=self.versions["embedding"]["model"],
|
| 70 |
+
reranker_model=self.versions["reranker"]["model"],
|
| 71 |
+
index_version=self.versions["index"]["version"],
|
| 72 |
+
mathlib_tag=self.versions["lean"]["mathlib_tag"],
|
| 73 |
+
mathlib_table=mathlib_table_name(),
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def _call(self, agent: str, prompt: str, schema_model, run_id: str, problem_id: str, attempt: int, images=()):
|
| 77 |
+
t0 = time.time()
|
| 78 |
+
resp = self.llm.generate(prompt, schema=schema_model.model_json_schema(), images=images, system=self.cfg["prompts"]["system"])
|
| 79 |
+
try:
|
| 80 |
+
out = schema_model.model_validate_json(resp.text)
|
| 81 |
+
except ValueError: # JSON truncado por `max_tokens` o inválido: un reintento con el doble de tokens (cuenta en el presupuesto)
|
| 82 |
+
resp2 = self.llm.generate(prompt, schema=schema_model.model_json_schema(), images=images, system=self.cfg["prompts"]["system"], max_tokens=2 * int(getattr(self.llm, "max_tokens", None) or 4000))
|
| 83 |
+
resp = LLMResponse(text=resp2.text, model=resp2.model, tokens_in=resp.tokens_in + resp2.tokens_in, tokens_out=resp.tokens_out + resp2.tokens_out, calls=resp.calls + resp2.calls, cost_usd=resp.cost_usd + resp2.cost_usd, attempts=resp.attempts + resp2.attempts)
|
| 84 |
+
try:
|
| 85 |
+
out = schema_model.model_validate_json(resp2.text)
|
| 86 |
+
except ValueError as e:
|
| 87 |
+
# ACC-1: el evento del intento roto lleva sus propios llm_calls/tokens/costo (antes
|
| 88 |
+
# `self.trace()` los ponía siempre en 0, aunque sí se habían gastado).
|
| 89 |
+
append_event(
|
| 90 |
+
TraceEvent(
|
| 91 |
+
run_id=run_id,
|
| 92 |
+
problem_id=problem_id,
|
| 93 |
+
attempt=attempt,
|
| 94 |
+
agent=agent,
|
| 95 |
+
latency_ms=(time.time() - t0) * 1000,
|
| 96 |
+
llm_calls=resp.calls,
|
| 97 |
+
tokens=resp.tokens_in + resp.tokens_out,
|
| 98 |
+
request={"model": resp.model, "tokens_in": resp.tokens_in, "tokens_out": resp.tokens_out, "cost_usd": round(resp.cost_usd, 6), "attempts": resp.attempts, "decision": {"invalid_json": str(e)[:200]}},
|
| 99 |
+
versions=self._versions_pin(),
|
| 100 |
+
),
|
| 101 |
+
base_dir=self.trace_dir,
|
| 102 |
+
)
|
| 103 |
+
raise InvalidLLMOutput(agent, resp.tokens_in, resp.tokens_out, resp.calls, resp.model) from e
|
| 104 |
+
# RF-7: decisión tomada por el agente (salida validada, truncada) junto a tokens y latencia
|
| 105 |
+
decision = {k: (v[:600] if isinstance(v, str) else v) for k, v in out.model_dump().items()}
|
| 106 |
+
append_event(
|
| 107 |
+
TraceEvent(
|
| 108 |
+
run_id=run_id,
|
| 109 |
+
problem_id=problem_id,
|
| 110 |
+
attempt=attempt,
|
| 111 |
+
agent=agent,
|
| 112 |
+
latency_ms=(time.time() - t0) * 1000,
|
| 113 |
+
llm_calls=1,
|
| 114 |
+
tokens=resp.tokens_in + resp.tokens_out,
|
| 115 |
+
request={"model": resp.model, "prompt_chars": len(prompt), "prompt_version": str(self.cfg["prompt_version"]), "tokens_in": resp.tokens_in, "tokens_out": resp.tokens_out, "cost_usd": round(resp.cost_usd, 6), "attempts": resp.attempts, "decision": decision},
|
| 116 |
+
versions=self._versions_pin(),
|
| 117 |
+
),
|
| 118 |
+
base_dir=self.trace_dir,
|
| 119 |
+
)
|
| 120 |
+
return out, resp
|
| 121 |
+
|
| 122 |
+
def trace(self, agent: str, run_id: str, problem_id: str, attempt: int, latency_ms: float, decision: dict) -> None:
|
| 123 |
+
"""Evento de traza para pasos sin LLM (verificador): veredicto + latencia (RF-5, RF-7)."""
|
| 124 |
+
append_event(TraceEvent(run_id=run_id, problem_id=problem_id, attempt=attempt, agent=agent, latency_ms=latency_ms, llm_calls=0, tokens=0, request={"decision": decision}), base_dir=self.trace_dir)
|
| 125 |
+
|
| 126 |
+
def plan(self, problem: Problem, exemplars: list[Hit], run_id: str, attempt: int, feedback: str = "") -> tuple[PlannerOutput, object]:
|
| 127 |
+
prompt = self.cfg["prompts"]["planner"].format(informal=problem.informal_statement, formal=problem.formal_statement, exemplars=format_exemplars(exemplars), feedback=feedback or "(none)")
|
| 128 |
+
return self._call("planner", prompt, PlannerOutput, run_id, problem.problem_id, attempt)
|
| 129 |
+
|
| 130 |
+
def generate(self, problem: Problem, plan: PlannerOutput, lemmas: list[Hit], exemplars: list[Hit], feedback: str, run_id: str, attempt: int) -> tuple[GeneratorOutput, object]:
|
| 131 |
+
plan_txt = f"Strategy: {plan.strategy}\n" + "\n".join(f"{s.subgoal_id}. {s.statement_nl} (hint: {s.formal_hint})" for s in plan.subgoals)
|
| 132 |
+
images = [f["image_path"] for h in exemplars[:2] for f in h.figures[:1] if f.get("image_path")] if self.cfg.get("llm_supports_vision") else []
|
| 133 |
+
prompt = self.cfg["prompts"]["generator"].format(informal=problem.informal_statement, formal=problem.formal_statement, plan=plan_txt, lemmas=format_lemmas(lemmas), exemplars=format_exemplars(exemplars), feedback=feedback or "(none)")
|
| 134 |
+
n = int(self.cfg.get("generator_samples", 1) or 1)
|
| 135 |
+
if n <= 1:
|
| 136 |
+
return self._call("generator", prompt, GeneratorOutput, run_id, problem.problem_id, attempt, images=images)
|
| 137 |
+
samples = [self._call("generator", prompt, GeneratorOutput, run_id, problem.problem_id, attempt, images=images) for _ in range(n)]
|
| 138 |
+
best = max(range(n), key=lambda i: sum(_jaccard(samples[i][0].sketch, samples[j][0].sketch) for j in range(n) if j != i))
|
| 139 |
+
out, resp = samples[best]
|
| 140 |
+
# BUG-5: cost_usd y attempts de las n muestras también se suman (antes se perdían: costaba
|
| 141 |
+
# como si cada muestra hubiese costado 0 y 1 intento, aunque alguna hubiese reintentado).
|
| 142 |
+
resp = type(resp)(resp.text, resp.model, sum(r.tokens_in for _, r in samples), sum(r.tokens_out for _, r in samples), calls=n, cost_usd=sum(r.cost_usd for _, r in samples), attempts=sum(r.attempts for _, r in samples))
|
| 143 |
+
return out, resp
|
| 144 |
+
|
| 145 |
+
def formalize(self, problem: Problem, sketch: str, lemmas: list[Hit], lean_error: str, run_id: str, attempt: int, skeleton: bool = False, proved: list[str] = ()) -> tuple[FormalizerOutput, object]:
|
| 146 |
+
if skeleton: # docs/PLAN_MEJORA_RAG.md Fase 2: `have … := by sorry` por afirmación; los goals se cierran después
|
| 147 |
+
prompt = self.cfg["prompts"]["formalizer_skeleton"].format(formal=problem.formal_statement, sketch=sketch, lemmas=format_lemmas(lemmas), proved="\n".join(proved) or "(none)", lean_error=lean_error or "(none)")
|
| 148 |
+
else:
|
| 149 |
+
prompt = self.cfg["prompts"]["formalizer"].format(formal=problem.formal_statement, sketch=sketch, lemmas=format_lemmas(lemmas), lean_error=lean_error or "(none)")
|
| 150 |
+
out, resp = self._call("formalizer", prompt, FormalizerOutput, run_id, problem.problem_id, attempt)
|
| 151 |
+
out.proof_tactics = strip_code_fences(out.proof_tactics)
|
| 152 |
+
return out, resp
|
| 153 |
+
|
| 154 |
+
def sketch(self, problem: Problem, feedback: str, run_id: str, attempt: int) -> tuple[str, LLMResponse]:
|
| 155 |
+
"""Esqueleto de la prueba en Lean (pasos `have … := by sorry`), en TEXTO: con salida JSON y razonamiento el
|
| 156 |
+
modelo gastaba todo su presupuesto y devolvía vacío (DECISIONS #124). La extracción la hace el grafo."""
|
| 157 |
+
t0 = time.time()
|
| 158 |
+
prompt = self.cfg["prompts"]["sketcher"].format(informal=problem.informal_statement, formal=f"{problem.header}\n\n{problem.formal_statement}".strip(), feedback=feedback or "(none)")
|
| 159 |
+
resp = self.llm.generate(prompt, system=self.cfg["prompts"]["system"], max_tokens=self.cfg.get("sketch_max_tokens"))
|
| 160 |
+
append_event(TraceEvent(run_id=run_id, problem_id=problem.problem_id, attempt=attempt, agent="sketcher", latency_ms=(time.time() - t0) * 1000, llm_calls=resp.calls, tokens=resp.tokens_in + resp.tokens_out, request={"model": resp.model, "prompt_chars": len(prompt), "prompt_version": str(self.cfg["prompt_version"]), "tokens_in": resp.tokens_in, "tokens_out": resp.tokens_out, "cost_usd": round(resp.cost_usd, 6), "attempts": resp.attempts, "decision": {"sketch": resp.text[-2000:]}}, versions=self._versions_pin()), base_dir=self.trace_dir)
|
| 161 |
+
return resp.text, resp
|
| 162 |
+
|
| 163 |
+
def fill(self, problem: Problem, goals_txt: str, run_id: str, attempt: int) -> tuple[FillOutput, object]:
|
| 164 |
+
prompt = self.cfg["prompts"]["fill"].format(formal=problem.formal_statement, goals=goals_txt)
|
| 165 |
+
out, resp = self._call("fill", prompt, FillOutput, run_id, problem.problem_id, attempt)
|
| 166 |
+
for p in out.proofs:
|
| 167 |
+
p.tactics = strip_code_fences(p.tactics)
|
| 168 |
+
return out, resp
|
| 169 |
+
|
| 170 |
+
def critique(self, problem: Problem, sketch: str, lean_code: str, lean_error: str, history: list[dict], run_id: str, attempt: int) -> tuple[CriticOutput, object]:
|
| 171 |
+
hist = "\n".join(f"attempt {h['attempt']}: {h.get('error_type', '?')} — {h.get('summary', '')[:200]}" for h in history) or "(first attempt)"
|
| 172 |
+
prompt = self.cfg["prompts"]["critic"].format(formal=problem.formal_statement, sketch=sketch, lean_code=lean_code, lean_error=lean_error, history=hist)
|
| 173 |
+
return self._call("critic", prompt, CriticOutput, run_id, problem.problem_id, attempt)
|
| 174 |
+
|
| 175 |
+
def baseline(self, mode: str, problem: Problem, run_id: str) -> tuple[BaselineAOutput | BaselineBOutput, object]:
|
| 176 |
+
prompt = self.cfg["prompts"][mode].format(formal=problem.formal_statement, informal=problem.informal_statement)
|
| 177 |
+
out, resp = self._call(mode, prompt, BaselineAOutput if mode == "baseline_a" else BaselineBOutput, run_id, problem.problem_id, 1)
|
| 178 |
+
if mode == "baseline_a":
|
| 179 |
+
out.proof_tactics = strip_code_fences(out.proof_tactics)
|
| 180 |
+
return out, resp
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _jaccard(a: str, b: str) -> float:
|
| 184 |
+
ta, tb = set(re.findall(r"[\w^+\-*/=<>≤≥]+", a.lower())), set(re.findall(r"[\w^+\-*/=<>≤≥]+", b.lower()))
|
| 185 |
+
return len(ta & tb) / len(ta | tb) if ta | tb else 0.0
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def strip_code_fences(code: str) -> str:
|
| 189 |
+
"""Quita vallas ```lean, un enunciado repetido, y la sangría común (el LLM suele traer las
|
| 190 |
+
tácticas ya indentadas a 2 espacios; `splice_proof` las vuelve a indentar, y si sólo la
|
| 191 |
+
primera línea perdía la sangría el bloque quedaba mal anidado y Lean daba `unsolved goals`)."""
|
| 192 |
+
code = re.sub(r"^\s*```[a-zA-Z0-9]*\n?", "", code.rstrip())
|
| 193 |
+
code = re.sub(r"\n?```$", "", code)
|
| 194 |
+
# si el modelo repitió el enunciado, quedarse con lo que sigue a `:= by`
|
| 195 |
+
m = re.search(r":=\s*by\s*\n", code)
|
| 196 |
+
if code.lstrip().startswith(("theorem", "lemma", "example")) and m:
|
| 197 |
+
code = code[m.end():]
|
| 198 |
+
code = re.sub(r"^\s*(?::=\s*)?by[ \t]*\n", "", code) # el modelo a veces empieza con un `by` suelto (8/30 intentos en eval_v1): sobra, el empalme ya lo pone
|
| 199 |
+
code = textwrap.dedent(code.strip("\n")).strip("\n")
|
| 200 |
+
# la primera línea llega sin sangría y las demás con la del editor del modelo ("rcases h1 …\n rcases h2 …",
|
| 201 |
+
# smoke 2026-10-14: Lean daba `unexpected identifier`): si la primera línea no abre un bloque, el resto
|
| 202 |
+
# baja a su nivel
|
| 203 |
+
lines = code.splitlines()
|
| 204 |
+
rest = [l for l in lines[1:] if l.strip()]
|
| 205 |
+
if rest and not lines[0].startswith("calc") and not re.search(r"(\bby|:=|=>|\bwith|\bdo|\bthen|\belse|\bfrom|[,(⟨\[])\s*$", lines[0]):
|
| 206 |
+
k = min(len(l) - len(l.lstrip()) for l in rest)
|
| 207 |
+
if k:
|
| 208 |
+
code = "\n".join([lines[0]] + [l[k:] if l.strip() else l for l in lines[1:]])
|
| 209 |
+
return code
|
ragsolver/agents/schemas.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contratos JSON Schema de los agentes (RNF-4; docs/PLAN_RAG.md §8.3, propuesta §6.3).
|
| 2 |
+
|
| 3 |
+
Cada agente es una función pura `state -> delta` cuya salida LLM se valida contra
|
| 4 |
+
uno de estos modelos (`model_json_schema()` va como `response_format` al LLM).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
from ragsolver.rag.schema import Citation
|
| 14 |
+
|
| 15 |
+
ErrorType = Literal["lean_syntax", "missing_lemma", "misformalization", "timeout", "planning", "unknown"]
|
| 16 |
+
CriticAction = Literal["retry", "replan", "abandon"]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Problem(BaseModel):
|
| 20 |
+
problem_id: str
|
| 21 |
+
source: str = "trivial" # trivial | minif2f_v2 | putnambench
|
| 22 |
+
split: str = "dev"
|
| 23 |
+
informal_statement: str
|
| 24 |
+
formal_statement: str # `theorem foo ... := by sorry`
|
| 25 |
+
area: str = ""
|
| 26 |
+
difficulty: int | None = None
|
| 27 |
+
imports: list[str] = Field(default_factory=list) # imports adicionales al de configs/agents.yaml
|
| 28 |
+
header: str = "" # p.ej. `open BigOperators Real Nat Topology` (miniF2F); sin imports
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Subgoal(BaseModel):
|
| 32 |
+
subgoal_id: str
|
| 33 |
+
statement_nl: str
|
| 34 |
+
formal_hint: str = ""
|
| 35 |
+
search_queries: list[str] = Field(default_factory=list, max_length=3)
|
| 36 |
+
math_area: str = ""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class PlannerOutput(BaseModel):
|
| 40 |
+
strategy: str
|
| 41 |
+
subgoals: list[Subgoal] = Field(min_length=1, max_length=6)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class GeneratorOutput(BaseModel):
|
| 45 |
+
sketch: str
|
| 46 |
+
used_citations: list[str] = Field(default_factory=list) # ids M*/I*
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class FormalizerOutput(BaseModel):
|
| 50 |
+
proof_tactics: str # sólo el cuerpo tras `:= by` — el enunciado no lo toca el LLM
|
| 51 |
+
notes: str = ""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class GoalProof(BaseModel):
|
| 55 |
+
goal_id: str # "G1", "G2", … en el orden en que se le mostraron los goals
|
| 56 |
+
tactics: str # bloque que reemplaza el `sorry` de ese goal
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class FillOutput(BaseModel):
|
| 60 |
+
"""FILL (docs/PLAN_MEJORA_RAG.md §4.1): pruebas de los goals que la batería CLOSE no cerró."""
|
| 61 |
+
|
| 62 |
+
proofs: list[GoalProof] = Field(default_factory=list)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class CriticOutput(BaseModel):
|
| 66 |
+
error_type: ErrorType
|
| 67 |
+
action: CriticAction
|
| 68 |
+
missing_identifiers: list[str] = Field(default_factory=list)
|
| 69 |
+
feedback: str
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class BaselineAOutput(BaseModel):
|
| 73 |
+
"""Línea base A: un solo LLM escribe la prueba Lean. Sólo el campo que la línea base produce, y
|
| 74 |
+
obligatorio: con un esquema común de campos opcionales DeepSeek dejaba `proof_tactics` vacío y
|
| 75 |
+
escribía la prueba en lenguaje natural (58/60 pruebas vacías, DECISIONS #65)."""
|
| 76 |
+
|
| 77 |
+
proof_tactics: str # como FormalizerOutput: sólo el cuerpo tras `:= by`
|
| 78 |
+
notes: str = ""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class BaselineBOutput(BaseModel):
|
| 82 |
+
"""Línea base B: prueba en lenguaje natural + autoevaluación (sin verificación)."""
|
| 83 |
+
|
| 84 |
+
natural_language_proof: str
|
| 85 |
+
self_assessed_correct: bool
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class Verdict(BaseModel):
|
| 89 |
+
ok: bool
|
| 90 |
+
errors: list[dict] = Field(default_factory=list) # {line, col, message}
|
| 91 |
+
uses_sorry: bool = False
|
| 92 |
+
axioms: list[str] = Field(default_factory=list) # VER-1: `#print axioms`; ok=True exige subconjunto de STANDARD_AXIOMS
|
| 93 |
+
used_constants: list[str] = Field(default_factory=list) # constantes del término aceptado (REPL `all_tactics`): `used_in_proof` real
|
| 94 |
+
seconds: float = 0.0
|
| 95 |
+
lean_file: str = ""
|
| 96 |
+
raw: str = ""
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class SolutionReport(BaseModel):
|
| 100 |
+
problem_id: str
|
| 101 |
+
config: str
|
| 102 |
+
status: Literal["done", "fail", "error"]
|
| 103 |
+
attempts: int = 0
|
| 104 |
+
replans: int = 0
|
| 105 |
+
llm_calls: int = 0
|
| 106 |
+
retrieval_calls: int = 0 # búsquedas del retriever (0 llamadas LLM cada una)
|
| 107 |
+
tokens_in: int = 0
|
| 108 |
+
tokens_out: int = 0
|
| 109 |
+
wall_seconds: float = 0.0
|
| 110 |
+
lean_code: str = ""
|
| 111 |
+
verdict: Verdict | None = None
|
| 112 |
+
citations: list[Citation] = Field(default_factory=list) # con used_in_proof / used_in_sketch resueltos
|
| 113 |
+
n_retrieved: int = 0
|
| 114 |
+
n_used_in_sketch: int = 0
|
| 115 |
+
n_used_in_proof: int = 0
|
| 116 |
+
error_types: list[str] = Field(default_factory=list) # taxonomía por intento
|
| 117 |
+
re_retrievals: int = 0
|
| 118 |
+
re_retrievals_ok: int = 0
|
| 119 |
+
models: list[str] = Field(default_factory=list)
|
| 120 |
+
self_assessed_correct: bool | None = None # sólo línea base B
|
| 121 |
+
solved_by: str = "" # prover_root | prover_goal | agents | auto (docs/PLAN_REDISENO_PROVER.md)
|
| 122 |
+
prover_samples: int = 0 # muestras del demostrador local (0 API)
|
ragsolver/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI `ragsolver` (docs/PLAN_RAG.md Fase 2 tarea 9, Fase 3 tarea 7).
|
| 2 |
+
|
| 3 |
+
ragsolver search "..." [--corpus mathlib|informal] [--mode nl|formal|error|exemplar] [--k 8]
|
| 4 |
+
ragsolver ingest --pdf <pdf> [--skip-llm] [--sections none|front|all]
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main(argv: list[str] | None = None) -> None:
|
| 13 |
+
argv = sys.argv[1:] if argv is None else argv
|
| 14 |
+
cmd, rest = (argv[0], argv[1:]) if argv else ("", [])
|
| 15 |
+
if cmd == "search":
|
| 16 |
+
from ragsolver.rag.retrieve import _cli
|
| 17 |
+
|
| 18 |
+
_cli(rest)
|
| 19 |
+
elif cmd == "ingest":
|
| 20 |
+
import json
|
| 21 |
+
|
| 22 |
+
from ragsolver.rag.ingest_informal import run
|
| 23 |
+
|
| 24 |
+
args = dict(zip(rest[::2], rest[1::2]))
|
| 25 |
+
rep = run(args.get("--pdf", "problemas resueltos olimpiadas.pdf"), args.get("--mineru-dir"), skip_llm="--skip-llm" in rest, sections=args.get("--sections", "none"))
|
| 26 |
+
print(json.dumps({k: v for k, v in rep.items() if k not in ("gaps", "formula_diffs")}, indent=2, ensure_ascii=False))
|
| 27 |
+
else:
|
| 28 |
+
print(__doc__)
|
| 29 |
+
sys.exit(0 if cmd in ("", "-h", "--help") else 2)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
if __name__ == "__main__":
|
| 33 |
+
main()
|
ragsolver/lean/verify.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verificador (Rol B): el kernel de Lean 4 es la única fuente de verdad.
|
| 2 |
+
|
| 3 |
+
Modelo de amenazas (docs/AUDIT_2026-09-23.md SEC-1/VER-1; niveles de
|
| 4 |
+
https://lean-lang.org/doc/reference/latest/ValidatingProofs/). El texto de
|
| 5 |
+
tácticas lo escribe un LLM que puede venir de un proveedor no confiable
|
| 6 |
+
(DECISIONS #62 enruta a varios) o estar contaminado por un corpus de terceros
|
| 7 |
+
(prompt injection). Antes de mandarlo a Lean:
|
| 8 |
+
1. `find_forbidden_tactic` (nivel 1: nada de comandos/metaprogramación)
|
| 9 |
+
rechaza `#comando`, metaprogramación, declaraciones nuevas, cambios de
|
| 10 |
+
entorno y caminos que no pasan por el kernel (`sorry`, `native_decide`,
|
| 11 |
+
`decide +native`). El Autoformalizador sólo puede devolver tácticas.
|
| 12 |
+
2. Tras un veredicto sin errores, se audita `#print axioms <nombre>`
|
| 13 |
+
(nivel 2): sólo se acepta si usa como mucho `propext`, `Classical.choice`
|
| 14 |
+
y `Quot.sound` — si aparece `Lean.trustCompiler`/`Lean.ofReduceBool` hubo
|
| 15 |
+
evaluación nativa, no verificación del kernel (Kimina-Prover tuvo 15/225
|
| 16 |
+
soluciones así: github.com/MoonshotAI/Kimina-Prover-Preview/issues/11).
|
| 17 |
+
No hay sandbox de sistema operativo todavía (Fase 10 de docs/PLAN_CORRECCIONES.md):
|
| 18 |
+
esto no protege contra bugs del elaborador de Lean en sí, ni limita el uso de
|
| 19 |
+
CPU/disco de una táctica válida pero cara (el heartbeat sólo limita pasos de
|
| 20 |
+
elaboración).
|
| 21 |
+
|
| 22 |
+
Dos backends con el mismo `Verdict`:
|
| 23 |
+
- `LeanChecker` (por defecto): REPL persistente vía `lean-interact` — `import Mathlib`
|
| 24 |
+
una sola vez (~3-5 min) y después ~0.3 s por teorema (medido, docs/DECISIONS.md #40).
|
| 25 |
+
- `verify(...)`: un proceso `lake env lean` por verificación (sin estado; ~3 min
|
| 26 |
+
cada uno por el import). Respaldo y prueba de humo.
|
| 27 |
+
|
| 28 |
+
El enunciado NUNCA lo escribe el LLM: se toma del problema y sólo se sustituye
|
| 29 |
+
`sorry` por las tácticas — así el LLM no puede debilitar el teorema.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import os
|
| 35 |
+
import re
|
| 36 |
+
import textwrap
|
| 37 |
+
import threading
|
| 38 |
+
import subprocess
|
| 39 |
+
import time
|
| 40 |
+
from pathlib import Path
|
| 41 |
+
|
| 42 |
+
from ragsolver.agents.schemas import Verdict
|
| 43 |
+
|
| 44 |
+
_MSG_RE = re.compile(r"^(?P<file>[^\n:]+\.lean):(?P<line>\d+):(?P<col>\d+): (?P<sev>error|warning|info): ?(?P<msg>.*)$")
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Validación de tácticas (SEC-1). El Autoformalizador sólo debe devolver el
|
| 48 |
+
# bloque tras `:= by` (docs/PLAN_RAG.md §6, Fase 4 tarea 4); cualquier otra
|
| 49 |
+
# cosa es sospechosa.
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
_COMMENT_RE = re.compile(r"--[^\n]*|/-.*?-/", re.S)
|
| 53 |
+
# `open Foo in <táctica>` sólo trae un nombre a escala: no ejecuta código, se permite.
|
| 54 |
+
_SAFE_OPEN_IN_RE = re.compile(r"\bopen(?:\s+[A-Za-z_][\w'.]*)+\s+in\b")
|
| 55 |
+
# `#eval`/`#print`/... en CUALQUIER posición, no sólo al inicio de línea: `open X in
|
| 56 |
+
# #eval ...` es sintaxis válida de Lean y sería un bypass de un chequeo anclado a línea.
|
| 57 |
+
_HASH_CMD_RE = re.compile(r"#[A-Za-z_]")
|
| 58 |
+
_SORRY_RE = re.compile(r"(?<![\w'])sorry(?![\w'])")
|
| 59 |
+
_NATIVE_DECIDE_RE = re.compile(r"\bdecide\s*\+\s*native\b")
|
| 60 |
+
_IO_RE = re.compile(r"\bIO\.")
|
| 61 |
+
_DENIED_WORD_RE = re.compile(
|
| 62 |
+
r"(?<![\w'])("
|
| 63 |
+
r"sorry|admit|native_decide|axiom|opaque"
|
| 64 |
+
r"|ofReduceBool|trustCompiler" # el axioma detrás de native_decide/decide +native (VER-1); rechazo temprano por nombre, además de la auditoría de axiomas
|
| 65 |
+
r"|run_tac|run_cmd|run_elab|elab_rules|elab|macro_rules|macro|notation|infix|infixl|infixr|prefix|postfix"
|
| 66 |
+
r"|attribute|unsafe|implemented_by|extern"
|
| 67 |
+
r"|set_option|import|open"
|
| 68 |
+
r"|theorem|lemma|def|abbrev|instance|example|structure|inductive|class|namespace|section|end"
|
| 69 |
+
r")(?![\w'])"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def find_forbidden_tactic(proof_tactics: str) -> str | None:
|
| 74 |
+
"""Primer patrón prohibido en el texto de tácticas, o None si es seguro: sin
|
| 75 |
+
`#comando`, sin metaprogramación, sin declaraciones nuevas, sin cambios de
|
| 76 |
+
entorno (salvo `open X in`) y sin caminos que evitan el kernel (`sorry`,
|
| 77 |
+
`native_decide`, `decide +native`, `IO.*`)."""
|
| 78 |
+
stripped = _COMMENT_RE.sub(" ", proof_tactics)
|
| 79 |
+
if _HASH_CMD_RE.search(stripped):
|
| 80 |
+
return "#-command"
|
| 81 |
+
if _NATIVE_DECIDE_RE.search(stripped):
|
| 82 |
+
return "decide +native"
|
| 83 |
+
if _IO_RE.search(stripped):
|
| 84 |
+
return "IO."
|
| 85 |
+
stripped = _SAFE_OPEN_IN_RE.sub(" ", stripped)
|
| 86 |
+
m = _DENIED_WORD_RE.search(stripped)
|
| 87 |
+
return m.group(1) if m else None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Auditoría de axiomas (VER-1). "Si aparece Lean.trustCompiler, la evaluación
|
| 92 |
+
# nativa se usó" en vez del kernel (guía oficial de Lean, enlace arriba).
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
_AXIOMS_RE = re.compile(r"depends on axioms:\s*\[([^\]]*)\]")
|
| 96 |
+
STANDARD_AXIOMS = frozenset({"propext", "Classical.choice", "Quot.sound"})
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def parse_axioms(text: str) -> list[str]:
|
| 100 |
+
"""Extrae la lista de `#print axioms <name>` ("depends on axioms: [...]";
|
| 101 |
+
sin coincidencia -> "no depende de ningún axioma" -> lista vacía)."""
|
| 102 |
+
m = _AXIOMS_RE.search(text)
|
| 103 |
+
return [a.strip() for a in m.group(1).split(",") if a.strip()] if m else []
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def non_standard_axioms(axioms: list[str]) -> list[str]:
|
| 107 |
+
return [a for a in axioms if a not in STANDARD_AXIOMS]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _axiom_check(errors: list[dict], uses_sorry: bool, axioms: list[str]) -> tuple[bool, list[dict]]:
|
| 111 |
+
"""ok sólo si no hay errores, no usa `sorry` y los axiomas son un subconjunto
|
| 112 |
+
de los estándar; si no, agrega un error legible (para el Crítico) sin perder
|
| 113 |
+
los que ya había."""
|
| 114 |
+
if errors or uses_sorry:
|
| 115 |
+
return False, errors
|
| 116 |
+
bad = non_standard_axioms(axioms)
|
| 117 |
+
if bad:
|
| 118 |
+
return False, errors + [{"line": 0, "col": 0, "message": f"non-standard axioms: {', '.join(bad)}"}]
|
| 119 |
+
return True, errors
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Fuente única REPL <-> reporte (BUG-2): una sola función construye el bloque
|
| 124 |
+
# que se verifica Y el que se reporta, para que no puedan divergir.
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
_THEOREM_NAME_RE = re.compile(r"^\s*(?:theorem|lemma)\s+([A-Za-z_][\w'.]*)")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _lean_project() -> Path:
|
| 131 |
+
"""`RAGSOLVER_LEAN_PROJECT` > copia en ext4 (~/ragsolver_cache/lean_project, misma tag,
|
| 132 |
+
10x más rápida que /mnt/c para cargar los .olean de Mathlib) > lean_project/ del repo."""
|
| 133 |
+
env = os.environ.get("RAGSOLVER_LEAN_PROJECT")
|
| 134 |
+
if env:
|
| 135 |
+
return Path(env)
|
| 136 |
+
cache = Path.home() / "ragsolver_cache" / "lean_project"
|
| 137 |
+
if (cache / ".lake" / "packages" / "mathlib" / ".lake" / "build").exists():
|
| 138 |
+
return cache
|
| 139 |
+
return Path(__file__).resolve().parents[3] / "lean_project"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
LEAN_PROJECT = _lean_project()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def splice_proof(formal_statement: str, proof_tactics: str) -> str:
|
| 146 |
+
"""Reemplaza el `sorry` final del enunciado por el bloque de tácticas (indentado)."""
|
| 147 |
+
stmt = formal_statement.rstrip()
|
| 148 |
+
tactics = textwrap.dedent(proof_tactics.strip("\n")) # sangría común fuera; la relativa (bloques `have ... := by`) se conserva
|
| 149 |
+
body = "\n".join(" " + line if line.strip() else "" for line in tactics.splitlines())
|
| 150 |
+
for pat in (r":=\s*by\s+sorry\s*$", r":=\s*sorry\s*$"):
|
| 151 |
+
if re.search(pat, stmt):
|
| 152 |
+
return re.sub(pat, lambda _m: ":= by\n" + body, stmt)
|
| 153 |
+
return stmt + " := by\n" + body
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
_HAVE_BY_RE = re.compile(r"^have\b.*:=\s*by\b(.*)$")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def skeletonize(proof: str) -> tuple[str, list[str | None]]:
|
| 160 |
+
"""Prueba del LLM -> (esqueleto, candidatos), docs/PLAN_MEJORA_RAG.md Fase 2.
|
| 161 |
+
|
| 162 |
+
El modelo casi nunca escribe el esqueleto con `sorry` que se le pide: escribe la prueba entera
|
| 163 |
+
(smoke del 2026-10-14). En vez de depender de que obedezca, cada `have … := by <bloque>` de nivel
|
| 164 |
+
superior pasa a `have … := by sorry` y su bloque queda como **candidato** para ese goal; las líneas
|
| 165 |
+
tras el último `have` (el cierre) pasan a un `sorry` con ese cierre como candidato. Sin `have`, la
|
| 166 |
+
prueba entera es el candidato del goal raíz. El resto (`rcases`, `intro`, `have … := término`) queda
|
| 167 |
+
tal cual. `candidatos[k]` corresponde al k-ésimo `sorry` del esqueleto (None = no hay candidato).
|
| 168 |
+
"""
|
| 169 |
+
lines = textwrap.dedent(proof.strip("\n")).splitlines()
|
| 170 |
+
top = lambda l: bool(l.strip()) and not l[0].isspace()
|
| 171 |
+
blocks = [] # (inicio, fin exclusivo, cabecera, candidato)
|
| 172 |
+
i = 0
|
| 173 |
+
while i < len(lines):
|
| 174 |
+
m = _HAVE_BY_RE.match(lines[i]) if top(lines[i]) else None
|
| 175 |
+
if not m:
|
| 176 |
+
i += 1
|
| 177 |
+
continue
|
| 178 |
+
j = i + 1
|
| 179 |
+
while j < len(lines) and not top(lines[j]):
|
| 180 |
+
j += 1
|
| 181 |
+
inline = m.group(1).strip()
|
| 182 |
+
body = textwrap.dedent("\n".join(lines[i + 1 : j])).strip("\n")
|
| 183 |
+
block = "\n".join(x for x in (inline, body) if x)
|
| 184 |
+
blocks.append((i, j, lines[i][: m.start(1)].rstrip(), block))
|
| 185 |
+
i = j
|
| 186 |
+
if not blocks:
|
| 187 |
+
body = "\n".join(lines).strip("\n")
|
| 188 |
+
return "sorry", [None if not body or body == "sorry" else body]
|
| 189 |
+
out: list[str] = []
|
| 190 |
+
cands: list[str | None] = []
|
| 191 |
+
pos = 0
|
| 192 |
+
for start, end, head, block in blocks:
|
| 193 |
+
for l in lines[pos:start]:
|
| 194 |
+
out.append(l)
|
| 195 |
+
cands += [None] * count_sorries(l)
|
| 196 |
+
out.append(f"{head} sorry")
|
| 197 |
+
cands.append(None if not block or block == "sorry" else block)
|
| 198 |
+
pos = end
|
| 199 |
+
tail = "\n".join(lines[pos:]).strip("\n")
|
| 200 |
+
if tail:
|
| 201 |
+
out.append("sorry")
|
| 202 |
+
cands.append(None if tail == "sorry" else tail)
|
| 203 |
+
return "\n".join(out), cands
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def count_sorries(skeleton: str) -> int:
|
| 207 |
+
return len(_SORRY_RE.findall(skeleton))
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def fill_sorries(skeleton: str, blocks: list[str]) -> str:
|
| 211 |
+
"""Reemplaza el k-ésimo `sorry` del esqueleto por `blocks[k]` (docs/PLAN_MEJORA_RAG.md Fase 2).
|
| 212 |
+
Un bloque de una línea va en el lugar del `sorry`; uno de varias líneas va en líneas nuevas con
|
| 213 |
+
la sangría de la línea del `sorry` + 2, salvo que al `sorry` le siga texto en la misma línea
|
| 214 |
+
(`exact foo (by sorry)`): entonces va entre paréntesis y separado por `;`."""
|
| 215 |
+
it = iter(blocks)
|
| 216 |
+
out = []
|
| 217 |
+
for line in skeleton.splitlines():
|
| 218 |
+
parts = _SORRY_RE.split(line)
|
| 219 |
+
if len(parts) == 1:
|
| 220 |
+
out.append(line)
|
| 221 |
+
continue
|
| 222 |
+
pad = " " * (len(line) - len(line.lstrip()) + 2)
|
| 223 |
+
new = parts[0]
|
| 224 |
+
for rest in parts[1:]:
|
| 225 |
+
block = textwrap.dedent(next(it, "sorry").strip("\n")).strip("\n")
|
| 226 |
+
if "\n" not in block:
|
| 227 |
+
new += block
|
| 228 |
+
elif rest.strip():
|
| 229 |
+
new += "(" + "; ".join(l.strip() for l in block.splitlines() if l.strip()) + ")"
|
| 230 |
+
else:
|
| 231 |
+
new = new.rstrip() + "\n" + "\n".join(pad + l if l.strip() else "" for l in block.splitlines())
|
| 232 |
+
new += rest
|
| 233 |
+
out.append(new)
|
| 234 |
+
return "\n".join(out)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def theorem_block(formal_statement: str, proof_tactics: str, header: str = "", max_heartbeats: int = 400000) -> tuple[str, str]:
|
| 238 |
+
"""Bloque que se envía al REPL (sin `import`: ya está en el entorno) y que
|
| 239 |
+
también se reporta como `lean_code` vía `build_file` (BUG-2): `open`s del
|
| 240 |
+
header + `set_option maxHeartbeats N in` + el enunciado con las tácticas
|
| 241 |
+
empalmadas + `#print axioms <nombre>` para la auditoría de VER-1. Devuelve
|
| 242 |
+
(código, nombre del teorema).
|
| 243 |
+
|
| 244 |
+
`formal_statement` debe empezar con `theorem <nombre>` o `lemma <nombre>`
|
| 245 |
+
(el formato que exige `Problem.formal_statement`); un `example` anónimo se
|
| 246 |
+
renombra de forma determinista y mecánica (no lo hace el LLM) para poder
|
| 247 |
+
auditar sus axiomas."""
|
| 248 |
+
stmt = formal_statement.rstrip()
|
| 249 |
+
m = _THEOREM_NAME_RE.match(stmt)
|
| 250 |
+
if m:
|
| 251 |
+
name = m.group(1)
|
| 252 |
+
elif stmt.lstrip().startswith("example"):
|
| 253 |
+
name = "_ragsolver_goal"
|
| 254 |
+
stmt = re.sub(r"^\s*example\b", f"theorem {name}", stmt, count=1)
|
| 255 |
+
else:
|
| 256 |
+
raise ValueError(f"formal_statement debe empezar con 'theorem', 'lemma' o 'example': {stmt[:60]!r}")
|
| 257 |
+
opens = "\n".join(l for l in header.splitlines() if l.strip() and not l.strip().startswith("import"))
|
| 258 |
+
code = (opens + "\n" if opens else "") + f"set_option maxHeartbeats {max_heartbeats} in\n" + splice_proof(stmt, proof_tactics) + f"\n#print axioms {name}"
|
| 259 |
+
return code, name
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def build_file(formal_statement: str, proof_tactics: str, imports: list[str], max_heartbeats: int = 400000, header: str = "") -> str:
|
| 263 |
+
"""Archivo Lean completo y autocontenido: el mismo texto que se le pide al
|
| 264 |
+
REPL, más el `import` inicial (que ahí ya viene del entorno). Es el
|
| 265 |
+
`lean_code` que se reporta (BUG-2): reproduce exactamente lo verificado,
|
| 266 |
+
incluidos los `open` del header."""
|
| 267 |
+
block, _ = theorem_block(formal_statement, proof_tactics, header, max_heartbeats)
|
| 268 |
+
hdr = "\n".join(f"import {m}" for m in dict.fromkeys(imports)) + "\n\n"
|
| 269 |
+
return hdr + block + "\n"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def parse_messages(output: str) -> list[dict]:
|
| 273 |
+
msgs: list[dict] = []
|
| 274 |
+
for line in output.splitlines():
|
| 275 |
+
m = _MSG_RE.match(line)
|
| 276 |
+
if m:
|
| 277 |
+
msgs.append({"line": int(m["line"]), "col": int(m["col"]), "severity": m["sev"], "message": m["msg"]})
|
| 278 |
+
elif msgs:
|
| 279 |
+
msgs[-1]["message"] += "\n" + line
|
| 280 |
+
return msgs
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def run_lean(source: str, name: str, project: Path = LEAN_PROJECT, timeout_s: int = 900) -> tuple[int | None, str, float, Path]:
|
| 284 |
+
scratch = project / "scratch"
|
| 285 |
+
scratch.mkdir(exist_ok=True)
|
| 286 |
+
path = scratch / f"{re.sub(r'[^A-Za-z0-9_]+', '_', name)}.lean"
|
| 287 |
+
path.write_text(source, encoding="utf-8")
|
| 288 |
+
env = {**os.environ, "PATH": f"{Path.home() / '.elan' / 'bin'}:{os.environ.get('PATH', '')}"}
|
| 289 |
+
t0 = time.time()
|
| 290 |
+
try:
|
| 291 |
+
proc = subprocess.run(["lake", "env", "lean", str(path.relative_to(project))], cwd=project, env=env, capture_output=True, text=True, timeout=timeout_s)
|
| 292 |
+
return proc.returncode, proc.stdout + proc.stderr, time.time() - t0, path
|
| 293 |
+
except subprocess.TimeoutExpired as e:
|
| 294 |
+
return None, (e.stdout or "") + (e.stderr or "") if isinstance(e.stdout, str) else "", time.time() - t0, path
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def verify(formal_statement: str, proof_tactics: str, name: str, imports: list[str], timeout_s: int = 900, max_heartbeats: int = 400000, project: Path = LEAN_PROJECT, header: str = "") -> Verdict:
|
| 298 |
+
forbidden = find_forbidden_tactic(proof_tactics)
|
| 299 |
+
if forbidden:
|
| 300 |
+
return Verdict(ok=False, errors=[{"line": 0, "col": 0, "message": f"forbidden token in proof: {forbidden}"}])
|
| 301 |
+
source = build_file(formal_statement, proof_tactics, imports, max_heartbeats, header)
|
| 302 |
+
code, out, secs, path = run_lean(source, name, project, timeout_s)
|
| 303 |
+
msgs = parse_messages(out)
|
| 304 |
+
errors = [m for m in msgs if m["severity"] == "error"]
|
| 305 |
+
uses_sorry = any("sorry" in m["message"] for m in msgs)
|
| 306 |
+
if code is None:
|
| 307 |
+
errors.append({"line": 0, "col": 0, "message": f"timeout after {timeout_s}s"})
|
| 308 |
+
return Verdict(ok=False, errors=errors, uses_sorry=uses_sorry, seconds=round(secs, 1), lean_file=str(path), raw=out[-4000:])
|
| 309 |
+
axioms = parse_axioms("\n".join(m["message"] for m in msgs if m["severity"] == "info"))
|
| 310 |
+
ok, errors = _axiom_check(errors, uses_sorry, axioms)
|
| 311 |
+
return Verdict(ok=ok, errors=errors, uses_sorry=uses_sorry, axioms=axioms, seconds=round(secs, 1), lean_file=str(path), raw=out[-4000:])
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def limit_heartbeats(tactic: str, heartbeats: int) -> str:
|
| 315 |
+
"""`set_option maxHeartbeats N in` sobre una táctica. Un bloque de varias líneas (el de un paso que escribió
|
| 316 |
+
el modelo o el demostrador) va dentro de `·`, que enfoca el goal y debe cerrarlo como el bloque en su sitio:
|
| 317 |
+
sin eso el límite no lo cubría y un bloque lento agotaba el timeout de reloj, reiniciaba el REPL y dejaba
|
| 318 |
+
"stale" los pasos de todos los hilos (eval-v4, DECISIONS #128)."""
|
| 319 |
+
body = tactic.strip()
|
| 320 |
+
if "\n" not in body:
|
| 321 |
+
return f"set_option maxHeartbeats {int(heartbeats)} in {body}"
|
| 322 |
+
return f"set_option maxHeartbeats {int(heartbeats)} in\n· " + body.replace("\n", "\n ")
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
class LeanChecker:
|
| 326 |
+
"""REPL persistente: el entorno con los imports se crea una vez y se reutiliza."""
|
| 327 |
+
|
| 328 |
+
def __init__(self, imports: list[str] = ("Mathlib",), project: Path | None = None, timeout_s: int = 900):
|
| 329 |
+
self.imports = list(imports)
|
| 330 |
+
self.project = project or LEAN_PROJECT
|
| 331 |
+
self.timeout_s = timeout_s
|
| 332 |
+
self._server = None
|
| 333 |
+
self._env = None
|
| 334 |
+
self._gen = 0 # sube en cada arranque del REPL: un `proof_state` sólo vale en la generación que lo creó
|
| 335 |
+
self._lock = threading.Lock() # un solo REPL; ~0.3 s por comprobación, no vale la pena más
|
| 336 |
+
|
| 337 |
+
@property
|
| 338 |
+
def gen(self) -> int:
|
| 339 |
+
"""Generación del REPL vivo (-1 si cayó): un `proof_state` de otra generación ya no vale."""
|
| 340 |
+
return self._gen if self._server is not None else -1
|
| 341 |
+
|
| 342 |
+
def _start(self) -> None:
|
| 343 |
+
from lean_interact import Command, LeanREPLConfig, LeanServer
|
| 344 |
+
from lean_interact.project import LocalProject
|
| 345 |
+
|
| 346 |
+
os.environ["PATH"] = f"{Path.home() / '.elan' / 'bin'}:{os.environ.get('PATH', '')}"
|
| 347 |
+
self._server = LeanServer(LeanREPLConfig(project=LocalProject(directory=str(self.project), auto_build=False)))
|
| 348 |
+
self._gen += 1
|
| 349 |
+
r = self._server.run(Command(cmd="\n".join(f"import {m}" for m in dict.fromkeys(self.imports))), timeout=max(self.timeout_s, 1800))
|
| 350 |
+
errors = [m.data for m in r.messages if m.severity == "error"]
|
| 351 |
+
if errors:
|
| 352 |
+
raise RuntimeError(f"imports fallaron en el REPL: {errors[:2]}")
|
| 353 |
+
self._env = r.env
|
| 354 |
+
|
| 355 |
+
def check(self, formal_statement: str, proof_tactics: str, header: str = "", max_heartbeats: int = 400000) -> Verdict:
|
| 356 |
+
forbidden = find_forbidden_tactic(proof_tactics)
|
| 357 |
+
if forbidden:
|
| 358 |
+
return Verdict(ok=False, errors=[{"line": 0, "col": 0, "message": f"forbidden token in proof: {forbidden}"}])
|
| 359 |
+
with self._lock:
|
| 360 |
+
return self._check_locked(formal_statement, proof_tactics, header, max_heartbeats)
|
| 361 |
+
|
| 362 |
+
def _check_locked(self, formal_statement: str, proof_tactics: str, header: str, max_heartbeats: int) -> Verdict:
|
| 363 |
+
from lean_interact import Command
|
| 364 |
+
|
| 365 |
+
if self._server is None or not self._server.is_alive():
|
| 366 |
+
self._start()
|
| 367 |
+
code, _name = theorem_block(formal_statement, proof_tactics, header, max_heartbeats)
|
| 368 |
+
t0 = time.time()
|
| 369 |
+
try:
|
| 370 |
+
# all_tactics: constantes que usa cada táctica -> qué lemas citados usa DE VERDAD la prueba
|
| 371 |
+
r = self._server.run(Command(cmd=code, env=self._env, all_tactics=True), timeout=self.timeout_s)
|
| 372 |
+
except Exception as e: # timeout o REPL caído: se reinicia en la próxima llamada
|
| 373 |
+
self._server = None
|
| 374 |
+
return Verdict(ok=False, errors=[{"line": 0, "col": 0, "message": f"lean repl failure: {type(e).__name__}: {str(e)[:200]}"}], seconds=round(time.time() - t0, 1), raw=code[-2000:])
|
| 375 |
+
msgs = [{"line": m.start_pos.line if m.start_pos else 0, "col": m.start_pos.column if m.start_pos else 0, "severity": m.severity, "message": m.data} for m in r.messages]
|
| 376 |
+
errors = [m for m in msgs if m["severity"] == "error"]
|
| 377 |
+
uses_sorry = any("sorry" in m["message"] for m in msgs)
|
| 378 |
+
axioms = parse_axioms("\n".join(m["message"] for m in msgs if m["severity"] == "info"))
|
| 379 |
+
ok, errors = _axiom_check(errors, uses_sorry, axioms)
|
| 380 |
+
used = sorted({c for t in getattr(r, "tactics", None) or [] for c in t.used_constants})
|
| 381 |
+
return Verdict(ok=ok, errors=errors, uses_sorry=uses_sorry, axioms=axioms, used_constants=used, seconds=round(time.time() - t0, 1), raw=code[-2000:])
|
| 382 |
+
|
| 383 |
+
def _run(self, query, timeout: float | None = None):
|
| 384 |
+
"""Una consulta al REPL con el lock tomado por el llamador; timeout o REPL caído -> None
|
| 385 |
+
(se reinicia en la próxima llamada, y los `proof_state` anteriores dejan de valer)."""
|
| 386 |
+
if self._server is None or not self._server.is_alive():
|
| 387 |
+
self._start()
|
| 388 |
+
try:
|
| 389 |
+
return self._server.run(query, timeout=timeout or self.timeout_s)
|
| 390 |
+
except Exception:
|
| 391 |
+
self._server = None
|
| 392 |
+
return None
|
| 393 |
+
|
| 394 |
+
def goals(self, formal_statement: str, skeleton: str, header: str = "", max_heartbeats: int = 400000) -> tuple[list[dict], list[dict]]:
|
| 395 |
+
"""Modo sonda (docs/PLAN_MEJORA_RAG.md Fase 2): elabora un esqueleto con `sorry` y devuelve
|
| 396 |
+
([{goal, proof_state}] en el orden en que aparecen los `sorry`, errores de elaboración). Nunca
|
| 397 |
+
produce un `Verdict`: el único camino a `ok` sigue siendo `check` sobre el archivo final, que
|
| 398 |
+
rechaza `sorry`. Fuera de `sorry`, el esqueleto pasa por el mismo validador que una prueba."""
|
| 399 |
+
from lean_interact import Command
|
| 400 |
+
|
| 401 |
+
forbidden = find_forbidden_tactic(_SORRY_RE.sub(" skip ", skeleton))
|
| 402 |
+
if forbidden:
|
| 403 |
+
return [], [{"line": 0, "col": 0, "message": f"forbidden token in proof: {forbidden}"}]
|
| 404 |
+
code, _name = theorem_block(formal_statement, skeleton, header, max_heartbeats)
|
| 405 |
+
with self._lock:
|
| 406 |
+
r = self._run(Command(cmd=code, env=self._env))
|
| 407 |
+
if r is None or not hasattr(r, "sorries"):
|
| 408 |
+
return [], [{"line": 0, "col": 0, "message": f"lean repl failure: {str(getattr(r, 'message', r))[:200]}"}]
|
| 409 |
+
errors = [{"line": m.start_pos.line if m.start_pos else 0, "col": m.start_pos.column if m.start_pos else 0, "message": m.data} for m in r.messages if m.severity == "error"]
|
| 410 |
+
found = sorted(r.sorries, key=lambda s: (s.start_pos.line, s.start_pos.column) if s.start_pos else (0, 0))
|
| 411 |
+
return [{"goal": s.goal, "proof_state": s.proof_state, "gen": self._gen} for s in found], errors
|
| 412 |
+
|
| 413 |
+
def step(self, proof_state: int, tactic: str, timeout: float = 120, heartbeats: int | None = None, gen: int | None = None) -> tuple[bool, str, list[str]]:
|
| 414 |
+
"""Aplica `tactic` a un `proof_state` de `goals` sin re-elaborar el teorema (`ProofStep`).
|
| 415 |
+
Devuelve (cerrado, error o estado, mensajes info — p.ej. el "Try this" de `try?`). La táctica
|
| 416 |
+
pasa por `find_forbidden_tactic` antes de tocar Lean, igual que en `check`."""
|
| 417 |
+
from lean_interact import ProofStep
|
| 418 |
+
|
| 419 |
+
forbidden = find_forbidden_tactic(tactic)
|
| 420 |
+
if forbidden:
|
| 421 |
+
return False, f"forbidden token in proof: {forbidden}", []
|
| 422 |
+
if heartbeats:
|
| 423 |
+
# el límite lo pone el sistema DESPUÉS del validador (que prohíbe `set_option` al LLM): Lean
|
| 424 |
+
# corta por heartbeats antes que el timeout de reloj, que reinicia el REPL (~5 min) e invalida
|
| 425 |
+
# los `proof_state` de los demás hilos
|
| 426 |
+
tactic = limit_heartbeats(tactic, heartbeats)
|
| 427 |
+
with self._lock:
|
| 428 |
+
if gen is not None and (gen != self._gen or self._server is None):
|
| 429 |
+
# el REPL se reinició (timeout de otra táctica): ese número de estado ya no existe o, peor,
|
| 430 |
+
# es otro goal del REPL nuevo
|
| 431 |
+
return False, "stale proof state (lean repl restarted)", []
|
| 432 |
+
r = self._run(ProofStep(tactic=tactic, proof_state=proof_state), timeout=timeout)
|
| 433 |
+
if r is None or not hasattr(r, "proof_status"): # LeanError (la táctica falló) o REPL caído
|
| 434 |
+
return False, str(getattr(r, "message", "lean repl failure"))[:2000], []
|
| 435 |
+
errs = [m.data for m in r.messages if m.severity == "error"]
|
| 436 |
+
infos = [m.data for m in r.messages if m.severity == "info"]
|
| 437 |
+
return r.proof_status == "Completed" and not errs, "\n".join(errs)[:2000] or r.proof_status, infos
|
| 438 |
+
|
| 439 |
+
def signatures(self, names: list[str], header: str = "") -> dict[str, str]:
|
| 440 |
+
"""{nombre: tipo} de los `names` que resuelve `#check @nombre` en el entorno cargado, uno por
|
| 441 |
+
comando (exacto, a diferencia de `check_identifiers_exist`). `header` aporta los `open` con
|
| 442 |
+
los que esos nombres se usan en las pruebas (p.ej. `open Nat` resuelve `succ_le_iff`)."""
|
| 443 |
+
from lean_interact import Command
|
| 444 |
+
|
| 445 |
+
opens = " ".join(l.strip()[len("open"):].strip() for l in header.splitlines() if l.strip().startswith("open"))
|
| 446 |
+
prefix = f"open {opens} in\n" if opens else ""
|
| 447 |
+
out: dict[str, str] = {}
|
| 448 |
+
with self._lock:
|
| 449 |
+
for n in names:
|
| 450 |
+
# con los `open`, un nombre de la raíz puede quedar ambiguo (`sq_nonneg` vs `Nat.sq_nonneg`):
|
| 451 |
+
# la prueba lo desambigua por tipo, `#check` no -> se reintenta sin los `open`
|
| 452 |
+
for pre in dict.fromkeys((prefix, "")):
|
| 453 |
+
r = self._run(Command(cmd=f"{pre}#check @{n}", env=self._env), timeout=60)
|
| 454 |
+
msgs = getattr(r, "messages", None)
|
| 455 |
+
if msgs is not None and not any(m.severity == "error" for m in msgs):
|
| 456 |
+
info = next((m.data for m in msgs if m.severity == "info"), "")
|
| 457 |
+
out[n] = info.split(" : ", 1)[1].strip() if " : " in info else info
|
| 458 |
+
break
|
| 459 |
+
return out
|
| 460 |
+
|
| 461 |
+
def check_identifiers_exist(self, decl_names: list[str]) -> tuple[int, int]:
|
| 462 |
+
"""(existen, total): cuántos de `decl_names` resuelve el entorno cargado,
|
| 463 |
+
vía `#check @nombre`. Consistencia índice <-> Lean (`docs/PLAN_RAG.md
|
| 464 |
+
§12`): si el índice tiene `decl_name` que no existen en esta versión de
|
| 465 |
+
Mathlib, el RAG cita lemas fantasma. No es una prueba de un LLM: no pasa
|
| 466 |
+
por `find_forbidden_tactic`. Aproximado (cuenta errores totales, no los
|
| 467 |
+
mapea 1 a 1 con cada nombre) — suficiente para un aviso, no aborta nada."""
|
| 468 |
+
from lean_interact import Command
|
| 469 |
+
|
| 470 |
+
if not decl_names:
|
| 471 |
+
return 0, 0
|
| 472 |
+
with self._lock:
|
| 473 |
+
if self._server is None or not self._server.is_alive():
|
| 474 |
+
self._start()
|
| 475 |
+
code = "\n".join(f"#check @{n}" for n in decl_names)
|
| 476 |
+
try:
|
| 477 |
+
r = self._server.run(Command(cmd=code, env=self._env), timeout=self.timeout_s)
|
| 478 |
+
except Exception:
|
| 479 |
+
return 0, len(decl_names)
|
| 480 |
+
n_errors = sum(1 for m in r.messages if m.severity == "error")
|
| 481 |
+
return max(0, len(decl_names) - n_errors), len(decl_names)
|
| 482 |
+
|
| 483 |
+
def close(self) -> None:
|
| 484 |
+
if self._server is not None:
|
| 485 |
+
self._server.kill()
|
| 486 |
+
self._server = None
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def identifiers_used(proof_tactics: str, decl_names: list[str]) -> list[str]:
|
| 490 |
+
"""Nombres de Mathlib citados que aparecen en el código aceptado (used_in_proof, §6.4)."""
|
| 491 |
+
return [d for d in decl_names if re.search(r"(?<![\w.'])" + re.escape(d) + r"(?![\w'.])", proof_tactics)]
|
ragsolver/llm.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cliente LLM/VLM único para agentes (Fase 4) y enriquecimiento (Fase 2).
|
| 2 |
+
|
| 3 |
+
Proveedores (configs/versions.yaml, secciones `llm` / `vlm_enrichment`):
|
| 4 |
+
- `gemini`: REST `generativelanguage.googleapis.com` (key en GEMINI_API_KEY).
|
| 5 |
+
- `openai_compatible`: `/chat/completions` con `base_url` (OpenRouter, OpenAI,
|
| 6 |
+
Ollama, vLLM...); key en la variable que diga `api_key_env`.
|
| 7 |
+
|
| 8 |
+
Un solo método: `generate(prompt, schema=None, images=(), system=None)` ->
|
| 9 |
+
`LLMResponse(text, model, tokens_in, tokens_out)`. Con `schema` (JSON Schema
|
| 10 |
+
estándar) la salida es JSON validado; para Gemini se traduce al subconjunto
|
| 11 |
+
OpenAPI que acepta `responseSchema`.
|
| 12 |
+
|
| 13 |
+
Cadena de modelos: `model` + `fallback_models`; al agotar la cuota diaria de uno
|
| 14 |
+
(429 con quotaId *PerDay*) se pasa al siguiente. Reintentos (LLM-1, hasta 4,
|
| 15 |
+
`_MAX_ATTEMPTS`): error de transporte o 429/5xx -> 3 s / exponencial; **nunca**
|
| 16 |
+
un timeout de lectura (el proveedor puede haber generado — y cobrado — la
|
| 17 |
+
respuesta antes de que el timeout cortara la conexión; reintentarlo la
|
| 18 |
+
duplicaría). Sin caché aquí (la tiene el Enricher).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import base64
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import threading
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import httpx
|
| 31 |
+
import yaml
|
| 32 |
+
from tenacity import Retrying, retry_if_exception
|
| 33 |
+
|
| 34 |
+
_GEMINI_API = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
| 35 |
+
# ponytail: fijo, no por instancia — si algún día hace falta variar el número de reintentos entre
|
| 36 |
+
# secciones (llm/vlm_enrichment), pasar `max_attempts` al constructor y usarlo en el `Retrying` de abajo.
|
| 37 |
+
_MAX_ATTEMPTS = 4
|
| 38 |
+
# Un 429 es una solicitud RECHAZADA (no se cobra): con el proveedor saturado ("engine_overloaded",
|
| 39 |
+
# DECISIONS #103-#104) conviene esperar más en vez de perder el par con lo ya gastado.
|
| 40 |
+
_MAX_ATTEMPTS_429 = 10
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class DailyQuotaExhausted(Exception):
|
| 44 |
+
"""429 con quotaId *PerDay*: el modelo no vuelve hasta mañana -> cambiar de modelo."""
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class LLMResponse:
|
| 49 |
+
text: str
|
| 50 |
+
model: str
|
| 51 |
+
tokens_in: int = 0
|
| 52 |
+
tokens_out: int = 0
|
| 53 |
+
calls: int = 1 # >1 cuando una respuesta agrega varias llamadas (self-consistency)
|
| 54 |
+
cost_usd: float = 0.0 # OpenRouter `usage.cost` (exacto, por proveedor); 0 si el backend no lo informa
|
| 55 |
+
attempts: int = 1 # LLM-1: cuántos intentos hizo _generate_with (reintentos de transporte/429/5xx)
|
| 56 |
+
|
| 57 |
+
def json(self) -> dict:
|
| 58 |
+
return json.loads(self.text)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _is_daily_quota(response: httpx.Response) -> bool:
|
| 62 |
+
try:
|
| 63 |
+
details = response.json()["error"].get("details", [])
|
| 64 |
+
except Exception:
|
| 65 |
+
return False
|
| 66 |
+
return any("PerDay" in v.get("quotaId", "") for d in details for v in d.get("violations", []))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _retryable(exc: BaseException) -> bool:
|
| 70 |
+
if isinstance(exc, httpx.HTTPStatusError):
|
| 71 |
+
return exc.response.status_code in (429, 500, 502, 503, 504)
|
| 72 |
+
if isinstance(exc, httpx.ReadTimeout):
|
| 73 |
+
# LLM-1: un timeout de LECTURA puede significar que el proveedor ya generó (y cobró) la
|
| 74 |
+
# respuesta y el corte fue nuestro; reintentar la duplicaría. ConnectTimeout y el resto de
|
| 75 |
+
# TransportError sí son seguros de reintentar (nada se envió o el proveedor no respondió nada).
|
| 76 |
+
return False
|
| 77 |
+
# JSON truncado/degenerado NO se reintenta aquí: cada intento cuesta `max_tokens` de salida y
|
| 78 |
+
# el reintento con `stop_after_attempt` alto no los contaba — fue la causa del sobrecosto de
|
| 79 |
+
# eval_v1 (DECISIONS #59). El único reintento (con el doble de tokens) lo hace `Agents._call`,
|
| 80 |
+
# y se contabiliza.
|
| 81 |
+
return isinstance(exc, httpx.TransportError)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _wait(retry_state) -> float:
|
| 85 |
+
exc = retry_state.outcome.exception()
|
| 86 |
+
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
|
| 87 |
+
return min(90.0, 4.0 * 2 ** (retry_state.attempt_number - 1))
|
| 88 |
+
return 3.0
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _stop(retry_state) -> bool:
|
| 92 |
+
exc = retry_state.outcome.exception()
|
| 93 |
+
is_429 = isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429
|
| 94 |
+
return retry_state.attempt_number >= (_MAX_ATTEMPTS_429 if is_429 else _MAX_ATTEMPTS)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _log_retry(retry_state) -> None:
|
| 98 |
+
exc = retry_state.outcome.exception()
|
| 99 |
+
print(f"[llm retry] intento {retry_state.attempt_number} falló: {type(exc).__name__}: {str(exc)[:150]}", flush=True)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _gemini_schema(schema: dict) -> dict:
|
| 103 |
+
"""JSON Schema (Pydantic) -> subconjunto OpenAPI de Gemini: resuelve `$ref`/`$defs`,
|
| 104 |
+
tipos en mayúsculas, `anyOf [X, null]` -> X nullable, sin claves no soportadas."""
|
| 105 |
+
defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
|
| 106 |
+
|
| 107 |
+
def conv(node):
|
| 108 |
+
if isinstance(node, list):
|
| 109 |
+
return [conv(n) for n in node]
|
| 110 |
+
if not isinstance(node, dict):
|
| 111 |
+
return node
|
| 112 |
+
if "$ref" in node:
|
| 113 |
+
return conv(defs[node["$ref"].rsplit("/", 1)[1]])
|
| 114 |
+
if "anyOf" in node:
|
| 115 |
+
options = [o for o in node["anyOf"] if o.get("type") != "null"]
|
| 116 |
+
nullable = len(options) < len(node["anyOf"])
|
| 117 |
+
base = conv(options[0]) if len(options) == 1 else {"anyOf": conv(options)}
|
| 118 |
+
if nullable:
|
| 119 |
+
base["nullable"] = True
|
| 120 |
+
return base
|
| 121 |
+
out = {}
|
| 122 |
+
for k, v in node.items():
|
| 123 |
+
if k in ("additionalProperties", "$schema", "title", "default", "$defs", "definitions", "examples"):
|
| 124 |
+
continue
|
| 125 |
+
if k == "type" and isinstance(v, str):
|
| 126 |
+
out[k] = v.upper()
|
| 127 |
+
elif k == "properties":
|
| 128 |
+
out[k] = {pk: conv(pv) for pk, pv in v.items()}
|
| 129 |
+
elif k == "items":
|
| 130 |
+
out[k] = conv(v)
|
| 131 |
+
else:
|
| 132 |
+
out[k] = v
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
return conv(schema)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _read_dotenv(name: str, path: str = ".env") -> str | None:
|
| 139 |
+
p = Path(path)
|
| 140 |
+
if not p.exists():
|
| 141 |
+
return None
|
| 142 |
+
for line in p.read_text(encoding="utf-8").splitlines():
|
| 143 |
+
if line.startswith(name + "="):
|
| 144 |
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class LLM:
|
| 149 |
+
def __init__(self, versions_yaml: str = "configs/versions.yaml", section: str = "llm", config: dict | None = None):
|
| 150 |
+
cfg = config or yaml.safe_load(open(versions_yaml, encoding="utf-8"))[section]
|
| 151 |
+
self.provider = cfg.get("provider", "gemini")
|
| 152 |
+
self.models: list[str] = [cfg["model"], *(cfg.get("fallback_models") or [])]
|
| 153 |
+
self.base_url = (cfg.get("base_url") or "").rstrip("/")
|
| 154 |
+
key_env = cfg.get("api_key_env") or ("GEMINI_API_KEY" if self.provider == "gemini" else "OPENROUTER_API_KEY")
|
| 155 |
+
self.api_key = os.environ.get(key_env) or _read_dotenv(key_env)
|
| 156 |
+
if not self.api_key:
|
| 157 |
+
raise RuntimeError(f"{key_env} no está en el entorno ni en .env")
|
| 158 |
+
self.temperature = float(cfg.get("temperature", 0.1))
|
| 159 |
+
self.seed = cfg.get("seed") # RNF-2: semilla fijada en cada llamada (Gemini `seed`, OpenAI `seed`)
|
| 160 |
+
self.max_tokens = cfg.get("max_tokens") # tope de salida (incluye el razonamiento en modelos "thinking")
|
| 161 |
+
self.reasoning_effort = cfg.get("reasoning_effort") # OpenRouter: off | low | medium | high
|
| 162 |
+
self.timeout_s = float(cfg.get("timeout_s", 300)) # LLM-1: bajado de 600-900; 4000 tok de salida al proveedor más lento medido son ~210s (#62)
|
| 163 |
+
self.provider_routing = cfg.get("provider_routing") # OpenRouter `provider` (max_price, sort, order…), se envía tal cual
|
| 164 |
+
self._model_idx = 0
|
| 165 |
+
self._lock = threading.Lock()
|
| 166 |
+
self.exhausted: list[str] = []
|
| 167 |
+
self.calls = 0
|
| 168 |
+
self.tokens_in = 0
|
| 169 |
+
self.tokens_out = 0
|
| 170 |
+
self.cost_usd = 0.0
|
| 171 |
+
self._client = httpx.Client(timeout=self.timeout_s)
|
| 172 |
+
|
| 173 |
+
@property
|
| 174 |
+
def model(self) -> str:
|
| 175 |
+
return self.models[self._model_idx]
|
| 176 |
+
|
| 177 |
+
def generate(self, prompt: str, schema: dict | None = None, images: tuple[str | Path, ...] | list = (), system: str | None = None, max_tokens: int | None = None) -> LLMResponse:
|
| 178 |
+
while True:
|
| 179 |
+
model = self.model
|
| 180 |
+
try:
|
| 181 |
+
return self._generate_with(model, prompt, schema, tuple(images), system, max_tokens)
|
| 182 |
+
except DailyQuotaExhausted:
|
| 183 |
+
with self._lock:
|
| 184 |
+
if self.models[self._model_idx] == model:
|
| 185 |
+
if self._model_idx + 1 >= len(self.models):
|
| 186 |
+
raise
|
| 187 |
+
self._model_idx += 1
|
| 188 |
+
self.exhausted.append(model)
|
| 189 |
+
|
| 190 |
+
def _generate_with(self, model: str, prompt: str, schema: dict | None, images: tuple, system: str | None, max_tokens: int | None) -> LLMResponse:
|
| 191 |
+
# `Retrying` en vez del decorador `@retry`: así se cuenta el número de intentos de esta
|
| 192 |
+
# llamada (LLM-1, `resp.attempts`) sin estado compartido entre hilos (el runner usa `--workers`).
|
| 193 |
+
n_attempts = 0
|
| 194 |
+
for attempt in Retrying(retry=retry_if_exception(_retryable), wait=_wait, stop=_stop, before_sleep=_log_retry, reraise=True):
|
| 195 |
+
with attempt:
|
| 196 |
+
n_attempts += 1
|
| 197 |
+
if self.provider == "gemini":
|
| 198 |
+
resp = self._gemini(model, prompt, schema, images, system, max_tokens)
|
| 199 |
+
else:
|
| 200 |
+
resp = self._openai(model, prompt, schema, images, system, max_tokens)
|
| 201 |
+
resp.attempts = n_attempts
|
| 202 |
+
with self._lock:
|
| 203 |
+
self.calls += 1
|
| 204 |
+
self.tokens_in += resp.tokens_in
|
| 205 |
+
self.tokens_out += resp.tokens_out
|
| 206 |
+
self.cost_usd += resp.cost_usd
|
| 207 |
+
return resp
|
| 208 |
+
|
| 209 |
+
# -- backends ----------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
def _gemini(self, model, prompt, schema, images, system, max_tokens) -> LLMResponse:
|
| 212 |
+
parts = [{"text": prompt}] + [
|
| 213 |
+
{"inline_data": {"mime_type": "image/png", "data": base64.b64encode(Path(p).read_bytes()).decode()}} for p in images
|
| 214 |
+
]
|
| 215 |
+
gen: dict = {"temperature": self.temperature}
|
| 216 |
+
if self.seed is not None:
|
| 217 |
+
gen["seed"] = int(self.seed)
|
| 218 |
+
if max_tokens or self.max_tokens:
|
| 219 |
+
gen["maxOutputTokens"] = int(max_tokens or self.max_tokens)
|
| 220 |
+
if schema is not None:
|
| 221 |
+
gen.update(responseMimeType="application/json", responseSchema=_gemini_schema(schema))
|
| 222 |
+
body: dict = {"contents": [{"parts": parts}], "generationConfig": gen}
|
| 223 |
+
if system:
|
| 224 |
+
body["systemInstruction"] = {"parts": [{"text": system}]}
|
| 225 |
+
r = self._client.post(_GEMINI_API.format(model=model), headers={"x-goog-api-key": self.api_key}, json=body)
|
| 226 |
+
if r.status_code == 429 and _is_daily_quota(r):
|
| 227 |
+
raise DailyQuotaExhausted(model)
|
| 228 |
+
r.raise_for_status()
|
| 229 |
+
data = r.json()
|
| 230 |
+
cand = data["candidates"][0]
|
| 231 |
+
text = "".join(p.get("text", "") for p in cand.get("content", {}).get("parts", []) if not p.get("thought"))
|
| 232 |
+
usage = data.get("usageMetadata", {})
|
| 233 |
+
return LLMResponse(text, model, usage.get("promptTokenCount", 0), usage.get("candidatesTokenCount", 0) + usage.get("thoughtsTokenCount", 0))
|
| 234 |
+
|
| 235 |
+
def _openai(self, model, prompt, schema, images, system, max_tokens) -> LLMResponse:
|
| 236 |
+
content: list[dict] = [{"type": "text", "text": prompt}] + [
|
| 237 |
+
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(Path(p).read_bytes()).decode()}} for p in images
|
| 238 |
+
]
|
| 239 |
+
messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": content}]
|
| 240 |
+
body: dict = {"model": model, "messages": messages, "temperature": self.temperature}
|
| 241 |
+
if self.seed is not None:
|
| 242 |
+
body["seed"] = int(self.seed)
|
| 243 |
+
if max_tokens or self.max_tokens:
|
| 244 |
+
body["max_tokens"] = int(max_tokens or self.max_tokens)
|
| 245 |
+
if "openrouter" in self.base_url:
|
| 246 |
+
body["usage"] = {"include": True} # costo exacto en la respuesta
|
| 247 |
+
if self.provider_routing:
|
| 248 |
+
body["provider"] = self.provider_routing
|
| 249 |
+
if self.reasoning_effort: # "off" = sin cadena de razonamiento (DeepSeek V4 Flash: 24 s vs 76-90 s por llamada)
|
| 250 |
+
body["reasoning"] = {"enabled": False} if self.reasoning_effort == "off" else {"effort": self.reasoning_effort}
|
| 251 |
+
if schema is not None:
|
| 252 |
+
body["response_format"] = {"type": "json_schema", "json_schema": {"name": "output", "schema": schema, "strict": False}}
|
| 253 |
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
| 254 |
+
if "openrouter" in self.base_url:
|
| 255 |
+
headers["HTTP-Referer"] = "https://github.com/ragsolver" # atribución opcional que pide OpenRouter
|
| 256 |
+
r = self._client.post(f"{self.base_url}/chat/completions", headers=headers, json=body)
|
| 257 |
+
if (r.status_code == 429 and "daily" in r.text.lower()) or r.status_code in (402, 403):
|
| 258 |
+
# 402/403 en OpenRouter = key sin crédito o fuera de su tope: se pasa al siguiente modelo y, si
|
| 259 |
+
# todos fallan, el runner corta limpio (status quota_exhausted) en vez de grabar 300 pares "error"
|
| 260 |
+
raise DailyQuotaExhausted(f"{model}: HTTP {r.status_code} {r.text[:120]}")
|
| 261 |
+
r.raise_for_status()
|
| 262 |
+
data = r.json()
|
| 263 |
+
text = data["choices"][0]["message"]["content"] or ""
|
| 264 |
+
usage = data.get("usage", {})
|
| 265 |
+
return LLMResponse(text, model, usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), cost_usd=float(usage.get("cost") or 0.0))
|
ragsolver/logging/__init__.py
ADDED
|
File without changes
|
ragsolver/logging/jsonl_writer.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Escritor de traza JSONL (RNF-5, docs/PLAN_RAG.md §7.6).
|
| 2 |
+
|
| 3 |
+
Un archivo por corrida (`run_id`), un evento por línea. `append_event` es la
|
| 4 |
+
única función que necesita el resto del código: no hay clase, no hay estado
|
| 5 |
+
oculto más allá del path del archivo — ponytail: no crear un logger genérico
|
| 6 |
+
para un único formato de evento.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import threading
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from ragsolver.rag.schema import TraceEvent
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def trace_path(run_id: str, base_dir: str | Path = "data/eval/traces") -> Path:
|
| 19 |
+
base = Path(base_dir)
|
| 20 |
+
base.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
return base / f"{run_id}.jsonl"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
_LOCK = threading.Lock()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def append_event(event: TraceEvent, base_dir: str | Path = "data/eval/traces") -> Path:
|
| 28 |
+
"""Serializa `event` como una línea JSON y la agrega al archivo de la corrida."""
|
| 29 |
+
path = trace_path(event.run_id, base_dir)
|
| 30 |
+
with _LOCK, path.open("a", encoding="utf-8") as f:
|
| 31 |
+
f.write(event.model_dump_json() + "\n")
|
| 32 |
+
return path
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def read_events(run_id: str, base_dir: str | Path = "data/eval/traces") -> list[TraceEvent]:
|
| 36 |
+
path = trace_path(run_id, base_dir)
|
| 37 |
+
if not path.exists():
|
| 38 |
+
return []
|
| 39 |
+
with path.open(encoding="utf-8") as f:
|
| 40 |
+
return [TraceEvent.model_validate(json.loads(line)) for line in f if line.strip()]
|
ragsolver/orchestrator/graph.py
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Orquestador (docs/PLAN_RAG.md §5.3, Fase 4; propuesta §6.1-6.2; docs/PLAN_MEJORA_RAG.md §4).
|
| 2 |
+
|
| 3 |
+
PROVE (demostrador local, 0 API) -> [DRAFT: plan + prueba informal de la API en el prompt del demostrador]
|
| 4 |
+
-> (si `prover.api_fallback` o no hay demostrador) PLAN -> RETRIEVE -> GENERATE -> FORMALIZE -> [CLOSE -> FILL]
|
| 5 |
+
-> VERIFY -> (DONE | CRITIQUE -> retry/replan/fail)
|
| 6 |
+
|
| 7 |
+
Con `goal_mode` (por defecto) el Autoformalizador escribe un esqueleto `have … := by sorry`; CLOSE
|
| 8 |
+
obtiene del REPL el goal de cada `sorry`, recupera premisas PARA ESE GOAL (rag/premises.py) y prueba
|
| 9 |
+
una batería de tácticas con ellas (0 LLM); FILL (1 llamada) sólo ve los goals que quedaron abiertos.
|
| 10 |
+
Los goals cerrados quedan en `proved` y se reutilizan en reintentos y replans (D5 del plan).
|
| 11 |
+
|
| 12 |
+
LangGraph `StateGraph` sobre un dict. El estado lleva `evidence` (citas
|
| 13 |
+
consultadas, §6.4), la recuperación por subobjetivo y contadores de presupuesto
|
| 14 |
+
(RNF-3: `llm_budget` llamadas por problema). El retriever cuesta 0 llamadas.
|
| 15 |
+
Sin checkpointer persistente (ponytail: MemorySaver/SQLite cuando haga falta
|
| 16 |
+
reanudar corridas largas).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
import time
|
| 23 |
+
from typing import Any, TypedDict
|
| 24 |
+
|
| 25 |
+
from langgraph.graph import END, START, StateGraph
|
| 26 |
+
|
| 27 |
+
from ragsolver.agents.agents import Agents, InvalidLLMOutput
|
| 28 |
+
from ragsolver.agents.schemas import CriticOutput, FillOutput, Problem, SolutionReport, Verdict
|
| 29 |
+
from ragsolver.lean.verify import LeanChecker, build_file, count_sorries, fill_sorries, identifiers_used, skeletonize, verify
|
| 30 |
+
from ragsolver.llm import DailyQuotaExhausted
|
| 31 |
+
from ragsolver.prover import ProverUnavailable, draft_hint, examples_hint, extract_tactics, goal_to_theorem, prove as prover_prove, renamed_inaccessible
|
| 32 |
+
from ragsolver.rag import premises as P
|
| 33 |
+
from ragsolver.rag.proofs import similar as similar_proofs
|
| 34 |
+
from ragsolver.rag.retrieve import Retriever
|
| 35 |
+
from ragsolver.rag.schema import Citation, Hit, RetrievalRequest
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class State(TypedDict, total=False):
|
| 39 |
+
problem: Problem
|
| 40 |
+
cfg: dict
|
| 41 |
+
run_id: str
|
| 42 |
+
exemplars: list[Hit]
|
| 43 |
+
plan: Any
|
| 44 |
+
lemmas: list[Hit]
|
| 45 |
+
evidence: dict[str, Citation]
|
| 46 |
+
citation_ids: dict[str, str]
|
| 47 |
+
sketch: str
|
| 48 |
+
used_citations: list[str]
|
| 49 |
+
proof_tactics: str
|
| 50 |
+
lean_code: str
|
| 51 |
+
verdict: Verdict | None
|
| 52 |
+
lean_error: str
|
| 53 |
+
feedback: str
|
| 54 |
+
missing_identifiers: list[str]
|
| 55 |
+
attempt: int
|
| 56 |
+
replans: int
|
| 57 |
+
history: list[dict]
|
| 58 |
+
error_types: list[str]
|
| 59 |
+
re_retrievals: int
|
| 60 |
+
re_retrievals_ok: int
|
| 61 |
+
llm_calls: int
|
| 62 |
+
retrieval_calls: int
|
| 63 |
+
tokens_in: int
|
| 64 |
+
tokens_out: int
|
| 65 |
+
models: list[str]
|
| 66 |
+
status: str
|
| 67 |
+
fail_reason: str
|
| 68 |
+
t0: float
|
| 69 |
+
skeleton: str
|
| 70 |
+
candidates: list # bloque del LLM para cada `sorry` del esqueleto (None = sin candidato)
|
| 71 |
+
goals: list[dict] # [{goal, proof_state, block, premises}] del esqueleto actual
|
| 72 |
+
proved: dict[str, str] # goal normalizado -> bloque que lo cerró (se conserva entre intentos y replans)
|
| 73 |
+
prover_samples: int
|
| 74 |
+
solved_by: str
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _count(state: State, resp) -> dict:
|
| 78 |
+
return {
|
| 79 |
+
"llm_calls": state.get("llm_calls", 0) + getattr(resp, "calls", 1),
|
| 80 |
+
"tokens_in": state.get("tokens_in", 0) + resp.tokens_in,
|
| 81 |
+
"tokens_out": state.get("tokens_out", 0) + resp.tokens_out,
|
| 82 |
+
"models": list(dict.fromkeys(state.get("models", []) + [resp.model])),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# lo que REPAIR y SKETCH conservan de un CLOSE que no terminó la prueba: pasos probados y contabilidad
|
| 87 |
+
_CLOSE_KEEP = ("proved", "prover_samples", "evidence", "citation_ids", "lemmas", "retrieval_calls")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _over_budget(state: State) -> bool:
|
| 91 |
+
return state.get("llm_calls", 0) >= state["cfg"]["llm_budget"]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _format_error(verdict: Verdict) -> str:
|
| 95 |
+
return "\n".join(f"line {e['line']}:{e['col']}: {e['message']}" for e in verdict.errors)[:3000] or ("uses sorry" if verdict.uses_sorry else "unknown error")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _assign_citation_ids(state: State, hits: list[Hit], prefix: str) -> dict[str, str]:
|
| 99 |
+
"""BUG-1: mapa `chunk_id -> citation_id` estable durante todo el problema —
|
| 100 |
+
reutiliza el id ya asignado a un chunk que vuelve a aparecer (tras un replan
|
| 101 |
+
o una re-recuperación) y sólo asigna el siguiente número libre del prefijo a
|
| 102 |
+
los chunks nuevos. Antes `retrieve()` renumeraba `M1..Mn` en cada llamada:
|
| 103 |
+
un chunk que ya estaba en `evidence` con `M3` podía volver a verse como `M1`
|
| 104 |
+
en la siguiente búsqueda, y el `M1` real quedaba duplicado (34/300 reportes
|
| 105 |
+
de `eval_v1` con `citation_id` repetidos, afectando `used_in_sketch`)."""
|
| 106 |
+
ids = dict(state.get("citation_ids", {}))
|
| 107 |
+
next_n = max((int(v[len(prefix):]) for v in ids.values() if v.startswith(prefix) and v[len(prefix):].isdigit()), default=0) + 1
|
| 108 |
+
for h in hits:
|
| 109 |
+
if h.chunk_id not in ids:
|
| 110 |
+
ids[h.chunk_id] = f"{prefix}{next_n}"
|
| 111 |
+
next_n += 1
|
| 112 |
+
h.citation.citation_id = ids[h.chunk_id]
|
| 113 |
+
return ids
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _goal_key(goal: str) -> str:
|
| 117 |
+
return " ".join(goal.split())
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def render_tactic(entry: dict, use_rag: bool, premises: list[str], hints: list[str]) -> str | None:
|
| 121 |
+
"""Táctica de la batería CLOSE para una entrada de `close.tactics` (None = se omite)."""
|
| 122 |
+
if not use_rag:
|
| 123 |
+
return entry.get("plain")
|
| 124 |
+
tac = entry["rag"]
|
| 125 |
+
if ("{premises}" in tac and not premises) or ("{hints}" in tac and not hints):
|
| 126 |
+
return entry.get("plain")
|
| 127 |
+
return tac.replace("{premises}", ", ".join(premises)).replace("{hints}", ", ".join(hints))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def concrete_block(tactic: str, infos: list[str]) -> str:
|
| 131 |
+
"""`try?` cierra el goal y sugiere el script concreto ("Try this: [apply] exact …"): ese script es
|
| 132 |
+
el que va al archivo final, para que el kernel nunca acepte una táctica con `?`."""
|
| 133 |
+
if not tactic.rstrip().endswith("?"):
|
| 134 |
+
return tactic
|
| 135 |
+
for info in infos:
|
| 136 |
+
for line in info.splitlines():
|
| 137 |
+
line = line.strip()
|
| 138 |
+
if line.startswith("[apply] "):
|
| 139 |
+
return line[len("[apply] "):]
|
| 140 |
+
return tactic
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def close_goal(checker: LeanChecker, goal: dict, tactics: list[dict], use_rag: bool, premises: list[str], hints: list[str], timeout_s: float = 60, heartbeats: int | None = 200000) -> tuple[str | None, list[dict]]:
|
| 144 |
+
"""Batería CLOSE sobre un goal (docs/PLAN_MEJORA_RAG.md Fase 4): la primera táctica que lo cierra
|
| 145 |
+
gana. Devuelve (bloque concreto o None, [{tactic, closed, error}])."""
|
| 146 |
+
log = []
|
| 147 |
+
for entry in tactics:
|
| 148 |
+
tac = render_tactic(entry, use_rag, premises, hints)
|
| 149 |
+
if not tac:
|
| 150 |
+
continue
|
| 151 |
+
closed, msg, infos = checker.step(goal["proof_state"], tac, timeout=timeout_s, heartbeats=heartbeats, gen=goal.get("gen"))
|
| 152 |
+
log.append({"tactic": tac[:200], "closed": closed, "error": "" if closed else msg[:300]})
|
| 153 |
+
if closed:
|
| 154 |
+
return concrete_block(tac, infos), log
|
| 155 |
+
if "stale proof state" in msg or "lean repl failure" in msg: # el REPL se reinició: el goal queda abierto
|
| 156 |
+
break
|
| 157 |
+
return None, log
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _premise_hit(name: str, sig: str, dense: dict[str, Hit], ctx: dict) -> Hit:
|
| 161 |
+
if name in dense:
|
| 162 |
+
return dense[name]
|
| 163 |
+
cit = Citation(citation_id="M0", chunk_id=f"mathlib:{name}:0", corpus="mathlib", unit_type="mathlib_decl", label=name, locator={"decl_name": name, "module_path": "", "docs_url": ""}, retrieved_for=ctx)
|
| 164 |
+
return Hit(chunk_id=cit.chunk_id, corpus="mathlib", unit_type="mathlib_decl", rank=1, text=f"{name} : {sig}", metadata={"decl_name": name, "signature": sig}, citation=cit)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def select_premises(retriever: Retriever | None, goal: str, k: int = 16, n_candidates: int = 50, rerank: bool = False, ctx: dict | None = None, dense_channel: bool = False) -> tuple[list[str], dict[str, Hit]]:
|
| 168 |
+
"""Premisas para un goal de Lean (docs/PLAN_MEJORA_RAG.md Fase 3): canal A (prior filtrado por los
|
| 169 |
+
símbolos del goal) y, si `dense_channel`, canal C (índice denso con la meta del goal) fusionado con
|
| 170 |
+
RRF. Devuelve (nombres, hits densos por nombre). 0 llamadas LLM.
|
| 171 |
+
|
| 172 |
+
El canal C está apagado por defecto (`close.dense_channel`, DECISIONS #98): recall@16 por goal 0.028
|
| 173 |
+
frente a 0.327 del prior, y fusionado con RRF baja el total a 0.097."""
|
| 174 |
+
a = P.prior_channel(goal, k=2 * k)
|
| 175 |
+
dense: dict[str, Hit] = {}
|
| 176 |
+
if retriever is not None and dense_channel:
|
| 177 |
+
try:
|
| 178 |
+
req = RetrievalRequest(queries=[P.goal_target(goal)[:2000] or goal[:2000]], mode="formal", corpora=["mathlib"], k=k, n_candidates=n_candidates, rerank=rerank, include_figures=False, context=ctx or {"problem_id": "", "agent": "close"})
|
| 179 |
+
dense = {h.metadata.get("decl_name"): h for h in retriever.search(req).hits if h.metadata.get("decl_name")}
|
| 180 |
+
except Exception: # sin índice denso (tests, demo sin tabla): sólo el prior
|
| 181 |
+
pass
|
| 182 |
+
return P.fuse(a, list(dense), k=k), dense
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class Orchestrator:
|
| 186 |
+
def __init__(self, agents: Agents, retriever: Retriever | None, checker: LeanChecker | None = None, prover=None, drafter: Agents | None = None):
|
| 187 |
+
self.agents = agents
|
| 188 |
+
self.retriever = retriever
|
| 189 |
+
self.checker = checker # None -> un proceso `lake env lean` por verificación
|
| 190 |
+
self.prover = prover # demostrador local (ragsolver.prover.LocalProver) o None
|
| 191 |
+
self.drafter = drafter # Agents con el LLM que escribe el borrador de DRAFT (`llm_draft`, con razonamiento); None = `agents`
|
| 192 |
+
self.graph = self._build()
|
| 193 |
+
|
| 194 |
+
# -- nodos ---------------------------------------------------------------------
|
| 195 |
+
|
| 196 |
+
def _prover_cfg(self, state: State) -> dict | None:
|
| 197 |
+
pcfg = state["cfg"].get("prover") or {}
|
| 198 |
+
return pcfg if pcfg.get("enabled") and self.prover is not None and self.checker is not None else None
|
| 199 |
+
|
| 200 |
+
def _api_loop(self, state: State) -> bool:
|
| 201 |
+
"""¿Corre el bucle de agentes por API (PLAN → … → CRITIQUE) cuando el demostrador no resuelve? Sin
|
| 202 |
+
demostrador, siempre; con él, sólo si `prover.api_fallback` (en eval-v3 costó US$0.06 en los 9
|
| 203 |
+
problemas que el demostrador no resolvió y no resolvió ninguno)."""
|
| 204 |
+
pcfg = self._prover_cfg(state)
|
| 205 |
+
return pcfg is None or bool(pcfg.get("api_fallback", True))
|
| 206 |
+
|
| 207 |
+
def prove(self, state: State) -> dict:
|
| 208 |
+
"""Etapa 0 (docs/PLAN_REDISENO_PROVER.md): el demostrador local intenta el teorema completo, 0 API.
|
| 209 |
+
Con RAG, la autocorrección recibe nombres que existen en el Mathlib fijado (se citan)."""
|
| 210 |
+
pcfg = self._prover_cfg(state)
|
| 211 |
+
if pcfg is None:
|
| 212 |
+
return {}
|
| 213 |
+
cfg, problem = state["cfg"], state["problem"]
|
| 214 |
+
name = re.match(r"\s*(?:theorem|lemma)\s+(\S+)", problem.formal_statement)
|
| 215 |
+
if not name:
|
| 216 |
+
return {}
|
| 217 |
+
retr = self.retriever if cfg["rag"] and pcfg.get("rag_names") else None
|
| 218 |
+
tac, log = prover_prove(self.prover, self.checker, problem.formal_statement, problem.header, name.group(1), k=pcfg["k_root"], correction_rounds=pcfg.get("correction_rounds", 1), retriever=retr, max_heartbeats=cfg["lean"]["max_heartbeats"])
|
| 219 |
+
self.agents.trace("prover", state["run_id"], problem.problem_id, 1, log.get("seconds", 0) * 1000, {"stage": "root", "solved": bool(tac), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}, "suggested": list(log.get("suggested", {}))})
|
| 220 |
+
delta = self._cite(state, log.get("suggested", {}), "prover")
|
| 221 |
+
delta["prover_samples"] = state.get("prover_samples", 0) + log["samples"] + log["rounds"] * 2
|
| 222 |
+
if tac:
|
| 223 |
+
delta.update(proof_tactics=tac, solved_by="prover_root")
|
| 224 |
+
return delta
|
| 225 |
+
if pcfg.get("examples") and cfg["rag"]:
|
| 226 |
+
delta.update(self.examples({**state, **delta}, name.group(1), pcfg))
|
| 227 |
+
if pcfg.get("repair") and not delta.get("solved_by"):
|
| 228 |
+
delta.update(self.repair({**state, **delta}, log.get("candidates", []), pcfg["repair"]))
|
| 229 |
+
if pcfg.get("sketch") and not delta.get("solved_by"):
|
| 230 |
+
delta.update(self.sketch({**state, **delta}, name.group(1), pcfg["sketch"]))
|
| 231 |
+
if not delta.get("solved_by") and not pcfg.get("dsp") and not self._api_loop(state):
|
| 232 |
+
delta.update(status="fail", fail_reason="prover: sin prueba (respaldo por API apagado: prover.api_fallback)")
|
| 233 |
+
return delta
|
| 234 |
+
|
| 235 |
+
def examples(self, state: State, name: str, pcfg: dict) -> dict:
|
| 236 |
+
"""EXAMPLES (`prover.examples`, RAG de pruebas, `rag/proofs.py`): si la etapa 0 falla, las pruebas
|
| 237 |
+
verificadas de los `examples` problemas más parecidos (embeddings sobre Lean Workbook) (1) se prueban tal
|
| 238 |
+
cual sobre el enunciado (0 GPU: la misma táctica a veces cierra un problema análogo) y (2) van como
|
| 239 |
+
ejemplos en el prompt del demostrador (`k_examples` muestras, como DRAFT; su control es
|
| 240 |
+
`baseline_prover16`)."""
|
| 241 |
+
cfg, problem = state["cfg"], state["problem"]
|
| 242 |
+
t0 = time.time()
|
| 243 |
+
hits = similar_proofs(f"{problem.informal_statement}\n{problem.formal_statement}", k=pcfg["examples"], max_sim=pcfg.get("max_sim", 0.95))
|
| 244 |
+
info = {"stage": "examples", "retrieved": [(h["problem_id"], h["sim"]) for h in hits]}
|
| 245 |
+
delta = {"retrieval_calls": state.get("retrieval_calls", 0) + 1}
|
| 246 |
+
for h in hits:
|
| 247 |
+
if self.checker.check(problem.formal_statement, h["proof"], problem.header, cfg["lean"]["max_heartbeats"]).ok:
|
| 248 |
+
self.agents.trace("prover", state["run_id"], problem.problem_id, 1, (time.time() - t0) * 1000, {**info, "solved": True, "solved_in": f"direct:{h['problem_id']}"})
|
| 249 |
+
return {**delta, "proof_tactics": h["proof"], "solved_by": "prover_examples"}
|
| 250 |
+
if not hits:
|
| 251 |
+
return delta
|
| 252 |
+
tac, log = prover_prove(self.prover, self.checker, problem.formal_statement, problem.header, name, k=pcfg.get("k_examples", 8), correction_rounds=0, max_heartbeats=cfg["lean"]["max_heartbeats"], hint=examples_hint(hits))
|
| 253 |
+
self.agents.trace("prover", state["run_id"], problem.problem_id, 1, (time.time() - t0) * 1000, {**info, "solved": bool(tac), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}})
|
| 254 |
+
delta["prover_samples"] = state.get("prover_samples", 0) + log["samples"]
|
| 255 |
+
if tac:
|
| 256 |
+
delta.update(proof_tactics=tac, solved_by="prover_examples")
|
| 257 |
+
return delta
|
| 258 |
+
|
| 259 |
+
def repair(self, state: State, candidates: list[str], n: int) -> dict:
|
| 260 |
+
"""REPAIR (0 API, `prover.repair`): una prueba fallida del demostrador suele tener casi todos los pasos
|
| 261 |
+
bien. Se parte en sus `have` como la del Formalizador (`skeletonize`) y pasa por CLOSE: el bloque del
|
| 262 |
+
propio demostrador primero, la batería y el demostrador sobre el lema corto sólo en los pasos que fallan.
|
| 263 |
+
Se prueban las `n` mejores (menos errores de Lean) cuyo esqueleto elabora; lo probado queda en `proved`."""
|
| 264 |
+
keep: dict = {}
|
| 265 |
+
tried = 0
|
| 266 |
+
for tac in candidates:
|
| 267 |
+
if tried >= n:
|
| 268 |
+
break
|
| 269 |
+
skeleton, cands = skeletonize(tac)
|
| 270 |
+
if count_sorries(skeleton) < 2: # sin `have` no hay nada que partir: es la misma prueba que ya falló
|
| 271 |
+
continue
|
| 272 |
+
d = self.close({**state, **keep, "skeleton": skeleton, "candidates": cands})
|
| 273 |
+
keep.update({k: d[k] for k in _CLOSE_KEEP if k in d})
|
| 274 |
+
if not d.get("goals"): # el esqueleto no elabora (falla el cierre o la estructura): cuesta una llamada al REPL
|
| 275 |
+
continue
|
| 276 |
+
tried += 1
|
| 277 |
+
if "proof_tactics" in d:
|
| 278 |
+
return {**keep, "proof_tactics": d["proof_tactics"], "solved_by": "prover_repair"}
|
| 279 |
+
return keep
|
| 280 |
+
|
| 281 |
+
def sketch(self, state: State, name: str, rounds: int) -> dict:
|
| 282 |
+
"""SKETCH (`prover.sketch` rondas; receta de DeepSeek-Prover-V2): el modelo con razonamiento (`llm_draft`)
|
| 283 |
+
escribe el esqueleto en Lean, pasos `have … := by sorry`; CLOSE cierra cada paso con la batería y el
|
| 284 |
+
demostrador sobre el lema corto. Si quedan pasos abiertos, la ronda siguiente le muestra cuáles para que
|
| 285 |
+
los parta más; si el esqueleto no elabora, el error de Lean."""
|
| 286 |
+
delta: dict = {}
|
| 287 |
+
feedback = ""
|
| 288 |
+
for rnd in range(rounds):
|
| 289 |
+
if _over_budget({**state, **delta}):
|
| 290 |
+
break
|
| 291 |
+
text, resp = (self.drafter or self.agents).sketch(state["problem"], feedback, state["run_id"], rnd + 1)
|
| 292 |
+
delta.update(_count({**state, **delta}, resp))
|
| 293 |
+
tac = extract_tactics(text, name)
|
| 294 |
+
if not tac:
|
| 295 |
+
feedback = f"Your answer had no ```lean4 block with `theorem {name}`."
|
| 296 |
+
continue
|
| 297 |
+
skeleton, cands = skeletonize(tac)
|
| 298 |
+
d = self.close({**state, **delta, "skeleton": skeleton, "candidates": cands})
|
| 299 |
+
delta.update({k: d[k] for k in _CLOSE_KEEP if k in d})
|
| 300 |
+
if "proof_tactics" in d:
|
| 301 |
+
return {**delta, "proof_tactics": d["proof_tactics"], "solved_by": "prover_sketch"}
|
| 302 |
+
if not d.get("goals"):
|
| 303 |
+
feedback = f"Previous skeleton:\n{tac[:3000]}\nIt does not compile in Lean:\n{d.get('lean_error', '')[:1500]}"
|
| 304 |
+
else:
|
| 305 |
+
open_ = "\n\n".join(g["goal"] for g in d["goals"] if not g.get("block"))
|
| 306 |
+
feedback = f"Previous skeleton:\n{tac[:3000]}\nNo automation could prove these steps (Lean goals). Split them into smaller `have` steps or use another argument:\n{open_[:3000]}"
|
| 307 |
+
return delta
|
| 308 |
+
|
| 309 |
+
def draft(self, state: State) -> dict:
|
| 310 |
+
"""DRAFT (Draft-Sketch-Prove, `prover.dsp`): si la etapa 0 no resolvió, el modelo de la API escribe el
|
| 311 |
+
plan y la prueba informal (Planificador + Generador, 2 llamadas) y el demostrador local recibe eso,
|
| 312 |
+
junto con los lemas del RAG para el goal raíz, DENTRO de su prompt (`k_draft` muestras). Es el lugar
|
| 313 |
+
donde la API y el RAG pueden mover la prueba: el demostrador sabe Lean y el modelo general razona en
|
| 314 |
+
lenguaje natural."""
|
| 315 |
+
if _over_budget(state):
|
| 316 |
+
return {"status": "fail", "fail_reason": "budget"}
|
| 317 |
+
pcfg, cfg, problem = self._prover_cfg(state), state["cfg"], state["problem"]
|
| 318 |
+
agents = self.drafter or self.agents
|
| 319 |
+
exemplars, delta = self._exemplars(state)
|
| 320 |
+
plan, r1 = agents.plan(problem, exemplars, state["run_id"], 1)
|
| 321 |
+
delta.update(plan=plan, exemplars=exemplars, **_count(state, r1))
|
| 322 |
+
out, r2 = agents.generate(problem, plan, [], exemplars, "", state["run_id"], 1)
|
| 323 |
+
delta.update(sketch=out.sketch, used_citations=out.used_citations, **_count({**state, **delta}, r2))
|
| 324 |
+
lemmas: dict[str, str] = {}
|
| 325 |
+
if cfg["rag"]: # canal A (prior de competición) sobre el goal raíz, con su firma
|
| 326 |
+
goals, errors = self.checker.goals(problem.formal_statement, "sorry", problem.header, cfg["lean"]["max_heartbeats"])
|
| 327 |
+
if len(goals) == 1 and not errors:
|
| 328 |
+
prior = {r["decl_name"]: r.get("type", "") for r in P.load_prior()}
|
| 329 |
+
lemmas = {n: prior.get(n, "") for n in P.prior_channel(goals[0]["goal"], k=pcfg.get("k_lemmas", 10))}
|
| 330 |
+
hint = draft_hint(plan.strategy, [s.statement_nl for s in plan.subgoals], out.sketch, lemmas)
|
| 331 |
+
name = re.match(r"\s*(?:theorem|lemma)\s+(\S+)", problem.formal_statement).group(1)
|
| 332 |
+
tac, log = prover_prove(self.prover, self.checker, problem.formal_statement, problem.header, name, k=pcfg.get("k_draft", 8), correction_rounds=0, max_heartbeats=cfg["lean"]["max_heartbeats"], hint=hint)
|
| 333 |
+
self.agents.trace("prover", state["run_id"], problem.problem_id, 1, log.get("seconds", 0) * 1000, {"stage": "draft", "solved": bool(tac), "lemmas": list(lemmas), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}})
|
| 334 |
+
delta.update(self._cite({**state, **delta}, lemmas, "prover"))
|
| 335 |
+
delta["prover_samples"] = state.get("prover_samples", 0) + log["samples"]
|
| 336 |
+
if tac:
|
| 337 |
+
delta.update(proof_tactics=tac, solved_by="prover_draft")
|
| 338 |
+
elif not self._api_loop(state):
|
| 339 |
+
delta.update(status="fail", fail_reason="prover: sin prueba tras DRAFT (respaldo por API apagado: prover.api_fallback)")
|
| 340 |
+
return delta
|
| 341 |
+
|
| 342 |
+
def _exemplars(self, state: State) -> tuple[list[Hit], dict]:
|
| 343 |
+
"""Ejemplares del corpus informal para el Planificador (0 LLM) y su delta de citas/evidencia."""
|
| 344 |
+
cfg, problem = state["cfg"], state["problem"]
|
| 345 |
+
exemplars: list[Hit] = []
|
| 346 |
+
searched = cfg["rag"] and "informal" in cfg["corpora"] and self.retriever is not None
|
| 347 |
+
if searched:
|
| 348 |
+
req = RetrievalRequest(queries=[problem.informal_statement[:2000]], mode="exemplar", corpora=["informal"], k=cfg["retrieval"]["k_informal"], n_candidates=cfg["retrieval"]["n_candidates"], rerank=cfg["retrieval"]["rerank"], context={"problem_id": problem.problem_id, "subgoal_id": "", "attempt": state.get("attempt", 1), "agent": "planner", "run_id": state["run_id"]})
|
| 349 |
+
exemplars = self.retriever.search(req).hits
|
| 350 |
+
citation_ids = _assign_citation_ids(state, exemplars, "I")
|
| 351 |
+
evidence = dict(state.get("evidence", {}))
|
| 352 |
+
for h in exemplars:
|
| 353 |
+
evidence.setdefault(h.chunk_id, h.citation)
|
| 354 |
+
return exemplars, {"evidence": evidence, "citation_ids": citation_ids, "retrieval_calls": state.get("retrieval_calls", 0) + int(searched)}
|
| 355 |
+
|
| 356 |
+
def _cite(self, state: State, suggested: dict[str, str], agent: str) -> dict:
|
| 357 |
+
"""Nombres que propuso el RAG (sugerencias de la autocorrección) como citas `M*`."""
|
| 358 |
+
if not suggested:
|
| 359 |
+
return {}
|
| 360 |
+
ctx = {"problem_id": state["problem"].problem_id, "subgoal_id": "", "attempt": state.get("attempt", 1), "agent": agent, "run_id": state["run_id"]}
|
| 361 |
+
hits = [_premise_hit(n, sig, {}, ctx) for n, sig in suggested.items()]
|
| 362 |
+
evidence = dict(state.get("evidence", {}))
|
| 363 |
+
ids = _assign_citation_ids(state, hits, "M")
|
| 364 |
+
for h in hits:
|
| 365 |
+
evidence.setdefault(h.chunk_id, h.citation)
|
| 366 |
+
return {"evidence": evidence, "citation_ids": ids}
|
| 367 |
+
|
| 368 |
+
def plan(self, state: State) -> dict:
|
| 369 |
+
if _over_budget(state):
|
| 370 |
+
return {"status": "fail", "fail_reason": "budget"}
|
| 371 |
+
problem = state["problem"]
|
| 372 |
+
exemplars, ex_delta = self._exemplars(state)
|
| 373 |
+
feedback = ""
|
| 374 |
+
if state.get("plan") is not None: # replan (DES-1): razón del Crítico + plan anterior + lo ya probado
|
| 375 |
+
proved = [P.goal_target(k) for k in state.get("proved", {})]
|
| 376 |
+
feedback = f"Critic: {state.get('feedback', '')}\nPrevious strategy: {state['plan'].strategy}" + ("\nAlready proved (reuse as facts):\n" + "\n".join(f"- {t}" for t in proved) if proved else "")
|
| 377 |
+
plan, resp = self.agents.plan(problem, exemplars, state["run_id"], state.get("attempt", 1), feedback=feedback)
|
| 378 |
+
return {"exemplars": exemplars, "plan": plan, "feedback": "", "solved_by": "", **ex_delta, **_count(state, resp)}
|
| 379 |
+
|
| 380 |
+
def retrieve(self, state: State) -> dict:
|
| 381 |
+
cfg, problem = state["cfg"], state["problem"]
|
| 382 |
+
if not cfg["rag"] or self.retriever is None:
|
| 383 |
+
return {"lemmas": [], "missing_identifiers": []}
|
| 384 |
+
hits: dict[str, Hit] = {}
|
| 385 |
+
k = cfg["retrieval"]["k_mathlib"]
|
| 386 |
+
ctx = lambda sg, agent: {"problem_id": problem.problem_id, "subgoal_id": sg, "attempt": state.get("attempt", 1), "agent": agent, "run_id": state["run_id"]}
|
| 387 |
+
re_ok, re_n = state.get("re_retrievals_ok", 0), state.get("re_retrievals", 0)
|
| 388 |
+
n_calls = state.get("retrieval_calls", 0)
|
| 389 |
+
missing = state.get("missing_identifiers", [])
|
| 390 |
+
if missing: # re-recuperación tras `lema faltante` (0 llamadas LLM)
|
| 391 |
+
re_n += 1
|
| 392 |
+
queries = [f"unknown identifier '{m}'" for m in missing[:3]] + [state.get("lean_error", "")[:500] or problem.formal_statement[:500]]
|
| 393 |
+
res = self.retriever.search(RetrievalRequest(queries=queries[:4], mode="error", corpora=["mathlib"], k=k, n_candidates=cfg["retrieval"]["n_candidates"], rerank=cfg["retrieval"]["rerank"], context=ctx("", "critic")))
|
| 394 |
+
n_calls += 1
|
| 395 |
+
if any(h.metadata.get("decl_name") in missing for h in res.hits):
|
| 396 |
+
re_ok += 1
|
| 397 |
+
for h in res.hits:
|
| 398 |
+
hits.setdefault(h.chunk_id, h)
|
| 399 |
+
for sg in [] if cfg.get("goal_mode") and self.checker is not None else state["plan"].subgoals: # goal_mode: las premisas se recuperan por goal en CLOSE
|
| 400 |
+
queries = ([sg.statement_nl] + list(sg.search_queries))[:4]
|
| 401 |
+
res = self.retriever.search(RetrievalRequest(queries=queries, mode="nl", corpora=["mathlib"], k=k, n_candidates=cfg["retrieval"]["n_candidates"], rerank=cfg["retrieval"]["rerank"], rerank_against=sg.statement_nl, context=ctx(sg.subgoal_id, "retriever")))
|
| 402 |
+
n_calls += 1
|
| 403 |
+
for h in res.hits:
|
| 404 |
+
hits.setdefault(h.chunk_id, h)
|
| 405 |
+
lemmas = list(hits.values())
|
| 406 |
+
citation_ids = _assign_citation_ids(state, lemmas, "M")
|
| 407 |
+
evidence = dict(state.get("evidence", {}))
|
| 408 |
+
for h in lemmas:
|
| 409 |
+
evidence.setdefault(h.chunk_id, h.citation)
|
| 410 |
+
return {"lemmas": lemmas, "evidence": evidence, "citation_ids": citation_ids, "missing_identifiers": [], "re_retrievals": re_n, "re_retrievals_ok": re_ok, "retrieval_calls": n_calls}
|
| 411 |
+
|
| 412 |
+
def generate(self, state: State) -> dict:
|
| 413 |
+
if _over_budget(state):
|
| 414 |
+
return {"status": "fail", "fail_reason": "budget"}
|
| 415 |
+
out, resp = self.agents.generate(state["problem"], state["plan"], state.get("lemmas", []), state.get("exemplars", []), state.get("feedback", ""), state["run_id"], state.get("attempt", 1))
|
| 416 |
+
return {"sketch": out.sketch, "used_citations": out.used_citations, **_count(state, resp)}
|
| 417 |
+
|
| 418 |
+
def formalize(self, state: State) -> dict:
|
| 419 |
+
if _over_budget(state):
|
| 420 |
+
return {"status": "fail", "fail_reason": "budget"}
|
| 421 |
+
goal_mode = bool(state["cfg"].get("goal_mode")) and self.checker is not None
|
| 422 |
+
proved = [f"have : {P.goal_target(k)} := by {b}" for k, b in state.get("proved", {}).items()]
|
| 423 |
+
out, resp = self.agents.formalize(state["problem"], state["sketch"], state.get("lemmas", []), state.get("lean_error", ""), state["run_id"], state.get("attempt", 1), skeleton=goal_mode, proved=proved)
|
| 424 |
+
if goal_mode: # la prueba del modelo se parte en goals; sus bloques son el primer candidato de cada uno
|
| 425 |
+
skeleton, candidates = skeletonize(out.proof_tactics)
|
| 426 |
+
return {"skeleton": skeleton, "candidates": candidates, "proof_tactics": skeleton, **_count(state, resp)}
|
| 427 |
+
lean = state["cfg"]["lean"]
|
| 428 |
+
problem = state["problem"]
|
| 429 |
+
# BUG-2: `lean_code` reportado == lo que verifica `verify()` (mismo `theorem_block`, mismos `open`).
|
| 430 |
+
code = build_file(problem.formal_statement, out.proof_tactics, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
|
| 431 |
+
return {"proof_tactics": out.proof_tactics, "lean_code": code, **_count(state, resp)}
|
| 432 |
+
|
| 433 |
+
def close(self, state: State) -> dict:
|
| 434 |
+
"""CLOSE (0 LLM): goals del esqueleto -> premisas por goal -> batería de tácticas."""
|
| 435 |
+
cfg, problem = state["cfg"], state["problem"]
|
| 436 |
+
lean, ccfg = cfg["lean"], cfg["close"]
|
| 437 |
+
skeleton = state["skeleton"]
|
| 438 |
+
goals, errors = self.checker.goals(problem.formal_statement, skeleton, problem.header, lean["max_heartbeats"])
|
| 439 |
+
n_tokens = count_sorries(skeleton)
|
| 440 |
+
if errors or len(goals) != n_tokens:
|
| 441 |
+
msg = "\n".join(f"line {e['line']}:{e['col']}: {e['message']}" for e in errors) or f"the skeleton has {n_tokens} `sorry` but Lean found {len(goals)} goals: use exactly one `sorry` per goal"
|
| 442 |
+
self.agents.trace("close", state["run_id"], problem.problem_id, state.get("attempt", 1), 0, {"skeleton_ok": False, "error": msg[:300]})
|
| 443 |
+
code = build_file(problem.formal_statement, skeleton, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
|
| 444 |
+
return {"goals": [], "lean_error": msg[:3000], "lean_code": code, "verdict": Verdict(ok=False, errors=errors or [{"line": 0, "col": 0, "message": msg}])}
|
| 445 |
+
use_rag = bool(cfg["rag"]) and self.retriever is not None
|
| 446 |
+
proved = dict(state.get("proved", {}))
|
| 447 |
+
evidence = dict(state.get("evidence", {}))
|
| 448 |
+
new_hits: list[Hit] = []
|
| 449 |
+
n_calls = state.get("retrieval_calls", 0)
|
| 450 |
+
candidates = state.get("candidates") or [None] * len(goals)
|
| 451 |
+
n_prover_goals, prover_samples = 0, state.get("prover_samples", 0)
|
| 452 |
+
pcfg = self._prover_cfg(state)
|
| 453 |
+
|
| 454 |
+
def refresh() -> None: # el REPL se reinició (timeout de este hilo o de otro): proof_state nuevos
|
| 455 |
+
fresh, errs = self.checker.goals(problem.formal_statement, skeleton, problem.header, lean["max_heartbeats"])
|
| 456 |
+
if not errs and len(fresh) == len(goals):
|
| 457 |
+
for h, f in zip(goals, fresh):
|
| 458 |
+
h.update(proof_state=f["proof_state"], gen=f["gen"])
|
| 459 |
+
|
| 460 |
+
for i, g in enumerate(goals):
|
| 461 |
+
g["candidate"] = candidates[i] if i < len(candidates) else None
|
| 462 |
+
for _ in range(2): # 2.º intento sólo si el REPL se reinició a mitad del goal (eval-v4: pasos "stale" sin probar)
|
| 463 |
+
if getattr(self.checker, "gen", g.get("gen")) != g.get("gen"):
|
| 464 |
+
refresh()
|
| 465 |
+
prover_ok = pcfg is not None and n_prover_goals < pcfg.get("max_goals", 3)
|
| 466 |
+
block, names, hits, log, n = self._close_one(state, g, i, proved, use_rag, prover_ok)
|
| 467 |
+
n_calls += hits is not None
|
| 468 |
+
new_hits += hits or []
|
| 469 |
+
prover_samples += n
|
| 470 |
+
n_prover_goals += any(t["tactic"] == "prover" for t in log)
|
| 471 |
+
if block or not any("stale proof state" in t["error"] for t in log):
|
| 472 |
+
break
|
| 473 |
+
g.update(block=block, premises=names, log=log)
|
| 474 |
+
if block:
|
| 475 |
+
proved[_goal_key(g["goal"])] = block
|
| 476 |
+
self.agents.trace("close", state["run_id"], problem.problem_id, state.get("attempt", 1), 0, {"goal": i, "closed": bool(block), "block": (block or "")[:200], "premises": names, "tried": log})
|
| 477 |
+
citation_ids = _assign_citation_ids(state, new_hits, "M")
|
| 478 |
+
for h in new_hits:
|
| 479 |
+
evidence.setdefault(h.chunk_id, h.citation)
|
| 480 |
+
delta = {"goals": goals, "prover_samples": prover_samples, "lean_error": "", "proved": proved, "evidence": evidence, "citation_ids": citation_ids, "lemmas": list({h.chunk_id: h for h in state.get("lemmas", []) + new_hits}.values()), "retrieval_calls": n_calls}
|
| 481 |
+
if all(g["block"] for g in goals):
|
| 482 |
+
delta["proof_tactics"] = fill_sorries(skeleton, [g["block"] for g in goals])
|
| 483 |
+
return delta
|
| 484 |
+
|
| 485 |
+
def _close_one(self, state: State, g: dict, i: int, proved: dict, use_rag: bool, prover_ok: bool) -> tuple[str | None, list[str], list[Hit] | None, list[dict], int]:
|
| 486 |
+
"""Un goal de CLOSE: lo ya probado en un intento anterior, el bloque del modelo para ese paso, la batería
|
| 487 |
+
con las premisas del goal y, si `prover_ok`, el demostrador sobre el lema corto. Devuelve (bloque o None,
|
| 488 |
+
premisas, hits del RAG o None si no se buscó, log, muestras del demostrador)."""
|
| 489 |
+
ccfg, hb = state["cfg"]["close"], state["cfg"]["lean"]["max_heartbeats"]
|
| 490 |
+
key = _goal_key(g["goal"])
|
| 491 |
+
if key in proved:
|
| 492 |
+
closed, _msg, _ = self.checker.step(g["proof_state"], proved[key], timeout=ccfg["timeout_s"], heartbeats=hb, gen=g.get("gen"))
|
| 493 |
+
if closed:
|
| 494 |
+
return proved[key], [], None, [{"tactic": "reused", "closed": True, "error": ""}], 0
|
| 495 |
+
cand_log = []
|
| 496 |
+
if g.get("candidate"):
|
| 497 |
+
closed, msg, _ = self.checker.step(g["proof_state"], g["candidate"], timeout=ccfg["timeout_s"], heartbeats=hb, gen=g.get("gen"))
|
| 498 |
+
cand_log = [{"tactic": "llm", "closed": closed, "error": "" if closed else msg[:300]}]
|
| 499 |
+
if closed:
|
| 500 |
+
return g["candidate"], [], None, cand_log, 0
|
| 501 |
+
names, hints, hits = [], [], None
|
| 502 |
+
if use_rag:
|
| 503 |
+
names, hits = self._premises(state, g["goal"], ccfg["k_premises"], i)
|
| 504 |
+
hints = P.nlinarith_hints(g["goal"])
|
| 505 |
+
block, log = close_goal(self.checker, g, ccfg["tactics"], use_rag, names, hints, ccfg["timeout_s"])
|
| 506 |
+
n = 0
|
| 507 |
+
if block is None and prover_ok:
|
| 508 |
+
block, plog, n = self._prove_goal(state, g, i, self._prover_cfg(state))
|
| 509 |
+
log = log + plog
|
| 510 |
+
return block, names, hits, cand_log + log, n
|
| 511 |
+
|
| 512 |
+
def _prove_goal(self, state: State, g: dict, i: int, pcfg: dict) -> tuple[str | None, list[dict], int]:
|
| 513 |
+
"""Un goal abierto como lema autónomo para el demostrador (receta DeepSeek-Prover-V2); el bloque se
|
| 514 |
+
acepta sólo si cierra el goal EN SU CONTEXTO (`step`), con `rename_i` para los nombres inaccesibles."""
|
| 515 |
+
name = f"rs_goal_{i}"
|
| 516 |
+
stmt = goal_to_theorem(g["goal"], name)
|
| 517 |
+
if stmt is None:
|
| 518 |
+
return None, [{"tactic": "prover", "closed": False, "error": "goal no convertible a lema"}], 0
|
| 519 |
+
problem = state["problem"]
|
| 520 |
+
tac, plog = prover_prove(self.prover, self.checker, stmt, problem.header, name, k=pcfg.get("k_goal", 2), correction_rounds=0, max_heartbeats=state["cfg"]["lean"]["max_heartbeats"])
|
| 521 |
+
n = plog["samples"]
|
| 522 |
+
if not tac:
|
| 523 |
+
return None, [{"tactic": "prover", "closed": False, "error": f"{plog['extracted']}/{n} muestras extraídas, ninguna verifica"}], n
|
| 524 |
+
renamed = renamed_inaccessible(g["goal"])
|
| 525 |
+
block = (f"rename_i {' '.join(renamed)}\n" if renamed else "") + tac
|
| 526 |
+
closed, msg, _ = self.checker.step(g["proof_state"], block, timeout=state["cfg"]["close"]["timeout_s"], heartbeats=state["cfg"]["lean"]["max_heartbeats"], gen=g.get("gen"))
|
| 527 |
+
return (block if closed else None), [{"tactic": "prover", "closed": closed, "error": "" if closed else msg[:300]}], n
|
| 528 |
+
|
| 529 |
+
def _premises(self, state: State, goal: str, k: int, i: int) -> tuple[list[str], list[Hit]]:
|
| 530 |
+
problem = state["problem"]
|
| 531 |
+
ctx = {"problem_id": problem.problem_id, "subgoal_id": f"G{i + 1}", "attempt": state.get("attempt", 1), "agent": "close", "run_id": state["run_id"]}
|
| 532 |
+
ccfg = state["cfg"]["close"]
|
| 533 |
+
names, dense = select_premises(self.retriever, goal, k, state["cfg"]["retrieval"]["n_candidates"], state["cfg"]["retrieval"]["rerank"], ctx, dense_channel=bool(ccfg.get("dense_channel")))
|
| 534 |
+
prior = {r["decl_name"]: r for r in P.load_prior()}
|
| 535 |
+
return names, [_premise_hit(n, prior.get(n, {}).get("type", ""), dense, ctx) for n in names]
|
| 536 |
+
|
| 537 |
+
def fill(self, state: State) -> dict:
|
| 538 |
+
"""FILL (1 llamada): los goals que CLOSE no cerró, cada uno con su goal exacto y sus premisas."""
|
| 539 |
+
if _over_budget(state):
|
| 540 |
+
return {"status": "fail", "fail_reason": "budget"}
|
| 541 |
+
problem, goals = state["problem"], [dict(g) for g in state["goals"]]
|
| 542 |
+
open_ = [i for i, g in enumerate(goals) if not g.get("block")]
|
| 543 |
+
sigs = {h.metadata.get("decl_name"): h.metadata.get("signature", "") for h in state.get("lemmas", [])}
|
| 544 |
+
parts = []
|
| 545 |
+
for j, i in enumerate(open_):
|
| 546 |
+
g = goals[i]
|
| 547 |
+
lem = "\n".join(f" {n} : {sigs.get(n, '')[:300]}" for n in g.get("premises", [])) or " (none)"
|
| 548 |
+
err = next((t["error"] for t in reversed(g.get("log", [])) if t["error"]), "")
|
| 549 |
+
prev = ""
|
| 550 |
+
if g.get("candidate"):
|
| 551 |
+
cand_err = next((t["error"] for t in g.get("log", []) if t["tactic"] == "llm"), "")
|
| 552 |
+
prev = f"\nYour previous attempt for this goal (rejected by Lean):\n{g['candidate'][:1500]}\nLean error: {cand_err[:400]}"
|
| 553 |
+
parts.append(f"[G{j + 1}]\n{g['goal']}\nLemmas:\n{lem}{prev}\nAutomation error: {err[:300] or '(none)'}")
|
| 554 |
+
try:
|
| 555 |
+
out, resp = self.agents.fill(problem, "\n\n".join(parts), state["run_id"], state.get("attempt", 1))
|
| 556 |
+
except InvalidLLMOutput as e: # #110: sin propuestas válidas los goals siguen abiertos; no se tira el problema
|
| 557 |
+
out, resp = FillOutput(proofs=[]), e
|
| 558 |
+
by_id = {p.goal_id.strip().upper(): p.tactics for p in out.proofs}
|
| 559 |
+
proved = dict(state.get("proved", {}))
|
| 560 |
+
for j, i in enumerate(open_):
|
| 561 |
+
tac = by_id.get(f"G{j + 1}", "").strip()
|
| 562 |
+
if not tac:
|
| 563 |
+
goals[i]["block"] = "done" # sin respuesta: el goal queda abierto y Lean lo reporta
|
| 564 |
+
continue
|
| 565 |
+
closed, msg, _ = self.checker.step(goals[i]["proof_state"], tac, timeout=state["cfg"]["close"]["timeout_s"], gen=goals[i].get("gen"))
|
| 566 |
+
goals[i]["block"] = tac
|
| 567 |
+
if closed:
|
| 568 |
+
proved[_goal_key(goals[i]["goal"])] = tac
|
| 569 |
+
return {"goals": goals, "proved": proved, "proof_tactics": fill_sorries(state["skeleton"], [g["block"] for g in goals]), **_count(state, resp)}
|
| 570 |
+
|
| 571 |
+
def verify(self, state: State) -> dict:
|
| 572 |
+
lean = state["cfg"]["lean"]
|
| 573 |
+
problem = state["problem"]
|
| 574 |
+
if self.checker is not None:
|
| 575 |
+
v = self.checker.check(problem.formal_statement, state["proof_tactics"], problem.header, lean["max_heartbeats"])
|
| 576 |
+
else:
|
| 577 |
+
v = verify(problem.formal_statement, state["proof_tactics"], f"{problem.problem_id}_a{state.get('attempt', 1)}", lean["imports"] + problem.imports, lean["timeout_s"], lean["max_heartbeats"], header=problem.header)
|
| 578 |
+
self.agents.trace("verifier", state["run_id"], problem.problem_id, state.get("attempt", 1), v.seconds * 1000, {"ok": v.ok, "uses_sorry": v.uses_sorry, "n_errors": len(v.errors), "first_error": (v.errors[0]["message"][:300] if v.errors else "")})
|
| 579 |
+
# BUG-2: `lean_code` reportado == lo verificado (en goal_mode el archivo se arma recién aquí)
|
| 580 |
+
code = build_file(problem.formal_statement, state["proof_tactics"], lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
|
| 581 |
+
if v.ok:
|
| 582 |
+
return {"verdict": v, "status": "done", "lean_error": "", "lean_code": code, "solved_by": state.get("solved_by") or "agents"}
|
| 583 |
+
return {"verdict": v, "lean_error": _format_error(v), "lean_code": code}
|
| 584 |
+
|
| 585 |
+
def critique(self, state: State) -> dict:
|
| 586 |
+
cfg = state["cfg"]
|
| 587 |
+
attempt = state.get("attempt", 1)
|
| 588 |
+
history = list(state.get("history", []))
|
| 589 |
+
if cfg["k_max"] <= 1: # ablación "sin Crítico": un solo intento, sin llamada
|
| 590 |
+
history.append({"attempt": attempt, "error_type": "unknown", "summary": state["lean_error"][:200]})
|
| 591 |
+
return {"status": "fail", "fail_reason": "exhausted", "history": history, "error_types": state.get("error_types", []) + ["unknown"]}
|
| 592 |
+
if _over_budget(state):
|
| 593 |
+
return {"status": "fail", "fail_reason": "budget", "history": history}
|
| 594 |
+
try:
|
| 595 |
+
out, resp = self.agents.critique(state["problem"], state["sketch"], state["lean_code"], state["lean_error"], history, state["run_id"], attempt)
|
| 596 |
+
except InvalidLLMOutput as e: # #110: crítica ilegible -> reintento con los errores de Lean como feedback
|
| 597 |
+
out, resp = CriticOutput(error_type="unknown", action="retry", feedback=f"Fix the Lean errors:\n{state['lean_error'][:1500]}"), e
|
| 598 |
+
history.append({"attempt": attempt, "error_type": out.error_type, "action": out.action, "summary": out.feedback[:300]})
|
| 599 |
+
error_types = state.get("error_types", []) + [out.error_type]
|
| 600 |
+
delta = {"history": history, "error_types": error_types, "feedback": out.feedback, "missing_identifiers": out.missing_identifiers, "attempt": attempt + 1, **_count(state, resp)}
|
| 601 |
+
if out.action == "abandon":
|
| 602 |
+
return {**delta, "status": "fail", "fail_reason": "abandon"}
|
| 603 |
+
if attempt + 1 > cfg["k_max"]:
|
| 604 |
+
return {**delta, "status": "fail", "fail_reason": "exhausted"}
|
| 605 |
+
if out.action == "replan":
|
| 606 |
+
if state.get("replans", 0) >= cfg["max_replans"]:
|
| 607 |
+
return {**delta, "status": "fail", "fail_reason": "exhausted"}
|
| 608 |
+
return {**delta, "replans": state.get("replans", 0) + 1, "status": "replan"}
|
| 609 |
+
return {**delta, "status": "retry"}
|
| 610 |
+
|
| 611 |
+
# -- grafo -------------------------------------------------------------------
|
| 612 |
+
|
| 613 |
+
@staticmethod
|
| 614 |
+
def _guard(node):
|
| 615 |
+
"""JSON inválido tras el reintento (`InvalidLLMOutput`) = el intento termina en fallo dentro del
|
| 616 |
+
grafo, conservando lo acumulado (llamadas, tokens, evidencia, historial). Antes la excepción
|
| 617 |
+
salía de `invoke` y el reporte perdía todo lo anterior (DECISIONS #61).
|
| 618 |
+
|
| 619 |
+
ACC-1: cualquier OTRA excepción (un bug, el REPL de Lean, la red) se captura igual, a nivel de
|
| 620 |
+
nodo — el estado acumulado hasta ese punto ya está fusionado por LangGraph y no se pierde,
|
| 621 |
+
a diferencia del `except Exception` que tenía `solve()` antes, que reemplazaba todo el estado
|
| 622 |
+
final por `{**init, "fail_reason": ...}` y descartaba llamadas/tokens/evidencia ya generados."""
|
| 623 |
+
|
| 624 |
+
def guarded(state: State) -> dict:
|
| 625 |
+
try:
|
| 626 |
+
return node(state)
|
| 627 |
+
except InvalidLLMOutput as e: # e lleva tokens_in/tokens_out/calls/model, como una respuesta
|
| 628 |
+
return {"status": "fail", "fail_reason": f"llm_output_invalid:{e.agent}", **_count(state, e)} # `report` lo agrega a error_types
|
| 629 |
+
except (DailyQuotaExhausted, ProverUnavailable):
|
| 630 |
+
raise # sin modelos disponibles hoy o sin demostrador: debe salir para que el runner corte la corrida y reanude mañana
|
| 631 |
+
except Exception as e:
|
| 632 |
+
return {"status": "error", "fail_reason": f"exception: {type(e).__name__}: {str(e)[:300]}"}
|
| 633 |
+
|
| 634 |
+
return guarded
|
| 635 |
+
|
| 636 |
+
def _build(self):
|
| 637 |
+
g = StateGraph(State)
|
| 638 |
+
for name in ("prove", "draft", "plan", "retrieve", "generate", "formalize", "close", "fill", "verify", "critique"):
|
| 639 |
+
g.add_node(name, self._guard(getattr(self, name)))
|
| 640 |
+
g.add_edge(START, "prove")
|
| 641 |
+
g.add_conditional_edges("prove", lambda s: "end" if s.get("status") in ("fail", "error") else ("verify" if s.get("solved_by") in ("prover_root", "prover_examples", "prover_repair", "prover_sketch") else ("draft" if (self._prover_cfg(s) or {}).get("dsp") else "plan")), {"verify": "verify", "draft": "draft", "plan": "plan", "end": END})
|
| 642 |
+
g.add_conditional_edges("draft", lambda s: "end" if s.get("status") in ("fail", "error") else ("verify" if s.get("solved_by") == "prover_draft" else "plan"), {"verify": "verify", "plan": "plan", "end": END})
|
| 643 |
+
g.add_conditional_edges("plan", lambda s: "end" if s.get("status") in ("fail", "error") else "retrieve", {"retrieve": "retrieve", "end": END})
|
| 644 |
+
g.add_conditional_edges("retrieve", lambda s: "end" if s.get("status") == "error" else "generate", {"generate": "generate", "end": END})
|
| 645 |
+
g.add_conditional_edges("generate", lambda s: "end" if s.get("status") in ("fail", "error") else "formalize", {"formalize": "formalize", "end": END})
|
| 646 |
+
g.add_conditional_edges("formalize", lambda s: "end" if s.get("status") in ("fail", "error") else ("close" if s.get("skeleton") is not None and s["cfg"].get("goal_mode") and self.checker is not None else "verify"), {"close": "close", "verify": "verify", "end": END})
|
| 647 |
+
# CLOSE: esqueleto que no elabora -> Crítico; todo cerrado -> VERIFY; quedan goals -> FILL
|
| 648 |
+
g.add_conditional_edges("close", lambda s: "end" if s.get("status") == "error" else ("critique" if not s.get("goals") and s.get("lean_error") else ("verify" if all(x.get("block") for x in s.get("goals", [])) else "fill")), {"critique": "critique", "verify": "verify", "fill": "fill", "end": END})
|
| 649 |
+
g.add_conditional_edges("fill", lambda s: "end" if s.get("status") in ("fail", "error") else "verify", {"verify": "verify", "end": END})
|
| 650 |
+
# si la etapa 0 dio una prueba que VERIFY no acepta (no debería: ya pasó `check`), siguen los agentes
|
| 651 |
+
g.add_conditional_edges("verify", lambda s: "end" if s.get("status") in ("done", "error") or not self._api_loop(s) else ("plan" if s.get("solved_by") in ("prover_root", "prover_examples", "prover_repair", "prover_sketch", "prover_draft") and not s.get("history") else "critique"), {"critique": "critique", "plan": "plan", "end": END})
|
| 652 |
+
g.add_conditional_edges(
|
| 653 |
+
"critique",
|
| 654 |
+
lambda s: {"retry": ("retrieve" if s.get("missing_identifiers") else "generate"), "replan": "plan"}.get(s.get("status"), "end"),
|
| 655 |
+
{"retrieve": "retrieve", "generate": "generate", "plan": "plan", "end": END},
|
| 656 |
+
)
|
| 657 |
+
return g.compile()
|
| 658 |
+
|
| 659 |
+
def solve(self, problem: Problem, cfg: dict, run_id: str) -> SolutionReport:
|
| 660 |
+
t0 = time.time()
|
| 661 |
+
if problem.imports: # nadie usa imports adicionales por problema hoy (auditoría BUG-4); error explícito en vez de ignorarlos en silencio
|
| 662 |
+
return self.report(problem, cfg, {"fail_reason": f"unsupported: Problem.imports no soportado: {problem.imports}"}, "fail", 0.0)
|
| 663 |
+
init: State = {"problem": problem, "cfg": cfg, "run_id": run_id, "attempt": 1, "replans": 0, "evidence": {}, "history": [], "error_types": [], "llm_calls": 0, "retrieval_calls": 0, "tokens_in": 0, "tokens_out": 0, "models": [], "status": "running", "lean_error": "", "feedback": "", "missing_identifiers": []}
|
| 664 |
+
try:
|
| 665 |
+
final = self.graph.invoke(init, config={"recursion_limit": 60})
|
| 666 |
+
# ACC-1: "error" (excepción capturada dentro de un nodo, ver `_guard`) es un estado
|
| 667 |
+
# terminal distinto de "fail" (el grafo llegó a FAIL de forma normal); cualquier otro
|
| 668 |
+
# valor (no debería darse aquí) cae a "fail" por seguridad.
|
| 669 |
+
status = final.get("status") if final.get("status") in ("done", "error") else "fail"
|
| 670 |
+
except (DailyQuotaExhausted, ProverUnavailable):
|
| 671 |
+
raise # sin modelos disponibles hoy o sin demostrador: el runner corta la corrida y se reanuda mañana
|
| 672 |
+
except Exception as e: # red de seguridad final: no debería alcanzarse con _guard ya en cada nodo
|
| 673 |
+
final, status = {**init, "fail_reason": f"exception: {type(e).__name__}: {str(e)[:300]}"}, "error"
|
| 674 |
+
return self.report(problem, cfg, final, status, time.time() - t0)
|
| 675 |
+
|
| 676 |
+
@staticmethod
|
| 677 |
+
def report(problem: Problem, cfg: dict, final: dict, status: str, wall: float) -> SolutionReport:
|
| 678 |
+
used_ids = set(final.get("used_citations", []))
|
| 679 |
+
proof = final.get("proof_tactics", "") if status == "done" else ""
|
| 680 |
+
# `used_in_proof`: la constante está en el término que aceptó el kernel (REPL). Nombrarla en el texto no
|
| 681 |
+
# basta: `grind +lax [16 lemas]` nombra 16 y usa uno o dos (smoke 2026-10-14, DECISIONS #101). Sin esa
|
| 682 |
+
# información (backend `lake`), el criterio textual de antes.
|
| 683 |
+
verdict = final.get("verdict")
|
| 684 |
+
used_consts = set(verdict.used_constants) if status == "done" and verdict is not None and verdict.used_constants else None
|
| 685 |
+
citations = []
|
| 686 |
+
for c in final.get("evidence", {}).values():
|
| 687 |
+
c = c.model_copy()
|
| 688 |
+
c.used_in_sketch = c.citation_id in used_ids
|
| 689 |
+
name = c.locator.get("decl_name") or ""
|
| 690 |
+
c.used_in_proof = bool(proof) and c.corpus == "mathlib" and (name in used_consts if used_consts is not None else bool(identifiers_used(proof, [name])))
|
| 691 |
+
citations.append(c)
|
| 692 |
+
return SolutionReport(
|
| 693 |
+
problem_id=problem.problem_id,
|
| 694 |
+
config=cfg["name"],
|
| 695 |
+
status=status,
|
| 696 |
+
attempts=final.get("attempt", 1) if status == "done" else (len(final.get("history", [])) or 1),
|
| 697 |
+
replans=final.get("replans", 0),
|
| 698 |
+
llm_calls=final.get("llm_calls", 0),
|
| 699 |
+
retrieval_calls=final.get("retrieval_calls", 0),
|
| 700 |
+
tokens_in=final.get("tokens_in", 0),
|
| 701 |
+
tokens_out=final.get("tokens_out", 0),
|
| 702 |
+
wall_seconds=round(wall, 1),
|
| 703 |
+
lean_code=final.get("lean_code", ""),
|
| 704 |
+
verdict=final.get("verdict"),
|
| 705 |
+
citations=citations,
|
| 706 |
+
n_retrieved=len(citations),
|
| 707 |
+
n_used_in_sketch=sum(c.used_in_sketch for c in citations),
|
| 708 |
+
n_used_in_proof=sum(c.used_in_proof for c in citations),
|
| 709 |
+
error_types=final.get("error_types", []) + ([final["fail_reason"]] if final.get("fail_reason") else []),
|
| 710 |
+
re_retrievals=final.get("re_retrievals", 0),
|
| 711 |
+
re_retrievals_ok=final.get("re_retrievals_ok", 0),
|
| 712 |
+
models=final.get("models", []),
|
| 713 |
+
solved_by=final.get("solved_by", "") if status == "done" else "",
|
| 714 |
+
prover_samples=final.get("prover_samples", 0),
|
| 715 |
+
)
|
ragsolver/orchestrator/run.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corrida de problemas × configuraciones de ablación (propuesta §8.2) -> SolutionReport
|
| 2 |
+
por (problema, config) en data/eval/runs/<run_id>/results.jsonl + resumen.
|
| 3 |
+
|
| 4 |
+
Uso: python -m ragsolver.orchestrator.run --problems data/eval/problems/dev_trivial.jsonl --configs full,no_rag
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
import uuid
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from ragsolver.agents.agents import Agents, InvalidLLMOutput, load_agents_config
|
| 17 |
+
from ragsolver.agents.schemas import Problem, SolutionReport
|
| 18 |
+
from ragsolver.lean.verify import LeanChecker, build_file, verify
|
| 19 |
+
from ragsolver.llm import LLM, DailyQuotaExhausted
|
| 20 |
+
from ragsolver.logging.jsonl_writer import trace_path
|
| 21 |
+
from ragsolver.orchestrator.graph import Orchestrator, close_goal
|
| 22 |
+
from ragsolver.prover import ProverUnavailable, local_prover
|
| 23 |
+
from ragsolver.rag.embed import embed_queries
|
| 24 |
+
from ragsolver.rag.retrieve import Retriever
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_problems(path: str | Path) -> list[Problem]:
|
| 28 |
+
return [Problem.model_validate_json(l) for l in Path(path).read_text(encoding="utf-8").splitlines() if l.strip()]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def check_index_consistency(checker: LeanChecker, retriever: Retriever, n: int = 20, seed: int = 13) -> None:
|
| 32 |
+
"""`PLAN_RAG.md §12`: `decl_name` al azar de la tabla que sirve el corpus
|
| 33 |
+
`mathlib`, comprobados con `#check` contra el Lean real del verificador — si
|
| 34 |
+
el índice tiene nombres que no existen en esta versión de Mathlib, el RAG
|
| 35 |
+
cita lemas fantasma. No aborta la corrida, sólo avisa en el log; cualquier
|
| 36 |
+
fallo (tabla ausente, REPL caído) se ignora igual."""
|
| 37 |
+
import random
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
table = retriever._table("mathlib")
|
| 41 |
+
rows = table.search().select(["decl_name"]).limit(table.count_rows()).to_list()
|
| 42 |
+
names = [r["decl_name"] for r in rows if r.get("decl_name")]
|
| 43 |
+
if not names:
|
| 44 |
+
return
|
| 45 |
+
sample = random.Random(seed).sample(names, min(n, len(names)))
|
| 46 |
+
found, total = checker.check_identifiers_exist(sample)
|
| 47 |
+
print(f"[index-check] {found}/{total} decl_name de la tabla '{retriever.tables.get('mathlib', 'mathlib')}' existen en Lean", flush=True)
|
| 48 |
+
if found < total - 1: # tolera 1 falso positivo del `#check` aproximado
|
| 49 |
+
print(f"[index-check] AVISO: {total - found} nombres del índice no se resolvieron en Lean — posibles lemas fantasma", flush=True)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"[index-check] omitido: {type(e).__name__}: {e}", flush=True)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def effective_config(base: dict, name: str) -> dict:
|
| 55 |
+
"""Config efectiva de una ablación: claves del yaml + sobreescrituras de `configs[name]`."""
|
| 56 |
+
over = dict(base["configs"][name])
|
| 57 |
+
cfg = {
|
| 58 |
+
"name": name,
|
| 59 |
+
"mode": over.pop("mode", "graph"),
|
| 60 |
+
"rag": over.pop("rag", True),
|
| 61 |
+
"corpora": ["mathlib", "informal"],
|
| 62 |
+
"k_max": over.pop("k_max", base["k_max"]),
|
| 63 |
+
"max_replans": base["max_replans"],
|
| 64 |
+
"llm_budget": base["llm_budget"],
|
| 65 |
+
"retrieval": dict(base["retrieval"]),
|
| 66 |
+
"lean": dict(base["lean"]),
|
| 67 |
+
"goal_mode": over.pop("goal_mode", base.get("goal_mode", False)),
|
| 68 |
+
"close": dict(base.get("close") or {}),
|
| 69 |
+
"prover": dict(base.get("prover") or {}),
|
| 70 |
+
}
|
| 71 |
+
for k, v in over.items(): # un dict (p.ej. `prover: {k_root: 32}`) se fusiona con el de base, no lo reemplaza
|
| 72 |
+
cfg[k] = {**cfg[k], **v} if isinstance(v, dict) and isinstance(cfg.get(k), dict) else v
|
| 73 |
+
return cfg
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def drafter_agents(base: dict, trace_dir: str) -> Agents | None:
|
| 77 |
+
"""Agentes con el LLM de `llm_draft` (mismo modelo, CON razonamiento) para el borrador de DRAFT; None si
|
| 78 |
+
la sección no existe en `configs/versions.yaml` (DRAFT usa entonces los agentes normales)."""
|
| 79 |
+
import yaml
|
| 80 |
+
|
| 81 |
+
if "llm_draft" not in (yaml.safe_load(open("configs/versions.yaml", encoding="utf-8")) or {}):
|
| 82 |
+
return None
|
| 83 |
+
return Agents(LLM(section="llm_draft"), base, trace_dir=trace_dir)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def run_auto(problem: Problem, cfg: dict, checker: LeanChecker | None, agents: Agents | None = None, run_id: str = "") -> SolutionReport:
|
| 87 |
+
"""Línea base `baseline_auto` (docs/PLAN_MEJORA_RAG.md Fase 6): 0 llamadas LLM, sólo la batería
|
| 88 |
+
CLOSE sin premisas sobre el goal raíz. Es lo que Lean resuelve solo; el sistema con agentes tiene
|
| 89 |
+
que superarla para justificar sus llamadas."""
|
| 90 |
+
t0 = time.time()
|
| 91 |
+
rep = SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", attempts=1, error_types=["unknown"])
|
| 92 |
+
if checker is None:
|
| 93 |
+
rep.error_types = ["unsupported: baseline_auto requiere el REPL de Lean"]
|
| 94 |
+
return rep
|
| 95 |
+
lean = cfg["lean"]
|
| 96 |
+
goals, errors = checker.goals(problem.formal_statement, "sorry", problem.header, lean["max_heartbeats"])
|
| 97 |
+
block, log = (close_goal(checker, goals[0], cfg["close"]["tactics"], False, [], [], cfg["close"]["timeout_s"]) if len(goals) == 1 and not errors else (None, []))
|
| 98 |
+
if block:
|
| 99 |
+
rep.verdict = checker.check(problem.formal_statement, block, problem.header, lean["max_heartbeats"])
|
| 100 |
+
rep.lean_code = build_file(problem.formal_statement, block, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
|
| 101 |
+
if rep.verdict.ok:
|
| 102 |
+
rep.status, rep.error_types = "done", []
|
| 103 |
+
if agents is not None:
|
| 104 |
+
agents.trace("close", run_id, problem.problem_id, 1, (time.time() - t0) * 1000, {"goal": 0, "closed": bool(block), "block": (block or "")[:200], "tried": log})
|
| 105 |
+
rep.wall_seconds = round(time.time() - t0, 1)
|
| 106 |
+
return rep
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def run_prover_only(problem: Problem, cfg: dict, checker: LeanChecker | None, prover, agents: Agents | None = None, run_id: str = "") -> SolutionReport:
|
| 110 |
+
"""Línea base `baseline_prover` (docs/PLAN_REDISENO_PROVER.md): 0 API, sólo el demostrador local sobre
|
| 111 |
+
el teorema completo (k_root muestras + autocorrección con los errores de Lean, SIN sugerencias del RAG)."""
|
| 112 |
+
import re as _re
|
| 113 |
+
|
| 114 |
+
from ragsolver.prover import prove
|
| 115 |
+
|
| 116 |
+
t0 = time.time()
|
| 117 |
+
rep = SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", attempts=1, error_types=["unknown"])
|
| 118 |
+
if checker is None or prover is None:
|
| 119 |
+
rep.error_types = ["unsupported: baseline_prover requiere el REPL y el demostrador local"]
|
| 120 |
+
return rep
|
| 121 |
+
pcfg, lean = cfg["prover"], cfg["lean"]
|
| 122 |
+
name = _re.match(r"\s*(?:theorem|lemma)\s+(\S+)", problem.formal_statement).group(1)
|
| 123 |
+
tac, log = prove(prover, checker, problem.formal_statement, problem.header, name, k=pcfg["k_root"], correction_rounds=pcfg.get("correction_rounds", 1), retriever=None, max_heartbeats=lean["max_heartbeats"])
|
| 124 |
+
rep.prover_samples = log["samples"] + log["rounds"] * 2
|
| 125 |
+
if tac:
|
| 126 |
+
rep.verdict = checker.check(problem.formal_statement, tac, problem.header, lean["max_heartbeats"])
|
| 127 |
+
rep.lean_code = build_file(problem.formal_statement, tac, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
|
| 128 |
+
if rep.verdict.ok:
|
| 129 |
+
rep.status, rep.error_types, rep.solved_by = "done", [], "prover_root"
|
| 130 |
+
if agents is not None:
|
| 131 |
+
agents.trace("prover", run_id, problem.problem_id, 1, (time.time() - t0) * 1000, {"stage": "baseline_prover", "solved": bool(tac), **{k: v for k, v in log.items() if k not in ("suggested", "candidates")}})
|
| 132 |
+
rep.wall_seconds = round(time.time() - t0, 1)
|
| 133 |
+
return rep
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def run_baseline(mode: str, problem: Problem, cfg: dict, agents: Agents, run_id: str, checker: LeanChecker | None = None) -> SolutionReport:
|
| 137 |
+
if problem.imports: # mismo guard que Orchestrator.solve (BUG-4): nadie usa imports adicionales por problema hoy
|
| 138 |
+
return SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", error_types=[f"unsupported: Problem.imports no soportado: {problem.imports}"])
|
| 139 |
+
t0 = time.time()
|
| 140 |
+
out, resp = agents.baseline(mode, problem, run_id)
|
| 141 |
+
rep = SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail", attempts=1, llm_calls=1, tokens_in=resp.tokens_in, tokens_out=resp.tokens_out, models=[resp.model])
|
| 142 |
+
if mode == "baseline_a":
|
| 143 |
+
lean = cfg["lean"]
|
| 144 |
+
if checker is not None:
|
| 145 |
+
v = checker.check(problem.formal_statement, out.proof_tactics, problem.header, lean["max_heartbeats"])
|
| 146 |
+
else:
|
| 147 |
+
v = verify(problem.formal_statement, out.proof_tactics, f"{problem.problem_id}_{mode}", lean["imports"] + problem.imports, lean["timeout_s"], lean["max_heartbeats"], header=problem.header)
|
| 148 |
+
rep.verdict, rep.status = v, ("done" if v.ok else "fail")
|
| 149 |
+
# BUG-2: mismo `header` que se verificó, para que `lean_code` sea fiel.
|
| 150 |
+
rep.lean_code = build_file(problem.formal_statement, out.proof_tactics, lean["imports"] + problem.imports, lean["max_heartbeats"], header=problem.header)
|
| 151 |
+
if not v.ok:
|
| 152 |
+
rep.error_types = ["unknown"]
|
| 153 |
+
else: # baseline_b: sin verificación formal; se reporta su autoevaluación (métrica "falsos positivos evitados")
|
| 154 |
+
rep.self_assessed_correct = out.self_assessed_correct
|
| 155 |
+
rep.status = "fail"
|
| 156 |
+
rep.wall_seconds = round(time.time() - t0, 1)
|
| 157 |
+
return rep
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def run_suite(problems: list[Problem], config_names: list[str], out_dir: str | Path, agents_yaml: str = "configs/agents.yaml", db_path: str = "data/index/lancedb", run_id: str | None = None, workers: int = 1) -> dict:
|
| 161 |
+
"""Corre problemas × configs. **Reanudable**: con el mismo `run_id`, los pares
|
| 162 |
+
(config, problema) ya presentes en results.jsonl se saltan; si se agota la cuota
|
| 163 |
+
diaria de todos los modelos, corta limpio (status "quota_exhausted") para reanudar
|
| 164 |
+
al día siguiente con el mismo comando. `workers` > 1 resuelve varios pares a la vez
|
| 165 |
+
(hilos: el cuello de botella es la latencia del LLM; el REPL y las trazas van con lock)."""
|
| 166 |
+
base = load_agents_config(agents_yaml)
|
| 167 |
+
run_id = run_id or f"run-{time.strftime('%Y%m%d-%H%M')}-{uuid.uuid4().hex[:6]}"
|
| 168 |
+
out = Path(out_dir) / run_id
|
| 169 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 170 |
+
results_path = out / "results.jsonl"
|
| 171 |
+
results: list[SolutionReport] = [SolutionReport.model_validate_json(l) for l in results_path.read_text(encoding="utf-8").splitlines() if l.strip()] if results_path.exists() else []
|
| 172 |
+
errors = [r for r in results if r.status == "error"]
|
| 173 |
+
if errors: # fallo de infraestructura (excepción, red, key): se reintentan; quedan auditados en errors.jsonl
|
| 174 |
+
with (out / "errors.jsonl").open("a", encoding="utf-8") as f:
|
| 175 |
+
f.writelines(r.model_dump_json() + "\n" for r in errors)
|
| 176 |
+
results = [r for r in results if r.status != "error"]
|
| 177 |
+
results_path.write_text("".join(r.model_dump_json() + "\n" for r in results), encoding="utf-8")
|
| 178 |
+
print(f"[resume] {len(errors)} pares con status=error se reintentan (movidos a errors.jsonl)", flush=True)
|
| 179 |
+
done = {(r.config, r.problem_id) for r in results}
|
| 180 |
+
if done:
|
| 181 |
+
print(f"[resume] {len(done)} resultados previos en {results_path}", flush=True)
|
| 182 |
+
|
| 183 |
+
llm = LLM(section="llm")
|
| 184 |
+
agents = Agents(llm, base, trace_dir=str(out / "traces"))
|
| 185 |
+
retriever = Retriever(db_path=db_path)
|
| 186 |
+
retriever.trace_dir = str(out / "traces")
|
| 187 |
+
checker = LeanChecker(imports=base["lean"]["imports"], timeout_s=base["lean"]["timeout_s"]) if base["lean"].get("backend", "repl") == "repl" else None
|
| 188 |
+
prover = local_prover(base.get("prover") or {})
|
| 189 |
+
needs = [n for n in config_names if (c := effective_config(base, n))["mode"] == "baseline_prover" or any(c["prover"].get(x) for x in ("dsp", "repair", "examples"))]
|
| 190 |
+
if needs and prover is None: # sin demostrador estas configuraciones degradarían en silencio (fallos falsos o el bucle por API)
|
| 191 |
+
raise SystemExit(f"[prover] {needs} necesitan el demostrador local y no responde: `bash scripts/prover_server.sh start`")
|
| 192 |
+
drafter = drafter_agents(base, str(out / "traces"))
|
| 193 |
+
orchestrator = Orchestrator(agents, retriever, checker, prover, drafter)
|
| 194 |
+
if checker is not None: # arranca el REPL (`import Mathlib`, minutos) fuera del reloj de los problemas
|
| 195 |
+
checker.check("theorem _warmup : True := by sorry", "trivial")
|
| 196 |
+
if getattr(retriever, "versions", None): # carga el embedder antes de los hilos (los fakes de los tests no lo tienen)
|
| 197 |
+
embed_queries(["warmup"], model_name=retriever.versions["embedding"]["model"])
|
| 198 |
+
if checker is not None and getattr(retriever, "versions", None):
|
| 199 |
+
check_index_consistency(checker, retriever)
|
| 200 |
+
|
| 201 |
+
def solve_pair(name: str, problem: Problem) -> SolutionReport:
|
| 202 |
+
cfg = effective_config(base, name)
|
| 203 |
+
pid = f"{run_id}:{name}:{problem.problem_id}"
|
| 204 |
+
tp = trace_path(pid, out / "traces")
|
| 205 |
+
if tp.exists(): # intento anterior interrumpido (sin fila en results.jsonl): se aparta para no mezclar eventos
|
| 206 |
+
(out / "traces" / "interrupted").mkdir(exist_ok=True)
|
| 207 |
+
tp.rename(out / "traces" / "interrupted" / f"{tp.stem}.{int(time.time())}.jsonl")
|
| 208 |
+
if cfg["mode"] == "baseline_auto":
|
| 209 |
+
return run_auto(problem, cfg, checker, agents, pid)
|
| 210 |
+
if cfg["mode"] == "baseline_prover":
|
| 211 |
+
return run_prover_only(problem, cfg, checker, prover, agents, pid)
|
| 212 |
+
if cfg["mode"].startswith("baseline"):
|
| 213 |
+
try:
|
| 214 |
+
return run_baseline(cfg["mode"], problem, cfg, agents, pid, checker)
|
| 215 |
+
except DailyQuotaExhausted:
|
| 216 |
+
raise
|
| 217 |
+
except Exception as e: # una línea base que explota no puede tumbar la corrida entera
|
| 218 |
+
# ACC-1: si es InvalidLLMOutput, .model existe (el modelo que produjo la salida rota);
|
| 219 |
+
# antes esta fila quedaba con `models=[]`, sin poder saber qué modelo falló.
|
| 220 |
+
models = [e.model] if isinstance(e, InvalidLLMOutput) and getattr(e, "model", None) else []
|
| 221 |
+
return SolutionReport(problem_id=problem.problem_id, config=cfg["name"], status="fail" if isinstance(e, InvalidLLMOutput) else "error", attempts=1, llm_calls=getattr(e, "calls", 0), tokens_in=getattr(e, "tokens_in", 0), tokens_out=getattr(e, "tokens_out", 0), models=models, error_types=[f"llm_output_invalid:{e.agent}" if isinstance(e, InvalidLLMOutput) else f"exception: {type(e).__name__}: {str(e)[:300]}"])
|
| 222 |
+
return orchestrator.solve(problem, cfg, pid)
|
| 223 |
+
|
| 224 |
+
pairs = [(n, p) for n in config_names for p in problems if (n, p.problem_id) not in done]
|
| 225 |
+
status = "completed"
|
| 226 |
+
with results_path.open("a", encoding="utf-8") as f, ThreadPoolExecutor(max_workers=workers) as pool:
|
| 227 |
+
futures = {pool.submit(solve_pair, n, p): (n, p) for n, p in pairs}
|
| 228 |
+
try:
|
| 229 |
+
for fut in as_completed(futures):
|
| 230 |
+
name, problem = futures[fut]
|
| 231 |
+
rep = fut.result()
|
| 232 |
+
results.append(rep)
|
| 233 |
+
f.write(rep.model_dump_json() + "\n")
|
| 234 |
+
f.flush()
|
| 235 |
+
print(f"[{name}] {problem.problem_id}: {rep.status} attempts={rep.attempts} llm_calls={rep.llm_calls} used_in_proof={rep.n_used_in_proof}/{rep.n_retrieved} {rep.wall_seconds}s", flush=True)
|
| 236 |
+
except DailyQuotaExhausted as e:
|
| 237 |
+
status = "quota_exhausted"
|
| 238 |
+
print(f"[stop] cuota diaria agotada en todos los modelos ({e}); reanudar mañana con --run-id {run_id}", flush=True)
|
| 239 |
+
for fu in futures:
|
| 240 |
+
fu.cancel()
|
| 241 |
+
except ProverUnavailable as e:
|
| 242 |
+
status = "prover_down"
|
| 243 |
+
print(f"[stop] el demostrador local dejó de responder ({e}); `bash scripts/prover_server.sh start` y reanudar con --run-id {run_id}", flush=True)
|
| 244 |
+
for fu in futures:
|
| 245 |
+
fu.cancel()
|
| 246 |
+
if checker is not None:
|
| 247 |
+
checker.close()
|
| 248 |
+
llms = [llm] + ([drafter.llm] if drafter is not None else [])
|
| 249 |
+
calls, cost = sum(x.calls for x in llms), sum(getattr(x, "cost_usd", 0.0) for x in llms)
|
| 250 |
+
print(f"[cost] {calls} llamadas, {sum(x.tokens_in for x in llms)} tokens de entrada, {sum(x.tokens_out for x in llms)} de salida, US${cost:.4f} (OpenRouter usage.cost)", flush=True)
|
| 251 |
+
summary = summarize(results)
|
| 252 |
+
(out / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 253 |
+
pending = [(n, p.problem_id) for n in config_names for p in problems if (n, p.problem_id) not in {(r.config, r.problem_id) for r in results}]
|
| 254 |
+
return {"run_id": run_id, "out": str(out), "status": status, "n_done": len(results), "n_pending": len(pending), "summary": summary, "llm_calls_total": calls, "cost_usd_total": round(cost, 4), "models_exhausted": llm.exhausted}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def summarize(results: list[SolutionReport]) -> dict:
|
| 258 |
+
by_cfg: dict[str, list[SolutionReport]] = {}
|
| 259 |
+
for r in results:
|
| 260 |
+
by_cfg.setdefault(r.config, []).append(r)
|
| 261 |
+
summary = {}
|
| 262 |
+
for name, rs in by_cfg.items():
|
| 263 |
+
n = len(rs)
|
| 264 |
+
solved = sum(r.status == "done" for r in rs)
|
| 265 |
+
retrieved = sum(r.n_retrieved for r in rs)
|
| 266 |
+
summary[name] = {
|
| 267 |
+
"n": n,
|
| 268 |
+
"pass@1": round(solved / n, 3) if n else None,
|
| 269 |
+
"pass@1_wilson95": wilson(solved, n),
|
| 270 |
+
"llm_calls_mean": round(sum(r.llm_calls for r in rs) / n, 2) if n else None,
|
| 271 |
+
"tokens_mean": round(sum(r.tokens_in + r.tokens_out for r in rs) / n) if n else None,
|
| 272 |
+
"wall_s_median": sorted(r.wall_seconds for r in rs)[n // 2] if n else None,
|
| 273 |
+
"retrieval_calls_mean": round(sum(r.retrieval_calls for r in rs) / n, 2) if n else None,
|
| 274 |
+
"llm_calls_mean_ci95_bootstrap": bootstrap_mean_ci([r.llm_calls for r in rs]),
|
| 275 |
+
"tokens_mean_ci95_bootstrap": bootstrap_mean_ci([r.tokens_in + r.tokens_out for r in rs]),
|
| 276 |
+
"wall_s_mean_ci95_bootstrap": bootstrap_mean_ci([r.wall_seconds for r in rs]),
|
| 277 |
+
"pass_by_attempt": pass_by_attempt(rs), # §8.3 robustez del refinamiento: acumulado por intento
|
| 278 |
+
"lemma_use_precision": round(sum(r.n_used_in_proof for r in rs) / retrieved, 3) if retrieved else None,
|
| 279 |
+
"re_retrieval_success": round(sum(r.re_retrievals_ok for r in rs) / sum(r.re_retrievals for r in rs), 3) if sum(r.re_retrievals for r in rs) else None,
|
| 280 |
+
"error_taxonomy": _count_errors(rs),
|
| 281 |
+
"self_assessed_correct": sum(1 for r in rs if r.self_assessed_correct) if any(r.self_assessed_correct is not None for r in rs) else None,
|
| 282 |
+
}
|
| 283 |
+
return summary
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _count_errors(rs: list[SolutionReport]) -> dict:
|
| 287 |
+
counts: dict[str, int] = {}
|
| 288 |
+
for r in rs:
|
| 289 |
+
for e in r.error_types:
|
| 290 |
+
counts[e] = counts.get(e, 0) + 1
|
| 291 |
+
return counts
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def pass_by_attempt(rs: list[SolutionReport]) -> dict[str, float]:
|
| 295 |
+
"""Fracción resuelta con ≤ i intentos (la ganancia marginal es la diferencia entre columnas)."""
|
| 296 |
+
n = len(rs)
|
| 297 |
+
k_max = max((r.attempts for r in rs), default=1)
|
| 298 |
+
return {str(i): round(sum(1 for r in rs if r.status == "done" and r.attempts <= i) / n, 3) for i in range(1, k_max + 1)} if n else {}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def bootstrap_mean_ci(xs: list[float], n_boot: int = 1000, seed: int = 13) -> list[float] | None:
|
| 302 |
+
"""IC 95 % bootstrap de la media (propuesta §8.4: costo y latencia)."""
|
| 303 |
+
import random
|
| 304 |
+
|
| 305 |
+
if not xs:
|
| 306 |
+
return None
|
| 307 |
+
rng = random.Random(seed)
|
| 308 |
+
means = sorted(sum(rng.choice(xs) for _ in xs) / len(xs) for _ in range(n_boot))
|
| 309 |
+
return [round(means[int(0.025 * n_boot)], 2), round(means[int(0.975 * n_boot) - 1], 2)]
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def wilson(k: int, n: int, z: float = 1.96) -> list[float] | None:
|
| 313 |
+
if not n:
|
| 314 |
+
return None
|
| 315 |
+
p = k / n
|
| 316 |
+
d = 1 + z * z / n
|
| 317 |
+
c = (p + z * z / (2 * n)) / d
|
| 318 |
+
h = z * ((p * (1 - p) / n + z * z / (4 * n * n)) ** 0.5) / d
|
| 319 |
+
return [round(max(0.0, c - h), 3), round(min(1.0, c + h), 3)]
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
if __name__ == "__main__":
|
| 323 |
+
ap = argparse.ArgumentParser()
|
| 324 |
+
ap.add_argument("--problems", required=True)
|
| 325 |
+
ap.add_argument("--configs", default="full")
|
| 326 |
+
ap.add_argument("--out", default="data/eval/runs")
|
| 327 |
+
ap.add_argument("--run-id", default=None)
|
| 328 |
+
ap.add_argument("--workers", type=int, default=1)
|
| 329 |
+
args = ap.parse_args()
|
| 330 |
+
res = run_suite(load_problems(args.problems), args.configs.split(","), args.out, run_id=args.run_id, workers=args.workers)
|
| 331 |
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
ragsolver/prover.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demostrador especializado en Lean servido en la GPU local (docs/PLAN_REDISENO_PROVER.md).
|
| 2 |
+
|
| 3 |
+
Goedel-Prover-V2-8B (Apache-2.0; 84.6 % pass@32 en miniF2F según su paper) vía `llama-server`
|
| 4 |
+
(OpenAI-compatible, Vulkan). Genera k pruebas en paralelo; de cada una se toma SÓLO el bloque de
|
| 5 |
+
tácticas del teorema pedido y se empalma en el enunciado del problema (el modelo nunca puede debilitar
|
| 6 |
+
el enunciado); cada candidato pasa por `LeanChecker.check` (validador + kernel + axiomas).
|
| 7 |
+
|
| 8 |
+
Autocorrección: el modelo se entrenó con Lean 4.9 y su Mathlib; el verificador usa v4.28, así que usa
|
| 9 |
+
nombres renombrados o borrados. Ante `unknown identifier/constant`, el RAG busca declaraciones parecidas
|
| 10 |
+
QUE EXISTEN en el entorno (índice + `#check`) y se las da al modelo junto con los errores de Lean.
|
| 11 |
+
Costo de API: 0 (todo local).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import hashlib
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import textwrap
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import httpx
|
| 27 |
+
|
| 28 |
+
PROMPT = """Complete the following Lean 4 code:
|
| 29 |
+
|
| 30 |
+
```lean4
|
| 31 |
+
{}```
|
| 32 |
+
|
| 33 |
+
Before producing the Lean 4 code to formally prove the given theorem, provide a detailed proof plan outlining the main proof steps and strategies.
|
| 34 |
+
The plan should highlight key ideas, intermediate lemmas, and proof structures that will guide the construction of the final formal proof."""
|
| 35 |
+
|
| 36 |
+
_FENCE_RE = re.compile(r"```(?:lean4|lean)?[ \t]*\n(.*?)```", re.S)
|
| 37 |
+
_UNKNOWN_RE = re.compile(r"[Uu]nknown (?:identifier|constant) [`']([^`']+)[`']")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def prover_url() -> str:
|
| 41 |
+
"""`RAGSOLVER_PROVER_URL` o el `llama-server` de Windows en el gateway de WSL, puerto 8082."""
|
| 42 |
+
if os.environ.get("RAGSOLVER_PROVER_URL"):
|
| 43 |
+
return os.environ["RAGSOLVER_PROVER_URL"].rstrip("/")
|
| 44 |
+
gw = "127.0.0.1"
|
| 45 |
+
try:
|
| 46 |
+
for line in open("/proc/net/route"):
|
| 47 |
+
f = line.split()
|
| 48 |
+
if f[1] == "00000000":
|
| 49 |
+
gw = ".".join(str(int(f[2][i : i + 2], 16)) for i in (6, 4, 2, 0))
|
| 50 |
+
break
|
| 51 |
+
except OSError:
|
| 52 |
+
pass
|
| 53 |
+
return f"http://{gw}:8082"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def lean_input(formal_statement: str, header: str) -> str:
|
| 57 |
+
"""El archivo que espera el modelo: imports, opens y el teorema con `sorry`."""
|
| 58 |
+
stmt = re.sub(r":=\s*by\s+sorry\s*$|:=\s*sorry\s*$", "", formal_statement.rstrip()).rstrip()
|
| 59 |
+
opens = "\n".join(l for l in header.splitlines() if l.strip() and not l.strip().startswith("import"))
|
| 60 |
+
return f"import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 400000\n\n{opens}\n\n{stmt} := by\n sorry"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def extract_tactics(output: str, theorem_name: str) -> str | None:
|
| 64 |
+
"""Tácticas del teorema `theorem_name` en el último bloque de código de la salida, o None.
|
| 65 |
+
Se descarta todo lo demás (enunciado, lemas auxiliares, comandos): el enunciado lo pone el problema."""
|
| 66 |
+
for block in reversed(_FENCE_RE.findall(output)):
|
| 67 |
+
m = re.search(rf"(?:theorem|lemma)\s+{re.escape(theorem_name)}\b.*?:=\s*by[ \t]*\n(.*)", block, re.S)
|
| 68 |
+
if not m:
|
| 69 |
+
continue
|
| 70 |
+
lines = m.group(1).splitlines()
|
| 71 |
+
body = []
|
| 72 |
+
for line in lines: # el cuerpo termina en la primera línea sin sangría (otra declaración, comando)
|
| 73 |
+
if line.strip() and not line[0].isspace():
|
| 74 |
+
break
|
| 75 |
+
body.append(line)
|
| 76 |
+
tactics = "\n".join(body).strip("\n")
|
| 77 |
+
if tactics.strip():
|
| 78 |
+
from textwrap import dedent
|
| 79 |
+
|
| 80 |
+
return dedent(tactics)
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def unknown_names(errors: list[dict]) -> list[str]:
|
| 85 |
+
return list(dict.fromkeys(n for e in errors for n in _UNKNOWN_RE.findall(e.get("message", ""))))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ProverUnavailable(RuntimeError):
|
| 89 |
+
"""El servidor del demostrador dejó de responder: el runner corta la corrida (como con la cuota diaria)."""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def local_prover(pcfg: dict) -> LocalProver | None:
|
| 93 |
+
"""El demostrador si `prover.enabled` y el servidor responde; si no, None (el sistema sigue sin él)."""
|
| 94 |
+
if not pcfg.get("enabled"):
|
| 95 |
+
return None
|
| 96 |
+
p = LocalProver(max_tokens=pcfg.get("max_tokens", 12000), temperature=pcfg.get("temperature", 0.8))
|
| 97 |
+
if p.alive():
|
| 98 |
+
print(f"[prover] demostrador local en {p.url}", flush=True)
|
| 99 |
+
return p
|
| 100 |
+
print(f"[prover] AVISO: {p.url} no responde; se corre sin demostrador local", flush=True)
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class LocalProver:
|
| 105 |
+
def __init__(self, url: str | None = None, max_tokens: int = 12000, temperature: float = 0.8, timeout_s: float = 1800, cache_path: str | None = "data/eval/prover_cache.jsonl"):
|
| 106 |
+
self.url = url or prover_url()
|
| 107 |
+
self.max_tokens, self.temperature, self.timeout_s = max_tokens, temperature, timeout_s
|
| 108 |
+
self.client = httpx.Client(timeout=timeout_s)
|
| 109 |
+
self.tokens_out = 0
|
| 110 |
+
self.calls = 0
|
| 111 |
+
# caché en disco por (mensajes, semilla, parámetros): dos configuraciones que piden la misma muestra
|
| 112 |
+
# (p.ej. `baseline_prover` y la etapa 0 de `full`) la generan una sola vez, y quedan pareadas
|
| 113 |
+
self.cache_path = Path(cache_path) if cache_path else None
|
| 114 |
+
self._cache: dict[str, str] = {}
|
| 115 |
+
self._lock = threading.Lock()
|
| 116 |
+
if self.cache_path and self.cache_path.exists():
|
| 117 |
+
for line in self.cache_path.open(encoding="utf-8"):
|
| 118 |
+
if line.strip():
|
| 119 |
+
d = json.loads(line)
|
| 120 |
+
self._cache[d["key"]] = d["out"]
|
| 121 |
+
|
| 122 |
+
def alive(self) -> bool:
|
| 123 |
+
try:
|
| 124 |
+
return self.client.get(f"{self.url}/health", timeout=5).status_code == 200
|
| 125 |
+
except httpx.HTTPError:
|
| 126 |
+
return False
|
| 127 |
+
|
| 128 |
+
def _chat(self, messages: list[dict], seed: int) -> str:
|
| 129 |
+
key = hashlib.sha256(json.dumps([messages, seed, self.max_tokens, self.temperature], ensure_ascii=False).encode()).hexdigest()
|
| 130 |
+
if key in self._cache:
|
| 131 |
+
return self._cache[key]
|
| 132 |
+
r = self.client.post(f"{self.url}/v1/chat/completions", json={"messages": messages, "max_tokens": self.max_tokens, "temperature": self.temperature, "top_p": 0.95, "seed": seed})
|
| 133 |
+
r.raise_for_status()
|
| 134 |
+
d = r.json()
|
| 135 |
+
self.calls += 1
|
| 136 |
+
self.tokens_out += (d.get("usage") or {}).get("completion_tokens", 0)
|
| 137 |
+
msg = d["choices"][0]["message"]
|
| 138 |
+
# el razonamiento (si el servidor lo separa) va ANTES: la extracción toma el último bloque de código
|
| 139 |
+
out = ((msg.get("reasoning_content") or "") + "\n" + (msg.get("content") or "")).strip()
|
| 140 |
+
with self._lock:
|
| 141 |
+
self._cache[key] = out
|
| 142 |
+
if self.cache_path:
|
| 143 |
+
with self.cache_path.open("a", encoding="utf-8") as f:
|
| 144 |
+
f.write(json.dumps({"key": key, "out": out}, ensure_ascii=False) + "\n")
|
| 145 |
+
return out
|
| 146 |
+
|
| 147 |
+
def sample(self, messages: list[dict], k: int, seed0: int = 13) -> list[str]:
|
| 148 |
+
with ThreadPoolExecutor(max_workers=k) as pool:
|
| 149 |
+
outs = list(pool.map(lambda i: self._safe_chat(messages, seed0 + i), range(k)))
|
| 150 |
+
# una muestra que falla se tolera; todas fallando con el servidor caído no son "muestras sin prueba":
|
| 151 |
+
# la corrida se detiene y se reanuda (eval-v4: el servidor murió y 22 problemas quedaron como fallos falsos)
|
| 152 |
+
if outs and all(o.startswith("(prover error") for o in outs) and not self.alive():
|
| 153 |
+
raise ProverUnavailable(f"{self.url} no responde")
|
| 154 |
+
return outs
|
| 155 |
+
|
| 156 |
+
def _safe_chat(self, messages: list[dict], seed: int) -> str:
|
| 157 |
+
try:
|
| 158 |
+
return self._chat(messages, seed)
|
| 159 |
+
except httpx.HTTPError as e:
|
| 160 |
+
return f"(prover error: {type(e).__name__})"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def name_suggestions(names: list[str], checker, retriever=None, header: str = "", k: int = 4) -> tuple[str, dict[str, str]]:
|
| 164 |
+
"""RAG para la autocorrección: por cada nombre desconocido, declaraciones parecidas que SÍ existen en
|
| 165 |
+
el entorno de Lean del verificador (búsqueda en el índice por los tokens del nombre + `#check`)."""
|
| 166 |
+
lines, suggested = [], {}
|
| 167 |
+
for n in names[:4]:
|
| 168 |
+
cands: list[str] = []
|
| 169 |
+
if retriever is not None:
|
| 170 |
+
from ragsolver.rag.schema import RetrievalRequest
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
res = retriever.search(RetrievalRequest(queries=[f"unknown identifier '{n}'"], mode="error", corpora=["mathlib"], k=k * 2, n_candidates=50, rerank=False, include_figures=False, context={"problem_id": "", "agent": "prover"}))
|
| 174 |
+
cands = [h.metadata.get("decl_name") for h in res.hits if h.metadata.get("decl_name")]
|
| 175 |
+
except Exception:
|
| 176 |
+
cands = []
|
| 177 |
+
sigs = checker.signatures(cands[: k * 2], header=header) if cands else {}
|
| 178 |
+
suggested.update(list(sigs.items())[:k])
|
| 179 |
+
if sigs:
|
| 180 |
+
lines.append(f"`{n}` does not exist in this Mathlib version. Existing declarations with similar names:\n" + "\n".join(f" {c} : {t[:200]}" for c, t in list(sigs.items())[:k]))
|
| 181 |
+
else:
|
| 182 |
+
lines.append(f"`{n}` does not exist in this Mathlib version.")
|
| 183 |
+
return "\n".join(lines), suggested
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def draft_hint(strategy: str, steps: list[str], sketch: str, lemmas: dict[str, str], max_sketch: int = 4000) -> str:
|
| 187 |
+
"""Draft-Sketch-Prove: el plan y la prueba informal del modelo de la API, más los lemas que recuperó el
|
| 188 |
+
RAG (que existen en el Mathlib fijado), como guía dentro del prompt del demostrador."""
|
| 189 |
+
parts = ["Here is an informal proof written by another model. It may contain mistakes: use it as guidance, not as ground truth.", f"Strategy: {strategy}"]
|
| 190 |
+
parts += [f"{i}. {s}" for i, s in enumerate(steps, 1)]
|
| 191 |
+
parts.append(f"Informal proof:\n{sketch[:max_sketch]}")
|
| 192 |
+
if lemmas:
|
| 193 |
+
parts.append("Mathlib lemmas that may be useful (they exist in the installed Mathlib version):\n" + "\n".join(f" {n} : {t[:200]}" for n, t in lemmas.items()))
|
| 194 |
+
return "\n\n".join(parts)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def examples_hint(hits: list[dict], max_proof: int = 1500) -> str:
|
| 198 |
+
"""RAG de pruebas (`rag/proofs.py`): pruebas verificadas de problemas parecidos como ejemplos en el prompt."""
|
| 199 |
+
blocks = [f"```lean4\n{h['statement']} := by\n{textwrap.indent(h['proof'][:max_proof], ' ')}\n```" for h in hits]
|
| 200 |
+
return "Verified Lean 4 proofs of similar problems (older Mathlib: some names may have changed). Reuse their techniques and hints where they fit:\n\n" + "\n\n".join(blocks)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def prove(prover: LocalProver, checker, formal_statement: str, header: str, theorem_name: str, k: int = 4, correction_rounds: int = 1, retriever=None, max_heartbeats: int = 400000, batch: int = 4, hint: str = "") -> tuple[str | None, dict]:
|
| 204 |
+
"""k muestras en lotes de `batch` en paralelo, con salida temprana: la primera que pasa `check` gana
|
| 205 |
+
(los problemas fáciles no pagan las k). Si ninguna, una ronda de autocorrección sobre las (hasta) 2
|
| 206 |
+
muestras con tácticas extraíbles: errores de Lean + sugerencias de nombres (RAG, si `retriever`).
|
| 207 |
+
`hint` (Draft-Sketch-Prove, `draft_hint`) se agrega al prompt; el prompt cambia, así que sus muestras
|
| 208 |
+
no comparten caché con las de la etapa 0."""
|
| 209 |
+
t0 = time.time()
|
| 210 |
+
user = PROMPT.format(lean_input(formal_statement, header)) + (f"\n\n{hint}" if hint else "")
|
| 211 |
+
log = {"samples": 0, "extracted": 0, "rounds": 0, "corrections_with_rag": 0, "suggested": {}}
|
| 212 |
+
pending, failed = [], [] # failed: todo candidato extraíble que Lean rechazó, para REPAIR (graph.py)
|
| 213 |
+
for start in range(0, k, batch):
|
| 214 |
+
outs = prover.sample([{"role": "user", "content": user}], min(batch, k - start), seed0=13 + start)
|
| 215 |
+
log["samples"] += len(outs) # generadas (trabajo de GPU), no sólo las revisadas antes del éxito
|
| 216 |
+
for out in outs:
|
| 217 |
+
tac = extract_tactics(out, theorem_name)
|
| 218 |
+
if not tac:
|
| 219 |
+
continue
|
| 220 |
+
log["extracted"] += 1
|
| 221 |
+
v = checker.check(formal_statement, tac, header, max_heartbeats)
|
| 222 |
+
if v.ok:
|
| 223 |
+
log["seconds"] = round(time.time() - t0, 1)
|
| 224 |
+
return tac, {**log, "solved_in": "sample"}
|
| 225 |
+
pending.append((out, tac, v))
|
| 226 |
+
failed.append((tac, v))
|
| 227 |
+
for rnd in range(correction_rounds):
|
| 228 |
+
if not pending:
|
| 229 |
+
break
|
| 230 |
+
log["rounds"] += 1
|
| 231 |
+
nxt = []
|
| 232 |
+
for out, tac, v in pending[:2]:
|
| 233 |
+
err = "\n".join(f"line {e['line']}: {e['message'][:400]}" for e in v.errors[:6])
|
| 234 |
+
names = unknown_names(v.errors)
|
| 235 |
+
hint, sugg = name_suggestions(names, checker, retriever, header) if names else ("", {})
|
| 236 |
+
log["suggested"].update(sugg)
|
| 237 |
+
log["corrections_with_rag"] += bool(sugg)
|
| 238 |
+
fix = f"The proof failed to compile in Lean 4 (recent Mathlib). Errors:\n{err}\n" + (f"\n{hint}\n" if hint else "") + "\nFix the proof and output the complete corrected Lean 4 code."
|
| 239 |
+
answer = out.rsplit("</think>", 1)[-1][-6000:] # la respuesta final, sin el razonamiento
|
| 240 |
+
outs2 = prover.sample([{"role": "user", "content": user}, {"role": "assistant", "content": answer}, {"role": "user", "content": fix}], 1, seed0=100 + rnd)
|
| 241 |
+
tac2 = extract_tactics(outs2[0], theorem_name)
|
| 242 |
+
if not tac2:
|
| 243 |
+
continue
|
| 244 |
+
v2 = checker.check(formal_statement, tac2, header, max_heartbeats)
|
| 245 |
+
if v2.ok:
|
| 246 |
+
log["seconds"] = round(time.time() - t0, 1)
|
| 247 |
+
return tac2, {**log, "solved_in": f"correction{rnd + 1}"}
|
| 248 |
+
nxt.append((outs2[0], tac2, v2))
|
| 249 |
+
failed.append((tac2, v2))
|
| 250 |
+
pending = nxt
|
| 251 |
+
log["seconds"] = round(time.time() - t0, 1)
|
| 252 |
+
# casi-pruebas para REPAIR, menos errores primero: Lean sigue elaborando tras un `have` que falla, así que
|
| 253 |
+
# ≤ 2 errores ≈ ≤ 2 pasos malos; con más, reparar paso a paso cuesta horas de GPU (smoke 2026-10-21)
|
| 254 |
+
log["candidates"] = [t for t, v in sorted(failed, key=lambda x: len(x[1].errors)) if len(v.errors) <= 2]
|
| 255 |
+
return None, log
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
_DAGGER_RE = re.compile(r"([A-Za-z_][\w']*)✝(\d*)")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def goal_to_theorem(goal: str, name: str) -> str | None:
|
| 262 |
+
"""Un goal pretty-printed del REPL ("a b : ℝ\\nh : 0 < a\\n⊢ T") como teorema autónomo, para pedírselo
|
| 263 |
+
al demostrador (receta de DeepSeek-Prover-V2: cada subobjetivo como lema). Los nombres inaccesibles
|
| 264 |
+
(`x✝`) se renombran; las instancias (`inst✝ : Fact p`) pasan a `[Fact p]`. None si no se puede."""
|
| 265 |
+
lines = [l for l in goal.splitlines() if l.strip() and not l.startswith("case ")]
|
| 266 |
+
if not lines or "⊢" not in goal:
|
| 267 |
+
return None
|
| 268 |
+
hyps, target = [], None
|
| 269 |
+
for i, l in enumerate(lines):
|
| 270 |
+
if l.startswith("⊢"):
|
| 271 |
+
target = " ".join([l[1:].strip()] + [x.strip() for x in lines[i + 1 :]])
|
| 272 |
+
break
|
| 273 |
+
if l[0].isspace() and hyps: # continuación de la hipótesis anterior
|
| 274 |
+
hyps[-1] += " " + l.strip()
|
| 275 |
+
else:
|
| 276 |
+
hyps.append(l.strip())
|
| 277 |
+
if target is None:
|
| 278 |
+
return None
|
| 279 |
+
binders = []
|
| 280 |
+
for h in hyps:
|
| 281 |
+
if " : " not in h:
|
| 282 |
+
return None
|
| 283 |
+
names, typ = h.split(" : ", 1)
|
| 284 |
+
if all(n.startswith("inst✝") for n in names.split()):
|
| 285 |
+
binders.append(f"[{typ}]")
|
| 286 |
+
else:
|
| 287 |
+
binders.append(f"({names} : {typ})")
|
| 288 |
+
stmt = f"theorem {name} " + " ".join(binders) + f" : {target} := by sorry"
|
| 289 |
+
return _DAGGER_RE.sub(lambda m: f"{m.group(1)}_{m.group(2) or 0}", stmt)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def renamed_inaccessible(goal: str) -> list[str]:
|
| 293 |
+
"""Nombres que `goal_to_theorem` dio a las hipótesis inaccesibles (no instancias), en orden: un
|
| 294 |
+
bloque probado para el lema autónomo funciona en el contexto original con `rename_i <estos>` delante."""
|
| 295 |
+
names = []
|
| 296 |
+
for l in goal.split("⊢", 1)[0].splitlines():
|
| 297 |
+
if " : " in l and not l[0].isspace():
|
| 298 |
+
for n in l.split(" : ", 1)[0].split():
|
| 299 |
+
m = _DAGGER_RE.fullmatch(n)
|
| 300 |
+
if m and not n.startswith("inst✝"):
|
| 301 |
+
names.append(f"{m.group(1)}_{m.group(2) or 0}")
|
| 302 |
+
return names
|
ragsolver/rag/__init__.py
ADDED
|
File without changes
|
ragsolver/rag/embed.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wrapper de embeddings (Qwen3-Embedding) usado por ambos corpus.
|
| 2 |
+
|
| 3 |
+
docs/PLAN_RAG.md §9.1: un solo modelo para Mathlib e informal. Documentos se
|
| 4 |
+
embeben sin instrucción; las consultas llevan instrucción (recomendación del
|
| 5 |
+
model card de Qwen3-Embedding — mejora retrieval sin tocar el índice).
|
| 6 |
+
|
| 7 |
+
Backends (`RAGSOLVER_EMBED_BACKEND`):
|
| 8 |
+
- `torch` (default): sentence-transformers en CPU (bf16), ~5 textos/s.
|
| 9 |
+
- `llama`: `llama-server` (llama.cpp, Vulkan) corriendo del lado de Windows con el
|
| 10 |
+
GGUF f16 del mismo modelo — usa la RX 9060 XT esquivando el bug de ROCm en WSL2
|
| 11 |
+
(docs/DECISIONS.md #48): ~48 textos/s, vectores idénticos (cos ≥ 0.9998).
|
| 12 |
+
URL en `RAGSOLVER_EMBED_URL` (default: gateway de WSL, puerto 8081).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import threading
|
| 19 |
+
from functools import lru_cache
|
| 20 |
+
|
| 21 |
+
# Un solo hilo carga y usa el modelo de torch: cargas concurrentes (lru_cache no lo impide) duplican
|
| 22 |
+
# la RAM y dejan el modelo a medio convertir a bf16 ("expected m1 and m2 to have the same dtype")
|
| 23 |
+
_TORCH_LOCK = threading.Lock()
|
| 24 |
+
|
| 25 |
+
QUERY_INSTRUCTION = "Instruct: Given a mathematical query, retrieve relevant Mathlib declarations or worked problems.\nQuery: "
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@lru_cache(maxsize=1)
|
| 29 |
+
def _get_model(model_name: str):
|
| 30 |
+
# Import perezoso: torch/sentence-transformers son pesados y no hacen falta
|
| 31 |
+
# para el resto del módulo (ingesta, esquema, índice sin embeddings).
|
| 32 |
+
import torch
|
| 33 |
+
from sentence_transformers import SentenceTransformer
|
| 34 |
+
|
| 35 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 36 |
+
# ponytail: sin flash_attention_2 (CUDA-only, no compila en ROCm —
|
| 37 |
+
# docs/PLAN_RAG.md §9.4); se usa la implementación de atención por defecto.
|
| 38 |
+
# bfloat16 en CPU: mitad de RAM (1.2 GB vs 2.4 GB para 0.6B); la máquina tiene 7.5 GB
|
| 39 |
+
# y el pipeline de Fase 4 convive con el REPL de Lean (docs/DECISIONS.md #40).
|
| 40 |
+
dtype = torch.bfloat16 if os.environ.get("RAGSOLVER_EMBED_BF16", "1") == "1" else torch.float32
|
| 41 |
+
return SentenceTransformer(model_name, device=device, model_kwargs={"torch_dtype": dtype})
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _backend() -> str:
|
| 45 |
+
return os.environ.get("RAGSOLVER_EMBED_BACKEND", "torch")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _llama_url() -> str:
|
| 49 |
+
url = os.environ.get("RAGSOLVER_EMBED_URL")
|
| 50 |
+
if url:
|
| 51 |
+
return url
|
| 52 |
+
import subprocess
|
| 53 |
+
|
| 54 |
+
gw = subprocess.run(["sh", "-c", "ip route show default | awk '{print $3}'"], capture_output=True, text=True).stdout.strip() or "127.0.0.1"
|
| 55 |
+
return f"http://{gw}:8081/v1/embeddings"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _llama_embed(texts: list[str], batch_size: int = 64, workers: int = 8) -> list[list[float]]:
|
| 59 |
+
"""Lotes concurrentes contra llama-server (8 slots). Textos truncados a 6000 caracteres
|
| 60 |
+
(ventana por slot de 2048 tokens); el servidor normaliza L2."""
|
| 61 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 62 |
+
|
| 63 |
+
import httpx
|
| 64 |
+
|
| 65 |
+
url = _llama_url()
|
| 66 |
+
client = httpx.Client(timeout=900)
|
| 67 |
+
batches = [[t[:6000] for t in texts[i : i + batch_size]] for i in range(0, len(texts), batch_size)]
|
| 68 |
+
|
| 69 |
+
def one(batch):
|
| 70 |
+
for attempt in range(5):
|
| 71 |
+
try:
|
| 72 |
+
r = client.post(url, json={"input": batch})
|
| 73 |
+
r.raise_for_status()
|
| 74 |
+
data = sorted(r.json()["data"], key=lambda d: d["index"])
|
| 75 |
+
return [d["embedding"] for d in data]
|
| 76 |
+
except Exception:
|
| 77 |
+
if attempt == 4:
|
| 78 |
+
raise
|
| 79 |
+
return []
|
| 80 |
+
|
| 81 |
+
with ThreadPoolExecutor(workers) as ex:
|
| 82 |
+
out = []
|
| 83 |
+
for vecs in ex.map(one, batches):
|
| 84 |
+
out.extend(vecs)
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def embed_documents(texts: list[str], model_name: str, batch_size: int = 32) -> list[list[float]]:
|
| 89 |
+
"""Embebe texto de documento (sin instrucción). Usado en la ingesta (offline)."""
|
| 90 |
+
if _backend() == "llama":
|
| 91 |
+
return _llama_embed(texts)
|
| 92 |
+
with _TORCH_LOCK:
|
| 93 |
+
model = _get_model(model_name)
|
| 94 |
+
vectors = model.encode(texts, batch_size=batch_size, show_progress_bar=False, normalize_embeddings=True)
|
| 95 |
+
return vectors.tolist()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def embed_queries(texts: list[str], model_name: str, batch_size: int = 8, instruction: str | None = None) -> list[list[float]]:
|
| 99 |
+
"""Embebe texto de consulta (con instrucción). Usado por el retriever (online).
|
| 100 |
+
|
| 101 |
+
`instruction`: texto tras "Instruct:" (configs/retrieval.yaml `query_instructions`);
|
| 102 |
+
por defecto la instrucción con la que se midió la Fase 1.
|
| 103 |
+
"""
|
| 104 |
+
prefix = f"Instruct: {instruction}\nQuery: " if instruction else QUERY_INSTRUCTION
|
| 105 |
+
with _QUERY_CACHE_LOCK:
|
| 106 |
+
found = {t: _QUERY_CACHE.get((prefix, t, model_name)) for t in texts}
|
| 107 |
+
missing = [t for t, v in found.items() if v is None]
|
| 108 |
+
if missing:
|
| 109 |
+
if _backend() == "llama":
|
| 110 |
+
vectors_list = _llama_embed([prefix + t for t in missing])
|
| 111 |
+
else:
|
| 112 |
+
with _TORCH_LOCK:
|
| 113 |
+
model = _get_model(model_name)
|
| 114 |
+
vectors_list = model.encode([prefix + t for t in missing], batch_size=batch_size, show_progress_bar=False, normalize_embeddings=True).tolist()
|
| 115 |
+
with _QUERY_CACHE_LOCK: # BUG-6: dict mutado desde varios hilos (--workers) sin lock podía romper la evicción a mitad de iteración
|
| 116 |
+
for t, v in zip(missing, vectors_list):
|
| 117 |
+
found[t] = v
|
| 118 |
+
_QUERY_CACHE[(prefix, t, model_name)] = v
|
| 119 |
+
while len(_QUERY_CACHE) > _QUERY_CACHE_MAX: # ponytail: LRU aproximado (FIFO)
|
| 120 |
+
_QUERY_CACHE.pop(next(iter(_QUERY_CACHE)), None)
|
| 121 |
+
return [found[t] for t in texts]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Caché de embeddings de consulta (docs/PLAN_RAG.md Fase 3, tarea 1): el orquestador
|
| 125 |
+
# repite consultas entre intentos (mismo subobjetivo, re-recuperación tras error).
|
| 126 |
+
_QUERY_CACHE: dict[tuple[str, str, str], list[float]] = {}
|
| 127 |
+
_QUERY_CACHE_MAX = 512
|
| 128 |
+
_QUERY_CACHE_LOCK = threading.Lock()
|
ragsolver/rag/embed_corpus.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embebe un corpus normalizado (data/corpus/*.jsonl) in-place: agrega
|
| 2 |
+
`embedding`, `embedding_model`, `embedding_dim`, `index_version` a cada línea.
|
| 3 |
+
|
| 4 |
+
Genérico para ambos corpus (mathlib/informal) — sólo depende de que cada línea
|
| 5 |
+
tenga un campo `text` (docs/PLAN_RAG.md §9.1: un solo modelo para ambos).
|
| 6 |
+
|
| 7 |
+
Uso: python -m ragsolver.rag.embed_corpus --in data/corpus/mathlib.jsonl --out data/corpus/mathlib_embedded.jsonl
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import yaml
|
| 18 |
+
|
| 19 |
+
from ragsolver.rag.embed import embed_documents
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def run(in_path: str, out_path: str, versions_path: str = "configs/versions.yaml", batch_size: int = 32, chunk: int = 2048) -> dict:
|
| 23 |
+
"""Streaming: lee/embebe/escribe por bloques de `chunk` filas (278k filas × 1024 floats no caben en RAM)."""
|
| 24 |
+
with open(versions_path, encoding="utf-8") as f:
|
| 25 |
+
versions = yaml.safe_load(f)
|
| 26 |
+
model_name = versions["embedding"]["model"]
|
| 27 |
+
index_version = versions["index"]["version"]
|
| 28 |
+
|
| 29 |
+
out = Path(out_path)
|
| 30 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
n, dim, t0 = 0, 0, time.time()
|
| 32 |
+
with open(in_path, encoding="utf-8") as fin, out.open("w", encoding="utf-8") as fout:
|
| 33 |
+
rows: list[dict] = []
|
| 34 |
+
|
| 35 |
+
def flush():
|
| 36 |
+
nonlocal n, dim
|
| 37 |
+
if not rows:
|
| 38 |
+
return
|
| 39 |
+
vectors = embed_documents([r["text"] for r in rows], model_name=model_name, batch_size=batch_size)
|
| 40 |
+
for row, vec in zip(rows, vectors):
|
| 41 |
+
row["embedding"] = vec
|
| 42 |
+
row["embedding_model"] = model_name
|
| 43 |
+
row["embedding_dim"] = len(vec)
|
| 44 |
+
row["index_version"] = index_version
|
| 45 |
+
fout.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 46 |
+
n += len(rows)
|
| 47 |
+
dim = len(vectors[0]) if vectors else dim
|
| 48 |
+
rows.clear()
|
| 49 |
+
print(f" {n} textos, {n / (time.time() - t0):.1f}/s", flush=True)
|
| 50 |
+
|
| 51 |
+
for line in fin:
|
| 52 |
+
if line.strip():
|
| 53 |
+
rows.append(json.loads(line))
|
| 54 |
+
if len(rows) >= chunk:
|
| 55 |
+
flush()
|
| 56 |
+
flush()
|
| 57 |
+
dt = time.time() - t0
|
| 58 |
+
return {"n": n, "seconds": round(dt, 1), "texts_per_sec": round(n / dt, 2) if dt > 0 else None, "model": model_name, "dim": dim, "backend": __import__("os").environ.get("RAGSOLVER_EMBED_BACKEND", "torch"), "out": str(out)}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
ap = argparse.ArgumentParser()
|
| 63 |
+
ap.add_argument("--in", dest="in_path", required=True)
|
| 64 |
+
ap.add_argument("--out", dest="out_path", required=True)
|
| 65 |
+
ap.add_argument("--versions", default="configs/versions.yaml")
|
| 66 |
+
ap.add_argument("--batch-size", type=int, default=32)
|
| 67 |
+
ap.add_argument("--backend", default=None, choices=["torch", "llama"], help="por defecto RAGSOLVER_EMBED_BACKEND o torch")
|
| 68 |
+
args = ap.parse_args()
|
| 69 |
+
if args.backend:
|
| 70 |
+
import os
|
| 71 |
+
|
| 72 |
+
os.environ["RAGSOLVER_EMBED_BACKEND"] = args.backend
|
| 73 |
+
stats = run(args.in_path, args.out_path, args.versions, args.batch_size)
|
| 74 |
+
print(json.dumps(stats, indent=2))
|
ragsolver/rag/enrich.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 2, tarea 6 — enriquecimiento offline con el VLM/LLM (docs/PLAN_RAG.md §6.2).
|
| 2 |
+
|
| 3 |
+
Proveedor: el que diga configs/versions.yaml `vlm_enrichment` (ragsolver.llm.LLM:
|
| 4 |
+
Gemini u OpenAI-compatible). Todas las llamadas son offline (indexación); el
|
| 5 |
+
retriever sigue en 0 llamadas (RNF-3).
|
| 6 |
+
|
| 7 |
+
Operaciones (prompts versionados en configs/ingest.yaml `enrich:`):
|
| 8 |
+
- transcribe_region: Plan B acotado — transcribe una región que MinerU se saltó.
|
| 9 |
+
- describe_figure (6a): recorte + enunciado -> descripción estructurada.
|
| 10 |
+
- qa_formulas (6b): imagen de página + LaTeX extraído -> LaTeX corregido.
|
| 11 |
+
- summarize_card (6c): problem card -> context_summary, math_area, techniques, ...
|
| 12 |
+
- split_solution: casos ambiguos del emparejamiento (numeral perdido por el OCR).
|
| 13 |
+
|
| 14 |
+
Caché JSONL por operación (data/parsed/<doc_id>/enrich/<op>.jsonl) con clave =
|
| 15 |
+
sha256(prompt_version + entradas): re-ejecutar la ingesta no repite llamadas.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import hashlib
|
| 21 |
+
import json
|
| 22 |
+
import re
|
| 23 |
+
import threading
|
| 24 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 25 |
+
from difflib import SequenceMatcher
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import yaml
|
| 29 |
+
|
| 30 |
+
from ragsolver.llm import LLM, DailyQuotaExhausted # noqa: F401 (re-export para tests/llamadores)
|
| 31 |
+
|
| 32 |
+
_FIGURE_SCHEMA = {
|
| 33 |
+
"type": "object",
|
| 34 |
+
"properties": {
|
| 35 |
+
"description": {"type": "string"},
|
| 36 |
+
"labels": {"type": "array", "items": {"type": "string"}},
|
| 37 |
+
"objects": {"type": "array", "items": {"type": "string"}},
|
| 38 |
+
"relations": {"type": "array", "items": {"type": "string"}},
|
| 39 |
+
"given_values": {"type": "array", "items": {"type": "string"}},
|
| 40 |
+
"figure_type": {"type": "string", "enum": ["geometric_diagram", "grid", "plot", "table_image", "other"]},
|
| 41 |
+
},
|
| 42 |
+
"required": ["description", "labels", "objects", "relations", "given_values", "figure_type"],
|
| 43 |
+
}
|
| 44 |
+
_FORMULAS_SCHEMA = {
|
| 45 |
+
"type": "object",
|
| 46 |
+
"properties": {
|
| 47 |
+
"items": {
|
| 48 |
+
"type": "array",
|
| 49 |
+
"items": {
|
| 50 |
+
"type": "object",
|
| 51 |
+
"properties": {"i": {"type": "integer"}, "latex": {"type": "string"}, "changed": {"type": "boolean"}},
|
| 52 |
+
"required": ["i", "latex", "changed"],
|
| 53 |
+
},
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
"required": ["items"],
|
| 57 |
+
}
|
| 58 |
+
_CARD_SCHEMA = {
|
| 59 |
+
"type": "object",
|
| 60 |
+
"properties": {
|
| 61 |
+
"context_summary": {"type": "string"},
|
| 62 |
+
"math_area": {"type": "array", "items": {"type": "string"}},
|
| 63 |
+
"techniques": {"type": "array", "items": {"type": "string"}},
|
| 64 |
+
"theorems_used": {"type": "array", "items": {"type": "string"}},
|
| 65 |
+
"difficulty": {"type": "integer"},
|
| 66 |
+
"keywords": {"type": "array", "items": {"type": "string"}},
|
| 67 |
+
},
|
| 68 |
+
"required": ["context_summary", "math_area", "techniques", "theorems_used", "difficulty", "keywords"],
|
| 69 |
+
}
|
| 70 |
+
_SPLIT_SCHEMA = {"type": "object", "properties": {"first_line": {"type": "string"}}, "required": ["first_line"]}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class Enricher:
|
| 74 |
+
def __init__(self, cache_dir: str | Path, ingest_yaml: str = "configs/ingest.yaml", versions_yaml: str = "configs/versions.yaml", llm: LLM | None = None):
|
| 75 |
+
cfg = yaml.safe_load(open(ingest_yaml, encoding="utf-8"))["enrich"]
|
| 76 |
+
self.llm = llm or LLM(versions_yaml, section="vlm_enrichment")
|
| 77 |
+
self.prompts = cfg
|
| 78 |
+
self.version = str(cfg["prompt_version"])
|
| 79 |
+
self.cache_dir = Path(cache_dir)
|
| 80 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
self._cache: dict[str, dict] = {}
|
| 82 |
+
self._lock = threading.Lock()
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def models(self) -> list[str]:
|
| 86 |
+
return self.llm.models
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def model(self) -> str:
|
| 90 |
+
return self.llm.model
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def exhausted(self) -> list[str]:
|
| 94 |
+
return self.llm.exhausted
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def calls(self) -> int:
|
| 98 |
+
return self.llm.calls
|
| 99 |
+
|
| 100 |
+
# -- caché ---------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
def _cache_file(self, op: str) -> Path:
|
| 103 |
+
return self.cache_dir / f"{op}.jsonl"
|
| 104 |
+
|
| 105 |
+
def _load(self, op: str) -> dict:
|
| 106 |
+
if op not in self._cache:
|
| 107 |
+
self._cache[op] = {}
|
| 108 |
+
f = self._cache_file(op)
|
| 109 |
+
if f.exists():
|
| 110 |
+
for line in f.read_text(encoding="utf-8").splitlines():
|
| 111 |
+
if line.strip():
|
| 112 |
+
rec = json.loads(line)
|
| 113 |
+
self._cache[op][rec["key"]] = (rec["result"], rec.get("model", self.llm.models[0]))
|
| 114 |
+
return self._cache[op]
|
| 115 |
+
|
| 116 |
+
def _key(self, op: str, *parts: str) -> str:
|
| 117 |
+
# El prompt de la operación entra en la clave: editar un prompt invalida sólo su caché.
|
| 118 |
+
# El modelo NO entra: un resultado ya calculado por cualquier modelo de la cadena se reutiliza.
|
| 119 |
+
h = hashlib.sha256(("\x00".join([self.version, op, self.prompts.get(op, ""), *parts])).encode("utf-8"))
|
| 120 |
+
return h.hexdigest()[:24]
|
| 121 |
+
|
| 122 |
+
def _cached(self, op: str, key: str, compute) -> tuple:
|
| 123 |
+
"""Devuelve (resultado, modelo que lo produjo)."""
|
| 124 |
+
with self._lock:
|
| 125 |
+
cache = self._load(op)
|
| 126 |
+
if key in cache:
|
| 127 |
+
return cache[key]
|
| 128 |
+
result, model = compute() # fuera del lock: las llamadas HTTP corren en paralelo
|
| 129 |
+
with self._lock:
|
| 130 |
+
cache[key] = (result, model)
|
| 131 |
+
with self._cache_file(op).open("a", encoding="utf-8") as f:
|
| 132 |
+
f.write(json.dumps({"key": key, "model": model, "result": result}, ensure_ascii=False) + "\n")
|
| 133 |
+
return result, model
|
| 134 |
+
|
| 135 |
+
def map(self, fn, items, workers: int = 3) -> list:
|
| 136 |
+
"""Aplica `fn` a `items` en paralelo (pocos hilos: la cuota es por minuto)."""
|
| 137 |
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
| 138 |
+
return list(ex.map(fn, items))
|
| 139 |
+
|
| 140 |
+
# -- llamadas (delegadas a ragsolver.llm.LLM) --------------------------------
|
| 141 |
+
|
| 142 |
+
def _generate(self, parts: list[dict], schema: dict | None) -> tuple[str, str]:
|
| 143 |
+
"""(texto, modelo). `parts`: [{"text": ...}] y/o [{"image": path}]."""
|
| 144 |
+
prompt = "\n".join(p["text"] for p in parts if "text" in p)
|
| 145 |
+
images = [p["image"] for p in parts if "image" in p]
|
| 146 |
+
r = self.llm.generate(prompt, schema=schema, images=images)
|
| 147 |
+
return r.text, r.model
|
| 148 |
+
|
| 149 |
+
def _json(self, parts: list[dict], schema: dict) -> tuple[dict, str]:
|
| 150 |
+
text, model = self._generate(parts, schema)
|
| 151 |
+
return json.loads(text), model
|
| 152 |
+
|
| 153 |
+
# -- operaciones -----------------------------------------------------------
|
| 154 |
+
|
| 155 |
+
def transcribe_region(self, png_path: str | Path) -> str:
|
| 156 |
+
img = _image_part(png_path)
|
| 157 |
+
key = self._key("transcribe", _sha(png_path))
|
| 158 |
+
text, _ = self._cached("transcribe", key, lambda: (lambda t, m: (t.strip(), m))(*self._generate([{"text": self.prompts["transcribe"]}, img], None)))
|
| 159 |
+
return text
|
| 160 |
+
|
| 161 |
+
def describe_figure(self, png_path: str | Path, statement: str, solution: str = "") -> dict:
|
| 162 |
+
img = _image_part(png_path)
|
| 163 |
+
prompt = f"{self.prompts['figure']}\n\nProblem statement (Spanish):\n{statement}\n\nSolution excerpt (Spanish):\n{solution[:2500]}"
|
| 164 |
+
key = self._key("figure", _sha(png_path), statement, solution[:2500])
|
| 165 |
+
d, model = self._cached("figure", key, lambda: self._json([{"text": prompt}, img], _FIGURE_SCHEMA))
|
| 166 |
+
return {**d, "model": model}
|
| 167 |
+
|
| 168 |
+
def qa_formulas(self, page_png: str | Path, latex_list: list[str], inline: list[bool] | None = None) -> list[dict]:
|
| 169 |
+
"""Devuelve [{i, latex, changed}] alineado con `latex_list`; `inline[i]` marca fórmulas en línea."""
|
| 170 |
+
if not latex_list:
|
| 171 |
+
return []
|
| 172 |
+
inline = inline or [False] * len(latex_list)
|
| 173 |
+
listing = "\n".join(f"[{i}] {s}" for i, s in enumerate(latex_list))
|
| 174 |
+
prompt = f"{self.prompts['formulas']}\n\n{listing}"
|
| 175 |
+
key = self._key("formulas", _sha(page_png), listing)
|
| 176 |
+
res, _ = self._cached("formulas", key, lambda: self._json([{"text": prompt}, _image_part(page_png)], _FORMULAS_SCHEMA))
|
| 177 |
+
out = [{"i": i, "latex": s, "changed": False} for i, s in enumerate(latex_list)]
|
| 178 |
+
for it in res.get("items", []):
|
| 179 |
+
i = it.get("i")
|
| 180 |
+
if not (isinstance(i, int) and 0 <= i < len(out) and it.get("changed")):
|
| 181 |
+
continue
|
| 182 |
+
if _alnum(it["latex"]) == _alnum(latex_list[i]):
|
| 183 |
+
continue # cambio sólo de puntuación/espaciado: ruido, se ignora
|
| 184 |
+
if _acceptable_fix(latex_list[i], it["latex"], inline[i]):
|
| 185 |
+
out[i] = {"i": i, "latex": it["latex"], "changed": True}
|
| 186 |
+
else:
|
| 187 |
+
out[i]["rejected"] = it["latex"]
|
| 188 |
+
return out
|
| 189 |
+
|
| 190 |
+
def summarize_card(self, content_md: str, figure_descriptions: list[str]) -> dict:
|
| 191 |
+
prompt = self.prompts["card"].format(math_areas=", ".join(self.prompts["math_areas"]), techniques=", ".join(self.prompts["techniques"]))
|
| 192 |
+
figs = "\n".join(f"- {d}" for d in figure_descriptions) or "(none)"
|
| 193 |
+
body = f"{prompt}\n\n## Problem and solution (Spanish, Markdown+LaTeX)\n{content_md}\n\n## Figure descriptions\n{figs}"
|
| 194 |
+
key = self._key("card", content_md, figs)
|
| 195 |
+
res, model = self._cached("card", key, lambda: self._json([{"text": body}], _CARD_SCHEMA))
|
| 196 |
+
res = {**res, "model": model}
|
| 197 |
+
res["math_area"] = [a for a in res.get("math_area", []) if a in self.prompts["math_areas"]] or ["other"]
|
| 198 |
+
res["difficulty"] = min(5, max(1, int(res.get("difficulty", 3))))
|
| 199 |
+
return res
|
| 200 |
+
|
| 201 |
+
def split_solution(self, n: int, merged_text: str, statement_prev: str, statement_n: str) -> str:
|
| 202 |
+
prompt = self.prompts["split"].format(prev=n - 1, n=n)
|
| 203 |
+
body = f"{prompt}\n\n## Problem {n-1}\n{statement_prev}\n\n## Problem {n}\n{statement_n}\n\n## Text\n{merged_text}"
|
| 204 |
+
key = self._key("split", str(n), merged_text)
|
| 205 |
+
res, _ = self._cached("split", key, lambda: self._json([{"text": body}], _SPLIT_SCHEMA))
|
| 206 |
+
return res.get("first_line", "").strip()
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _alnum(latex: str) -> str:
|
| 210 |
+
return re.sub(r"[^0-9A-Za-z^_{}]", "", latex)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
_CONTENT_RE = re.compile(r"[0-9+\-=<>/*]|\\(?:frac|sqrt|sum|prod|int|binom|cdot|times|le|ge|leq|geq|neq|equiv|in|pm|infty|angle|underbrace|overbrace)")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _garbage(chunk: str) -> bool:
|
| 217 |
+
"""Trozo de LaTeX que se puede borrar sin perder matemática: envoltorios
|
| 218 |
+
(\\mathrm{...}, \\hat{...}) o texto absorbido por el OCR ("¿Cuál", "y"), sin
|
| 219 |
+
dígitos ni operadores."""
|
| 220 |
+
if _CONTENT_RE.search(chunk):
|
| 221 |
+
return False
|
| 222 |
+
letters = re.sub(r"[^A-Za-z]", "", re.sub(r"\\[A-Za-z]+", "", chunk))
|
| 223 |
+
return len(letters) <= 6
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _canon(latex: str) -> str:
|
| 227 |
+
"""Normaliza diferencias cosméticas antes de comparar (espacios, puntos suspensivos, espaciado LaTeX)."""
|
| 228 |
+
s = re.sub(r"\\[lc]?dots", "...", latex)
|
| 229 |
+
s = re.sub(r"\\[,;!:]|\\q?quad|~", "", s)
|
| 230 |
+
return re.sub(r"\s+", "", s)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _acceptable_fix(old_latex: str, new_latex: str, inline: bool) -> bool:
|
| 234 |
+
"""Medido sobre el PDF de ejemplo (docs/DECISIONS.md #19): el VLM propone ruido de
|
| 235 |
+
puntuación, absorbe texto vecino en fórmulas inline, recorta contenido real cuando
|
| 236 |
+
el OCR fusionó texto+matemática, y a veces devuelve LaTeX destrozado (`\\underset`
|
| 237 |
+
-> `\\raw` con caracteres de control; `\\mathbb{R}` -> `ℒ`). Reglas:
|
| 238 |
+
(a) caracteres de control o no-ASCII nuevos -> rechazo;
|
| 239 |
+
(b) borrado puro -> aceptar sólo si cada trozo borrado es basura/envoltorio;
|
| 240 |
+
(c) otro cambio -> similitud >= 0.8 (arreglo local) y, en inline, no alargar."""
|
| 241 |
+
if re.search(r"[\x00-\x1f]", new_latex) or (set(new_latex) - set(old_latex)) & {c for c in new_latex if ord(c) > 127}:
|
| 242 |
+
return False
|
| 243 |
+
old, new = _canon(old_latex), _canon(new_latex)
|
| 244 |
+
if new == old or _alnum(new) == _alnum(old):
|
| 245 |
+
return False
|
| 246 |
+
ops = SequenceMatcher(None, old, new, autojunk=False).get_opcodes()
|
| 247 |
+
inserted = "".join(new[j1:j2] for tag, _, _, j1, j2 in ops if tag in ("insert", "replace"))
|
| 248 |
+
removed = [old[i1:i2] for tag, i1, i2, _, _ in ops if tag in ("delete", "replace")]
|
| 249 |
+
if not inserted:
|
| 250 |
+
return all(_garbage(chunk) for chunk in removed)
|
| 251 |
+
if inline and len(_alnum(new)) > len(_alnum(old)):
|
| 252 |
+
return False
|
| 253 |
+
return SequenceMatcher(None, old, new, autojunk=False).ratio() >= 0.8
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _sha(path: str | Path) -> str:
|
| 257 |
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:16]
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _image_part(path: str | Path) -> dict:
|
| 261 |
+
return {"image": str(path)}
|
ragsolver/rag/evaluate.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 1, tarea 6 / Fase 3 aceptación — evaluación de retrieval sobre MathlibQR
|
| 2 |
+
(docs/PLAN_RAG.md §10, §11).
|
| 3 |
+
|
| 4 |
+
Compara 4 variantes (denso, BM25, híbrido, híbrido+rerank) con Recall@k/nDCG@10/MRR
|
| 5 |
+
vía `rag/metrics.py` (no `ranx`: dos corridas largas del CLI devolvieron valores
|
| 6 |
+
absurdos, docs/DECISIONS.md #43; `metrics.py` se verifica igual a `ranx` en
|
| 7 |
+
`tests/test_metrics.py`), usando `full_name` de cada fila de MathlibQR como
|
| 8 |
+
documento gold. Las cuatro variantes pasan por `Retriever.search` (la misma
|
| 9 |
+
función que usan los agentes) — sólo cambian `channels` y `rerank`, no hay una
|
| 10 |
+
segunda implementación de la búsqueda.
|
| 11 |
+
|
| 12 |
+
IMPORTANTE: la cobertura (`n_gold_covered_in_table`) depende de la tabla que se
|
| 13 |
+
evalúe. Con la muestra corregida (docs/DECISIONS.md #31-32, `mathlib_sample.jsonl`)
|
| 14 |
+
la cobertura es 83.8 % (793/946); con el corpus completo (`mathlib_full`) el
|
| 15 |
+
recall real baja a ~0.41-0.45 (#56, #68) — el número de la muestra sobreestima
|
| 16 |
+
por el tamaño acotado del pool de candidatos, no sólo por cobertura.
|
| 17 |
+
|
| 18 |
+
Uso: python -m ragsolver.rag.evaluate --qr data/eval/MathlibQR.json [--limit 20]
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import json
|
| 25 |
+
import random
|
| 26 |
+
import tempfile
|
| 27 |
+
|
| 28 |
+
from ragsolver.rag.metrics import evaluate_run
|
| 29 |
+
from ragsolver.rag.retrieve import Retriever
|
| 30 |
+
from ragsolver.rag.schema import RetrievalRequest
|
| 31 |
+
|
| 32 |
+
QUERY_FIELDS = ["q1a_lean", "q1b_latex", "q1c_natural", "q2_slogan", "q3_nickname", "q4_special_case"]
|
| 33 |
+
|
| 34 |
+
VARIANTS = { # nombre -> (canales, rerank)
|
| 35 |
+
"denso": (("dense",), False),
|
| 36 |
+
"bm25": (("sparse",), False),
|
| 37 |
+
"hibrido": (("dense", "sparse"), False),
|
| 38 |
+
"hibrido_rerank": (("dense", "sparse"), True),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _build_queries(qr_path: str, limit: int | None = None, sample: int | None = None, seed: int = 13) -> list[dict]:
|
| 43 |
+
rows = json.load(open(qr_path, encoding="utf-8"))
|
| 44 |
+
queries = []
|
| 45 |
+
for row in rows:
|
| 46 |
+
gold_id = f"mathlib:{row['full_name']}:0"
|
| 47 |
+
for field in QUERY_FIELDS:
|
| 48 |
+
text = row.get(field, "")
|
| 49 |
+
if text:
|
| 50 |
+
queries.append({"query_id": f"{row['id']}:{field}", "text": text, "gold_id": gold_id})
|
| 51 |
+
if sample: # subconjunto aleatorio reproducible (el rerank en CPU no alcanza para las 946)
|
| 52 |
+
queries = random.Random(seed).sample(queries, min(sample, len(queries)))
|
| 53 |
+
return queries[:limit] if limit else queries
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def evaluate_variants(retriever: Retriever, qr_path: str, table_name: str = "mathlib", n_candidates: int = 50, k: int = 10, limit: int | None = None, sample: int | None = None, variants: list[str] | None = None) -> dict:
|
| 57 |
+
queries = _build_queries(qr_path, limit, sample)
|
| 58 |
+
chosen = {name: VARIANTS[name] for name in (variants or VARIANTS)}
|
| 59 |
+
retriever.tables["mathlib"] = table_name # la tabla evaluada sirve el corpus "mathlib"
|
| 60 |
+
table = retriever._table("mathlib")
|
| 61 |
+
all_ids = {r["chunk_id"] for r in table.search().select(["chunk_id"]).limit(table.count_rows()).to_list()}
|
| 62 |
+
n_covered = sum(1 for q in queries if q["gold_id"] in all_ids)
|
| 63 |
+
qrels = {q["query_id"]: {q["gold_id"]: 1} for q in queries}
|
| 64 |
+
|
| 65 |
+
retriever.trace_dir = tempfile.mkdtemp(prefix="ragsolver-eval-") # no ensuciar data/eval/traces
|
| 66 |
+
runs: dict[str, dict] = {name: {} for name in chosen}
|
| 67 |
+
for q in queries:
|
| 68 |
+
for name, (channels, rerank) in chosen.items():
|
| 69 |
+
retriever.channels = channels
|
| 70 |
+
req = RetrievalRequest(queries=[q["text"]], corpora=["mathlib"], k=n_candidates, n_candidates=n_candidates, rerank=rerank, include_figures=False, context={"problem_id": "eval", "agent": "evaluate"})
|
| 71 |
+
hits = retriever.search(req).hits
|
| 72 |
+
runs[name][q["query_id"]] = {h.chunk_id: (h.citation.scores.rerank if rerank else h.citation.scores.fused) for h in hits}
|
| 73 |
+
retriever.channels = ("dense", "sparse")
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"n_queries": len(queries),
|
| 77 |
+
"n_gold_covered_in_table": n_covered,
|
| 78 |
+
"coverage_ratio": round(n_covered / len(queries), 3) if queries else 0.0,
|
| 79 |
+
**{name: evaluate_run(qrels, run, ks=(k,)) for name, run in runs.items()},
|
| 80 |
+
"_runs": runs, # hits crudos por consulta: permiten recomputar las métricas sin repetir búsquedas
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
ap = argparse.ArgumentParser()
|
| 86 |
+
ap.add_argument("--db-path", default="data/index/lancedb")
|
| 87 |
+
ap.add_argument("--table", default="mathlib")
|
| 88 |
+
ap.add_argument("--qr", default="data/eval/MathlibQR.json")
|
| 89 |
+
ap.add_argument("--k", type=int, default=10)
|
| 90 |
+
ap.add_argument("--limit", type=int, default=None, help="evaluar sólo las primeras N consultas")
|
| 91 |
+
ap.add_argument("--sample", type=int, default=None, help="muestra aleatoria de N consultas (semilla fija)")
|
| 92 |
+
ap.add_argument("--variants", default=None, help="p.ej. denso,bm25,hibrido (por defecto las 4)")
|
| 93 |
+
ap.add_argument("--out", default=None, help="JSON con métricas y hits crudos (_runs)")
|
| 94 |
+
args = ap.parse_args()
|
| 95 |
+
out = evaluate_variants(Retriever(db_path=args.db_path), args.qr, args.table, k=args.k, limit=args.limit, sample=args.sample, variants=args.variants.split(",") if args.variants else None)
|
| 96 |
+
if args.out:
|
| 97 |
+
json.dump(out, open(args.out, "w", encoding="utf-8"), default=float)
|
| 98 |
+
print(json.dumps({k: v for k, v in out.items() if k != "_runs"}, indent=2, default=float))
|
ragsolver/rag/figures.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 2, tareas 3-4 — recortes de figuras a partir de los bloques `image` de
|
| 2 |
+
MinerU, unidos a la red de seguridad de clusters vectoriales (docs/PLAN_RAG.md §6.2).
|
| 3 |
+
|
| 4 |
+
Un recorte por bloque imagen, con padding para que entren las etiquetas de los
|
| 5 |
+
puntos (A, B, O...) que el PDF deja como texto suelto alrededor del dibujo.
|
| 6 |
+
`figure_id = {doc_id}:fig:{page}:{block}` coincide con el locator markdown
|
| 7 |
+
`.../page:{page}/block:{block}` que ya usa parse_structure, así el enlace
|
| 8 |
+
figura <-> problem card no necesita nada más.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import hashlib
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import pymupdf
|
| 17 |
+
|
| 18 |
+
from ragsolver.rag.ingest_pdf import detect_vector_figures
|
| 19 |
+
from ragsolver.rag.mineru_blocks import _item_bboxes
|
| 20 |
+
from ragsolver.rag.schema import Figure
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _covered(rect: pymupdf.Rect, boxes: list[pymupdf.Rect]) -> float:
|
| 24 |
+
"""Fracción de `rect` cubierta por la unión (aproximada por suma) de `boxes`."""
|
| 25 |
+
if rect.is_empty or rect.get_area() == 0:
|
| 26 |
+
return 0.0
|
| 27 |
+
area = sum((rect & b).get_area() for b in boxes if not (rect & b).is_empty)
|
| 28 |
+
return min(1.0, area / rect.get_area())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _crop(page: pymupdf.Page, rect: pymupdf.Rect, out: Path, dpi: int) -> str:
|
| 32 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
page.get_pixmap(dpi=dpi, clip=rect & page.rect).save(out)
|
| 34 |
+
return hashlib.sha256(out.read_bytes()).hexdigest()[:16]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def crop_figures(
|
| 38 |
+
pdf_path: str | Path,
|
| 39 |
+
pages: dict[int, list[dict]],
|
| 40 |
+
out_dir: str | Path,
|
| 41 |
+
doc_id: str,
|
| 42 |
+
pad_pt: float = 10.0,
|
| 43 |
+
dpi: int = 200,
|
| 44 |
+
min_overlap: float = 0.6,
|
| 45 |
+
) -> tuple[list[Figure], list[str]]:
|
| 46 |
+
"""Devuelve (figuras, avisos). Avisos = clusters vectoriales que ningún bloque de
|
| 47 |
+
MinerU cubre (>= min_overlap): se recortan igual con detected_by=vector_cluster."""
|
| 48 |
+
doc = pymupdf.open(pdf_path)
|
| 49 |
+
out_dir = Path(out_dir)
|
| 50 |
+
vectors: dict[int, list[pymupdf.Rect]] = {}
|
| 51 |
+
for v in detect_vector_figures(pdf_path):
|
| 52 |
+
vectors.setdefault(v.page, []).append(pymupdf.Rect(*v.bbox))
|
| 53 |
+
|
| 54 |
+
figures: list[Figure] = []
|
| 55 |
+
warnings: list[str] = []
|
| 56 |
+
for pg in sorted(pages):
|
| 57 |
+
page = doc[pg - 1]
|
| 58 |
+
W, H = page.rect.width, page.rect.height
|
| 59 |
+
to_pt = lambda bb: pymupdf.Rect(bb[0] * W, bb[1] * H, bb[2] * W, bb[3] * H)
|
| 60 |
+
all_boxes = [to_pt(bb) for b in pages[pg] for bb in _item_bboxes(b)]
|
| 61 |
+
image_boxes = []
|
| 62 |
+
for b in pages[pg]:
|
| 63 |
+
if b["type"] not in ("image", "chart"):
|
| 64 |
+
continue
|
| 65 |
+
caption = " ".join(
|
| 66 |
+
it_c.get("content", "")
|
| 67 |
+
for it in b["content"] if it["type"] in ("image_caption", "image_footnote")
|
| 68 |
+
for it_c in it["content"]
|
| 69 |
+
).strip()
|
| 70 |
+
for it in b["content"]:
|
| 71 |
+
if it["type"] not in ("image_body", "chart_body"):
|
| 72 |
+
continue
|
| 73 |
+
rect = to_pt(it["bbox"])
|
| 74 |
+
image_boxes.append(rect)
|
| 75 |
+
padded = rect + (-pad_pt, -pad_pt, pad_pt, pad_pt)
|
| 76 |
+
block = it["index"] + 1
|
| 77 |
+
png = out_dir / f"p{pg:03d}_b{block}.png"
|
| 78 |
+
sha = _crop(page, padded, png, dpi)
|
| 79 |
+
has_vector = any(_covered(v, [rect]) >= min_overlap for v in vectors.get(pg, []))
|
| 80 |
+
figures.append(
|
| 81 |
+
Figure(
|
| 82 |
+
figure_id=f"{doc_id}:fig:{pg}:{block}",
|
| 83 |
+
image_path=str(png),
|
| 84 |
+
image_sha=sha,
|
| 85 |
+
page=pg,
|
| 86 |
+
bbox=[round(x, 4) for x in it["bbox"]],
|
| 87 |
+
detected_by="both" if has_vector else "layout",
|
| 88 |
+
caption=caption,
|
| 89 |
+
)
|
| 90 |
+
)
|
| 91 |
+
# Red de seguridad: dibujo vectorial que MinerU no cubrió con ningún bloque
|
| 92 |
+
# (ni imagen, ni ecuación/texto — los radicales del Equation Editor caen en
|
| 93 |
+
# bloques equation/text y no cuentan como figura perdida).
|
| 94 |
+
for k, v in enumerate(vectors.get(pg, []), start=1):
|
| 95 |
+
if _covered(v, all_boxes) >= min_overlap:
|
| 96 |
+
continue
|
| 97 |
+
png = out_dir / f"p{pg:03d}_v{k}.png"
|
| 98 |
+
sha = _crop(page, v, png, dpi)
|
| 99 |
+
figures.append(
|
| 100 |
+
Figure(
|
| 101 |
+
figure_id=f"{doc_id}:fig:{pg}:v{k}",
|
| 102 |
+
image_path=str(png),
|
| 103 |
+
image_sha=sha,
|
| 104 |
+
page=pg,
|
| 105 |
+
bbox=[round(v.x0 / W, 4), round(v.y0 / H, 4), round(v.x1 / W, 4), round(v.y1 / H, 4)],
|
| 106 |
+
detected_by="vector_cluster",
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
warnings.append(f"página {pg}: cluster vectorial {tuple(round(x) for x in v)} sin bloque de MinerU -> {png.name}")
|
| 110 |
+
return figures, warnings
|
ragsolver/rag/index.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Índice LanceDB: construcción de tabla + búsqueda densa/FTS + fusión RRF.
|
| 2 |
+
|
| 3 |
+
docs/PLAN_RAG.md §6.3 y §9.2. Una tabla por corpus (`mathlib`, `informal`).
|
| 4 |
+
No hay clase "Store" genérica: son funciones sobre `lancedb.Table`, que ya es
|
| 5 |
+
la abstracción — ponytail rung 2 (reusar lo que ya existe).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import lancedb
|
| 14 |
+
from lancedb.index import FTS
|
| 15 |
+
|
| 16 |
+
MATHLIB_FTS_COLUMNS = ["decl_name", "informal_name", "informal_description", "signature"]
|
| 17 |
+
INFORMAL_FTS_COLUMNS = ["text", "keywords", "theorems_used"]
|
| 18 |
+
|
| 19 |
+
_FTS_COLUMN = "_fts_text" # columna derivada: lancedb >=0.25 sólo indexa FTS de a un campo
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_jsonl(path: str | Path) -> list[dict]:
|
| 23 |
+
rows = []
|
| 24 |
+
with open(path, encoding="utf-8") as f:
|
| 25 |
+
for line in f:
|
| 26 |
+
if line.strip():
|
| 27 |
+
rows.append(json.loads(line))
|
| 28 |
+
return rows
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _write(db, table, table_name: str, batch: list[dict]):
|
| 32 |
+
if table is None:
|
| 33 |
+
return db.create_table(table_name, data=batch)
|
| 34 |
+
table.add(batch) # devuelve un AddResult (truthy) en lancedb >= 0.25: no encadenar con `or`
|
| 35 |
+
return table
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def build_table(db_path: str, table_name: str, rows, fts_columns: list[str] | None = None, chunk: int = 5000) -> lancedb.table.Table:
|
| 39 |
+
"""`fts_columns=None` crea una tabla de metadatos sin índice FTS (p.ej. `figures`).
|
| 40 |
+
`rows` puede ser un iterador (JSONL grande): se escribe por bloques de `chunk` filas."""
|
| 41 |
+
db = lancedb.connect(db_path)
|
| 42 |
+
if table_name in db.list_tables().tables:
|
| 43 |
+
db.drop_table(table_name)
|
| 44 |
+
|
| 45 |
+
def prep(row):
|
| 46 |
+
if fts_columns:
|
| 47 |
+
# lancedb >=0.25 sólo indexa FTS de un campo por índice; concatenamos las
|
| 48 |
+
# columnas buscables en una columna derivada en vez de mantener N índices.
|
| 49 |
+
return {**row, _FTS_COLUMN: " ".join(str(row.get(c, "")) for c in fts_columns)}
|
| 50 |
+
return row
|
| 51 |
+
|
| 52 |
+
table = None
|
| 53 |
+
batch: list[dict] = []
|
| 54 |
+
for row in rows:
|
| 55 |
+
batch.append(prep(row))
|
| 56 |
+
if len(batch) >= chunk:
|
| 57 |
+
table = _write(db, table, table_name, batch)
|
| 58 |
+
batch = []
|
| 59 |
+
if batch or table is None:
|
| 60 |
+
table = _write(db, table, table_name, batch)
|
| 61 |
+
if fts_columns:
|
| 62 |
+
table.create_index(_FTS_COLUMN, config=FTS())
|
| 63 |
+
return table
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def iter_jsonl(path: str | Path):
|
| 67 |
+
with open(path, encoding="utf-8") as f:
|
| 68 |
+
for line in f:
|
| 69 |
+
if line.strip():
|
| 70 |
+
yield json.loads(line)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def open_table(db_path: str, table_name: str) -> lancedb.table.Table:
|
| 74 |
+
db = lancedb.connect(db_path)
|
| 75 |
+
return db.open_table(table_name)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Índice ANN (sólo tablas grandes, p.ej. `mathlib_full`): IVF con cuantización escalar de 8 bits y
|
| 79 |
+
# re-ranking exacto de los `refine_factor × k` mejores → casi exacto con ~1 % de las lecturas. En la
|
| 80 |
+
# búsqueda plana (sin índice) estos parámetros no tienen efecto. Ver DECISIONS #60.
|
| 81 |
+
ANN_NPROBES = 32
|
| 82 |
+
ANN_REFINE = 5
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_ann_index(table: lancedb.table.Table, num_partitions: int = 256) -> None:
|
| 86 |
+
table.create_index(metric="l2", vector_column_name="embedding", index_type="IVF_SQ", num_partitions=num_partitions, replace=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def vector_search(table: lancedb.table.Table, query_vector: list[float], k: int, where: str | None = None) -> list[dict]:
|
| 90 |
+
q = table.search(query_vector, vector_column_name="embedding").nprobes(ANN_NPROBES).refine_factor(ANN_REFINE)
|
| 91 |
+
if where:
|
| 92 |
+
q = q.where(where, prefilter=True)
|
| 93 |
+
return q.limit(k).to_list()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def fts_search(table: lancedb.table.Table, query_text: str, k: int, where: str | None = None) -> list[dict]:
|
| 97 |
+
q = table.search(query_text, query_type="fts", fts_columns=_FTS_COLUMN)
|
| 98 |
+
if where:
|
| 99 |
+
q = q.where(where, prefilter=True)
|
| 100 |
+
return q.limit(k).to_list()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def sql_quote(value: str) -> str:
|
| 104 |
+
return "'" + str(value).replace("'", "''") + "'"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def exact_match(table: lancedb.table.Table, column: str, values: list[str], where: str | None = None) -> list[dict]:
|
| 108 |
+
"""Filas cuyo `column` es exactamente uno de `values` (modo `error`: identificador Lean literal)."""
|
| 109 |
+
if not values:
|
| 110 |
+
return []
|
| 111 |
+
expr = f"{column} IN ({', '.join(sql_quote(v) for v in values)})"
|
| 112 |
+
if where:
|
| 113 |
+
expr = f"({expr}) AND ({where})"
|
| 114 |
+
return table.search().where(expr).limit(len(values)).to_list()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
|
| 118 |
+
"""Reciprocal Rank Fusion estándar (docs/PLAN_RAG.md §6.3).
|
| 119 |
+
|
| 120 |
+
`ranked_lists`: una lista de listas de ids, ya ordenadas por relevancia
|
| 121 |
+
(rank 0 = mejor). Devuelve ids fusionados ordenados por score RRF desc.
|
| 122 |
+
"""
|
| 123 |
+
scores: dict[str, float] = {}
|
| 124 |
+
for ranked in ranked_lists:
|
| 125 |
+
for rank, doc_id in enumerate(ranked):
|
| 126 |
+
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
|
| 127 |
+
return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
import argparse
|
| 132 |
+
|
| 133 |
+
ap = argparse.ArgumentParser(description="Construye (o reemplaza) una tabla LanceDB desde un JSONL embebido.")
|
| 134 |
+
ap.add_argument("--in", dest="in_path", default=None, help="p.ej. data/corpus/informal_embedded.jsonl (no hace falta con --ann-only)")
|
| 135 |
+
ap.add_argument("--table", required=True, choices=["mathlib", "mathlib_full", "informal", "figures"])
|
| 136 |
+
ap.add_argument("--db-path", default="data/index/lancedb")
|
| 137 |
+
ap.add_argument("--ann-only", action="store_true", help="no reconstruye: sólo crea el índice ANN (IVF_SQ) sobre la tabla existente")
|
| 138 |
+
args = ap.parse_args()
|
| 139 |
+
if args.ann_only:
|
| 140 |
+
table = lancedb.connect(args.db_path).open_table(args.table)
|
| 141 |
+
build_ann_index(table)
|
| 142 |
+
print(json.dumps({"table": args.table, "rows": table.count_rows(), "indices": [str(i.index_type) for i in table.list_indices()]}))
|
| 143 |
+
raise SystemExit
|
| 144 |
+
if not args.in_path:
|
| 145 |
+
ap.error("--in es obligatorio salvo con --ann-only")
|
| 146 |
+
fts = {"mathlib": MATHLIB_FTS_COLUMNS, "mathlib_full": MATHLIB_FTS_COLUMNS, "informal": INFORMAL_FTS_COLUMNS, "figures": None}[args.table]
|
| 147 |
+
table = build_table(args.db_path, args.table, iter_jsonl(args.in_path), fts_columns=fts)
|
| 148 |
+
print(json.dumps({"table": args.table, "rows": table.count_rows(), "db_path": args.db_path}))
|
ragsolver/rag/ingest_informal.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 2 — Corpus B de punta a punta (docs/PLAN_RAG.md §6.2, tareas 3-9):
|
| 2 |
+
|
| 3 |
+
MinerU (JSON de bloques)
|
| 4 |
+
-> huecos del layout transcritos por VLM (Plan B acotado)
|
| 5 |
+
-> QA de fórmulas por página (6b)
|
| 6 |
+
-> Markdown regenerado -> problem cards (parse_structure)
|
| 7 |
+
-> soluciones con numeral perdido: split por LLM
|
| 8 |
+
-> recortes de figuras + descripciones (6a)
|
| 9 |
+
-> resumen contextual por card (6c)
|
| 10 |
+
-> chunks `problem_card` + `figure` -> data/corpus/informal.jsonl, figures.jsonl
|
| 11 |
+
|
| 12 |
+
Idempotente: MinerU no se re-ejecuta (lee su salida ya guardada) y toda llamada
|
| 13 |
+
al VLM/LLM queda cacheada en data/parsed/<doc_id>/enrich/. `--skip-llm` produce
|
| 14 |
+
el corpus sin enriquecimiento (context_summary vacío), útil para tests.
|
| 15 |
+
|
| 16 |
+
Uso: python -m ragsolver.rag.ingest_informal --pdf "problemas resueltos olimpiadas.pdf"
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import hashlib
|
| 23 |
+
import json
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import pymupdf
|
| 27 |
+
import yaml
|
| 28 |
+
|
| 29 |
+
from ragsolver.rag.enrich import Enricher
|
| 30 |
+
from ragsolver.rag.figures import crop_figures
|
| 31 |
+
from ragsolver.rag.ingest_pdf import Inventory, inventory
|
| 32 |
+
from ragsolver.rag.mineru_blocks import (
|
| 33 |
+
extract_item_numbers,
|
| 34 |
+
find_uncovered_regions,
|
| 35 |
+
image_locators,
|
| 36 |
+
insert_block,
|
| 37 |
+
load_pages,
|
| 38 |
+
render_markdown,
|
| 39 |
+
section_chunks,
|
| 40 |
+
text_block,
|
| 41 |
+
)
|
| 42 |
+
from ragsolver.rag.parse_structure import pair_problems_and_solutions, parse_markdown, split_item
|
| 43 |
+
from ragsolver.rag.schema import Figure, InformalChunk
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def build_document_record(inv: Inventory, pdf_path: str) -> dict:
|
| 47 |
+
# Metadatos leídos del propio PDF (portada + metadata de Acrobat) durante el
|
| 48 |
+
# análisis inicial de la sesión de diseño — no vienen de un LLM.
|
| 49 |
+
return {
|
| 50 |
+
"doc_id": inv.doc_id,
|
| 51 |
+
"title": "Colectivo de Problemas resueltos para Estudiantes de Alto Rendimiento",
|
| 52 |
+
"authors": ["Miguel Angel Moreno Núñez", "Eduardo Tellechea Armenta"],
|
| 53 |
+
"year": 2002,
|
| 54 |
+
"source_type": "olympiad_collection",
|
| 55 |
+
"language": "es",
|
| 56 |
+
"file_path": pdf_path,
|
| 57 |
+
"sha256": inv.sha256,
|
| 58 |
+
"n_pages": inv.n_pages,
|
| 59 |
+
"license": "",
|
| 60 |
+
"redistributable": False, # no se conoce la licencia -> no publicar en HF
|
| 61 |
+
"parser": "mineru",
|
| 62 |
+
"parser_version": "4.0.3",
|
| 63 |
+
"parser_tier": "standard",
|
| 64 |
+
"text_layer_reliable": inv.text_layer_reliable,
|
| 65 |
+
"index_version": "0.1.0",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _page_png(doc: pymupdf.Document, page: int, out_dir: Path, dpi: int, clip: pymupdf.Rect | None = None, name: str | None = None) -> Path:
|
| 70 |
+
out = out_dir / (name or f"page_{page:03d}.png")
|
| 71 |
+
if not out.exists():
|
| 72 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
doc[page - 1].get_pixmap(dpi=dpi, clip=clip).save(out)
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def fill_gaps(pdf_path: str, pages: dict, enricher: Enricher, out_dir: Path, min_words: int, dpi: int) -> list[dict]:
|
| 78 |
+
"""Plan B acotado: transcribe con el VLM las regiones que MinerU no cubrió y las
|
| 79 |
+
inserta como bloques de texto sintéticos en orden de lectura."""
|
| 80 |
+
doc = pymupdf.open(pdf_path)
|
| 81 |
+
log = []
|
| 82 |
+
for r in find_uncovered_regions(pdf_path, pages, min_words=min_words):
|
| 83 |
+
page = doc[r.page - 1]
|
| 84 |
+
W, H = page.rect.width, page.rect.height
|
| 85 |
+
x0, y0, x1, y1 = r.bbox
|
| 86 |
+
clip = pymupdf.Rect(0.08 * W, y0 - 6, 0.95 * W, y1 + 6) # ancho completo de columna
|
| 87 |
+
png = _page_png(doc, r.page, out_dir / "gaps", dpi, clip=clip, name=f"gap_p{r.page:03d}.png")
|
| 88 |
+
md = enricher.transcribe_region(png)
|
| 89 |
+
insert_block(pages, r.page, text_block(md, [clip.x0 / W, clip.y0 / H, clip.x1 / W, clip.y1 / H]))
|
| 90 |
+
log.append({"page": r.page, "bbox_pt": [round(v) for v in r.bbox], "text_layer": r.words, "transcription": md})
|
| 91 |
+
return log
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def qa_formulas(pdf_path: str, pages: dict, enricher: Enricher, out_dir: Path, dpi: int) -> list[dict]:
|
| 95 |
+
"""6b: por página con >= 1 ecuación, el VLM revisa el LaTeX contra la imagen.
|
| 96 |
+
Modifica los bloques in-place; devuelve el registro de cambios."""
|
| 97 |
+
doc = pymupdf.open(pdf_path)
|
| 98 |
+
jobs = []
|
| 99 |
+
for pg in sorted(pages):
|
| 100 |
+
refs: list[tuple[dict, bool]] = [] # (contenedor con clave "content", es inline)
|
| 101 |
+
for b in pages[pg]:
|
| 102 |
+
if b.get("synthetic"):
|
| 103 |
+
continue
|
| 104 |
+
if b["type"] == "equation":
|
| 105 |
+
refs.append((b, False))
|
| 106 |
+
elif isinstance(b.get("content"), list):
|
| 107 |
+
refs += [(it, True) for it in b["content"] if it.get("type") == "equation_inline"]
|
| 108 |
+
if refs:
|
| 109 |
+
jobs.append((pg, refs, _page_png(doc, pg, out_dir / "pages", dpi)))
|
| 110 |
+
results = enricher.map(lambda j: enricher.qa_formulas(j[2], [r["content"] for r, _ in j[1]], inline=[i for _, i in j[1]]), jobs)
|
| 111 |
+
diffs = []
|
| 112 |
+
for (pg, refs, _), items in zip(jobs, results):
|
| 113 |
+
for item in items:
|
| 114 |
+
ref, _ = refs[item["i"]]
|
| 115 |
+
if item["changed"]:
|
| 116 |
+
diffs.append({"page": pg, "before": ref["content"], "after": item["latex"]})
|
| 117 |
+
ref["content"] = item["latex"]
|
| 118 |
+
elif "rejected" in item:
|
| 119 |
+
diffs.append({"page": pg, "before": ref["content"], "rejected": item["rejected"]})
|
| 120 |
+
return diffs
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def recover_missing_solutions(parsed: dict, enricher: Enricher) -> list[str]:
|
| 124 |
+
"""Soluciones cuyo numeral perdió el OCR: el LLM señala la primera línea dentro
|
| 125 |
+
de la solución anterior y `split_item` corta ahí (docs/PLAN_RAG.md: 'casos ambiguos')."""
|
| 126 |
+
problems = {p.number: p for p in parsed["problems"]}
|
| 127 |
+
solutions = {s.number: s for s in parsed["solutions"]}
|
| 128 |
+
notes = []
|
| 129 |
+
for n in sorted(problems):
|
| 130 |
+
if n in solutions or (n - 1) not in solutions:
|
| 131 |
+
continue
|
| 132 |
+
prev = solutions[n - 1]
|
| 133 |
+
first_line = enricher.split_solution(n, prev.text_md, problems[n - 1].text_md, problems[n].text_md)
|
| 134 |
+
res = split_item(prev, n, first_line) if first_line else None
|
| 135 |
+
if res is None:
|
| 136 |
+
notes.append(f"solución {n}: el LLM no encontró un corte verbatim ('{first_line[:60]}')")
|
| 137 |
+
continue
|
| 138 |
+
solutions[n - 1], solutions[n] = res
|
| 139 |
+
notes.append(f"solución {n}: recuperada por split de la {n - 1} en '{first_line[:60]}'")
|
| 140 |
+
parsed["solutions"] = [solutions[k] for k in sorted(solutions)]
|
| 141 |
+
return notes
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def build_chunks(cards: list[dict], figures_by_locator: dict[str, Figure], doc_id: str, enricher: Enricher | None) -> tuple[list[InformalChunk], list[Figure]]:
|
| 145 |
+
empty_meta = {"context_summary": "", "math_area": [], "techniques": [], "theorems_used": [], "difficulty": None, "keywords": []}
|
| 146 |
+
prepared = []
|
| 147 |
+
for card in cards:
|
| 148 |
+
n = card["item_number"]
|
| 149 |
+
prob, sol = card["problem"], card["solution"]
|
| 150 |
+
statement = prob.text_md if prob else ""
|
| 151 |
+
solution = sol.text_md if sol else ""
|
| 152 |
+
card_id = f"informal:{doc_id}:problem_card:{n}"
|
| 153 |
+
figs = [figures_by_locator[loc] for loc in card["image_locators"] if loc in figures_by_locator]
|
| 154 |
+
for f in figs:
|
| 155 |
+
f.linked_chunk_ids = [card_id]
|
| 156 |
+
prepared.append({"n": n, "prob": prob, "sol": sol, "statement": statement, "solution": solution, "card_id": card_id, "figs": figs})
|
| 157 |
+
|
| 158 |
+
if enricher is not None: # 6a en paralelo
|
| 159 |
+
jobs = [(f, c["statement"], c["solution"]) for c in prepared for f in c["figs"]]
|
| 160 |
+
|
| 161 |
+
def _describe(job):
|
| 162 |
+
f, statement, solution = job
|
| 163 |
+
d = enricher.describe_figure(f.image_path, statement, solution)
|
| 164 |
+
f.description, f.labels, f.objects = d["description"], d["labels"], d["objects"]
|
| 165 |
+
f.relations, f.given_values, f.figure_type = d["relations"], d["given_values"], d["figure_type"]
|
| 166 |
+
f.description_model, f.description_version = d["model"], enricher.version
|
| 167 |
+
|
| 168 |
+
enricher.map(_describe, jobs)
|
| 169 |
+
|
| 170 |
+
for c in prepared:
|
| 171 |
+
parts = [c["statement"]] if c["statement"] else []
|
| 172 |
+
if c["solution"]:
|
| 173 |
+
parts.append("## Solución\n\n" + c["solution"])
|
| 174 |
+
if any(f.description for f in c["figs"]):
|
| 175 |
+
parts.append("## Figures\n\n" + "\n\n".join(f"[{f.figure_id}] {f.description}" for f in c["figs"] if f.description))
|
| 176 |
+
c["content_md"] = "\n\n".join(parts)
|
| 177 |
+
|
| 178 |
+
if enricher is not None: # 6c en paralelo
|
| 179 |
+
metas = enricher.map(lambda c: enricher.summarize_card(c["content_md"], [f.description for f in c["figs"] if f.description]), prepared)
|
| 180 |
+
else:
|
| 181 |
+
metas = [dict(empty_meta) for _ in prepared]
|
| 182 |
+
|
| 183 |
+
chunks: list[InformalChunk] = []
|
| 184 |
+
used_figures: list[Figure] = []
|
| 185 |
+
for c, meta in zip(prepared, metas):
|
| 186 |
+
n, figs, card_id, content_md = c["n"], c["figs"], c["card_id"], c["content_md"]
|
| 187 |
+
used_figures += figs
|
| 188 |
+
text = (meta["context_summary"] + "\n\n" + content_md).strip()
|
| 189 |
+
pages = [p for item in (c["prob"], c["sol"]) if item for p in (item.page_start, item.page_end)]
|
| 190 |
+
chunks.append(
|
| 191 |
+
InformalChunk(
|
| 192 |
+
chunk_id=card_id,
|
| 193 |
+
doc_id=doc_id,
|
| 194 |
+
unit_type="problem_card",
|
| 195 |
+
text=text,
|
| 196 |
+
content_md=content_md,
|
| 197 |
+
context_summary=meta["context_summary"],
|
| 198 |
+
page_start=min(pages) if pages else None,
|
| 199 |
+
page_end=max(pages) if pages else None,
|
| 200 |
+
section_path=["Problemario", str(n)],
|
| 201 |
+
item_number=n,
|
| 202 |
+
math_area=meta["math_area"],
|
| 203 |
+
techniques=meta["techniques"],
|
| 204 |
+
theorems_used=meta["theorems_used"],
|
| 205 |
+
difficulty=meta["difficulty"],
|
| 206 |
+
keywords=meta["keywords"],
|
| 207 |
+
figure_ids=[f.figure_id for f in figs],
|
| 208 |
+
children_ids=[f"informal:{doc_id}:figure:{f.figure_id.rsplit(':fig:', 1)[1]}" for f in figs if f.description],
|
| 209 |
+
content_sha=hashlib.sha256(content_md.encode("utf-8")).hexdigest()[:16],
|
| 210 |
+
index_version="0.1.0",
|
| 211 |
+
)
|
| 212 |
+
)
|
| 213 |
+
for f in figs:
|
| 214 |
+
if not f.description:
|
| 215 |
+
continue
|
| 216 |
+
chunks.append(
|
| 217 |
+
InformalChunk(
|
| 218 |
+
chunk_id=f"informal:{doc_id}:figure:{f.figure_id.rsplit(':fig:', 1)[1]}",
|
| 219 |
+
doc_id=doc_id,
|
| 220 |
+
unit_type="figure",
|
| 221 |
+
text=f"{f.description}\n\nProblem: {c['statement']}",
|
| 222 |
+
content_md=f.description,
|
| 223 |
+
parent_id=card_id,
|
| 224 |
+
page_start=f.page,
|
| 225 |
+
page_end=f.page,
|
| 226 |
+
bboxes=[[f.page, *f.bbox]],
|
| 227 |
+
section_path=["Problemario", str(n)],
|
| 228 |
+
item_number=n,
|
| 229 |
+
language="en",
|
| 230 |
+
math_area=meta["math_area"],
|
| 231 |
+
keywords=f.labels + f.objects,
|
| 232 |
+
figure_ids=[f.figure_id],
|
| 233 |
+
content_sha=hashlib.sha256(f.description.encode("utf-8")).hexdigest()[:16],
|
| 234 |
+
index_version="0.1.0",
|
| 235 |
+
)
|
| 236 |
+
)
|
| 237 |
+
return chunks, used_figures
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def run(pdf_path: str, mineru_dir: str | None = None, parsed_root: str = "data/parsed", corpus_dir: str = "data/corpus", skip_llm: bool = False, ingest_yaml: str = "configs/ingest.yaml", sections: str = "none") -> dict:
|
| 241 |
+
"""`sections`: `none` (sólo problem cards), `front` (además, chunks `paper_section` de lo
|
| 242 |
+
anterior al Problemario), `all` (todo el documento por secciones: papers/apuntes)."""
|
| 243 |
+
cfg = yaml.safe_load(open(ingest_yaml, encoding="utf-8"))["pdf"]
|
| 244 |
+
inv = inventory(pdf_path)
|
| 245 |
+
doc_id = inv.doc_id
|
| 246 |
+
out_dir = Path(parsed_root) / doc_id
|
| 247 |
+
mineru_dir = mineru_dir or str(out_dir / "mineru")
|
| 248 |
+
pages = load_pages(mineru_dir)
|
| 249 |
+
report_numerals = extract_item_numbers(pages)
|
| 250 |
+
doc_short = doc_id[:7] # MinerU usa los 7 primeros hex del sha256 en sus locators
|
| 251 |
+
enricher = None if skip_llm else Enricher(out_dir / "enrich")
|
| 252 |
+
report: dict = {"doc_id": doc_id, "n_pages": inv.n_pages, "item_numbers_from_equations": report_numerals}
|
| 253 |
+
|
| 254 |
+
if enricher is not None:
|
| 255 |
+
report["gaps"] = fill_gaps(pdf_path, pages, enricher, out_dir, cfg["gap_min_words"], cfg["render_dpi"])
|
| 256 |
+
report["formula_diffs"] = qa_formulas(pdf_path, pages, enricher, out_dir, cfg["render_dpi"])
|
| 257 |
+
|
| 258 |
+
md = render_markdown(pages, doc_short)
|
| 259 |
+
(out_dir / "full.fixed.md").write_text(md, encoding="utf-8")
|
| 260 |
+
parsed = parse_markdown(md)
|
| 261 |
+
if enricher is not None:
|
| 262 |
+
report["split_notes"] = recover_missing_solutions(parsed, enricher)
|
| 263 |
+
cards, warnings = pair_problems_and_solutions(parsed)
|
| 264 |
+
|
| 265 |
+
figures, fig_warnings = crop_figures(pdf_path, pages, out_dir / "figures", doc_id, pad_pt=cfg["figure_crop_padding_pt"], dpi=cfg["render_dpi"], min_overlap=cfg["vector_cluster_min_overlap"])
|
| 266 |
+
by_id = {f.figure_id: f for f in figures}
|
| 267 |
+
by_locator = {}
|
| 268 |
+
for loc, (pg, _) in image_locators(pages, doc_short).items():
|
| 269 |
+
fid = f"{doc_id}:fig:{pg}:{loc.rsplit(':', 1)[1]}"
|
| 270 |
+
if fid in by_id:
|
| 271 |
+
by_locator[loc] = by_id[fid]
|
| 272 |
+
# ponytail: figuras vectoriales no cubiertas por MinerU (ninguna en el PDF de ejemplo)
|
| 273 |
+
# se asignan al primer ítem cuyo rango de páginas las contiene; afinar por orden de
|
| 274 |
+
# lectura si aparece un corpus donde sí ocurra.
|
| 275 |
+
for f in figures:
|
| 276 |
+
if f.detected_by == "vector_cluster":
|
| 277 |
+
for card in cards:
|
| 278 |
+
items = [it for it in (card["problem"], card["solution"]) if it]
|
| 279 |
+
if any(it.page_start <= f.page <= it.page_end for it in items):
|
| 280 |
+
loc = f"vector:{f.figure_id}"
|
| 281 |
+
card["image_locators"].append(loc)
|
| 282 |
+
by_locator[loc] = f
|
| 283 |
+
break
|
| 284 |
+
|
| 285 |
+
chunks, used_figures = build_chunks(cards, by_locator, doc_id, enricher)
|
| 286 |
+
if sections != "none": # docs/PLAN_RAG.md §6.2 paso 7: papers/apuntes -> paper_section (400-800 tokens)
|
| 287 |
+
for sc in section_chunks(md, doc_id, stop_at="Problemario" if sections == "front" else None):
|
| 288 |
+
chunks.append(InformalChunk(**sc, text=sc["content_md"], content_sha=hashlib.sha256(sc["content_md"].encode("utf-8")).hexdigest()[:16], index_version="0.1.0"))
|
| 289 |
+
report["n_sections"] = sum(1 for c in chunks if c.unit_type == "paper_section")
|
| 290 |
+
|
| 291 |
+
corpus = Path(corpus_dir)
|
| 292 |
+
corpus.mkdir(parents=True, exist_ok=True)
|
| 293 |
+
with (corpus / "informal.jsonl").open("w", encoding="utf-8") as f:
|
| 294 |
+
for c in chunks:
|
| 295 |
+
f.write(c.model_dump_json() + "\n")
|
| 296 |
+
with (corpus / "figures.jsonl").open("w", encoding="utf-8") as f:
|
| 297 |
+
for fig in used_figures:
|
| 298 |
+
f.write(fig.model_dump_json() + "\n")
|
| 299 |
+
(out_dir / "document.json").write_text(json.dumps(build_document_record(inv, pdf_path), ensure_ascii=False, indent=2), encoding="utf-8")
|
| 300 |
+
|
| 301 |
+
report.update(
|
| 302 |
+
n_cards=len(cards),
|
| 303 |
+
n_paired=sum(1 for c in cards if c["problem"] and c["solution"]),
|
| 304 |
+
n_chunks=len(chunks),
|
| 305 |
+
n_figures=len(used_figures),
|
| 306 |
+
n_figures_detected=len(figures),
|
| 307 |
+
figure_warnings=fig_warnings,
|
| 308 |
+
warnings=warnings,
|
| 309 |
+
llm_calls=enricher.calls if enricher else 0,
|
| 310 |
+
models_exhausted=enricher.exhausted if enricher else [],
|
| 311 |
+
)
|
| 312 |
+
(out_dir / "ingest_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 313 |
+
return report
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
if __name__ == "__main__":
|
| 317 |
+
ap = argparse.ArgumentParser()
|
| 318 |
+
ap.add_argument("--pdf", default="problemas resueltos olimpiadas.pdf")
|
| 319 |
+
ap.add_argument("--mineru-dir", default=None, help="por defecto data/parsed/<doc_id>/mineru")
|
| 320 |
+
ap.add_argument("--skip-llm", action="store_true")
|
| 321 |
+
ap.add_argument("--sections", default="none", choices=["none", "front", "all"], help="chunks paper_section: none | front (antes del Problemario) | all (papers/apuntes)")
|
| 322 |
+
args = ap.parse_args()
|
| 323 |
+
rep = run(args.pdf, args.mineru_dir, skip_llm=args.skip_llm, sections=args.sections)
|
| 324 |
+
print(json.dumps({k: v for k, v in rep.items() if k not in ("gaps", "formula_diffs")}, indent=2, ensure_ascii=False))
|
| 325 |
+
print("gaps:", len(rep.get("gaps", [])), "formula_diffs:", len(rep.get("formula_diffs", [])))
|
ragsolver/rag/ingest_mathlib.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 1 — Corpus A: normaliza el JSONL de LeanSearch v2 al esquema MathlibChunk.
|
| 2 |
+
|
| 3 |
+
docs/PLAN_RAG.md §6.1. Entrada: `lsv2-mathlib-v4.28.0-rc1.jsonl` (310 579 registros,
|
| 4 |
+
FrenzyMath/lsv2-mathlib-v4.28.0-rc1-jsonl, Apache-2.0). Salida: `data/corpus/mathlib.jsonl`
|
| 5 |
+
con un `MathlibChunk` por línea (sin `embedding` todavía — eso lo hace `embed.py`).
|
| 6 |
+
|
| 7 |
+
Uso: python -m ragsolver.rag.ingest_mathlib [--config configs/ingest.yaml] [--limit N]
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import hashlib
|
| 14 |
+
import json
|
| 15 |
+
import re
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import yaml
|
| 19 |
+
|
| 20 |
+
from ragsolver.rag.schema import MathlibChunk
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _load_config(path: str) -> dict:
|
| 24 |
+
with open(path, encoding="utf-8") as f:
|
| 25 |
+
return yaml.safe_load(f)["mathlib"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _should_exclude(record: dict, cfg: dict) -> bool:
|
| 29 |
+
if record.get("kind") in cfg["exclude_kinds"]:
|
| 30 |
+
return True
|
| 31 |
+
module_path = ".".join(record.get("module_name") or [])
|
| 32 |
+
if any(module_path.startswith(p) for p in cfg["exclude_module_prefixes"]):
|
| 33 |
+
return True
|
| 34 |
+
decl_name = ".".join(record.get("name") or [])
|
| 35 |
+
if any(sub in decl_name for sub in cfg["exclude_name_substrings"]):
|
| 36 |
+
return True
|
| 37 |
+
# lemas de ecuación autogenerados (`foo.eq_1`, `foo.eq_def`), sin borrar lemas reales como
|
| 38 |
+
# `Nat.Prime.eq_one_or_self_of_dvd` (docs/PLAN_MEJORA_RAG.md §2.4, Fase 3 tarea 4b)
|
| 39 |
+
if cfg.get("exclude_name_regex") and re.search(cfg["exclude_name_regex"], decl_name):
|
| 40 |
+
return True
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _docs_url(module_path: str, decl_name: str, base: str) -> str:
|
| 45 |
+
return f"{base}/{module_path.replace('.', '/')}.html#{decl_name}"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def record_to_chunk(record: dict, cfg: dict, mathlib_tag: str, lean_version: str) -> MathlibChunk | None:
|
| 49 |
+
names = record.get("name") or []
|
| 50 |
+
if not names:
|
| 51 |
+
return None
|
| 52 |
+
# `name` es la lista de COMPONENTES del nombre (["GCDMonoid", "dvd_gcd"]), no
|
| 53 |
+
# alias: el nombre completo es su unión con "." (docs/DECISIONS.md #31).
|
| 54 |
+
decl_name = ".".join(names)
|
| 55 |
+
module_path = ".".join(record.get("module_name") or [])
|
| 56 |
+
informal_name = record.get("informal_name", "") or ""
|
| 57 |
+
informal_description = record.get("informal_description", "") or ""
|
| 58 |
+
signature = record.get("signature", "") or record.get("type", "") or ""
|
| 59 |
+
|
| 60 |
+
text = "\n".join(part for part in [informal_name, informal_description, "", signature] if part is not None).strip()
|
| 61 |
+
content_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
| 62 |
+
|
| 63 |
+
return MathlibChunk(
|
| 64 |
+
chunk_id=f"mathlib:{decl_name}:0",
|
| 65 |
+
doc_id=cfg["doc_id"],
|
| 66 |
+
text=text,
|
| 67 |
+
content_sha=content_sha,
|
| 68 |
+
decl_name=decl_name,
|
| 69 |
+
decl_names_alt=[],
|
| 70 |
+
module_path=module_path,
|
| 71 |
+
kind=record.get("kind", ""),
|
| 72 |
+
signature=signature,
|
| 73 |
+
type=record.get("type", "") or "",
|
| 74 |
+
value=record.get("value", "") or "",
|
| 75 |
+
informal_name=informal_name,
|
| 76 |
+
informal_description=informal_description,
|
| 77 |
+
mathlib_tag=mathlib_tag,
|
| 78 |
+
lean_version=lean_version,
|
| 79 |
+
docs_url=_docs_url(module_path, decl_name, cfg["docs_url_base"]),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def run(config_path: str = "configs/ingest.yaml", versions_path: str = "configs/versions.yaml", limit: int | None = None) -> dict:
|
| 84 |
+
cfg = _load_config(config_path)
|
| 85 |
+
with open(versions_path, encoding="utf-8") as f:
|
| 86 |
+
versions = yaml.safe_load(f)
|
| 87 |
+
mathlib_tag = versions["lean"]["mathlib_tag"]
|
| 88 |
+
lean_version = versions["lean"]["toolchain"]
|
| 89 |
+
|
| 90 |
+
raw_path = Path(cfg["raw_jsonl"])
|
| 91 |
+
out_path = Path(cfg["out_jsonl"])
|
| 92 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
|
| 94 |
+
n_in = n_out = n_excluded = 0
|
| 95 |
+
with raw_path.open(encoding="utf-8") as fin, out_path.open("w", encoding="utf-8") as fout:
|
| 96 |
+
for line in fin:
|
| 97 |
+
if not line.strip():
|
| 98 |
+
continue
|
| 99 |
+
n_in += 1
|
| 100 |
+
if limit is not None and n_in > limit:
|
| 101 |
+
break
|
| 102 |
+
record = json.loads(line)
|
| 103 |
+
if _should_exclude(record, cfg):
|
| 104 |
+
n_excluded += 1
|
| 105 |
+
continue
|
| 106 |
+
chunk = record_to_chunk(record, cfg, mathlib_tag, lean_version)
|
| 107 |
+
if chunk is None:
|
| 108 |
+
n_excluded += 1
|
| 109 |
+
continue
|
| 110 |
+
fout.write(chunk.model_dump_json(exclude={"embedding"}) + "\n")
|
| 111 |
+
n_out += 1
|
| 112 |
+
|
| 113 |
+
return {"n_in": n_in, "n_out": n_out, "n_excluded": n_excluded, "out_path": str(out_path)}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
parser = argparse.ArgumentParser()
|
| 118 |
+
parser.add_argument("--config", default="configs/ingest.yaml")
|
| 119 |
+
parser.add_argument("--versions", default="configs/versions.yaml")
|
| 120 |
+
parser.add_argument("--limit", type=int, default=None, help="sólo las primeras N líneas del raw (debug)")
|
| 121 |
+
args = parser.parse_args()
|
| 122 |
+
stats = run(args.config, args.versions, args.limit)
|
| 123 |
+
print(json.dumps(stats, indent=2))
|
ragsolver/rag/ingest_pdf.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 2 — Corpus B: inventario, render y red de seguridad de figuras vectoriales.
|
| 2 |
+
|
| 3 |
+
docs/PLAN_RAG.md §6.2, Fase 2 tareas 1-4. El parseo estructural (MinerU +
|
| 4 |
+
emparejamiento problema/solución) y el enriquecimiento VLM (tareas 5-6) van en
|
| 5 |
+
módulos separados porque dependen de MinerU y de un proveedor de LLM/VLM
|
| 6 |
+
respectivamente — ver docs/PROGRESS.md.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import pymupdf
|
| 17 |
+
|
| 18 |
+
# Heurística de fiabilidad de la capa de texto (docs/PLAN_RAG.md §3): líneas de
|
| 19 |
+
# 1-2 caracteres sueltas y tokens matemáticos aislados delatan una capa de texto
|
| 20 |
+
# que separó exponentes/subíndices de su base (p.ej. "2^2001" -> "22001").
|
| 21 |
+
_SHORT_LINE_RE = re.compile(r"^\s*.{1,2}\s*$")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def sha256_file(path: str | Path) -> str:
|
| 25 |
+
h = hashlib.sha256()
|
| 26 |
+
with open(path, "rb") as f:
|
| 27 |
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
| 28 |
+
h.update(chunk)
|
| 29 |
+
return h.hexdigest()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class Inventory:
|
| 34 |
+
doc_id: str
|
| 35 |
+
sha256: str
|
| 36 |
+
n_pages: int
|
| 37 |
+
text_layer_reliable: bool
|
| 38 |
+
short_line_ratio: float
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def inventory(pdf_path: str | Path) -> Inventory:
|
| 42 |
+
sha = sha256_file(pdf_path)
|
| 43 |
+
doc = pymupdf.open(pdf_path)
|
| 44 |
+
n_pages = len(doc)
|
| 45 |
+
|
| 46 |
+
total_lines = short_lines = 0
|
| 47 |
+
for page in doc:
|
| 48 |
+
for line in page.get_text().splitlines():
|
| 49 |
+
if not line.strip():
|
| 50 |
+
continue
|
| 51 |
+
total_lines += 1
|
| 52 |
+
if _SHORT_LINE_RE.match(line):
|
| 53 |
+
short_lines += 1
|
| 54 |
+
ratio = short_lines / total_lines if total_lines else 0.0
|
| 55 |
+
|
| 56 |
+
return Inventory(
|
| 57 |
+
doc_id=sha[:12],
|
| 58 |
+
sha256=sha,
|
| 59 |
+
n_pages=n_pages,
|
| 60 |
+
text_layer_reliable=ratio < 0.05, # umbral conservador; documentos "limpios" están muy por debajo
|
| 61 |
+
short_line_ratio=round(ratio, 4),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def render_pages(pdf_path: str | Path, out_dir: str | Path, dpi: int = 200) -> list[Path]:
|
| 66 |
+
out_dir = Path(out_dir)
|
| 67 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
doc = pymupdf.open(pdf_path)
|
| 69 |
+
paths = []
|
| 70 |
+
for i, page in enumerate(doc):
|
| 71 |
+
pix = page.get_pixmap(dpi=dpi)
|
| 72 |
+
p = out_dir / f"page_{i + 1:03d}.png"
|
| 73 |
+
pix.save(p)
|
| 74 |
+
paths.append(p)
|
| 75 |
+
return paths
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class VectorFigure:
|
| 80 |
+
page: int # 1-based
|
| 81 |
+
bbox: tuple[float, float, float, float]
|
| 82 |
+
n_paths: int
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def detect_vector_figures(pdf_path: str | Path, min_size_pt: float = 15.0, padding: float = 8.0) -> list[VectorFigure]:
|
| 86 |
+
"""Red de seguridad de Fase 2, tarea 4: agrupa trazos vectoriales en figuras.
|
| 87 |
+
|
| 88 |
+
No depende de MinerU — se usa para verificar que MinerU no se comió ningún
|
| 89 |
+
diagrama de línea (docs/PLAN_RAG.md, riesgo "MinerU clasifica mal diagramas").
|
| 90 |
+
|
| 91 |
+
Filtra por tamaño mínimo (ambas dimensiones), no por cantidad de trazos: se
|
| 92 |
+
midió sobre el PDF de ejemplo (docs/DECISIONS.md) que diagramas geométricos
|
| 93 |
+
reales pueden tener sólo 2 trazos (un círculo + una línea), mientras que
|
| 94 |
+
reglas/subrayados decorativos con 1 solo trazo son anchos y muy bajos
|
| 95 |
+
(~12-15pt de alto). `min_size_pt=15` separa eso; recalibrar si se agregan
|
| 96 |
+
corpus con otro estilo de diagramación.
|
| 97 |
+
"""
|
| 98 |
+
doc = pymupdf.open(pdf_path)
|
| 99 |
+
figures: list[VectorFigure] = []
|
| 100 |
+
for i, page in enumerate(doc):
|
| 101 |
+
drawings = page.get_drawings()
|
| 102 |
+
if not drawings:
|
| 103 |
+
continue
|
| 104 |
+
clusters = page.cluster_drawings(drawings=drawings)
|
| 105 |
+
for rect in clusters:
|
| 106 |
+
if min(rect.width, rect.height) < min_size_pt:
|
| 107 |
+
continue
|
| 108 |
+
padded = pymupdf.Rect(rect) + (-padding, -padding, padding, padding)
|
| 109 |
+
n_paths = sum(1 for d in drawings if padded.intersects(d["rect"]))
|
| 110 |
+
figures.append(VectorFigure(page=i + 1, bbox=tuple(padded), n_paths=n_paths))
|
| 111 |
+
return figures
|
ragsolver/rag/metrics.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métricas de IR (docs/PLAN_RAG.md §11.2) sin dependencias: recall@k, nDCG@k (relevancia
|
| 2 |
+
binaria, varios relevantes por consulta) y MRR. Reemplazan a `ranx` en las corridas
|
| 3 |
+
largas: en dos corridas de evaluación `ranx` devolvió valores absurdos (recall 0.11 con
|
| 4 |
+
las mismas listas de hits que, recomputadas desde las trazas, dan 0.76 —
|
| 5 |
+
docs/DECISIONS.md #43). `tests/test_metrics.py` verifica la igualdad con ranx.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def ranked_ids(scored: dict[str, float]) -> list[str]:
|
| 14 |
+
return [d for d, _ in sorted(scored.items(), key=lambda kv: -kv[1])]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def recall_at_k(ranked: list[str], relevant: set[str], k: int) -> float:
|
| 18 |
+
return len(relevant & set(ranked[:k])) / len(relevant) if relevant else 0.0
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def ndcg_at_k(ranked: list[str], relevant: set[str], k: int) -> float:
|
| 22 |
+
dcg = sum(1.0 / math.log2(i + 2) for i, d in enumerate(ranked[:k]) if d in relevant)
|
| 23 |
+
idcg = sum(1.0 / math.log2(i + 2) for i in range(min(len(relevant), k)))
|
| 24 |
+
return dcg / idcg if idcg else 0.0
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def mrr(ranked: list[str], relevant: set[str]) -> float:
|
| 28 |
+
return next((1.0 / (i + 1) for i, d in enumerate(ranked) if d in relevant), 0.0)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def evaluate_run(qrels: dict[str, dict[str, int]], run: dict[str, dict[str, float]], ks: tuple[int, ...] = (10,)) -> dict[str, float]:
|
| 32 |
+
"""Promedio sobre las consultas de `qrels` (consulta sin hits = 0, como ranx)."""
|
| 33 |
+
out: dict[str, float] = {}
|
| 34 |
+
n = len(qrels) or 1
|
| 35 |
+
for k in ks:
|
| 36 |
+
out[f"recall@{k}"] = sum(recall_at_k(ranked_ids(run.get(q, {})), set(rel), k) for q, rel in qrels.items()) / n
|
| 37 |
+
out[f"ndcg@{k}"] = sum(ndcg_at_k(ranked_ids(run.get(q, {})), set(rel), k) for q, rel in qrels.items()) / n
|
| 38 |
+
out["mrr"] = sum(mrr(ranked_ids(run.get(q, {})), set(rel)) for q, rel in qrels.items()) / n
|
| 39 |
+
return out
|
ragsolver/rag/mineru_blocks.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 2 — lectura de la salida de MinerU 4 (esquema `docvortex.middle` 2.0).
|
| 2 |
+
|
| 3 |
+
MinerU deja un JSON por rango de páginas con bloques tipados (text / equation /
|
| 4 |
+
image / table / paragraph_title / ...) con bbox normalizado y contenido (LaTeX
|
| 5 |
+
para ecuaciones). Este módulo:
|
| 6 |
+
|
| 7 |
+
1. Carga esos bloques (`load_pages`).
|
| 8 |
+
2. Cruza la capa de texto del PDF contra los bbox de MinerU para encontrar
|
| 9 |
+
contenido que el layout se saltó (`find_uncovered_regions`) — el PDF de
|
| 10 |
+
ejemplo perdió una solución entera (pág. 64) y varias líneas sueltas.
|
| 11 |
+
La capa de texto NO sirve para leer fórmulas (docs/PLAN_RAG.md §3), pero
|
| 12 |
+
sí para saber *dónde hay texto*.
|
| 13 |
+
3. Regenera el Markdown con el mismo formato que `mineru-kit` (`render_markdown`),
|
| 14 |
+
para poder insertar bloques sintéticos (transcripciones VLM de las regiones
|
| 15 |
+
perdidas) y correcciones de LaTeX antes de parsear la estructura.
|
| 16 |
+
|
| 17 |
+
Bloques sintéticos: dicts con la misma forma (`type`, `bbox`, `content`) más
|
| 18 |
+
`synthetic: True`; `insert_block` los ubica por orden de lectura (y0).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import glob
|
| 24 |
+
import json
|
| 25 |
+
import re
|
| 26 |
+
from dataclasses import dataclass
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import pymupdf
|
| 30 |
+
|
| 31 |
+
# Numeral de ítem que MinerU absorbió dentro de una ecuación en display:
|
| 32 |
+
# "2 7. A = B", "\begin{array}{l} 5 2. \text{...}", "... \tag{88.}"
|
| 33 |
+
# Sólo con el punto pegado al último dígito (un decimal espaciado sale "1 0 . 5").
|
| 34 |
+
_LEADING_NUM_RE = re.compile(r"^(?:\\begin\{array\}\{[a-z ]*\}\s*)?(\d)(?:\s?(\d))?(?:\s?(\d))?\.\s+")
|
| 35 |
+
_TAG_NUM_RE = re.compile(r"\\tag\s*\{(\d{1,3})\.\}\s*$")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_pages(mineru_dir: str | Path) -> dict[int, list[dict]]:
|
| 39 |
+
"""{página 1-based: [bloques en orden de lectura]} a partir de los *.json de MinerU."""
|
| 40 |
+
pages: dict[int, list[dict]] = {}
|
| 41 |
+
for f in sorted(glob.glob(str(Path(mineru_dir) / "*.json"))):
|
| 42 |
+
for p in json.load(open(f, encoding="utf-8"))["pages"]:
|
| 43 |
+
pages[p["page_idx"] + 1] = sorted(p["blocks"], key=lambda b: b["index"])
|
| 44 |
+
return pages
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _item_bboxes(block: dict) -> list[list[float]]:
|
| 48 |
+
boxes = [block["bbox"]]
|
| 49 |
+
if isinstance(block.get("content"), list):
|
| 50 |
+
boxes += [it["bbox"] for it in block["content"] if isinstance(it, dict) and "bbox" in it]
|
| 51 |
+
return boxes
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class UncoveredRegion:
|
| 56 |
+
page: int # 1-based
|
| 57 |
+
bbox: tuple[float, float, float, float] # puntos PDF
|
| 58 |
+
words: str
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def find_uncovered_regions(
|
| 62 |
+
pdf_path: str | Path, pages: dict[int, list[dict]], min_words: int = 4, tol_pt: float = 4.0
|
| 63 |
+
) -> list[UncoveredRegion]:
|
| 64 |
+
"""Palabras de la capa de texto cuyo centro no cae en ningún bbox de MinerU.
|
| 65 |
+
|
| 66 |
+
Una región por página (unión de las palabras sueltas); se ignora el pie de
|
| 67 |
+
página. `min_words` filtra ruido (separadores "* * *", etc.).
|
| 68 |
+
"""
|
| 69 |
+
doc = pymupdf.open(pdf_path)
|
| 70 |
+
regions = []
|
| 71 |
+
for pg in range(1, len(doc) + 1):
|
| 72 |
+
page = doc[pg - 1]
|
| 73 |
+
W, H = page.rect.width, page.rect.height
|
| 74 |
+
boxes = [
|
| 75 |
+
pymupdf.Rect(x0 * W - tol_pt, y0 * H - tol_pt, x1 * W + tol_pt, y1 * H + tol_pt)
|
| 76 |
+
for b in pages.get(pg, [])
|
| 77 |
+
for x0, y0, x1, y1 in _item_bboxes(b)
|
| 78 |
+
]
|
| 79 |
+
loose = []
|
| 80 |
+
for x0, y0, x1, y1, word, *_ in page.get_text("words"):
|
| 81 |
+
if y0 > 0.92 * H:
|
| 82 |
+
continue
|
| 83 |
+
c = pymupdf.Point((x0 + x1) / 2, (y0 + y1) / 2)
|
| 84 |
+
if not any(bx.contains(c) for bx in boxes):
|
| 85 |
+
loose.append((pymupdf.Rect(x0, y0, x1, y1), word))
|
| 86 |
+
if len(loose) >= min_words:
|
| 87 |
+
r = pymupdf.Rect(loose[0][0])
|
| 88 |
+
for rr, _ in loose[1:]:
|
| 89 |
+
r |= rr
|
| 90 |
+
regions.append(UncoveredRegion(pg, tuple(r), " ".join(w for _, w in loose)))
|
| 91 |
+
return regions
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def insert_block(pages: dict[int, list[dict]], page: int, block: dict) -> None:
|
| 95 |
+
"""Inserta un bloque sintético en `page` por orden de lectura (y0 del bbox)."""
|
| 96 |
+
blocks = pages.setdefault(page, [])
|
| 97 |
+
y0 = block["bbox"][1]
|
| 98 |
+
pos = next((i for i, b in enumerate(blocks) if b["bbox"][1] > y0), len(blocks))
|
| 99 |
+
blocks.insert(pos, {**block, "synthetic": True})
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def text_block(text: str, bbox_norm: list[float]) -> dict:
|
| 103 |
+
return {"type": "text", "index": -1, "bbox": list(bbox_norm), "content": [{"type": "text", "content": text}]}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# Render Markdown (mismo formato que `mineru-kit`, ver data/parsed/*/full.md)
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _inline(items: list[dict]) -> str:
|
| 112 |
+
out = []
|
| 113 |
+
for it in items:
|
| 114 |
+
if it["type"] == "equation_inline":
|
| 115 |
+
out.append(f"${it['content']}$")
|
| 116 |
+
elif isinstance(it.get("content"), list): # index: items anidados
|
| 117 |
+
out.append(_inline(it["content"]))
|
| 118 |
+
else:
|
| 119 |
+
s = it.get("content", "")
|
| 120 |
+
for style in it.get("styles", []):
|
| 121 |
+
s = {"bold": f"**{s}**", "superscript": f"<sup>{s}</sup>", "subscript": f"<sub>{s}</sub>"}.get(style, s)
|
| 122 |
+
out.append(s)
|
| 123 |
+
return "".join(out)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _table(html: str) -> str:
|
| 127 |
+
rows = [re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", r, flags=re.S) for r in re.findall(r"<tr[^>]*>(.*?)</tr>", html, flags=re.S)]
|
| 128 |
+
rows = [[c.strip() for c in r] for r in rows if r]
|
| 129 |
+
if not rows:
|
| 130 |
+
return html
|
| 131 |
+
width = max(len(r) for r in rows)
|
| 132 |
+
lines = ["| " + " | ".join(r + [""] * (width - len(r))) + " |" for r in rows]
|
| 133 |
+
lines.insert(1, "| " + " | ".join(["---"] * width) + " |")
|
| 134 |
+
return "\n".join(lines)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def split_item_number(latex: str) -> tuple[int | None, str]:
|
| 138 |
+
"""Devuelve (número de ítem, latex sin el numeral) si la ecuación lo absorbió."""
|
| 139 |
+
m = _LEADING_NUM_RE.match(latex)
|
| 140 |
+
if m:
|
| 141 |
+
n = int("".join(g for g in m.groups() if g))
|
| 142 |
+
prefix = latex[: m.start(1)] # conserva un \begin{array} si lo había
|
| 143 |
+
return n, prefix + latex[m.end() :]
|
| 144 |
+
m = _TAG_NUM_RE.search(latex)
|
| 145 |
+
if m:
|
| 146 |
+
return int(m.group(1)), latex[: m.start()].rstrip()
|
| 147 |
+
return None, latex
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def extract_item_numbers(pages: dict[int, list[dict]]) -> list[tuple[int, int]]:
|
| 151 |
+
"""Saca el numeral absorbido en ecuaciones a un bloque de texto propio ("N.")
|
| 152 |
+
justo antes. Correr ANTES de la QA de fórmulas (el VLM lo borra como basura)."""
|
| 153 |
+
found = []
|
| 154 |
+
for pg, blocks in pages.items():
|
| 155 |
+
i = 0
|
| 156 |
+
while i < len(blocks):
|
| 157 |
+
b = blocks[i]
|
| 158 |
+
if b["type"] == "equation":
|
| 159 |
+
n, latex = split_item_number(b["content"])
|
| 160 |
+
if n is not None:
|
| 161 |
+
b["content"] = latex
|
| 162 |
+
blocks.insert(i, {**text_block(f"{n}.", b["bbox"]), "synthetic": True})
|
| 163 |
+
found.append((pg, n))
|
| 164 |
+
i += 1
|
| 165 |
+
i += 1
|
| 166 |
+
return found
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def render_page(blocks: list[dict], page: int, n_pages: int, doc_short: str, tier: str) -> str:
|
| 170 |
+
parts = [f"<!-- page {page} of {n_pages} -->"]
|
| 171 |
+
for b in blocks:
|
| 172 |
+
t = b["type"]
|
| 173 |
+
if t == "page_number":
|
| 174 |
+
continue
|
| 175 |
+
if t in ("text",):
|
| 176 |
+
parts.append(_inline(b["content"]))
|
| 177 |
+
elif t in ("paragraph_title", "doc_title"):
|
| 178 |
+
level = 1 if t == "doc_title" else b.get("level", 2)
|
| 179 |
+
parts.append("#" * level + " " + _inline(b["content"]))
|
| 180 |
+
elif t == "equation":
|
| 181 |
+
parts.append(f"$$\n{b['content']}\n$$")
|
| 182 |
+
elif t in ("image", "chart"):
|
| 183 |
+
for it in b["content"]:
|
| 184 |
+
if it["type"] in ("image_body", "chart_body"):
|
| 185 |
+
parts.append(f"")
|
| 186 |
+
else: # image_caption / image_footnote
|
| 187 |
+
parts.append(_inline(it["content"]))
|
| 188 |
+
elif t == "table":
|
| 189 |
+
for it in b["content"]:
|
| 190 |
+
parts.append(_table(it["content"]) if it["type"] == "table_body" else _inline(it["content"]))
|
| 191 |
+
elif t == "index":
|
| 192 |
+
parts.append("\n".join(f"- {_inline([it])}" for it in b["content"]))
|
| 193 |
+
else:
|
| 194 |
+
parts.append(_inline(b["content"]) if isinstance(b["content"], list) else str(b["content"]))
|
| 195 |
+
return "\n\n".join(p for p in parts if p.strip())
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def render_markdown(pages: dict[int, list[dict]], doc_short: str, tier: str = "standard") -> str:
|
| 199 |
+
n = max(pages)
|
| 200 |
+
return "\n\n".join(render_page(pages[pg], pg, n, doc_short, tier) for pg in sorted(pages)) + "\n"
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def image_locators(pages: dict[int, list[dict]], doc_short: str, tier: str = "standard") -> dict[str, tuple[int, list[float]]]:
|
| 204 |
+
"""{locator markdown: (página, bbox normalizado)} para cada image_body/chart_body."""
|
| 205 |
+
out = {}
|
| 206 |
+
for pg, blocks in pages.items():
|
| 207 |
+
for b in blocks:
|
| 208 |
+
if b["type"] in ("image", "chart"):
|
| 209 |
+
for it in b["content"]:
|
| 210 |
+
if it["type"] in ("image_body", "chart_body"):
|
| 211 |
+
out[f"doc:{doc_short}/tier:{tier}/page:{pg}/block:{it['index'] + 1}"] = (pg, it["bbox"])
|
| 212 |
+
return out
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
# Chunking por secciones (docs/PLAN_RAG.md §6.2, paso 7: papers/apuntes -> `paper_section`)
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
|
| 219 |
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def section_chunks(md_text: str, doc_id: str, max_chars: int = 3000, min_chars: int = 200, stop_at: str | None = None) -> list[dict]:
|
| 223 |
+
"""Corta el Markdown en chunks `paper_section` de ~400-800 tokens (≈ `max_chars`) que
|
| 224 |
+
respetan párrafos y títulos; `section_path` = títulos vigentes; `page_start/end` desde
|
| 225 |
+
los marcadores `<!-- page N of T -->`. `stop_at`: título en el que se deja de cortar
|
| 226 |
+
(p.ej. "Problemario", cuyo contenido va por el camino de problem cards)."""
|
| 227 |
+
chunks: list[dict] = []
|
| 228 |
+
path: list[str] = []
|
| 229 |
+
page = 1
|
| 230 |
+
buf: list[str] = []
|
| 231 |
+
buf_pages: list[int] = []
|
| 232 |
+
|
| 233 |
+
def flush():
|
| 234 |
+
text = "\n\n".join(buf).strip()
|
| 235 |
+
if len(text) >= min_chars:
|
| 236 |
+
n = len(chunks)
|
| 237 |
+
chunks.append({"chunk_id": f"informal:{doc_id}:paper_section:{n}", "doc_id": doc_id, "unit_type": "paper_section", "section_path": list(path), "page_start": min(buf_pages), "page_end": max(buf_pages), "content_md": text, "order": n})
|
| 238 |
+
buf.clear()
|
| 239 |
+
buf_pages.clear()
|
| 240 |
+
|
| 241 |
+
for para in re.split(r"\n\s*\n", md_text):
|
| 242 |
+
para = para.strip()
|
| 243 |
+
if not para:
|
| 244 |
+
continue
|
| 245 |
+
pm = re.match(r"<!--\s*page (\d+) of \d+\s*-->", para)
|
| 246 |
+
if pm:
|
| 247 |
+
page = int(pm.group(1))
|
| 248 |
+
continue
|
| 249 |
+
hm = _HEADING_RE.match(para)
|
| 250 |
+
if hm:
|
| 251 |
+
title = hm.group(2).strip()
|
| 252 |
+
if stop_at and title.lower().startswith(stop_at.lower()):
|
| 253 |
+
break
|
| 254 |
+
flush()
|
| 255 |
+
level = len(hm.group(1))
|
| 256 |
+
path = path[: level - 1] + [title]
|
| 257 |
+
continue
|
| 258 |
+
if re.fullmatch(r"\d{1,3}", para): # número de página suelto
|
| 259 |
+
continue
|
| 260 |
+
if buf and sum(len(p) for p in buf) + len(para) > max_chars:
|
| 261 |
+
flush()
|
| 262 |
+
buf.append(para)
|
| 263 |
+
buf_pages.append(page)
|
| 264 |
+
flush()
|
| 265 |
+
return chunks
|
ragsolver/rag/parse_structure.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 2, tarea 5 — parser estructural sobre el Markdown de MinerU.
|
| 2 |
+
|
| 3 |
+
Emparienta problema N (sección "Problemario") con su solución N (sección
|
| 4 |
+
"Soluciones"), y extrae los locators de imagen (`doc:.../block:N`) que caen
|
| 5 |
+
dentro de cada ítem. No usa LLM: es la ruta determinista que describe
|
| 6 |
+
docs/PLAN_RAG.md §6.2 ("casos ambiguos -> una llamada LLM", que no aplica acá
|
| 7 |
+
porque el documento tiene una estructura numerada regular).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
|
| 15 |
+
_PAGE_RE = re.compile(r"^<!--\s*page (\d+) of \d+\s*-->\s*$")
|
| 16 |
+
# "21. texto", "## 21. texto", o "13." solo (la solución sigue en la línea siguiente,
|
| 17 |
+
# frecuente cuando el ítem arranca directo con una fórmula en bloque).
|
| 18 |
+
_ITEM_RE = re.compile(r"^(?:## )?(\d{1,3})\.\s*(?:\S.*)?$")
|
| 19 |
+
_IMAGE_RE = re.compile(r"!\[Image block\]\((doc:[^)]+)\)")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Item:
|
| 24 |
+
number: int
|
| 25 |
+
text_md: str
|
| 26 |
+
page_start: int
|
| 27 |
+
page_end: int
|
| 28 |
+
image_locators: list[str] = field(default_factory=list)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _split_section(lines: list[tuple[int, str]]) -> list[Item]:
|
| 32 |
+
"""`lines`: [(page_num, line_text), ...] ya recortado a una sección.
|
| 33 |
+
|
| 34 |
+
Sólo se acepta un nuevo ítem si su número es >= el siguiente esperado
|
| 35 |
+
(no-decreciente, no estrictamente secuencial: a veces el número de un
|
| 36 |
+
ítem real queda atrapado dentro de una fórmula LaTeX -- p.ej. `\tag{100.}`
|
| 37 |
+
-- y ese ítem nunca hace match, lo cual no debe romper el resto de la
|
| 38 |
+
numeración). Rechazar los números MENORES al esperado es lo que evita que
|
| 39 |
+
un sub-caso dentro de una explicación (p.ej. "1. Prolongar el segmento
|
| 40 |
+
DC...", "2. Prolongar DA...") se confunda con el inicio de un ítem nuevo y
|
| 41 |
+
pise al ítem real con ese número -- pasó de verdad con la solución 1 en el
|
| 42 |
+
PDF de ejemplo (ver docs/DECISIONS.md).
|
| 43 |
+
"""
|
| 44 |
+
items: list[Item] = []
|
| 45 |
+
current: list[tuple[int, str]] | None = None
|
| 46 |
+
current_number: int | None = None
|
| 47 |
+
expected = 1
|
| 48 |
+
|
| 49 |
+
# La sección "Soluciones" del PDF de ejemplo arranca con la solución 1 sin
|
| 50 |
+
# repetir el numeral: si el primer ítem numerado es el 2, lo que hay antes
|
| 51 |
+
# (salvo el título de sección) es el ítem 1.
|
| 52 |
+
first = next((i for i, (_, t) in enumerate(lines) if _ITEM_RE.match(t)), None)
|
| 53 |
+
if first is not None and int(_ITEM_RE.match(lines[first][1]).group(1)) == 2:
|
| 54 |
+
current, current_number, expected = [(p, t) for p, t in lines[:first] if not t.startswith("## ")], 1, 2
|
| 55 |
+
lines = lines[first:]
|
| 56 |
+
|
| 57 |
+
def flush():
|
| 58 |
+
nonlocal current, current_number
|
| 59 |
+
if current_number is not None and current:
|
| 60 |
+
text = "\n".join(t for _, t in current).strip()
|
| 61 |
+
pages = [p for p, _ in current]
|
| 62 |
+
images = _IMAGE_RE.findall(text)
|
| 63 |
+
items.append(Item(current_number, text, min(pages), max(pages), images))
|
| 64 |
+
current, current_number = [], None
|
| 65 |
+
|
| 66 |
+
for page, line in lines:
|
| 67 |
+
m = _ITEM_RE.match(line)
|
| 68 |
+
number = int(m.group(1)) if m else None
|
| 69 |
+
if number is not None and number >= expected:
|
| 70 |
+
flush()
|
| 71 |
+
current_number = number
|
| 72 |
+
expected = number + 1
|
| 73 |
+
current = [(page, line)]
|
| 74 |
+
elif current is not None:
|
| 75 |
+
current.append((page, line))
|
| 76 |
+
flush()
|
| 77 |
+
return items
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def parse_markdown(md_text: str) -> dict:
|
| 81 |
+
"""Devuelve {"problems": [Item...], "solutions": [Item...], "warnings": [...]}."""
|
| 82 |
+
lines_with_page: list[tuple[int, str]] = []
|
| 83 |
+
page = 0
|
| 84 |
+
for raw_line in md_text.splitlines():
|
| 85 |
+
pm = _PAGE_RE.match(raw_line)
|
| 86 |
+
if pm:
|
| 87 |
+
page = int(pm.group(1))
|
| 88 |
+
continue
|
| 89 |
+
lines_with_page.append((page, raw_line))
|
| 90 |
+
|
| 91 |
+
full_text = "\n".join(t for _, t in lines_with_page)
|
| 92 |
+
idx_problemario = full_text.find("## Problemario")
|
| 93 |
+
idx_soluciones = full_text.find("## Soluciones")
|
| 94 |
+
idx_bibliografia = full_text.find("## Bibliografía")
|
| 95 |
+
|
| 96 |
+
warnings: list[str] = []
|
| 97 |
+
for name, idx in [("Problemario", idx_problemario), ("Soluciones", idx_soluciones), ("Bibliografía", idx_bibliografia)]:
|
| 98 |
+
if idx == -1:
|
| 99 |
+
warnings.append(f"sección '{name}' no encontrada")
|
| 100 |
+
|
| 101 |
+
def _char_offset_to_line_index(char_offset: int) -> int:
|
| 102 |
+
acc = 0
|
| 103 |
+
for i, (_, t) in enumerate(lines_with_page):
|
| 104 |
+
acc += len(t) + 1
|
| 105 |
+
if acc > char_offset:
|
| 106 |
+
return i
|
| 107 |
+
return len(lines_with_page)
|
| 108 |
+
|
| 109 |
+
i_problemario = _char_offset_to_line_index(idx_problemario) if idx_problemario >= 0 else 0
|
| 110 |
+
i_soluciones = _char_offset_to_line_index(idx_soluciones) if idx_soluciones >= 0 else len(lines_with_page)
|
| 111 |
+
i_bibliografia = _char_offset_to_line_index(idx_bibliografia) if idx_bibliografia >= 0 else len(lines_with_page)
|
| 112 |
+
|
| 113 |
+
problems = _split_section(lines_with_page[i_problemario:i_soluciones])
|
| 114 |
+
solutions = _split_section(lines_with_page[i_soluciones:i_bibliografia])
|
| 115 |
+
|
| 116 |
+
return {"problems": problems, "solutions": solutions, "warnings": warnings}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def split_item(item: Item, n: int, first_line: str) -> tuple[Item, Item] | None:
|
| 120 |
+
"""Parte `item` en (item, nuevo ítem `n`) en la línea `first_line` (verbatim).
|
| 121 |
+
|
| 122 |
+
Si esa línea está dentro de un bloque `$$`, el corte sube al `$$` de apertura.
|
| 123 |
+
Devuelve None si la línea no aparece. Los locators de imagen se reparten según
|
| 124 |
+
en qué mitad quedan.
|
| 125 |
+
"""
|
| 126 |
+
lines = item.text_md.splitlines()
|
| 127 |
+
first_line = first_line.strip()
|
| 128 |
+
idx = next((i for i, l in enumerate(lines) if l.strip() == first_line), None)
|
| 129 |
+
if idx is None:
|
| 130 |
+
idx = next((i for i, l in enumerate(lines) if first_line and first_line in l), None)
|
| 131 |
+
if idx is None or idx == 0:
|
| 132 |
+
return None
|
| 133 |
+
opens = [i for i, l in enumerate(lines[:idx]) if l.strip() == "$$"]
|
| 134 |
+
if len(opens) % 2 == 1: # dentro de un display: cortar en su apertura
|
| 135 |
+
idx = opens[-1]
|
| 136 |
+
head, tail = "\n".join(lines[:idx]).strip(), "\n".join(lines[idx:]).strip()
|
| 137 |
+
if not tail.startswith(f"{n}."):
|
| 138 |
+
tail = f"{n}.\n\n{tail}"
|
| 139 |
+
return (
|
| 140 |
+
Item(item.number, head, item.page_start, item.page_end, _IMAGE_RE.findall(head)),
|
| 141 |
+
Item(n, tail, item.page_start, item.page_end, _IMAGE_RE.findall(tail)),
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def pair_problems_and_solutions(parsed: dict) -> tuple[list[dict], list[str]]:
|
| 146 |
+
"""Empareja por número. Devuelve (cards, warnings) — cards con problema+solución."""
|
| 147 |
+
problems_by_n = {p.number: p for p in parsed["problems"]}
|
| 148 |
+
solutions_by_n = {s.number: s for s in parsed["solutions"]}
|
| 149 |
+
|
| 150 |
+
warnings = list(parsed["warnings"])
|
| 151 |
+
all_numbers = sorted(set(problems_by_n) | set(solutions_by_n))
|
| 152 |
+
cards = []
|
| 153 |
+
for n in all_numbers:
|
| 154 |
+
prob = problems_by_n.get(n)
|
| 155 |
+
sol = solutions_by_n.get(n)
|
| 156 |
+
if prob is None:
|
| 157 |
+
warnings.append(f"solución {n} sin problema correspondiente")
|
| 158 |
+
if sol is None:
|
| 159 |
+
warnings.append(f"problema {n} sin solución correspondiente")
|
| 160 |
+
cards.append(
|
| 161 |
+
{
|
| 162 |
+
"item_number": n,
|
| 163 |
+
"problem": prob,
|
| 164 |
+
"solution": sol,
|
| 165 |
+
"image_locators": (prob.image_locators if prob else []) + (sol.image_locators if sol else []),
|
| 166 |
+
}
|
| 167 |
+
)
|
| 168 |
+
return cards, warnings
|
ragsolver/rag/premises.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Premisas por goal de Lean (docs/PLAN_MEJORA_RAG.md Fases 1, 3 y 4).
|
| 2 |
+
|
| 3 |
+
El input del retrieval es el goal pretty-printed que devuelve el REPL en cada `sorry`, no el
|
| 4 |
+
enunciado en lenguaje natural:
|
| 5 |
+
- canal A, prior de competición: lemas por frecuencia en pruebas de Lean Workbook
|
| 6 |
+
(`scripts/build_premise_prior.py`), filtrados por los símbolos del goal;
|
| 7 |
+
- canal B, selección nativa de Lean: se consume dentro de CLOSE (`grind +suggestions`), no como
|
| 8 |
+
lista de nombres (su lista para goals de competición es ruido: `not_fermat_42`, `BitVec.*`);
|
| 9 |
+
- canal C, índice denso actual con el goal como consulta (`Retriever`, modo `formal`).
|
| 10 |
+
A y C se fusionan con RRF. Todo cuesta 0 llamadas LLM.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import math
|
| 17 |
+
import re
|
| 18 |
+
from functools import lru_cache
|
| 19 |
+
from itertools import combinations
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from ragsolver.rag.index import rrf_fuse
|
| 23 |
+
|
| 24 |
+
PRIOR_PATH = "data/corpus/premise_prior.jsonl"
|
| 25 |
+
|
| 26 |
+
IDENT_RE = re.compile(r"(?<![\w.'])([A-Za-z_][\w']*(?:\.[A-Za-z_][\w']*)*)")
|
| 27 |
+
# palabras de tácticas y sintaxis que el regex de identificadores confunde con premisas
|
| 28 |
+
TACTIC_WORDS = {
|
| 29 |
+
"by", "have", "exact", "apply", "intro", "intros", "rcases", "obtain", "refine", "refine'", "constructor", "simp", "simp_all", "norm_num", "linarith", "nlinarith",
|
| 30 |
+
"omega", "ring", "ring_nf", "field_simp", "positivity", "decide", "rfl", "use", "cases", "cases'", "induction", "induction'", "rw", "calc", "show", "fun", "at", "with",
|
| 31 |
+
"this", "norm_cast", "push_cast", "exact_mod_cast", "interval_cases", "by_contra", "by_cases", "push_neg", "split_ifs", "gcongr", "aesop", "tauto", "zify", "qify",
|
| 32 |
+
"simp_arith", "rw_mod_cast", "apply_fun", "native_decide", "nlinarith!", "linarith!", "contradiction", "exfalso", "assumption", "trivial", "specialize", "subst",
|
| 33 |
+
}
|
| 34 |
+
# `foo.mpr`, `foo.symm.le`: el lema es `foo`
|
| 35 |
+
FIELD_SUFFIXES = {"mp", "mpr", "symm", "le", "lt", "ne", "ne'", "trans", "elim", "1", "2", "resolve_left", "resolve_right", "out", "mono", "ge", "gt", "not_lt", "not_le", "trans_le", "trans_lt"}
|
| 36 |
+
# constantes de plomería que aparecen en `used_constants` de cualquier `rw`/`simp`/`linarith` y no son premisas
|
| 37 |
+
PLUMBING_PREFIXES = ("Mathlib.Tactic.", "Mathlib.Meta.", "Lean.", "Std.Tactic.", "Aesop.", "Linarith.", "Nat.Linear.", "Int.Linear.", "Mathlib.Algebra.Order.Field.Basic.")
|
| 38 |
+
PLUMBING = {"Eq.mpr", "Eq.mp", "congrArg", "congrFun", "congr", "id", "Eq.refl", "rfl", "of_eq_true", "eq_self", "propext", "Eq.trans", "Iff.refl", "trivial", "True.intro", "Eq.rec", "Eq.ndrec", "absurd", "Classical.byContradiction", "Decidable.byContradiction", "Not.intro", "eq_true", "eq_false", "False.elim", "id_eq", "Iff.intro", "And.intro"}
|
| 39 |
+
_RELATION_RE = re.compile(r"[=≤<≥>≠↔∣∈∉¬∧∨]|\bFalse\b|\bTrue\b")
|
| 40 |
+
# símbolos que "anclan" un lema a un dominio: si el lema los menciona y el goal no, no aplica
|
| 41 |
+
SALIENT_RE = re.compile(r"[A-Z][\w']*(?:\.[\w']+)+|√|∑|∏|∣|%|!|⌊|⌈|ℕ|ℤ|ℝ|ℚ|ℂ|π|\blogb\b|\bchoose\b|\bgcd\b|\blcm\b|\babs\b|\|")
|
| 42 |
+
_GENERIC_SALIENT = {"Nat.cast", "Int.cast", "OfNat.ofNat", "HPow.hPow", "HMul.hMul", "HAdd.hAdd", "HSub.hSub", "HDiv.hDiv"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def strip_field(name: str, known: set[str] | None = None) -> str:
|
| 46 |
+
"""`sub_ne_zero.mpr` -> `sub_ne_zero` (si `known` se da, sólo cuando la base está en `known`)."""
|
| 47 |
+
parts = name.split(".")
|
| 48 |
+
while len(parts) > 1 and parts[-1] in FIELD_SUFFIXES:
|
| 49 |
+
parts.pop()
|
| 50 |
+
if known is not None and ".".join(parts) in known:
|
| 51 |
+
return ".".join(parts)
|
| 52 |
+
return ".".join(parts) if known is None else name
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def is_lemma_type(type_str: str) -> bool:
|
| 56 |
+
"""Heurística: el tipo de un lema es una proposición (relación, conectivo); el de una definición
|
| 57 |
+
(`ℝ → ℝ`, `ℕ → ℕ → Prop`) no. ponytail: textual; `#print` exacto si aparecen falsos positivos."""
|
| 58 |
+
t = type_str.strip()
|
| 59 |
+
return bool(t) and not t.endswith("Prop") and not t.endswith("Type") and bool(_RELATION_RE.search(t))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_premise_constant(name: str) -> bool:
|
| 63 |
+
return bool(name) and name not in PLUMBING and not name.startswith(PLUMBING_PREFIXES) and "._" not in name and ".match_" not in name and ".proof_" not in name and not name.split(".")[-1].startswith("inst")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def proof_tokens(proof: str) -> set[str]:
|
| 67 |
+
"""Identificadores candidatos a premisa en el cuerpo de una prueba (tras `:= by`), sin
|
| 68 |
+
tácticas ni accesos a campo; la validación contra Lean la hace quien llama."""
|
| 69 |
+
body = proof.split(":= by", 1)[1] if ":= by" in proof else proof
|
| 70 |
+
return {strip_field(t) for t in IDENT_RE.findall(body) if t not in TACTIC_WORDS and ("_" in t or "." in t) and len(t) > 3}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def goal_target(goal: str) -> str:
|
| 74 |
+
"""La meta de un goal pretty-printed (lo que sigue al último `⊢`)."""
|
| 75 |
+
return goal.rsplit("⊢", 1)[-1].strip()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def salient(text: str) -> set[str]:
|
| 79 |
+
return {s for s in SALIENT_RE.findall(text) if s not in _GENERIC_SALIENT}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@lru_cache(maxsize=2)
|
| 83 |
+
def load_prior(path: str = PRIOR_PATH) -> tuple[dict, ...]:
|
| 84 |
+
p = Path(path)
|
| 85 |
+
if not p.exists():
|
| 86 |
+
return ()
|
| 87 |
+
rows = [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
|
| 88 |
+
for r in rows:
|
| 89 |
+
r["_salient"] = frozenset(salient(r.get("type", "")))
|
| 90 |
+
return tuple(rows)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def prior_channel(goal: str, k: int = 32, prior: tuple[dict, ...] | None = None, filtered: bool = True) -> list[str]:
|
| 94 |
+
"""Canal A: top-k del prior. `filtered`: descarta los lemas anclados a símbolos que el goal no
|
| 95 |
+
tiene (un lema de `√` no sirve para un goal de `%`); los genéricos (`sq_nonneg`) pasan siempre."""
|
| 96 |
+
prior = load_prior() if prior is None else prior
|
| 97 |
+
if not filtered:
|
| 98 |
+
return [r["decl_name"] for r in prior[:k]]
|
| 99 |
+
g = salient(goal)
|
| 100 |
+
return [r["decl_name"] for r in prior if r["_salient"] <= g][:k]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def nlinarith_hints(goal: str, max_hints: int = 12) -> list[str]:
|
| 104 |
+
"""Instancias de los lemas más frecuentes del prior (`sq_nonneg`, `mul_pos`) con las variables
|
| 105 |
+
del goal — el patrón `nlinarith [sq_nonneg (a - b), …]` de las pruebas de competición."""
|
| 106 |
+
hyps = [l for l in goal.split("⊢", 1)[0].splitlines() if " : " in l]
|
| 107 |
+
num_vars, pos_hyps = [], []
|
| 108 |
+
for l in hyps:
|
| 109 |
+
names, typ = l.split(" : ", 1)
|
| 110 |
+
typ = typ.strip()
|
| 111 |
+
if typ in ("ℝ", "ℚ", "ℤ", "ℕ"):
|
| 112 |
+
num_vars += names.split()
|
| 113 |
+
elif re.fullmatch(r"0 < [\w']+", typ) or re.fullmatch(r"[\w']+ > 0", typ):
|
| 114 |
+
pos_hyps += names.split()
|
| 115 |
+
hints = [f"sq_nonneg ({a} - {b})" for a, b in combinations(num_vars, 2)]
|
| 116 |
+
hints += [f"sq_nonneg ({a} + {b})" for a, b in combinations(num_vars, 2)]
|
| 117 |
+
hints += [f"mul_pos {a} {b}" for a, b in combinations(pos_hyps, 2)]
|
| 118 |
+
hints += [f"sq_nonneg {a}" for a in num_vars]
|
| 119 |
+
return hints[:max_hints]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def fuse(*ranked: list[str], k: int = 16) -> list[str]:
|
| 123 |
+
"""RRF de los canales (nombres de declaración), sin duplicados."""
|
| 124 |
+
return [n for n, _ in rrf_fuse([r for r in ranked if r], k=60)][:k]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def log_prior(freq: int) -> float:
|
| 128 |
+
return math.log(max(freq, 1))
|
ragsolver/rag/proofs.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG de pruebas (DECISIONS #127): pruebas Lean verificadas de los problemas más parecidos al pedido.
|
| 2 |
+
|
| 3 |
+
El RAG de lemas no le aporta al demostrador (los lemas genéricos ya los conoce, DECISIONS #109-#115); lo que
|
| 4 |
+
le falta es cómo se resolvió un problema parecido (TacticToe, Tactician, Rango: recuperar pruebas, no sólo
|
| 5 |
+
premisas). Corpus: Goedel-LM/Lean-workbook-proofs (29 750 pruebas verificadas, MIT; la misma descarga que el
|
| 6 |
+
prior de `scripts/build_premise_prior.py`), sin las que coinciden con un enunciado de miniF2F. Se indexa el
|
| 7 |
+
enunciado informal (el comentario `/- … -/`) + el enunciado Lean con Qwen3-Embedding, el modelo del resto del
|
| 8 |
+
RAG, en la tabla `proofs`; la prueba va sin comentarios.
|
| 9 |
+
|
| 10 |
+
Construcción: `python -m ragsolver.rag.proofs build` (con `RAGSOLVER_EMBED_BACKEND=llama`, minutos en la GPU).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
import textwrap
|
| 19 |
+
from functools import lru_cache
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import yaml
|
| 23 |
+
|
| 24 |
+
TABLE = "proofs"
|
| 25 |
+
INSTRUCTION = "Given a competition mathematics problem, retrieve similar problems whose Lean 4 proofs use the same techniques"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _model() -> str:
|
| 29 |
+
return yaml.safe_load(open("configs/versions.yaml", encoding="utf-8"))["embedding"]["model"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def norm_statement(s: str) -> str:
|
| 33 |
+
"""Enunciado sin nombre ni espacios redundantes (para excluir los de miniF2F, como el prior)."""
|
| 34 |
+
s = re.sub(r"^\s*theorem\s+\S+", "", s.split(":= by", 1)[0])
|
| 35 |
+
return re.sub(r"\s+", " ", s).strip()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse(full_proof: str) -> dict | None:
|
| 39 |
+
"""Archivo de Lean Workbook -> {informal, statement, proof}; None si no hay `theorem … := by`."""
|
| 40 |
+
i = full_proof.find("theorem")
|
| 41 |
+
m = re.search(r":=\s*by\b", full_proof[i:]) if i >= 0 else None
|
| 42 |
+
if not m:
|
| 43 |
+
return None
|
| 44 |
+
body = re.sub(r"/-.*?-/", "", full_proof[i + m.end() :], flags=re.S)
|
| 45 |
+
lines = [l for l in body.splitlines() if l.strip() and not l.strip().startswith("--")]
|
| 46 |
+
proof = textwrap.dedent("\n".join(lines)).strip()
|
| 47 |
+
if not proof:
|
| 48 |
+
return None
|
| 49 |
+
return {"informal": " ".join(re.findall(r"/-(.*?)-/", full_proof[:i], re.S)).strip(), "statement": full_proof[i : i + m.start()].strip(), "proof": proof}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build(parquet: str, db_path: str, minif2f: list[str]) -> dict:
|
| 53 |
+
import pyarrow.parquet as pq
|
| 54 |
+
|
| 55 |
+
from ragsolver.rag.embed import embed_documents
|
| 56 |
+
from ragsolver.rag.index import build_table
|
| 57 |
+
|
| 58 |
+
excluded = {norm_statement(json.loads(l)["formal_statement"]) for path in minif2f for l in open(path, encoding="utf-8")}
|
| 59 |
+
rows, n_excl = [], 0
|
| 60 |
+
for r in pq.read_table(parquet).to_pylist():
|
| 61 |
+
p = parse(r["full_proof"])
|
| 62 |
+
if p is None:
|
| 63 |
+
continue
|
| 64 |
+
if norm_statement(p["statement"]) in excluded:
|
| 65 |
+
n_excl += 1
|
| 66 |
+
continue
|
| 67 |
+
rows.append({"problem_id": r["problem_id"], **p})
|
| 68 |
+
vecs = embed_documents([f"{r['informal']}\n{r['statement']}"[:1500] for r in rows], _model())
|
| 69 |
+
build_table(db_path, TABLE, ({**r, "embedding": v} for r, v in zip(rows, vecs)), fts_columns=None)
|
| 70 |
+
return {"n_rows": len(rows), "n_excluded_minif2f": n_excl}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@lru_cache(maxsize=1)
|
| 74 |
+
def _table(db_path: str):
|
| 75 |
+
from ragsolver.rag.index import open_table
|
| 76 |
+
|
| 77 |
+
return open_table(db_path, TABLE)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def similar(text: str, k: int = 3, max_sim: float = 0.95, db_path: str = "data/index/lancedb") -> list[dict]:
|
| 81 |
+
"""Las k pruebas más parecidas a `text` (informal + enunciado Lean) con similitud coseno < `max_sim`:
|
| 82 |
+
por encima es el mismo problema (o casi) y la prueba sería la respuesta, no un ejemplo."""
|
| 83 |
+
from ragsolver.rag.embed import embed_queries
|
| 84 |
+
from ragsolver.rag.index import vector_search
|
| 85 |
+
|
| 86 |
+
q = embed_queries([text[:1500]], _model(), instruction=INSTRUCTION)[0]
|
| 87 |
+
out = []
|
| 88 |
+
for row in vector_search(_table(db_path), q, 4 * k):
|
| 89 |
+
sim = sum(a * b for a, b in zip(q, row["embedding"])) # vectores normalizados
|
| 90 |
+
if sim < max_sim:
|
| 91 |
+
out.append({"problem_id": row["problem_id"], "statement": row["statement"], "proof": row["proof"], "sim": round(sim, 4)})
|
| 92 |
+
if len(out) == k:
|
| 93 |
+
break
|
| 94 |
+
return out
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main() -> None:
|
| 98 |
+
ap = argparse.ArgumentParser()
|
| 99 |
+
ap.add_argument("cmd", choices=["build", "preview"])
|
| 100 |
+
ap.add_argument("--parquet", default="data/raw/lean_workbook_proofs/data/train-00000-of-00001.parquet")
|
| 101 |
+
ap.add_argument("--minif2f", nargs="+", default=["data/eval/minif2f_v2/miniF2F_v2s.jsonl", "data/eval/minif2f_v2/miniF2F_v2c.jsonl"])
|
| 102 |
+
ap.add_argument("--db-path", default="data/index/lancedb")
|
| 103 |
+
ap.add_argument("--problems", nargs="*", default=[], help="preview: JSONL de problemas")
|
| 104 |
+
ap.add_argument("--max-sim", type=float, default=1.01)
|
| 105 |
+
args = ap.parse_args()
|
| 106 |
+
if args.cmd == "build":
|
| 107 |
+
print(json.dumps(build(args.parquet, args.db_path, args.minif2f)), flush=True)
|
| 108 |
+
return
|
| 109 |
+
for path in args.problems: # preview: qué recuperaría cada problema (para revisar contaminación a mano)
|
| 110 |
+
for l in Path(path).read_text(encoding="utf-8").splitlines():
|
| 111 |
+
p = json.loads(l)
|
| 112 |
+
print(f"\n## {p['problem_id']}\n{p['formal_statement'][:300]}")
|
| 113 |
+
for h in similar(f"{p['informal_statement']}\n{p['formal_statement']}", 3, args.max_sim, args.db_path):
|
| 114 |
+
print(f" [{h['sim']}] {h['problem_id']}: {h['statement'][:250]}")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
main()
|
ragsolver/rag/retrieve.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fase 3 — Servicio de recuperación y citación (docs/PLAN_RAG.md §6.3, §6.4, §8).
|
| 2 |
+
|
| 3 |
+
`Retriever.search(RetrievalRequest) -> RetrievalResult`:
|
| 4 |
+
plantilla por `mode` -> por consulta: denso top-N + FTS top-N (prefiltro SQL)
|
| 5 |
+
-> RRF multi-consulta y multi-canal -> rerank (cross-encoder) contra `rerank_against`
|
| 6 |
+
-> dedupe (figura vs. su problem card; máx. por módulo opcional) -> top-k
|
| 7 |
+
-> Citation[] + figuras adjuntas + evento JSONL.
|
| 8 |
+
|
| 9 |
+
Modo `error`: los identificadores Lean del mensaje se buscan de forma exacta
|
| 10 |
+
(`decl_name IN (...)`) y se fijan al tope del resultado; el resto de la consulta
|
| 11 |
+
va por los canales normales. Costo en llamadas LLM: siempre 0 (RNF-3).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import re
|
| 18 |
+
import time
|
| 19 |
+
import uuid
|
| 20 |
+
from functools import lru_cache
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import yaml
|
| 24 |
+
|
| 25 |
+
from ragsolver.logging.jsonl_writer import append_event
|
| 26 |
+
from ragsolver.rag.embed import embed_queries
|
| 27 |
+
from ragsolver.rag.index import exact_match, fts_search, open_table, rrf_fuse, sql_quote, vector_search
|
| 28 |
+
from ragsolver.rag.schema import (
|
| 29 |
+
Citation,
|
| 30 |
+
CitationScores,
|
| 31 |
+
Hit,
|
| 32 |
+
HitRecord,
|
| 33 |
+
RetrievalFilters,
|
| 34 |
+
RetrievalRequest,
|
| 35 |
+
RetrievalResult,
|
| 36 |
+
RetrievalTrace,
|
| 37 |
+
TraceEvent,
|
| 38 |
+
VersionsPin,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# `unknown identifier 'Nat.Prime.two_le'`, `unknown constant 'Foo.bar'`, `` `Foo.bar` ``
|
| 42 |
+
_QUOTED_IDENT_RE = re.compile(r"(?:identifier|constant|namespace|declaration)\s+'([^']+)'|`([^`\s]+)`")
|
| 43 |
+
_DOTTED_IDENT_RE = re.compile(r"\b[A-Z][A-Za-z0-9_']*(?:\.[A-Za-z0-9_']+)+\b")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def lean_identifiers(text: str) -> list[str]:
|
| 47 |
+
"""Identificadores Lean citados en un mensaje de error (sin duplicados, en orden)."""
|
| 48 |
+
found = [a or b for a, b in _QUOTED_IDENT_RE.findall(text)]
|
| 49 |
+
found += _DOTTED_IDENT_RE.findall(_QUOTED_IDENT_RE.sub(" ", text)) # los citados ya están; evita `Baz.qux` de `Baz.qux'`
|
| 50 |
+
return list(dict.fromkeys(f for f in found if f))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _mathlib_citation_label(row: dict) -> str:
|
| 54 |
+
return f"{row.get('decl_name', '?')} — {row.get('module_path', '?')}"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _mathlib_locator(row: dict) -> dict:
|
| 58 |
+
return {
|
| 59 |
+
"decl_name": row.get("decl_name"),
|
| 60 |
+
"module_path": row.get("module_path"),
|
| 61 |
+
"docs_url": row.get("docs_url"),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _informal_citation_label(row: dict) -> str:
|
| 66 |
+
item = row.get("item_number")
|
| 67 |
+
page = row.get("page_start")
|
| 68 |
+
suffix = f", ítem {item}" if item else ""
|
| 69 |
+
suffix += f", p. {page}" if page else ""
|
| 70 |
+
return f"{row.get('doc_id', '?')}{suffix}"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _informal_locator(row: dict) -> dict:
|
| 74 |
+
return {
|
| 75 |
+
"doc_id": row.get("doc_id"),
|
| 76 |
+
"page_start": row.get("page_start"),
|
| 77 |
+
"page_end": row.get("page_end"),
|
| 78 |
+
"item_number": row.get("item_number"),
|
| 79 |
+
"figure_ids": list(row.get("figure_ids") or []),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_LABEL_BUILDERS = {"mathlib": _mathlib_citation_label, "informal": _informal_citation_label}
|
| 84 |
+
_LOCATOR_BUILDERS = {"mathlib": _mathlib_locator, "informal": _informal_locator}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def mathlib_table_name() -> str:
|
| 88 |
+
"""MET-1: qué tabla LanceDB sirve el corpus `mathlib` — misma fuente de verdad
|
| 89 |
+
para el `Retriever` y para el pin de versiones que registran los agentes."""
|
| 90 |
+
return os.environ.get("RAGSOLVER_MATHLIB_TABLE", "mathlib")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def sql_filter(corpus: str, filters: RetrievalFilters) -> str | None:
|
| 94 |
+
"""Prefiltro SQL de LanceDB a partir de §8.1 `filters` (sólo los campos que aplican al corpus)."""
|
| 95 |
+
clauses = []
|
| 96 |
+
if filters.doc_ids:
|
| 97 |
+
clauses.append(f"doc_id IN ({', '.join(sql_quote(d) for d in filters.doc_ids)})")
|
| 98 |
+
if filters.unit_types:
|
| 99 |
+
clauses.append(f"unit_type IN ({', '.join(sql_quote(u) for u in filters.unit_types)})")
|
| 100 |
+
if corpus == "mathlib" and filters.kinds:
|
| 101 |
+
clauses.append(f"kind IN ({', '.join(sql_quote(k) for k in filters.kinds)})")
|
| 102 |
+
if corpus == "informal" and filters.math_area:
|
| 103 |
+
clauses.append(f"array_has_any(math_area, [{', '.join(sql_quote(a) for a in filters.math_area)}])")
|
| 104 |
+
return " AND ".join(clauses) or None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Retriever:
|
| 108 |
+
def __init__(self, db_path: str = "data/index/lancedb", versions_path: str = "configs/versions.yaml", config_path: str = "configs/retrieval.yaml"):
|
| 109 |
+
self.db_path = db_path
|
| 110 |
+
self.versions = yaml.safe_load(open(versions_path, encoding="utf-8"))
|
| 111 |
+
self.config = yaml.safe_load(open(config_path, encoding="utf-8")) if Path(config_path).exists() else {}
|
| 112 |
+
self.channels: tuple[str, ...] = ("dense", "sparse") # sólo para ablaciones de evaluate.py
|
| 113 |
+
self.trace_dir = "data/eval/traces"
|
| 114 |
+
# el corpus "mathlib" puede servirse desde otra tabla (p.ej. `mathlib_full`, 278k) sin tocar filtros ni citas
|
| 115 |
+
self.tables = {"mathlib": mathlib_table_name()}
|
| 116 |
+
|
| 117 |
+
@lru_cache(maxsize=8)
|
| 118 |
+
def _table(self, name: str):
|
| 119 |
+
return open_table(self.db_path, self.tables.get(name, name))
|
| 120 |
+
|
| 121 |
+
@lru_cache(maxsize=1)
|
| 122 |
+
def _reranker(self):
|
| 123 |
+
import torch
|
| 124 |
+
from sentence_transformers import CrossEncoder
|
| 125 |
+
|
| 126 |
+
# bf16 en CPU: mitad de RAM que fp32 (docs/DECISIONS.md #40); RAGSOLVER_EMBED_BF16=0 restaura fp32
|
| 127 |
+
dtype = torch.bfloat16 if os.environ.get("RAGSOLVER_EMBED_BF16", "1") == "1" else torch.float32
|
| 128 |
+
return CrossEncoder(self.versions["reranker"]["model"], device="cpu", model_kwargs={"torch_dtype": dtype})
|
| 129 |
+
|
| 130 |
+
# -- candidatos ------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
def _candidates(self, corpus: str, request: RetrievalRequest) -> tuple[dict, list[list[str]], list[str]]:
|
| 133 |
+
"""(filas por id, listas rankeadas para RRF, ids fijados al tope)."""
|
| 134 |
+
table = self._table(corpus)
|
| 135 |
+
where = sql_filter(corpus, request.filters)
|
| 136 |
+
instruction = (self.config.get("query_instructions") or {}).get(request.mode)
|
| 137 |
+
rows_by_id: dict = {}
|
| 138 |
+
ranked: list[list[str]] = []
|
| 139 |
+
pinned: list[str] = []
|
| 140 |
+
|
| 141 |
+
if request.mode == "error" and corpus == "mathlib":
|
| 142 |
+
idents = [i for q in request.queries for i in lean_identifiers(q)]
|
| 143 |
+
for row in exact_match(table, "decl_name", idents, where):
|
| 144 |
+
rows_by_id.setdefault(row["chunk_id"], row)
|
| 145 |
+
pinned.append(row["chunk_id"])
|
| 146 |
+
if idents: # tokens del identificador ("Nat Prime two_le") como consulta FTS extra
|
| 147 |
+
sparse = fts_search(table, " ".join(re.sub(r"[._']", " ", i) for i in idents), request.n_candidates, where)
|
| 148 |
+
for row in sparse:
|
| 149 |
+
rows_by_id.setdefault(row["chunk_id"], row)
|
| 150 |
+
ranked.append([r["chunk_id"] for r in sparse])
|
| 151 |
+
|
| 152 |
+
vectors = embed_queries(request.queries, model_name=self.versions["embedding"]["model"], instruction=instruction) if "dense" in self.channels else [None] * len(request.queries)
|
| 153 |
+
for q_text, q_vec in zip(request.queries, vectors):
|
| 154 |
+
if "dense" in self.channels:
|
| 155 |
+
dense = vector_search(table, q_vec, request.n_candidates, where)
|
| 156 |
+
for row in dense:
|
| 157 |
+
rows_by_id.setdefault(row["chunk_id"], row)
|
| 158 |
+
ranked.append([r["chunk_id"] for r in dense])
|
| 159 |
+
if "sparse" in self.channels:
|
| 160 |
+
sparse = fts_search(table, q_text, request.n_candidates, where)
|
| 161 |
+
for row in sparse:
|
| 162 |
+
rows_by_id.setdefault(row["chunk_id"], row)
|
| 163 |
+
ranked.append([r["chunk_id"] for r in sparse])
|
| 164 |
+
return rows_by_id, ranked, list(dict.fromkeys(pinned))
|
| 165 |
+
|
| 166 |
+
# -- dedupe ------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
def _dedupe(self, corpus: str, rows: list[dict]) -> list[dict]:
|
| 169 |
+
out: list[dict] = []
|
| 170 |
+
if corpus == "informal":
|
| 171 |
+
# figura cuyo problem card también fue recuperado: se colapsa en el card
|
| 172 |
+
# (que ya lleva `figure_ids`); la figura sola sigue siendo un hit válido.
|
| 173 |
+
ids = {r["chunk_id"] for r in rows}
|
| 174 |
+
rows = [r for r in rows if not (r.get("unit_type") == "figure" and r.get("parent_id") in ids)]
|
| 175 |
+
per_module: dict[str, int] = {}
|
| 176 |
+
limit = int(self.config.get("max_per_module") or 0)
|
| 177 |
+
for r in rows:
|
| 178 |
+
if corpus == "mathlib" and limit:
|
| 179 |
+
m = r.get("module_path", "")
|
| 180 |
+
per_module[m] = per_module.get(m, 0) + 1
|
| 181 |
+
if per_module[m] > limit:
|
| 182 |
+
continue
|
| 183 |
+
out.append(r)
|
| 184 |
+
return out
|
| 185 |
+
|
| 186 |
+
def _figures(self, figure_ids: list[str]) -> list[dict]:
|
| 187 |
+
if not figure_ids:
|
| 188 |
+
return []
|
| 189 |
+
try:
|
| 190 |
+
table = self._table("figures")
|
| 191 |
+
except Exception: # tabla `figures` ausente (índice construido sin corpus informal)
|
| 192 |
+
return []
|
| 193 |
+
rows = exact_match(table, "figure_id", figure_ids)
|
| 194 |
+
keep = ("figure_id", "image_path", "description", "page", "bbox", "figure_type", "labels")
|
| 195 |
+
return [{k: r.get(k) for k in keep} for r in rows]
|
| 196 |
+
|
| 197 |
+
# -- búsqueda ----------------------------------------------------------------
|
| 198 |
+
|
| 199 |
+
def search(self, request: RetrievalRequest) -> RetrievalResult:
|
| 200 |
+
t0 = time.time()
|
| 201 |
+
ablation = self.config.get("ablation") or {}
|
| 202 |
+
corpora = [c for c in request.corpora if c in (ablation.get("corpora") or request.corpora)]
|
| 203 |
+
if ablation.get("enabled", True) is False:
|
| 204 |
+
corpora = []
|
| 205 |
+
|
| 206 |
+
all_hits: list[Hit] = []
|
| 207 |
+
for corpus in corpora:
|
| 208 |
+
rows_by_id, ranked, pinned = self._candidates(corpus, request)
|
| 209 |
+
fused = rrf_fuse(ranked, k=int(self.config.get("rrf_k", 60)))
|
| 210 |
+
fused_score = dict(fused)
|
| 211 |
+
order_ids = pinned + [cid for cid, _ in fused if cid not in pinned]
|
| 212 |
+
candidates = self._dedupe(corpus, [rows_by_id[c] for c in order_ids if c in rows_by_id])[: request.n_candidates]
|
| 213 |
+
|
| 214 |
+
rerank_scores: dict[str, float] = {}
|
| 215 |
+
if request.rerank and candidates:
|
| 216 |
+
against = request.rerank_against or request.queries[0]
|
| 217 |
+
scores = self._reranker().predict([(against, r["text"]) for r in candidates])
|
| 218 |
+
rerank_scores = {r["chunk_id"]: float(s) for r, s in zip(candidates, scores)}
|
| 219 |
+
unpinned = sorted((r for r in candidates if r["chunk_id"] not in pinned), key=lambda r: rerank_scores[r["chunk_id"]], reverse=True)
|
| 220 |
+
candidates = [r for r in candidates if r["chunk_id"] in pinned] + unpinned
|
| 221 |
+
|
| 222 |
+
prefix = "M" if corpus == "mathlib" else "I"
|
| 223 |
+
for rank, row in enumerate(candidates[: request.k], start=1):
|
| 224 |
+
cid = row["chunk_id"]
|
| 225 |
+
citation = Citation(
|
| 226 |
+
citation_id=f"{prefix}{rank}",
|
| 227 |
+
chunk_id=cid,
|
| 228 |
+
corpus=corpus,
|
| 229 |
+
unit_type=row.get("unit_type", ""),
|
| 230 |
+
label=_LABEL_BUILDERS[corpus](row),
|
| 231 |
+
locator=_LOCATOR_BUILDERS[corpus](row),
|
| 232 |
+
scores=CitationScores(fused=fused_score.get(cid), rerank=rerank_scores.get(cid)),
|
| 233 |
+
retrieved_for=request.context,
|
| 234 |
+
snippet=(row.get("content_md") or row.get("text") or "")[:300],
|
| 235 |
+
)
|
| 236 |
+
figures = self._figures(list(row.get("figure_ids") or [])) if (corpus == "informal" and request.include_figures) else []
|
| 237 |
+
all_hits.append(
|
| 238 |
+
Hit(
|
| 239 |
+
chunk_id=cid,
|
| 240 |
+
corpus=corpus,
|
| 241 |
+
unit_type=row.get("unit_type", ""),
|
| 242 |
+
rank=rank,
|
| 243 |
+
text=row.get("text", ""),
|
| 244 |
+
content_md=row.get("content_md", ""),
|
| 245 |
+
metadata={k: v for k, v in row.items() if k not in ("embedding", "text", "content_md", "_fts_text", "_distance", "_score")},
|
| 246 |
+
figures=figures,
|
| 247 |
+
citation=citation,
|
| 248 |
+
)
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
latency_ms = (time.time() - t0) * 1000
|
| 252 |
+
pin = VersionsPin(
|
| 253 |
+
embedding_model=self.versions["embedding"]["model"],
|
| 254 |
+
reranker_model=self.versions["reranker"]["model"],
|
| 255 |
+
index_version=self.versions["index"]["version"],
|
| 256 |
+
mathlib_tag=self.versions["lean"]["mathlib_tag"],
|
| 257 |
+
mathlib_table=self.tables.get("mathlib", "mathlib"),
|
| 258 |
+
)
|
| 259 |
+
# TRZ-1: si el llamador pasa `context.run_id` (el orquestador pasa el pid de la corrida), el
|
| 260 |
+
# evento va al MISMO archivo que los eventos de los agentes de ese (config, problema) en vez
|
| 261 |
+
# de crear un archivo nuevo por búsqueda. Sin run_id (CLI, demo, evaluate.py) se mantiene el
|
| 262 |
+
# comportamiento anterior: un archivo por búsqueda, con sufijo aleatorio.
|
| 263 |
+
append_event(
|
| 264 |
+
TraceEvent(
|
| 265 |
+
run_id=request.context.run_id or f"{request.context.problem_id}:{uuid.uuid4().hex[:8]}",
|
| 266 |
+
problem_id=request.context.problem_id,
|
| 267 |
+
subgoal_id=request.context.subgoal_id,
|
| 268 |
+
attempt=request.context.attempt,
|
| 269 |
+
agent="retriever",
|
| 270 |
+
latency_ms=latency_ms,
|
| 271 |
+
llm_calls=0,
|
| 272 |
+
request=request.model_dump(mode="json"),
|
| 273 |
+
n_candidates=request.n_candidates,
|
| 274 |
+
hits=[HitRecord(chunk_id=h.chunk_id, rank=h.rank, scores=h.citation.scores) for h in all_hits],
|
| 275 |
+
versions=pin,
|
| 276 |
+
),
|
| 277 |
+
base_dir=self.trace_dir,
|
| 278 |
+
)
|
| 279 |
+
return RetrievalResult(
|
| 280 |
+
hits=all_hits,
|
| 281 |
+
citations=[h.citation for h in all_hits],
|
| 282 |
+
trace=RetrievalTrace(latency_ms=latency_ms, n_candidates=request.n_candidates, versions=pin),
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _cli(argv: list[str] | None = None) -> None:
|
| 287 |
+
"""`ragsolver search "query" --corpus mathlib --k 8` (docs/PLAN_RAG.md Fase 3, tarea 7)."""
|
| 288 |
+
import argparse
|
| 289 |
+
import json as _json
|
| 290 |
+
|
| 291 |
+
ap = argparse.ArgumentParser(prog="ragsolver search")
|
| 292 |
+
ap.add_argument("query", nargs="+", help="una o más consultas (se fusionan con RRF)")
|
| 293 |
+
ap.add_argument("--corpus", action="append", choices=["mathlib", "informal"], help="repetible; por defecto mathlib")
|
| 294 |
+
ap.add_argument("--mode", default="nl", choices=["nl", "formal", "error", "exemplar"])
|
| 295 |
+
ap.add_argument("--k", type=int, default=8)
|
| 296 |
+
ap.add_argument("--no-rerank", action="store_true")
|
| 297 |
+
ap.add_argument("--math-area", action="append", default=[])
|
| 298 |
+
ap.add_argument("--db-path", default="data/index/lancedb")
|
| 299 |
+
args = ap.parse_args(argv)
|
| 300 |
+
|
| 301 |
+
req = RetrievalRequest(
|
| 302 |
+
queries=args.query,
|
| 303 |
+
mode=args.mode,
|
| 304 |
+
corpora=args.corpus or ["mathlib"],
|
| 305 |
+
k=args.k,
|
| 306 |
+
rerank=not args.no_rerank,
|
| 307 |
+
filters={"math_area": args.math_area},
|
| 308 |
+
context={"problem_id": "cli", "agent": "cli"},
|
| 309 |
+
)
|
| 310 |
+
result = Retriever(db_path=args.db_path).search(req)
|
| 311 |
+
for hit in result.hits:
|
| 312 |
+
sc = hit.citation.scores
|
| 313 |
+
print(f"[{hit.citation.citation_id}] {hit.citation.label} (fused={sc.fused and round(sc.fused, 4)}, rerank={sc.rerank and round(sc.rerank, 3)})")
|
| 314 |
+
print(f" {hit.text[:160]!r}")
|
| 315 |
+
for fig in hit.figures:
|
| 316 |
+
print(f" fig {fig['figure_id']}: {fig['image_path']} — {(fig['description'] or '')[:100]!r}")
|
| 317 |
+
print(_json.dumps({"latency_ms": round(result.trace.latency_ms, 1), "n_hits": len(result.hits)}))
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
_cli()
|
ragsolver/rag/schema.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contratos tipados del módulo RAG (RNF-4).
|
| 2 |
+
|
| 3 |
+
Espejo exacto de docs/PLAN_RAG.md §7 (esquema de metadatos) y §8 (contratos del
|
| 4 |
+
recuperador). No importar nada de ragsolver.agents/ragsolver.orchestrator desde
|
| 5 |
+
aquí — el RAG es una capa independiente que los agentes consumen (docs/PLAN_RAG.md §0).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from typing import Literal
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
Corpus = Literal["mathlib", "informal"]
|
| 16 |
+
QueryMode = Literal["nl", "formal", "error", "exemplar"]
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# §7.1 — documents
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Document(BaseModel):
|
| 24 |
+
doc_id: str
|
| 25 |
+
title: str = ""
|
| 26 |
+
authors: list[str] = Field(default_factory=list)
|
| 27 |
+
year: int | None = None
|
| 28 |
+
source_type: Literal["olympiad_collection", "paper", "textbook", "notes", "mathlib"]
|
| 29 |
+
language: str = "en"
|
| 30 |
+
file_path: str = ""
|
| 31 |
+
sha256: str = ""
|
| 32 |
+
n_pages: int = 0
|
| 33 |
+
url: str = ""
|
| 34 |
+
license: str = ""
|
| 35 |
+
redistributable: bool = False
|
| 36 |
+
parser: str = ""
|
| 37 |
+
parser_version: str = ""
|
| 38 |
+
parser_tier: str = ""
|
| 39 |
+
text_layer_reliable: bool | None = None
|
| 40 |
+
ingested_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 41 |
+
index_version: str = ""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# §7.2 — chunks (núcleo común) + §7.3/§7.4 extensiones por corpus
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ChunkBase(BaseModel):
|
| 50 |
+
"""Campos comunes a las dos tablas LanceDB (`mathlib`, `informal`)."""
|
| 51 |
+
|
| 52 |
+
chunk_id: str
|
| 53 |
+
doc_id: str
|
| 54 |
+
corpus: Corpus
|
| 55 |
+
unit_type: str
|
| 56 |
+
text: str # lo que se embebe y se indexa en BM25/FTS
|
| 57 |
+
embedding: list[float] | None = None
|
| 58 |
+
embedding_model: str = ""
|
| 59 |
+
embedding_dim: int = 0
|
| 60 |
+
index_version: str = ""
|
| 61 |
+
content_sha: str = ""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class InformalChunk(ChunkBase):
|
| 65 |
+
"""Tabla `informal`: problem_card, solution_step, figure, paper_section, theorem."""
|
| 66 |
+
|
| 67 |
+
corpus: Literal["informal"] = "informal"
|
| 68 |
+
parent_id: str | None = None
|
| 69 |
+
children_ids: list[str] = Field(default_factory=list)
|
| 70 |
+
order: int = 0
|
| 71 |
+
content_md: str = ""
|
| 72 |
+
context_summary: str = ""
|
| 73 |
+
page_start: int | None = None
|
| 74 |
+
page_end: int | None = None
|
| 75 |
+
bboxes: list[list[float]] = Field(default_factory=list) # [page, x0, y0, x1, y1] normalizados
|
| 76 |
+
section_path: list[str] = Field(default_factory=list)
|
| 77 |
+
item_number: int | None = None
|
| 78 |
+
language: str = "es"
|
| 79 |
+
math_area: list[str] = Field(default_factory=list)
|
| 80 |
+
techniques: list[str] = Field(default_factory=list)
|
| 81 |
+
theorems_used: list[str] = Field(default_factory=list)
|
| 82 |
+
mathlib_candidates: list[str] = Field(default_factory=list)
|
| 83 |
+
difficulty: int | None = None
|
| 84 |
+
keywords: list[str] = Field(default_factory=list)
|
| 85 |
+
figure_ids: list[str] = Field(default_factory=list)
|
| 86 |
+
refs_to: list[str] = Field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class MathlibChunk(ChunkBase):
|
| 90 |
+
"""Tabla `mathlib`: una declaración por registro (§7.4)."""
|
| 91 |
+
|
| 92 |
+
corpus: Literal["mathlib"] = "mathlib"
|
| 93 |
+
unit_type: Literal["mathlib_decl"] = "mathlib_decl"
|
| 94 |
+
decl_name: str
|
| 95 |
+
decl_names_alt: list[str] = Field(default_factory=list)
|
| 96 |
+
module_path: str = ""
|
| 97 |
+
kind: str = ""
|
| 98 |
+
signature: str = ""
|
| 99 |
+
type: str = ""
|
| 100 |
+
value: str = ""
|
| 101 |
+
informal_name: str = ""
|
| 102 |
+
informal_description: str = ""
|
| 103 |
+
docstring: str = ""
|
| 104 |
+
dependencies: list[str] = Field(default_factory=list)
|
| 105 |
+
mathlib_tag: str = ""
|
| 106 |
+
lean_version: str = ""
|
| 107 |
+
docs_url: str = ""
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class Figure(BaseModel):
|
| 111 |
+
"""§7.3 — tabla `figures`; también existe como InformalChunk(unit_type='figure')."""
|
| 112 |
+
|
| 113 |
+
figure_id: str
|
| 114 |
+
image_path: str
|
| 115 |
+
image_sha: str = ""
|
| 116 |
+
page: int
|
| 117 |
+
bbox: list[float]
|
| 118 |
+
detected_by: Literal["layout", "vector_cluster", "raster", "both"]
|
| 119 |
+
caption: str = ""
|
| 120 |
+
description: str = ""
|
| 121 |
+
labels: list[str] = Field(default_factory=list)
|
| 122 |
+
objects: list[str] = Field(default_factory=list)
|
| 123 |
+
relations: list[str] = Field(default_factory=list)
|
| 124 |
+
given_values: list[str] = Field(default_factory=list)
|
| 125 |
+
figure_type: Literal["geometric_diagram", "grid", "plot", "table_image", "other"] = "other"
|
| 126 |
+
linked_chunk_ids: list[str] = Field(default_factory=list)
|
| 127 |
+
description_model: str = ""
|
| 128 |
+
description_version: str = ""
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# §7.5 — Citation
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class RetrievalContext(BaseModel):
|
| 137 |
+
problem_id: str
|
| 138 |
+
subgoal_id: str = ""
|
| 139 |
+
attempt: int = 0
|
| 140 |
+
agent: str = ""
|
| 141 |
+
run_id: str = "" # TRZ-1: si se da, el evento de traza va al archivo de esta corrida en vez de uno por búsqueda
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class CitationScores(BaseModel):
|
| 145 |
+
dense: float | None = None
|
| 146 |
+
sparse: float | None = None
|
| 147 |
+
fused: float | None = None
|
| 148 |
+
rerank: float | None = None
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class Citation(BaseModel):
|
| 152 |
+
citation_id: str
|
| 153 |
+
chunk_id: str
|
| 154 |
+
corpus: Corpus
|
| 155 |
+
unit_type: str
|
| 156 |
+
label: str
|
| 157 |
+
locator: dict = Field(default_factory=dict)
|
| 158 |
+
scores: CitationScores = Field(default_factory=CitationScores)
|
| 159 |
+
retrieved_for: RetrievalContext
|
| 160 |
+
used_in_proof: bool = False
|
| 161 |
+
used_in_sketch: bool = False
|
| 162 |
+
snippet: str = ""
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
# §7.6 — evento de traza JSONL (RNF-5)
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class VersionsPin(BaseModel):
|
| 171 |
+
embedding_model: str = ""
|
| 172 |
+
reranker_model: str = ""
|
| 173 |
+
index_version: str = ""
|
| 174 |
+
mathlib_tag: str = ""
|
| 175 |
+
mathlib_table: str = "" # MET-1: qué tabla sirve el corpus `mathlib` (`mathlib` muestra / `mathlib_full`)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class HitRecord(BaseModel):
|
| 179 |
+
chunk_id: str
|
| 180 |
+
rank: int
|
| 181 |
+
scores: CitationScores = Field(default_factory=CitationScores)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class TraceEvent(BaseModel):
|
| 185 |
+
run_id: str
|
| 186 |
+
problem_id: str
|
| 187 |
+
subgoal_id: str = ""
|
| 188 |
+
attempt: int = 0
|
| 189 |
+
agent: str = "retriever"
|
| 190 |
+
ts: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 191 |
+
latency_ms: float = 0.0
|
| 192 |
+
llm_calls: int = 0 # el retriever SIEMPRE reporta 0 (RNF-3, §6.3 del plan)
|
| 193 |
+
tokens: int = 0
|
| 194 |
+
request: dict = Field(default_factory=dict)
|
| 195 |
+
n_candidates: int = 0
|
| 196 |
+
hits: list[HitRecord] = Field(default_factory=list)
|
| 197 |
+
versions: VersionsPin = Field(default_factory=VersionsPin)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
# §8.1/§8.2 — contratos del recuperador
|
| 202 |
+
# ---------------------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class RetrievalFilters(BaseModel):
|
| 206 |
+
math_area: list[str] = Field(default_factory=list)
|
| 207 |
+
doc_ids: list[str] = Field(default_factory=list)
|
| 208 |
+
unit_types: list[str] = Field(default_factory=list)
|
| 209 |
+
kinds: list[str] = Field(default_factory=list)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class RetrievalRequest(BaseModel):
|
| 213 |
+
queries: list[str] = Field(min_length=1, max_length=4)
|
| 214 |
+
rerank_against: str | None = None
|
| 215 |
+
mode: QueryMode = "nl"
|
| 216 |
+
corpora: list[Corpus] = Field(default_factory=lambda: ["mathlib"])
|
| 217 |
+
k: int = 8
|
| 218 |
+
n_candidates: int = 50
|
| 219 |
+
filters: RetrievalFilters = Field(default_factory=RetrievalFilters)
|
| 220 |
+
rerank: bool = True
|
| 221 |
+
include_figures: bool = True
|
| 222 |
+
context: RetrievalContext
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class Hit(BaseModel):
|
| 226 |
+
chunk_id: str
|
| 227 |
+
corpus: Corpus
|
| 228 |
+
unit_type: str
|
| 229 |
+
rank: int
|
| 230 |
+
text: str
|
| 231 |
+
content_md: str = ""
|
| 232 |
+
metadata: dict = Field(default_factory=dict)
|
| 233 |
+
figures: list[dict] = Field(default_factory=list)
|
| 234 |
+
citation: Citation
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
class RetrievalTrace(BaseModel):
|
| 238 |
+
latency_ms: float = 0.0
|
| 239 |
+
n_candidates: int = 0
|
| 240 |
+
versions: VersionsPin = Field(default_factory=VersionsPin)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class RetrievalResult(BaseModel):
|
| 244 |
+
hits: list[Hit] = Field(default_factory=list)
|
| 245 |
+
citations: list[Citation] = Field(default_factory=list)
|
| 246 |
+
trace: RetrievalTrace = Field(default_factory=RetrievalTrace)
|