Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,23 +192,18 @@ def get_task_provider_model(self, plan: str, task: str) -> tuple[str, str]:
oauth_retries: int = Field(default=2, ge=1, le=4)

# -------------------------------------------------------------------------
# Quality Gate post-render (BREAK-GLASS toggles)
# Quality Gate post-render
# -------------------------------------------------------------------------
# Estos flags existen como mecanismo de hotfix en producción si la inspección
# post-render comienza a rechazar documentos legítimos. EN CONDICIONES NORMALES
# AMBOS DEBEN PERMANECER `True`. Cambiarlos es decisión consciente que afecta
# la integridad del producto y debe quedar registrada en el incidente.
#
# `quality_gate_enabled=False` desactiva por completo la auditoría: el pipeline
# vuelve al comportamiento previo (status decidido sólo por pipeline_warnings).
# Útil únicamente si el gate falla de forma sistémica y no podemos hacer rollback.
#
# `quality_gate_block_on_p0=False` mantiene la auditoría activa, registra los
# findings en metadata, pero NO escala a status `FAILED` cuando hay
# severity=blocked. El documento se entrega como `completed_with_warnings`
# de forma excepcional. Mantener este flag en True salvo emergencia.
# `quality_gate_block_on_p0=False` (default) mantiene la auditoría activa y
# registra los findings en metadata, pero NO escala a status `FAILED` cuando
# hay severity=blocked. El documento siempre se entrega como
# `completed_with_warnings`. Cambiar a True solo para pruebas o si se requiere
# bloqueo estricto en ambientes controlados.
quality_gate_enabled: bool = Field(default=True)
quality_gate_block_on_p0: bool = Field(default=True)
quality_gate_block_on_p0: bool = Field(default=False)

# Cuando el renderer de Mermaid falla, omitimos el bloque por completo —
# nunca insertamos el código fuente crudo en el documento final. Este flag
Expand Down
29 changes: 14 additions & 15 deletions backend/app/core/document/assemblers/word/assembly_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ def _looks_like_figure_caption(text: str) -> bool:
"""Heurística: True si `text` parece ser caption de figura.

Cubre los formatos que VisualInjector y captions del LLM producen:
``Figura 3: Texto``, ``Figura 3. Texto``, ``§§FIGURE_AUTO§§: Texto``.
``Figura 3: Texto``, ``Figura 3. Texto``, ``Figura: Texto``,
``Figura. Texto``, ``§§FIGURE_AUTO§§: Texto``.
Se usa en `_render_body` para descartar captions huérfanos cuando el
visual previo falló en renderizar.
"""
Expand All @@ -87,7 +88,8 @@ def _looks_like_figure_caption(text: str) -> bool:
if stripped.startswith(FIGURE_AUTO_SENTINEL):
return True
# Patrones tolerantes; el LLM puede usar : . – — como separador.
return bool(re.match(r"^Figura\s+\d+\s*[:.\-—]", stripped, flags=re.IGNORECASE))
# Acepta tanto "Figura N:" (con número) como "Figura:" (sin número).
return bool(re.match(r"^Figura(?:\s+\d+)?\s*[:.\-—]", stripped, flags=re.IGNORECASE))


def _resolve_visual_caption(
Expand Down Expand Up @@ -195,9 +197,9 @@ def _validate_chart_provenance(

_WHITE = RGBColor(0xFF, 0xFF, 0xFF)

# Patrones para detectar caption con etiqueta secuencial ("Tabla 3.", "Figura 2:")
# que el LLM puede haber emitido con números inconsistentes. El assembler
# reemplaza el N entrante por el counter monótono que mantiene esta clase.
# Patrones para detectar caption con etiqueta de tabla o figura que el LLM
# puede haber emitido con números. El assembler elimina el número para
# producir etiquetas sin numeración secuencial ("Tabla.", "Figura.").
_RE_TABLE_LABEL = re.compile(r"^(\s*)(?:Tabla|Cuadro)\s+\d+\s*[.:\-—]?\s*", re.IGNORECASE)
_RE_FIGURE_LABEL = re.compile(r"^(\s*)(?:Figura|Fig\.?)\s+\d+\s*[.:\-—]?\s*", re.IGNORECASE)

Expand Down Expand Up @@ -965,28 +967,25 @@ def _add_caption(self, doc: Document, text: str, school: SchoolConfig) -> None:
run.font.color.rgb = hex_to_rgb("5D6D7E") # gris azulado, distinguible pero sobrio

def _renumber_caption_label(self, text: str) -> str:
"""Reescribe etiquetas "Tabla N." / "Figura N." con counters monótonos.
"""Normaliza etiquetas "Tabla N." / "Figura N." eliminando el número.

Retorna el texto original si no detecta una etiqueta. Si detecta:
- "Tabla X" → "Tabla {table_counter+1}"
- "Figura X" / "Fig. X" → "Figura {figure_counter_in_captions+1}"
- "Tabla X: Texto" → "Tabla. Texto"
- "Figura X: Texto" / "Fig. X: Texto" → "Figura. Texto"

El counter se incrementa en cada renumeración. Los charts/mermaid y
las imágenes reales tienen su propia secuencia (asignada antes de
llegar al assembler) y no pasan por aquí.
No se aplica numeración secuencial: los documentos académicos de
Studymation no incluyen numeración de figuras ni tablas.
"""
if not text:
return text
m_table = _RE_TABLE_LABEL.match(text)
if m_table:
self._table_counter += 1
rest = text[m_table.end() :].lstrip()
return f"Tabla {self._table_counter}. {rest}".rstrip()
return f"Tabla. {rest}".rstrip()
m_fig = _RE_FIGURE_LABEL.match(text)
if m_fig:
self._figure_counter_in_captions += 1
rest = text[m_fig.end() :].lstrip()
return f"Figura {self._figure_counter_in_captions}. {rest}".rstrip()
return f"Figura. {rest}".rstrip()
return text

def _add_docx_table(self, doc: Document, rows: list[list[str]], school: SchoolConfig) -> None:
Expand Down
74 changes: 35 additions & 39 deletions backend/app/core/document/figure_registry.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
"""
FigureRegistry — numeración estable de figuras del documento.

Reemplaza la asignación pre-baked de `figure_number` que hacían VisualInjector
y RealImageStep. El problema con la asignación previa: si una figura fallaba
(mermaid render error, image download error), el documento mostraba Figura 1,
Figura 3, sin Figura 2 — porque el slot de la fallida quedaba con número
pero sin contenido o se omitía entero dejando un hueco en la secuencia.

La solución:

1. Cada productor (`VisualInjector`, `RealImageStep`) llama a `register()` para
reservar un *token* (string opaco). El token se inserta en la caption del
bloque visual como sustituto de "Figura N" — el N final aún no se conoce.
2. Cuando el assembler intenta renderizar el visual y falla (mmdc inválido,
descarga fallida), invoca `mark_failed(token)` para marcar el slot como
abortado.
3. Al cierre del ensamblado, antes de serializar, `assign_numbers()` recorre
los tokens en orden de inserción y asigna 1..N solo a los slots aún vivos.
El assembler hace un segundo pase de string-replace sobre los runs/captions
sustituyendo cada token por la etiqueta final "Figura N.".
FigureRegistry — gestión de figuras del documento sin numeración secuencial.

Cada productor (`VisualInjector`, `RealImageStep`) llama a `register()` para
reservar un *token* (string opaco). El token se inserta en el caption del
bloque visual. Cuando el assembler intenta renderizar el visual y falla
(mmdc inválido, descarga fallida), invoca `mark_failed(token)` para marcar
el slot como abortado y que el caption se elimine limpiamente.

Al cierre del ensamblado, `assign_numbers()` identifica qué slots sobrevivieron
(no fallidos) y `replace_tokens_in_text()` reemplaza cada token por la
etiqueta simple "Figura" (sin número secuencial). Los slots fallidos se
eliminan junto con su caption adyacente.

Sin numeración secuencial no hay riesgo de huecos (Figura 1, Figura 3 sin
Figura 2) cuando algún visual falla en renderizar.

Este módulo NO depende de python-docx ni del LLM. Es completamente puro y
fácil de testear. Una instancia es válida solo para una corrida del pipeline
Expand Down Expand Up @@ -170,18 +165,18 @@ def slot_by_token(self, token: str) -> FigureSlot | None:
def replace_tokens_in_text(self, text: str) -> str:
"""Reemplaza todos los tokens del texto por su etiqueta final.

Tokens de slots fallidos se eliminan junto con la palabra "Figura {token}"
o el separador adyacente para no dejar caption huérfano. Tokens sin
número asignado (caso imposible si `assign_numbers()` corrió) se borran.
Los slots vivos (no fallidos) se sustituyen por la etiqueta "Figura"
sin número secuencial. Los slots fallidos se eliminan junto con el
prefijo "Figura " adyacente para no dejar captions huérfanos.

Esta función es la herramienta principal del assembler para resolver
captions tras el render.
"""
if not text or _TOKEN_PREFIX not in text:
return text
if not self._numbers_assigned:
# Sin números aún: no podemos reemplazar de forma significativa.
# Eliminar tokens para no dejarlos crudos en el documento.
# assign_numbers() no corrió aún: eliminar tokens para no
# dejarlos crudos en el documento.
return self._strip_tokens(text)

out = text
Expand All @@ -190,7 +185,8 @@ def replace_tokens_in_text(self, text: str) -> str:
if tok not in out:
continue
if slot.final_number is not None:
out = out.replace(tok, f"Figura {slot.final_number}")
# Slot vivo: reemplazar token con etiqueta simple sin número.
out = out.replace(tok, "Figura")
else:
out = self._remove_failed_token_phrase(out, tok)
return out
Expand All @@ -208,19 +204,20 @@ def _strip_tokens(text: str) -> str:

@staticmethod
def _remove_failed_token_phrase(text: str, token: str) -> str:
"""Elimina el token y el separador residual ("Figura " redundante,
": " o ". " si quedan al principio o aislados)."""
"""Elimina el token de un slot fallido y el prefijo "Figura" adyacente.

Cubre los formatos que produce `_resolve_visual_caption`:
``Figura [[FIGREG-...]]: Texto`` y ``[[FIGREG-...]]: Texto``.
"""
import re

# Patrones comunes que el VisualInjector arma: "Figura [[FIGREG-...]]: Texto"
# o "Figura [[FIGREG-...]]. Texto" o "Figura [[FIGREG-...]]".
escaped = re.escape(token)
# Caso completo "Figura <token>[:.]\s*" → eliminar todo el prefijo.
text = re.sub(rf"Figura\s+{escaped}\s*[:.\-—]?\s*", "", text)
# Cualquier ocurrencia residual aislada del token también se elimina.
# Caso "Figura <token>[:.]\s*" → eliminar el bloque completo de etiqueta.
text = re.sub(rf"(?i)Figura\s+{escaped}\s*[:.\-—]?\s*", "", text)
# Caso donde el token está solo al inicio sin "Figura " delante.
text = text.replace(token, "")
# Limpieza de espacios duplicados.
text = re.sub(r"\s{2,}", " ", text).strip(" -—.")
# Limpieza de separadores residuales al inicio y espacios duplicados.
text = re.sub(r"\s{2,}", " ", text).strip(" :.-—")
return text

# -------------------------------------------------------------------------
Expand All @@ -231,18 +228,17 @@ def summary(self) -> dict[str, object]:
"""Resumen para QualityReport.figure_summary."""
by_kind: dict[str, dict[str, int]] = {}
for slot in self._slots:
entry = by_kind.setdefault(slot.kind, {"registered": 0, "failed": 0, "numbered": 0})
entry = by_kind.setdefault(slot.kind, {"registered": 0, "failed": 0, "rendered": 0})
entry["registered"] += 1
if slot.failed:
entry["failed"] += 1
elif slot.final_number is not None:
entry["numbered"] += 1
entry["rendered"] += 1
return {
"by_kind": by_kind,
"total_registered": len(self._slots),
"total_failed": sum(1 for s in self._slots if s.failed),
"total_numbered": sum(1 for s in self._slots if s.final_number is not None),
"sequence": [s.final_number for s in self._slots if s.final_number is not None],
"total_rendered": sum(1 for s in self._slots if s.final_number is not None),
}

# -------------------------------------------------------------------------
Expand Down
Loading
Loading