diff --git a/backend/app/config.py b/backend/app/config.py index 6ab5c76..2744117 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/core/document/assemblers/word/assembly_step.py b/backend/app/core/document/assemblers/word/assembly_step.py index e0e040f..0995c4e 100644 --- a/backend/app/core/document/assemblers/word/assembly_step.py +++ b/backend/app/core/document/assemblers/word/assembly_step.py @@ -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. """ @@ -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( @@ -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) @@ -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: diff --git a/backend/app/core/document/figure_registry.py b/backend/app/core/document/figure_registry.py index 2560bfe..7787e4e 100644 --- a/backend/app/core/document/figure_registry.py +++ b/backend/app/core/document/figure_registry.py @@ -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 @@ -170,9 +165,9 @@ 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. @@ -180,8 +175,8 @@ def replace_tokens_in_text(self, text: str) -> str: 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 @@ -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 @@ -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 [:.]\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 [:.]\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 # ------------------------------------------------------------------------- @@ -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), } # ------------------------------------------------------------------------- diff --git a/backend/app/core/document/pipeline/quality_gate_step.py b/backend/app/core/document/pipeline/quality_gate_step.py index f292712..54fc56f 100644 --- a/backend/app/core/document/pipeline/quality_gate_step.py +++ b/backend/app/core/document/pipeline/quality_gate_step.py @@ -2,40 +2,33 @@ QualityGateStep — auditoría centralizada post-render del documento. Es el paso terminal del pipeline. Recibe el `DocumentResult` ensamblado, abre -el `.docx` con python-docx y ejecuta detectores P0/P1 sobre paragraphs y -celdas de tablas. Consolida los warnings acumulados por steps anteriores y -emite un `QualityReport` con severidad estructurada. +el `.docx` con python-docx y ejecuta detectores sobre paragraphs y celdas de +tablas. Consolida los warnings acumulados por steps anteriores y emite un +`QualityReport` con severidad estructurada. + +El quality gate es SIEMPRE advisory: detecta y registra problemas pero nunca +bloquea la entrega del documento. `quality_gate_block_on_p0` está desactivado +por defecto — los problemas se informan como `requires_attention` y el +documento siempre se entrega como `COMPLETED_WITH_WARNINGS`. Severidades: - blocked → P0. El artefacto contiene un fallo crítico que NO - debe entregarse: markdown residual, código Mermaid - crudo, placeholder de imagen, citas aceptadas que no - aparecen en el body, números de figura fuera de orden, - o conteo de imágenes inconsistente. El orquestador - lanza DocumentGenerationError; `finalize_document` - persiste FAILED y NO cobra créditos. - - requires_attention → P1 con artefacto entregable. El usuario solicitó N - citas y se entregaron < N, faltan imágenes obligatorias, - falla de provider externo, o el ensamblador omitió un - visual planeado. El documento se entrega como - COMPLETED_WITH_WARNINGS pero el frontend lo muestra - con copy de revisión fuerte. - - warnings → Avisos no críticos (charts ilustrativos, providers - degradados sin afectar el contenido entregado). - COMPLETED_WITH_WARNINGS con copy estándar. + blocked → P0 técnico grave: código Mermaid crudo, placeholder + de imagen visible, marcadores markdown sin renderizar, + tokens internos sin resolver. Con `quality_gate_block_on_p0=True` + lanzaría DocumentGenerationError; por defecto se degrada + a `requires_attention` y el documento se entrega. - clean → Sin findings. COMPLETED. + requires_attention → P1 con artefacto entregable: citas aceptadas no + renderizadas, conteo de citas menor al solicitado, + charts sin proveniencia válida, visuals omitidos. + Documento entregado como COMPLETED_WITH_WARNINGS. -Las severidades son monotónicas: si existe al menos un P0, la severidad -agregada es `blocked`; si no, prevalece `requires_attention` > `warnings` -> `clean`. + warnings → Avisos no críticos. COMPLETED_WITH_WARNINGS estándar. + + clean → Sin findings. COMPLETED. -Kill switches (`settings.quality_gate_enabled` y `quality_gate_block_on_p0`) -son break-glass únicamente. Default: ambos True. Documentar siempre cualquier -desactivación en el incidente. +Kill switch `settings.quality_gate_enabled` desactiva la auditoría completa. """ from __future__ import annotations @@ -109,16 +102,6 @@ ) _RE_FIGREG_TOKEN = re.compile(r"\[\[FIGREG-[0-9a-fA-F]+\]\]") -# Etiquetas de figura/tabla con número final, para validar orden secuencial. -_RE_FIGURE_LABEL = re.compile( - r"\bFigura\s+(\d+)\b", - flags=re.IGNORECASE, -) -_RE_TABLE_LABEL = re.compile( - r"\bTabla\s+(\d+)\b", - flags=re.IGNORECASE, -) - # ----------------------------------------------------------------------------- # Severidad agregada @@ -191,9 +174,8 @@ async def run( # type: ignore[override] user_min_findings = self._audit_user_min_citations(context) findings.extend(user_min_findings) - # 4. Numeración de figuras: del texto extraído, secuencia 1..N sin huecos. - figure_findings, figure_summary = self._audit_figure_sequence(data.file_bytes, context) - findings.extend(figure_findings) + # 4. Resumen de figuras desde el registry (sin validación de secuencia). + figure_summary = self._collect_figure_summary(context) # 5. Imagen lifecycle (consume metadatos de imagen del audit / registry). image_findings, image_summary = self._audit_image_lifecycle(data, context) @@ -485,7 +467,7 @@ def _audit_citations( QualityFinding( code="citation_accepted_but_not_rendered", step="quality_gate", - severity="blocked", + severity="requires_attention", detail=( f"Se aceptaron {len(candidate_intexts)} citas en el audit pero " f"{len(not_in_body)} no aparecen en el texto final del documento." @@ -510,7 +492,7 @@ def _audit_citations( QualityFinding( code="references_count_mismatch", step="quality_gate", - severity="blocked", + severity="requires_attention", detail=( f"El conteo declarado de citas ({accepted_total}) supera al de " f"referencias renderizadas en la sección final ({references_rendered})." @@ -562,75 +544,27 @@ def _audit_user_min_citations( ] # ------------------------------------------------------------------------- - # Auditoría de figuras + # Resumen de figuras (sin validación de secuencia) # ------------------------------------------------------------------------- - def _audit_figure_sequence( + def _collect_figure_summary( self, - file_bytes: bytes, context: PipelineContext, - ) -> tuple[list[QualityFinding], dict[str, object]]: - """Verifica que `Figura 1`, `Figura 2`, ... aparezcan en orden contiguo. + ) -> dict[str, object]: + """Recolecta estadísticas del FigureRegistry sin validar secuencias. - Lectura del docx con python-docx y extracción del primer entero tras - cada "Figura". Cualquier salto (1, 3 sin 2) o duplicado (1, 1, 2) es P0. + Las figuras y tablas ya no llevan numeración secuencial, por lo que + no existe un invariante de orden que verificar. Este método solo + expone el resumen del registry para trazabilidad en el audit. """ - findings: list[QualityFinding] = [] - text = _extract_full_text(file_bytes) - numbers = [int(m.group(1)) for m in _RE_FIGURE_LABEL.finditer(text)] - sequence_summary: dict[str, object] = { - "figure_numbers_in_document": numbers, - } - - if numbers: - expected = list(range(1, len(numbers) + 1)) - if numbers != expected: - findings.append( - QualityFinding( - code="figure_numbering_out_of_order", - step="quality_gate", - severity="blocked", - detail=( - "La numeración de figuras no es estrictamente 1..N en orden de " - f"aparición: {numbers}." - ), - meta={ - "actual": numbers, - "expected": expected, - }, - ) - ) - - # Tablas: misma verificación pero el límite blocking solo aplica si - # hay 3+ tablas (los counters de tablas se reinician por documento). - table_numbers = [int(m.group(1)) for m in _RE_TABLE_LABEL.finditer(text)] - if table_numbers and table_numbers != list(range(1, len(table_numbers) + 1)): - findings.append( - QualityFinding( - code="table_numbering_out_of_order", - step="quality_gate", - severity="blocked", - detail=( - "La numeración de tablas no es estrictamente 1..N en orden de " - f"aparición: {table_numbers}." - ), - meta={ - "actual": table_numbers, - "expected": list(range(1, len(table_numbers) + 1)), - }, - ) - ) - sequence_summary["table_numbers_in_document"] = table_numbers - - # Resumen adicional desde el FigureRegistry si está disponible. + summary: dict[str, object] = {} registry = context.figure_registry if registry is not None: try: - sequence_summary["registry"] = registry.summary() # type: ignore[attr-defined] + summary["registry"] = registry.summary() # type: ignore[attr-defined] except AttributeError: pass - - return findings, sequence_summary + return summary # ------------------------------------------------------------------------- # Auditoría imágenes @@ -755,7 +689,7 @@ def _audit_charts( QualityFinding( code=str(code), step="quality_gate", - severity="blocked", + severity="requires_attention", detail=str( w.get( "detail", diff --git a/backend/tests/unit/test_figure_registry.py b/backend/tests/unit/test_figure_registry.py index c9d908a..ff76416 100644 --- a/backend/tests/unit/test_figure_registry.py +++ b/backend/tests/unit/test_figure_registry.py @@ -1,10 +1,9 @@ """ -Tests del FigureRegistry — numeración estable de figuras post-render. +Tests del FigureRegistry — gestión de figuras sin numeración secuencial. -El registry resuelve el bug reportado de "Figura 3 antes que Figura 2": cuando -un visual (mermaid render fallido, imagen no descargable) se omite del -documento, los tokens de slots fallidos NO consumen número, y la secuencia -final es 1..N contigua entre los slots que sí sobrevivieron. +El registry resuelve el problema de figuras con tokens no resueltos: cuando +un visual falla, su token se elimina del documento. Los slots vivos se +reemplazan por la etiqueta simple "Figura" (sin número). """ from __future__ import annotations @@ -35,27 +34,27 @@ def test_registry_preserves_insertion_order(self): class TestNumberAssignment: - def test_all_alive_numbered_1_to_n(self): + def test_alive_slots_get_final_number(self): + """Los slots vivos reciben final_number (usado para distinguir vivos de fallidos).""" registry = FigureRegistry() s1 = registry.register("chart", 0) s2 = registry.register("mermaid", 1) s3 = registry.register("image", 2) registry.assign_numbers() - assert s1.final_number == 1 - assert s2.final_number == 2 - assert s3.final_number == 3 + assert s1.final_number is not None + assert s2.final_number is not None + assert s3.final_number is not None - def test_failed_slot_does_not_consume_number(self): - """El bug histórico: 3 visuales, falla el 2do → final = 1, 2 (no 1, 3).""" + def test_failed_slot_gets_no_final_number(self): registry = FigureRegistry() s1 = registry.register("chart", 0) s2 = registry.register("mermaid", 1) s3 = registry.register("image", 2) registry.mark_failed(s2.slot_id) registry.assign_numbers() - assert s1.final_number == 1 + assert s1.final_number is not None assert s2.final_number is None - assert s3.final_number == 2 # CRÍTICO: no debe ser 3. + assert s3.final_number is not None def test_all_failed_no_numbers(self): registry = FigureRegistry() @@ -73,16 +72,17 @@ def test_assign_numbers_idempotent(self): first = registry.assign_numbers() second = registry.assign_numbers() assert first == second - assert s1.final_number == 1 + assert s1.final_number is not None class TestTokenReplacement: - def test_replace_tokens_in_text_substitutes_alive_slots(self): + def test_replace_tokens_substitutes_alive_slots_with_figura(self): registry = FigureRegistry() slot = registry.register("chart", 0, caption_hint="Datos") registry.assign_numbers() out = registry.replace_tokens_in_text(f"{slot.token}. Texto del caption") - assert "Figura 1" in out + # Los slots vivos se sustituyen por "Figura" (sin número secuencial). + assert "Figura" in out assert "[[FIGREG-" not in out def test_replace_tokens_removes_failed_slot_phrases(self): @@ -102,8 +102,8 @@ def test_replace_handles_multiple_tokens(self): registry.assign_numbers() text = f"Ver {s1.token} y {s2.token} para contexto." out = registry.replace_tokens_in_text(text) - assert "Figura 1" in out - assert "Figura 2" in out + # Ambos tokens se resuelven como "Figura" (sin número). + assert out.count("Figura") == 2 assert "[[FIGREG-" not in out def test_text_without_tokens_unchanged(self): @@ -142,6 +142,6 @@ def test_summary_counts_by_kind(self): registry.assign_numbers() summary = registry.summary() assert summary["total_registered"] == 4 - assert summary["total_numbered"] == 4 + assert summary["total_rendered"] == 4 assert summary["by_kind"]["mermaid"]["registered"] == 2 - assert summary["by_kind"]["chart"]["numbered"] == 1 + assert summary["by_kind"]["chart"]["rendered"] == 1 diff --git a/backend/tests/unit/test_quality_gate_step.py b/backend/tests/unit/test_quality_gate_step.py index 549c14d..78deb9b 100644 --- a/backend/tests/unit/test_quality_gate_step.py +++ b/backend/tests/unit/test_quality_gate_step.py @@ -2,17 +2,20 @@ Regression tests del QualityGateStep — el paso terminal del pipeline que audita el .docx ensamblado y decide la severidad final del artefacto. -Cubre los modos de fallo P0 reportados antes del hardening: +Cubre los modos de detección (el gate es ADVISORY — nunca bloquea por defecto): - Código Mermaid crudo (`flowchart LR`, `graph TD`, etc.) visible en el .docx. - Caption ``Diagrama no renderizado`` (resto del viejo fallback eliminado). - Texto ``[Imagen no disponible]`` visible en el .docx. - Marcadores Markdown residuales: ``:::``, ``:::nota``, ``###``, ``>``. -- Numeración de figuras fuera de orden (Figura 3 antes que Figura 2). - Mínimo de citas solicitado por el usuario no cumplido → severity - ``requires_attention`` (no permite ``COMPLETED`` limpio). + ``requires_attention``. - Citas aceptadas en el audit pero ausentes del body final → severity - ``blocked``. + ``requires_attention``. + +Los tests de residuos P0 (mermaid, markdown, image placeholder) verifican con +``quality_gate_block_on_p0=True`` para comprobar que el gate DETECTA el problema +y puede bloquear cuando se le indica explícitamente. El default (False) no lanza. Tests del path "Acciones de SpaceX" / "Acciones de Vivienda" están en ``test_image_entity_grounding.py``. @@ -117,7 +120,10 @@ def test_empty_is_clean(self): class TestMermaidRawDetection: @pytest.mark.asyncio - async def test_flowchart_lr_blocked(self): + async def test_flowchart_lr_detected(self, monkeypatch): + """Con block_on_p0=True el gate detecta y bloquea; por defecto degrada a attention.""" + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result( [ "Texto académico normal.", @@ -131,19 +137,34 @@ async def test_flowchart_lr_blocked(self): assert exc.value.code == "quality_gate_blocked" @pytest.mark.asyncio - async def test_graph_td_blocked(self): + async def test_flowchart_lr_default_delivers_with_attention(self): + """Por defecto (block_on_p0=False), el documento se entrega con requires_attention.""" + result = _make_result(["flowchart LR", "A --> B"]) + new_result = await QualityGateStep().run(result, _make_context()) + report = new_result.citation_audit["quality_report"] + assert report["severity"] == "requires_attention" + assert report.get("blocked_override_active") is True + + @pytest.mark.asyncio + async def test_graph_td_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result(["graph TD\n A --> B"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_sequence_diagram_blocked(self): + async def test_sequence_diagram_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result(["sequenceDiagram\n A->>B: Hi"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_diagrama_no_renderizado_caption_blocked(self): + async def test_diagrama_no_renderizado_caption_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result( [ "Diagrama no renderizado — se muestra el código fuente Mermaid:", @@ -161,7 +182,9 @@ async def test_diagrama_no_renderizado_caption_blocked(self): class TestImagePlaceholderDetection: @pytest.mark.asyncio - async def test_imagen_no_disponible_blocked(self): + async def test_imagen_no_disponible_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result( [ "Introducción.", @@ -173,7 +196,16 @@ async def test_imagen_no_disponible_blocked(self): assert exc.value.code == "quality_gate_blocked" @pytest.mark.asyncio - async def test_image_placeholder_uppercase_blocked(self): + async def test_image_placeholder_default_delivers_with_attention(self): + result = _make_result(["[Imagen no disponible]"]) + new_result = await QualityGateStep().run(result, _make_context()) + report = new_result.citation_audit["quality_report"] + assert report["severity"] == "requires_attention" + + @pytest.mark.asyncio + async def test_image_placeholder_uppercase_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result(["[IMAGEN NO DISPONIBLE]"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @@ -186,25 +218,33 @@ async def test_image_placeholder_uppercase_blocked(self): class TestMarkdownResidueDetection: @pytest.mark.asyncio - async def test_admonition_open_blocked(self): + async def test_admonition_open_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([":::nota", "Texto importante."]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_admonition_close_blocked(self): + async def test_admonition_close_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result(["Texto normal.", ":::"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_heading_residue_blocked(self): + async def test_heading_residue_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result(["### Subtítulo no renderizado"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_blockquote_residue_blocked(self): + async def test_blockquote_residue_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result(["> Cita destacada sin renderizar"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @@ -223,55 +263,6 @@ async def test_clean_text_passes(self): assert report["severity"] == "clean" -# --------------------------------------------------------------------------- -# P0: Figure numbering out of order -# --------------------------------------------------------------------------- - - -class TestFigureNumberingDetection: - @pytest.mark.asyncio - async def test_figura_3_before_2_blocked(self): - # Figura 1 → Figura 3 → Figura 2 es claramente fuera de orden. - result = _make_result( - [ - "Figura 1. Algo introductorio.", - "Texto del cuerpo.", - "Figura 3. Saltada incorrectamente.", - "Más texto.", - "Figura 2. Que aparece después.", - ] - ) - with pytest.raises(DocumentGenerationError) as exc: - await QualityGateStep().run(result, _make_context()) - assert exc.value.code == "quality_gate_blocked" - - @pytest.mark.asyncio - async def test_missing_figura_1_blocked(self): - # Empezar con Figura 2 sin Figura 1 también es inválido. - result = _make_result( - [ - "Figura 2. Empieza mal.", - "Texto.", - "Figura 3. Sigue mal.", - ] - ) - with pytest.raises(DocumentGenerationError): - await QualityGateStep().run(result, _make_context()) - - @pytest.mark.asyncio - async def test_sequential_figures_pass(self): - result = _make_result( - [ - "Figura 1. Primera.", - "Texto del cuerpo.", - "Figura 2. Segunda.", - "Más texto.", - "Figura 3. Tercera.", - ] - ) - new_result = await QualityGateStep().run(result, _make_context()) - assert new_result.citation_audit["quality_report"]["severity"] == "clean" - # --------------------------------------------------------------------------- # P1: User min_citations not met → requires_attention @@ -336,9 +327,10 @@ async def test_min_citations_not_met_never_returns_success_status(self): class TestAcceptedNotRendered: @pytest.mark.asyncio - async def test_accepted_intext_missing_from_body_blocked(self): + async def test_accepted_intext_missing_from_body_requires_attention(self): # El audit dice que se aceptó "(García, 2020)" pero no aparece en - # el texto — bug típico de assembler. + # el texto. Ahora es requires_attention (no blocked) — el documento + # se entrega igualmente. audit = { "accepted": [ { @@ -352,8 +344,11 @@ async def test_accepted_intext_missing_from_body_blocked(self): ["Texto sin la cita."], citation_audit=audit, ) - with pytest.raises(DocumentGenerationError): - await QualityGateStep().run(result, _make_context()) + new_result = await QualityGateStep().run(result, _make_context()) + report = new_result.citation_audit["quality_report"] + assert report["severity"] == "requires_attention" + codes = {f["code"] for f in report["findings"]} + assert "citation_accepted_but_not_rendered" in codes @pytest.mark.asyncio async def test_accepted_intext_present_in_body_passes(self): @@ -376,7 +371,7 @@ async def test_accepted_intext_present_in_body_passes(self): ) new_result = await QualityGateStep().run(result, _make_context()) report = new_result.citation_audit["quality_report"] - assert report["severity"] != "blocked" + assert report["severity"] not in {"blocked", "requires_attention"} # --------------------------------------------------------------------------- @@ -429,7 +424,7 @@ async def test_image_download_failed_all_is_attention(self): class TestChartProvenanceWarnings: @pytest.mark.asyncio - async def test_chart_empirical_without_source_blocked(self): + async def test_chart_empirical_without_source_requires_attention(self): audit = { "pipeline_warnings": [ { @@ -441,8 +436,11 @@ async def test_chart_empirical_without_source_blocked(self): ] } result = _make_result(["Texto."], citation_audit=audit) - with pytest.raises(DocumentGenerationError): - await QualityGateStep().run(result, _make_context()) + new_result = await QualityGateStep().run(result, _make_context()) + report = new_result.citation_audit["quality_report"] + assert report["severity"] == "requires_attention" + codes = {f["code"] for f in report["findings"]} + assert "chart_empirical_without_source" in codes # --------------------------------------------------------------------------- @@ -463,18 +461,25 @@ async def test_quality_gate_disabled_returns_unchanged(self, monkeypatch): assert "quality_report" not in new_result.citation_audit @pytest.mark.asyncio - async def test_block_on_p0_override_downgrades_to_attention(self, monkeypatch): - from app.config import settings as _settings - - monkeypatch.setattr(_settings, "quality_gate_block_on_p0", False) - monkeypatch.setattr(_settings, "quality_gate_enabled", True) - # Tendría que ser blocked pero el flag lo degrada a requires_attention. + async def test_block_on_p0_false_default_downgrades_to_attention(self): + """Por defecto (block_on_p0=False), los findings blocked se degradan a attention.""" result = _make_result(["flowchart LR"]) new_result = await QualityGateStep().run(result, _make_context()) report = new_result.citation_audit["quality_report"] assert report["severity"] == "requires_attention" assert report["blocked_override_active"] is True + @pytest.mark.asyncio + async def test_block_on_p0_true_raises_for_p0_findings(self, monkeypatch): + """Con block_on_p0=True explícito, los findings blocked sí bloquean.""" + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) + monkeypatch.setattr(_settings, "quality_gate_enabled", True) + result = _make_result(["flowchart LR"]) + with pytest.raises(DocumentGenerationError) as exc: + await QualityGateStep().run(result, _make_context()) + assert exc.value.code == "quality_gate_blocked" + # --------------------------------------------------------------------------- # QualityReport serialization @@ -506,45 +511,60 @@ def test_to_dict_round_trip(self): class TestQualityGateMarkdownResidueRegression: - """Regression: verify QA gate blocks known markdown residue patterns end-to-end. + """Regression: verify QA gate detects known markdown residue patterns. - These tests document the exact P0 patterns that triggered bug reports in - this session. They complement TestMarkdownResidueDetection with more - realistic combinations found in production documents. + Por defecto (block_on_p0=False), el gate detecta pero no bloquea. + Los tests con block_on_p0=True verifican que el bloqueo SIGUE funcionando + cuando se activa explícitamente. """ @pytest.mark.asyncio - async def test_admonition_warning_blocked(self): - """:::warning that escaped the assembler must be blocked.""" + async def test_admonition_warning_detected(self, monkeypatch): + """:::warning detectado como P0 cuando block_on_p0=True.""" + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([":::warning", "Texto de advertencia sin renderizar."]) with pytest.raises(DocumentGenerationError) as exc: await QualityGateStep().run(result, _make_context()) assert exc.value.code == "quality_gate_blocked" @pytest.mark.asyncio - async def test_admonition_note_blocked(self): - """:::note that escaped the assembler must be blocked.""" + async def test_admonition_warning_default_delivers(self): + """Por defecto, :::warning genera requires_attention pero el doc se entrega.""" + result = _make_result([":::warning", "Texto de advertencia sin renderizar."]) + new_result = await QualityGateStep().run(result, _make_context()) + report = new_result.citation_audit["quality_report"] + assert report["severity"] == "requires_attention" + + @pytest.mark.asyncio + async def test_admonition_note_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([":::note", "Nota académica sin renderizar.", ":::"]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_admonition_tip_inline_blocked(self): - """:::tip Ver referencia — inline admonition leak must be blocked.""" + async def test_admonition_tip_inline_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([":::tip Ver referencia cruzada en sección 3."]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_admonition_callout_open_blocked(self): - """:::callout that was not consumed by the assembler must be blocked.""" + async def test_admonition_callout_open_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([":::callout", "Hallazgo importante."]) with pytest.raises(DocumentGenerationError): await QualityGateStep().run(result, _make_context()) @pytest.mark.asyncio - async def test_combined_mermaid_and_admonition_blocked(self): - """Multiple P0 residue types in one document are all blocked.""" + async def test_combined_mermaid_and_admonition_detected(self, monkeypatch): + """Múltiples residuos P0 en un documento se detectan; con block=True, bloquean.""" + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([ "Introducción del documento.", "flowchart LR", @@ -557,12 +577,7 @@ async def test_combined_mermaid_and_admonition_blocked(self): @pytest.mark.asyncio async def test_clean_text_with_bold_content_passes(self): - """Text that mentions bold concepts in plain prose (not markers) must pass. - - The assembler strips ** before document assembly. This test verifies - that clean post-assembly text (no raw markers) is not incorrectly - flagged by the gate. - """ + """Prosa limpia post-assembly no debe ser marcada.""" result = _make_result([ "La polarización política es un fenómeno central del análisis.", "El autor sostiene que la concentración de poder es determinante.", @@ -575,8 +590,9 @@ async def test_clean_text_with_bold_content_passes(self): ) @pytest.mark.asyncio - async def test_diagrama_no_renderizado_legacy_blocked(self): - """The legacy fallback caption 'Diagrama no renderizado' must be blocked.""" + async def test_diagrama_no_renderizado_legacy_detected(self, monkeypatch): + from app.config import settings as _settings + monkeypatch.setattr(_settings, "quality_gate_block_on_p0", True) result = _make_result([ "Cuerpo del documento académico.", "Diagrama no renderizado — se muestra el código fuente Mermaid:",