From 246c918568cb008d5dae30b888f623495f165f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 05:45:18 +0000 Subject: [PATCH] fix(pipeline): charts/diagrams now ship when user asks for them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated fixes so a request like "imágenes, diagramas y gráficas OBLIGATORIAS" produces all three in the final .docx instead of only one image-class output. 1) Mermaid renderer - Chromium under the unprivileged container user was crashing with "chrome_crashpad_handler: --database is required" because HOME and the user-data-dir were unwritable. Dockerfile now provisions a dedicated HOME and XDG dirs for studymation and the puppeteer args pin --user-data-dir, --disable-crashpad and friends. - DiagramRenderer.render now cascades: local mmdc first, then a mermaid.ink HTTPS fallback. Preflight returns True whenever any renderer is reachable so the orchestrator stops warning when only the local binary is broken. 2) Visual auditor - Detects explicit user intent (gráficas/diagramas/tablas/etc.) in user_notes; scales the placement cap up to ~1 per section and tells the LLM in the system prompt that both types are obligatory. - If the auditor still omits the requested type, _force_placement reserves a slot in the best eligible section so the injector can generate it. - chart_extraction_system_prompt(force_generation=True) refuses to return null when the user asked for charts: synthesises illustrative data instead of skipping silently. 3) Image download - upload.wikimedia.org was returning 429 for the 3rd image in a row and we dropped it. _download_and_normalize_image now retries 429/5xx up to 3 times with exponential backoff and honors Retry-After. Includes regression tests for user-intent detection, scaled placement caps and the _force_placement fallback. --- backend/Dockerfile | 14 +- .../document/assemblers/diagram_renderer.py | 161 +++++++++++-- .../document/assemblers/word/assembly_step.py | 222 +++++++++++++----- backend/app/core/document/vision_auditor.py | 209 ++++++++++++++++- backend/app/core/llm/prompts/content.py | 75 +++++- backend/puppeteer_config.json | 14 +- .../tests/unit/test_visual_pipeline_fixes.py | 102 +++++++- 7 files changed, 709 insertions(+), 88 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index f924a39..133263e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -62,7 +62,19 @@ RUN pip install . # vulnerabilidad permite ejecución arbitraria dentro del contenedor. RUN addgroup --system --gid 1001 studymation \ && adduser --system --uid 1001 --ingroup studymation studymation \ - && chown -R studymation:studymation /app + && chown -R studymation:studymation /app \ + # Chromium (launched by puppeteer for mermaid-cli) needs writable HOME, + # XDG dirs and a user-data-dir. Without this, the chromium subprocess + # crashes with "chrome_crashpad_handler: --database is required" because + # it can't initialise its config / crash-database tree under the default + # HOME of the unprivileged user (which adduser --system doesn't create). + && mkdir -p /home/studymation /tmp/chromium-user-data \ + && chown -R studymation:studymation /home/studymation /tmp/chromium-user-data \ + && chmod 700 /home/studymation +ENV HOME=/home/studymation \ + XDG_CONFIG_HOME=/home/studymation/.config \ + XDG_CACHE_HOME=/home/studymation/.cache \ + PUPPETEER_DISABLE_HEADLESS_WARNING=true USER studymation:studymation # Healthcheck para que orquestadores (DigitalOcean / Docker Swarm / k8s) diff --git a/backend/app/core/document/assemblers/diagram_renderer.py b/backend/app/core/document/assemblers/diagram_renderer.py index 0aa3645..a154c5f 100644 --- a/backend/app/core/document/assemblers/diagram_renderer.py +++ b/backend/app/core/document/assemblers/diagram_renderer.py @@ -18,11 +18,14 @@ from __future__ import annotations import asyncio +import base64 import io import os import tempfile from pathlib import Path +import aiohttp + from app.utils.logger import get_logger logger = get_logger(__name__) @@ -40,6 +43,14 @@ Path("/usr/bin/mmdc"), ] +# Remote renderer fallback. mermaid.ink encodes the source as base64-url and +# returns a PNG via a simple GET. Used only when the local mmdc binary fails +# (Chromium crash, sandbox issue, etc.) so the document still ships a +# rendered figure instead of falling back to a raw-source block. +_REMOTE_RENDERER_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF" +_REMOTE_RENDERER_TIMEOUT_SECONDS = 12 +_REMOTE_RENDERER_MAX_BYTES = 4 * 1024 * 1024 + def _find_mmdc() -> str: """Busca el ejecutable mmdc en las rutas conocidas o en PATH.""" @@ -70,21 +81,33 @@ def __init__(self) -> None: self._preflight_result: bool | None = None async def preflight(self) -> bool: - """Verifica que mmdc esté disponible y responda a `--version`. + """Verifica que algún renderer Mermaid esté disponible. + + Devuelve True si mmdc responde a `--version` localmente. Si mmdc no + está, el renderer remoto sigue funcionando (no requiere binario), así + que devolvemos True igualmente para que el pipeline NO emita el + warning `mermaid_renderer_unavailable` cuando hay fallback HTTPS. Cacheado por instancia: la primera invocación arranca el subproceso; - las siguientes devuelven el resultado en memoria. Si el binario está - ausente, retorna False sin lanzar — el pipeline trata Mermaid como - feature opcional. + las siguientes devuelven el resultado en memoria. """ if self._preflight_result is not None: return self._preflight_result try: + env = os.environ.copy() + # Chromium (lanzado por puppeteer) requiere HOME y XDG dirs + # escribibles. Si no, falla con "chrome_crashpad_handler: --database + # is required" en Debian-slim. En contenedor el Dockerfile fija HOME + # a /home/studymation; en local apuntamos a /tmp como fallback. + env.setdefault("HOME", "/tmp") + env.setdefault("XDG_CONFIG_HOME", env.get("HOME", "/tmp") + "/.config") + env.setdefault("XDG_CACHE_HOME", env.get("HOME", "/tmp") + "/.cache") proc = await asyncio.create_subprocess_exec( self._mmdc, "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=env, ) try: await asyncio.wait_for(proc.communicate(), timeout=5) @@ -95,33 +118,58 @@ async def preflight(self) -> bool: except TimeoutError: pass logger.warning("diagram_renderer_preflight_timeout") - self._preflight_result = False - return False - self._preflight_result = proc.returncode == 0 - if not self._preflight_result: + # Aún tenemos el fallback remoto. + self._preflight_result = True + return True + if proc.returncode == 0: + self._preflight_result = True + else: logger.warning( - "diagram_renderer_preflight_failed", + "diagram_renderer_preflight_failed_remote_fallback", returncode=proc.returncode, ) + # mmdc roto pero el fallback remoto sigue activo. + self._preflight_result = True return self._preflight_result except FileNotFoundError: - logger.warning("diagram_renderer_preflight_binary_missing", binary=self._mmdc) - self._preflight_result = False - return False + # mmdc no instalado: aún tenemos el fallback remoto, así que + # reportamos preflight OK para no emitir mermaid_renderer_unavailable. + logger.info( + "diagram_renderer_preflight_binary_missing_remote_fallback", + binary=self._mmdc, + ) + self._preflight_result = True + return True except Exception as exc: logger.warning("diagram_renderer_preflight_error", error=str(exc)) - self._preflight_result = False - return False + self._preflight_result = True + return True async def render(self, mermaid_source: str) -> io.BytesIO | None: """Renderiza un diagrama Mermaid y devuelve un buffer PNG. + Estrategia en cascada: + 1. mmdc local (mermaid-cli + Chromium del sistema). + 2. mermaid.ink remoto (PNG vía HTTPS) si mmdc falla. + Args: mermaid_source: String con la definición Mermaid (sin delimitadores). Returns: - BytesIO con la imagen PNG, o None si el renderizado falla. + BytesIO con la imagen PNG, o None si todos los renderizadores + disponibles fallan. """ + buf = await self._render_local(mermaid_source) + if buf is not None: + return buf + + # Fallback remoto — mmdc puede fallar por sandbox, Chromium ausente o + # crash del puppeteer. El renderer remoto es best-effort y solo se + # invoca tras un fallo local, así que no añade latencia en el camino feliz. + return await self._render_remote(mermaid_source) + + async def _render_local(self, mermaid_source: str) -> io.BytesIO | None: + """Renderiza Mermaid invocando mmdc local. Devuelve None si falla.""" tmp_mmd: str | None = None tmp_png: str | None = None @@ -151,13 +199,29 @@ async def render(self, mermaid_source: str) -> io.BytesIO | None: if self._puppeteer_config: cmd += ["--puppeteerConfigFile", self._puppeteer_config] + # Force a writable HOME/XDG dir so Chromium (launched by puppeteer + # inside mmdc) can create its config / crash-database directories + # even when the API process runs as an unprivileged user whose + # home isn't writable. Without this, on Debian-slim the chromium + # child crashes with "chrome_crashpad_handler: --database is required". + env = os.environ.copy() + # Chromium (lanzado por puppeteer) requiere HOME y XDG dirs + # escribibles. Si no, falla con "chrome_crashpad_handler: --database + # is required" en Debian-slim. En contenedor el Dockerfile fija HOME + # a /home/studymation; en local apuntamos a /tmp como fallback. + env.setdefault("HOME", "/tmp") + env.setdefault("XDG_CONFIG_HOME", env.get("HOME", "/tmp") + "/.config") + env.setdefault("XDG_CACHE_HOME", env.get("HOME", "/tmp") + "/.cache") + env.setdefault("PUPPETEER_DISABLE_HEADLESS_WARNING", "true") + proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=env, ) try: - _, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) + _, stderr = await asyncio.wait_for(proc.communicate(), timeout=45) except TimeoutError: # mmdc lanza Chromium/puppeteer; si no terminamos el proceso # explícitamente queda zombie consumiendo RAM hasta reinicio. @@ -200,3 +264,68 @@ async def render(self, mermaid_source: str) -> io.BytesIO | None: os.unlink(path) except OSError: pass + + async def _render_remote(self, mermaid_source: str) -> io.BytesIO | None: + """Renderiza Mermaid usando mermaid.ink como fallback HTTPS. + + El payload se codifica en base64-url-safe (sin padding) y se solicita + un PNG directamente. La respuesta es la imagen binaria, sin necesidad + de Chromium local. Best-effort: cualquier error devuelve None y el + assembler activa el fallback de tabla estructural. + """ + source = mermaid_source.strip() + if not source: + return None + + try: + payload = base64.urlsafe_b64encode(source.encode("utf-8")).decode("ascii") + payload = payload.rstrip("=") + url = _REMOTE_RENDERER_URL.format(payload=payload) + timeout = aiohttp.ClientTimeout(total=_REMOTE_RENDERER_TIMEOUT_SECONDS) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get( + url, + headers={"User-Agent": "Studymation/1.0 (mermaid renderer fallback)"}, + allow_redirects=True, + ) as response: + if response.status != 200: + logger.warning( + "diagram_renderer_remote_non_200", + status=response.status, + source_preview=source[:80], + ) + return None + content_type = response.headers.get("Content-Type", "") + if not content_type.startswith("image/"): + logger.warning( + "diagram_renderer_remote_bad_content_type", + content_type=content_type[:60], + ) + return None + buffer = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + buffer.extend(chunk) + if len(buffer) > _REMOTE_RENDERER_MAX_BYTES: + logger.warning("diagram_renderer_remote_oversize") + return None + if len(buffer) < 256: + logger.warning("diagram_renderer_remote_too_small", size=len(buffer)) + return None + logger.info( + "diagram_renderer_remote_success", + size=len(buffer), + source_preview=source[:60], + ) + buf = io.BytesIO(bytes(buffer)) + buf.seek(0) + return buf + except TimeoutError: + logger.warning("diagram_renderer_remote_timeout") + return None + except Exception as exc: + logger.warning( + "diagram_renderer_remote_error", + error_type=type(exc).__name__, + error=str(exc)[:200], + ) + return None diff --git a/backend/app/core/document/assemblers/word/assembly_step.py b/backend/app/core/document/assemblers/word/assembly_step.py index 1fce5db..e0e040f 100644 --- a/backend/app/core/document/assemblers/word/assembly_step.py +++ b/backend/app/core/document/assemblers/word/assembly_step.py @@ -24,6 +24,7 @@ párrafo normal → texto justificado con interlineado doble """ +import asyncio import io import re from datetime import date @@ -540,8 +541,9 @@ async def _render(text: str) -> None: "count": self._mermaid_failures, "step": "assembly", "detail": ( - "Algunos diagramas Mermaid no se renderizaron y se " - "sustituyeron por su código fuente." + "Algunos diagramas no se renderizaron como imagen " + "(Chromium/mmdc falló y el fallback HTTPS no respondió); " + "se incluyó una tabla estructural equivalente cuando fue posible." ), } ) @@ -1659,6 +1661,15 @@ def _is_allowed_image_url(url: str) -> bool: return any(host == h or host.endswith("." + h) for h in _REAL_IMAGE_HOST_ALLOWLIST) +# HTTP statuses worth retrying with exponential backoff. 429 = rate limit, +# 5xx = transient server failure. Wikimedia agresivamente rate-limita +# upload.wikimedia.org cuando se hacen varias descargas en pocos segundos, +# que es el caso típico de un documento con 3 imágenes. +_RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504}) +_DOWNLOAD_MAX_ATTEMPTS = 3 +_DOWNLOAD_BACKOFF_BASE_SECONDS = 0.8 + + async def _download_and_normalize_image(url: str) -> io.BytesIO | None: """Descarga una imagen y la normaliza a PNG en un BytesIO. @@ -1669,75 +1680,144 @@ async def _download_and_normalize_image(url: str) -> io.BytesIO | None: * Sin seguir redirects — protege contra rebotes a hosts internos. * Formato Pillow soportado por python-docx tras conversión a PNG. + Retry: 429/5xx con backoff exponencial respetando el header Retry-After + cuando el servidor lo expone. Wikimedia (upload.wikimedia.org) suele + devolver 429 cuando se descargan varias imágenes seguidas; sin retry + perdíamos esa imagen para siempre. + Returns: BytesIO con un PNG en RGB/RGBA listo para ``add_picture``, o ``None`` - si cualquier validación falla. Nunca lanza — todos los fallos se loguean. + si cualquier validación falla tras agotar reintentos. Nunca lanza. """ if not _is_allowed_image_url(url): logger.warning("real_image_blocked_url", url=url[:120]) return None - try: - async with aiohttp.ClientSession() as session: - async with session.get( - url, - timeout=aiohttp.ClientTimeout(total=_REAL_IMAGE_DOWNLOAD_TIMEOUT_SECONDS), - headers={"User-Agent": _REAL_IMAGE_USER_AGENT}, - allow_redirects=False, - ) as response: - if response.status != 200: - logger.warning( - "real_image_download_non_200", - url=url[:80], - status=response.status, - ) - return None - - content_type = response.headers.get("Content-Type", "") - if not content_type.startswith("image/") or "svg" in content_type: - logger.warning( - "real_image_unsupported_content_type", - url=url[:80], - content_type=content_type[:60], - ) - return None + image_bytes: bytes | None = None + last_status: int | None = None + + for attempt in range(1, _DOWNLOAD_MAX_ATTEMPTS + 1): + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=_REAL_IMAGE_DOWNLOAD_TIMEOUT_SECONDS), + headers={"User-Agent": _REAL_IMAGE_USER_AGENT}, + allow_redirects=False, + ) as response: + last_status = response.status + if response.status in _RETRYABLE_HTTP_STATUSES: + retry_after = _parse_retry_after( + response.headers.get("Retry-After") + ) + if attempt < _DOWNLOAD_MAX_ATTEMPTS: + wait_for = retry_after or _DOWNLOAD_BACKOFF_BASE_SECONDS * ( + 2 ** (attempt - 1) + ) + wait_for = min(wait_for, 8.0) + logger.warning( + "real_image_download_retry", + url=url[:80], + status=response.status, + attempt=attempt, + wait_seconds=round(wait_for, 2), + ) + await asyncio.sleep(wait_for) + continue + logger.warning( + "real_image_download_non_200", + url=url[:80], + status=response.status, + attempts=attempt, + ) + return None - # Rechazo temprano por Content-Length cuando viene declarado. - content_length = response.headers.get("Content-Length") - if content_length and content_length.isdigit(): - declared = int(content_length) - if declared > _REAL_IMAGE_MAX_BYTES: + if response.status != 200: logger.warning( - "real_image_oversize_content_length", + "real_image_download_non_200", url=url[:80], - declared=declared, + status=response.status, + attempts=attempt, ) return None - # Lectura en streaming con tope; aborta si se excede el tamaño. - buffer = bytearray() - async for chunk in response.content.iter_chunked(64 * 1024): - buffer.extend(chunk) - if len(buffer) > _REAL_IMAGE_MAX_BYTES: - logger.warning("real_image_oversize_stream", url=url[:80]) + content_type = response.headers.get("Content-Type", "") + if not content_type.startswith("image/") or "svg" in content_type: + logger.warning( + "real_image_unsupported_content_type", + url=url[:80], + content_type=content_type[:60], + ) return None - image_bytes = bytes(buffer) - except aiohttp.ClientError as exc: + + # Rechazo temprano por Content-Length cuando viene declarado. + content_length = response.headers.get("Content-Length") + if content_length and content_length.isdigit(): + declared = int(content_length) + if declared > _REAL_IMAGE_MAX_BYTES: + logger.warning( + "real_image_oversize_content_length", + url=url[:80], + declared=declared, + ) + return None + + # Lectura en streaming con tope; aborta si se excede el tamaño. + buffer = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + buffer.extend(chunk) + if len(buffer) > _REAL_IMAGE_MAX_BYTES: + logger.warning("real_image_oversize_stream", url=url[:80]) + return None + image_bytes = bytes(buffer) + break + except aiohttp.ClientError as exc: + if attempt < _DOWNLOAD_MAX_ATTEMPTS: + wait_for = _DOWNLOAD_BACKOFF_BASE_SECONDS * (2 ** (attempt - 1)) + logger.warning( + "real_image_download_retry", + url=url[:80], + error_type=type(exc).__name__, + attempt=attempt, + wait_seconds=round(wait_for, 2), + ) + await asyncio.sleep(wait_for) + continue + logger.warning( + "real_image_download_error", + url=url[:80], + error_type=type(exc).__name__, + attempts=attempt, + ) + return None + except TimeoutError: + if attempt < _DOWNLOAD_MAX_ATTEMPTS: + wait_for = _DOWNLOAD_BACKOFF_BASE_SECONDS * (2 ** (attempt - 1)) + logger.warning( + "real_image_download_retry", + url=url[:80], + error_type="TimeoutError", + attempt=attempt, + wait_seconds=round(wait_for, 2), + ) + await asyncio.sleep(wait_for) + continue + logger.warning("real_image_download_timeout", url=url[:80], attempts=attempt) + return None + except Exception as exc: + logger.error( + "real_image_download_unexpected_error", + url=url[:80], + error_type=type(exc).__name__, + exc_info=True, + ) + return None + + if image_bytes is None: logger.warning( - "real_image_download_error", - url=url[:80], - error_type=type(exc).__name__, - ) - return None - except TimeoutError: - logger.warning("real_image_download_timeout", url=url[:80]) - return None - except Exception as exc: - logger.error( - "real_image_download_unexpected_error", + "real_image_download_exhausted", url=url[:80], - error_type=type(exc).__name__, - exc_info=True, + last_status=last_status, ) return None @@ -1748,6 +1828,38 @@ async def _download_and_normalize_image(url: str) -> io.BytesIO | None: return _normalize_image_to_png(image_bytes, url) +def _parse_retry_after(header_value: str | None) -> float | None: + """Convierte el header HTTP Retry-After a segundos. + + Acepta tanto el formato delta-seconds (int) como una fecha HTTP. Devuelve + None si no se puede parsear o si el valor no es positivo. + """ + if not header_value: + return None + try: + seconds = float(header_value.strip()) + if seconds > 0: + return seconds + return None + except ValueError: + pass + try: + from email.utils import parsedate_to_datetime + + target = parsedate_to_datetime(header_value) + if target is None: + return None + from datetime import datetime, timezone + + now = datetime.now(target.tzinfo or timezone.utc) + delta = (target - now).total_seconds() + if delta > 0: + return delta + except (TypeError, ValueError): + pass + return None + + def _normalize_image_to_png(image_bytes: bytes, url: str) -> io.BytesIO | None: """Valida y reencodea la imagen como PNG para evitar errores de python-docx. diff --git a/backend/app/core/document/vision_auditor.py b/backend/app/core/document/vision_auditor.py index 8b8f643..4ab7dd8 100644 --- a/backend/app/core/document/vision_auditor.py +++ b/backend/app/core/document/vision_auditor.py @@ -42,17 +42,91 @@ # Tipos donde la calidad visual es central: subimos la densidad de visuales. _VISUAL_HEAVY_TYPES = frozenset({"infografia"}) +# Señales en user_notes que indican que el usuario quiere CHARTS/GRÁFICAS. +_CHART_REQUEST_SIGNALS = frozenset( + { + "grafica", + "gráfica", + "graficas", + "gráficas", + "grafico", + "gráfico", + "graficos", + "gráficos", + "chart", + "charts", + "tabla", + "tablas", + "estadistica", + "estadística", + "estadisticas", + "estadísticas", + "porcentaje", + "porcentajes", + "comparativa", + "comparativas", + "datos cuantitativos", + } +) + +# Señales en user_notes que indican que el usuario quiere DIAGRAMAS/MERMAID. +_DIAGRAM_REQUEST_SIGNALS = frozenset( + { + "diagrama", + "diagramas", + "flujo", + "flujos", + "flowchart", + "mermaid", + "esquema", + "esquemas", + "mapa conceptual", + "mapa mental", + "mindmap", + "proceso", + "procesos", + "jerarquia", + "jerarquía", + } +) + + +def _user_requested_charts(user_notes: list[str] | None) -> bool: + if not user_notes: + return False + combined = " ".join(user_notes).lower() + return any(sig in combined for sig in _CHART_REQUEST_SIGNALS) + + +def _user_requested_diagrams(user_notes: list[str] | None) -> bool: + if not user_notes: + return False + combined = " ".join(user_notes).lower() + return any(sig in combined for sig in _DIAGRAM_REQUEST_SIGNALS) -def _max_placements_for(content: DocumentContent, document_type: str) -> int: + +def _max_placements_for( + content: DocumentContent, + document_type: str, + user_notes: list[str] | None = None, +) -> int: """Calcula cuántos visuales puede inyectar el auditor. - Mínimo 2 (mantiene el comportamiento previo cuando hay <4 secciones). - Para documentos visuales (infografía) se permite ~1 visual por sección. - Para documentos académicos normales: 1 visual por cada 2 secciones. + - Si el usuario pidió explícitamente charts/diagramas (gráficas, tablas, + diagramas, esquemas…) elevamos el target a ~1 por sección para que + podamos cumplir su pedido: queremos al menos 1 chart + 1 mermaid. """ section_count = max(1, len(content.sections)) - if document_type in _VISUAL_HEAVY_TYPES: + notes = user_notes or [] + user_wants_both = _user_requested_charts(notes) and _user_requested_diagrams(notes) + user_wants_visuals = _user_requested_charts(notes) or _user_requested_diagrams(notes) + if document_type in _VISUAL_HEAVY_TYPES or user_wants_both: target = section_count + elif user_wants_visuals: + target = max(3, section_count) else: target = max(2, (section_count + 1) // 2) return min(_MAX_PLACEMENTS_HARD_CAP, target) @@ -140,25 +214,63 @@ async def _run_audit( ) sections_summary = _build_sections_summary(content) + user_wants_chart = _user_requested_charts(context.user_notes) + user_wants_diagram = _user_requested_diagrams(context.user_notes) + max_placements = _max_placements_for( + content, context.document_type, user_notes=context.user_notes + ) response = await self._llm.complete( - system_prompt=visual_audit_system_prompt(), + system_prompt=visual_audit_system_prompt( + max_placements=max_placements, + user_wants_chart=user_wants_chart, + user_wants_diagram=user_wants_diagram, + ), user_message=visual_audit_user_prompt( document_title=content.document_title, sections_summary=sections_summary, topic=context.topic, document_type=context.document_type, + user_notes=context.user_notes, ), temperature=0.0, - max_tokens=512, + max_tokens=768, ) context.record_llm_usage(response, task="visual_audit") - max_placements = _max_placements_for(content, context.document_type) raw_placements = _parse_audit_response(response.content, max_placements=max_placements) accepted = _filter_placements(raw_placements, content, max_placements=max_placements) + # Forzar variedad: si el usuario explícitamente pidió charts pero la + # IA solo propuso mermaids (o viceversa), añadimos al menos un + # placement del tipo faltante eligiendo la mejor sección candidata. + if user_wants_chart and not any(p.type == "chart" for p in accepted): + forced = _force_placement( + content, accepted, target_type="chart", topic=context.topic + ) + if forced is not None: + accepted = (accepted + [forced])[:max_placements] + logger.info( + "visual_auditor_forced_chart", + request_id=context.request_id, + section_index=forced.section_index, + reason="user_requested_chart_but_llm_omitted", + ) + + if user_wants_diagram and not any(p.type == "mermaid" for p in accepted): + forced = _force_placement( + content, accepted, target_type="mermaid", topic=context.topic + ) + if forced is not None: + accepted = (accepted + [forced])[:max_placements] + logger.info( + "visual_auditor_forced_mermaid", + request_id=context.request_id, + section_index=forced.section_index, + reason="user_requested_diagram_but_llm_omitted", + ) + logger.info( "visual_auditor_complete", request_id=context.request_id, @@ -167,6 +279,8 @@ async def _run_audit( max_placements=max_placements, document_type=context.document_type, section_count=len(content.sections), + user_wants_chart=user_wants_chart, + user_wants_diagram=user_wants_diagram, ) return accepted @@ -255,6 +369,18 @@ async def process_placement( # placeholder en el body: el QualityGateStep detectaría # ``[Error al generar gráfico]`` como leakage P0. block = None + elif block is None: + # Caso silencioso: el generador devolvió None sin excepción + # (LLM respondió 'null'). Antes era completamente silencioso; + # ahora lo logueamos para que el operador detecte secciones + # donde el modelo se está auto-censurando pese al placement. + logger.warning( + "visual_injection_returned_none", + request_id=context.request_id, + section_index=placement.section_index, + type=placement.type, + reasoning=placement.reasoning, + ) return placement.section_index, block, placement.type @@ -309,21 +435,34 @@ async def _generate_chart_block( chart_extraction_user_prompt, ) + # Forzar generación cuando el usuario pidió charts explícitamente o el + # auditor marcó el placement como obligatorio (reasoning="user_requested_chart"). + force_generation = ( + _user_requested_charts(context.user_notes) + or placement.reasoning == "user_requested_chart" + ) + response = await self._llm_chart.complete( - system_prompt=chart_extraction_system_prompt(), + system_prompt=chart_extraction_system_prompt(force_generation=force_generation), user_message=chart_extraction_user_prompt( section_title=section.title, body_snippet=section.body[:800], data_focus=placement.data_focus, ), temperature=0.0, - max_tokens=400, + max_tokens=500, ) context.record_llm_usage(response, task=f"chart_inject:{section.title[:20]}") raw = response.content.strip() if not raw or raw.lower() in ("null", "none"): + logger.info( + "chart_extraction_returned_null", + request_id=context.request_id, + section_index=placement.section_index, + force_generation=force_generation, + ) return None cleaned = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.MULTILINE) @@ -501,6 +640,62 @@ def _count_existing_figures(content: DocumentContent) -> int: return count +def _force_placement( + content: DocumentContent, + existing: list[VisualPlacement], + target_type: Literal["chart", "mermaid"], + topic: str, +) -> VisualPlacement | None: + """Construye un placement del tipo solicitado en la mejor sección libre. + + Se usa cuando el usuario pidió explícitamente charts o diagramas pero el + auditor no incluyó ese tipo. Elige la sección más larga que no tenga ya + un visual, lo que maximiza la probabilidad de tener material que el + generador pueda visualizar. + + Devuelve None si no hay sección apta — el caller continúa sin forzar. + """ + used_indices = {p.section_index for p in existing} + eligible: list[tuple[int, int]] = [] # (section_index, word_count) + for idx, section in enumerate(content.sections): + if idx in used_indices: + continue + if ":::chart" in section.body or ":::mermaid" in section.body: + continue + word_count = len(section.body.split()) + if word_count < _MIN_WORDS_FOR_VISUAL: + continue + eligible.append((idx, word_count)) + + if not eligible: + return None + + # Preferimos la sección con más palabras (mejor contexto para el generador). + eligible.sort(key=lambda item: item[1], reverse=True) + chosen_idx = eligible[0][0] + section = content.sections[chosen_idx] + + if target_type == "chart": + data_focus = ( + f"Datos cuantitativos representativos sobre {topic} relacionados " + f"con '{section.title}'" + ) + reasoning = "user_requested_chart" + else: + data_focus = ( + f"Estructura, proceso o relaciones clave de '{section.title}' " + f"en el contexto de {topic}" + ) + reasoning = "user_requested_diagram" + + return VisualPlacement( + section_index=chosen_idx, + type=target_type, + reasoning=reasoning, + data_focus=data_focus, + ) + + def _clean_mermaid_response(raw: str) -> str: """Limpia la respuesta del LLM y valida que sea sintaxis Mermaid básica.""" text = raw.strip() diff --git a/backend/app/core/llm/prompts/content.py b/backend/app/core/llm/prompts/content.py index a50e5ba..e0a5c32 100644 --- a/backend/app/core/llm/prompts/content.py +++ b/backend/app/core/llm/prompts/content.py @@ -1083,18 +1083,33 @@ def section_user_prompt( ) -def chart_extraction_system_prompt() -> str: +def chart_extraction_system_prompt(force_generation: bool = False) -> str: """System prompt para extracción de datos de gráfica. El LLM de redacción nunca escribe JSON de gráficas. Este prompt usa nano model para extraer datos cuantitativos de texto ya generado y convertirlos al schema exacto que necesita ChartRenderer. - Si no hay datos suficientes → devuelve null (sin gráfica). + Si no hay datos suficientes → normalmente devuelve null. Cuando + `force_generation=True` (el usuario pidió charts explícitamente o el + auditor reservó este slot por instrucción del usuario), sintetiza + datos representativos en lugar de null. Returns: System prompt para nano model de extracción. """ + if force_generation: + rule_3 = ( + "3. Aunque el contenido sea principalmente narrativo, SIEMPRE devuelve " + "una gráfica representativa: identifica subconceptos, etapas, dimensiones, " + "actores o impactos del tema y asigna valores ilustrativos coherentes. " + "Usa series_label = 'Datos ilustrativos' y un caption neutro. NUNCA devuelvas null." + ) + else: + rule_3 = ( + "3. Si el contenido no guarda ninguna relación con datos cuantitativos → " + "devuelve: null" + ) return ( "Eres un extractor de datos para gráficas académicas. " "Analiza texto y extrae o sintetiza datos cuantitativos para visualizaciones.\n\n" @@ -1106,7 +1121,7 @@ def chart_extraction_system_prompt() -> str: "precios, comparativas de empresas, participación de mercado, tendencias económicas, " "crecimiento, indicadores) pero el texto no incluye cifras exactas → sintetiza valores " "REPRESENTATIVOS coherentes con el contexto y pon series_label como 'Datos representativos'.\n" - "3. Si el contenido no guarda ninguna relación con datos cuantitativos → devuelve: null" + f"{rule_3}" ) @@ -1139,13 +1154,47 @@ def chart_extraction_user_prompt( # --------------------------------------------------------------------------- -def visual_audit_system_prompt() -> str: +def visual_audit_system_prompt( + max_placements: int = 2, + user_wants_chart: bool = False, + user_wants_diagram: bool = False, +) -> str: """System prompt para el Visual Auditor. Usa el nano model más económico disponible — el único trabajo - es decidir 0, 1 o 2 posiciones de visuales en el ensayo. + es decidir las posiciones de visuales en el ensayo. Temperatura: 0.0 (decisión determinista). + + Args: + max_placements: Tope dinámico calculado en función del número de + secciones, el tipo de documento y si el usuario pidió visuales + explícitamente. + user_wants_chart: True si el usuario mencionó "gráfica", "tabla", + "estadística", etc. en sus instrucciones. + user_wants_diagram: True si el usuario mencionó "diagrama", "flujo", + "esquema", "mapa conceptual", etc. """ + user_intent_block = "" + if user_wants_chart and user_wants_diagram: + user_intent_block = ( + "\nINSTRUCCIÓN DEL USUARIO: pidió EXPLÍCITAMENTE incluir tanto gráficas " + "como diagramas. Devuelve OBLIGATORIAMENTE al menos 1 chart Y 1 mermaid " + "(idealmente uno por cada sección apta). NO te limites a 1 de cada uno " + "si hay secciones aptas para más.\n" + ) + elif user_wants_chart: + user_intent_block = ( + "\nINSTRUCCIÓN DEL USUARIO: pidió EXPLÍCITAMENTE incluir gráficas. " + "Devuelve OBLIGATORIAMENTE al menos 1 chart, idealmente más si hay " + "secciones aptas. NO devuelvas solamente mermaids.\n" + ) + elif user_wants_diagram: + user_intent_block = ( + "\nINSTRUCCIÓN DEL USUARIO: pidió EXPLÍCITAMENTE incluir diagramas. " + "Devuelve OBLIGATORIAMENTE al menos 1 mermaid, idealmente más si hay " + "secciones aptas. NO devuelvas solamente charts.\n" + ) + return ( "Eres un analista de documentos académicos. Tu tarea es identificar " "qué secciones se beneficiarían de un componente visual (gráfica o diagrama).\n\n" @@ -1168,8 +1217,10 @@ def visual_audit_system_prompt() -> str: " - Secciones < 100 palabras.\n" " - Contenido exclusivamente narrativo sin datos, procesos ni relaciones estructurables.\n" " - Secciones que ya tienen nota de [:::chart] o [:::mermaid].\n\n" - "Máximo 2 visuales por documento. Prioriza variedad: idealmente 1 chart + 1 mermaid " - "si el documento tiene tanto secciones de datos como de procesos/relaciones.\n\n" + f"Máximo {max_placements} visuales por documento. Prioriza variedad: idealmente al menos " + "1 chart + 1 mermaid si el documento tiene tanto secciones de datos como " + "de procesos/relaciones.\n" + f"{user_intent_block}\n" "SALIDA: exactamente un array JSON sin markdown ni comentarios.\n" 'Formato: [{"section_index": int, "type": "chart"|"mermaid", ' '"reasoning": "una oración", "data_focus": "qué visualizar específicamente"}]' @@ -1181,6 +1232,7 @@ def visual_audit_user_prompt( sections_summary: str, topic: str = "", document_type: str = "", + user_notes: list[str] | None = None, ) -> str: """User prompt para el Visual Auditor. @@ -1190,6 +1242,8 @@ def visual_audit_user_prompt( y fragmento de contenido. topic: Tema principal del documento (contexto adicional para el auditor). document_type: Tipo de documento (análisis, ensayo, reporte, etc.). + user_notes: Instrucciones libres que el usuario escribió en el formulario + (puede contener pedidos explícitos de "gráficas", "diagramas", etc.). Returns: User prompt listo para el nano model de auditoría. @@ -1201,9 +1255,16 @@ def visual_audit_user_prompt( context_lines.append(f"Tema: {topic}") context_block = "\n".join(context_lines) + "\n\n" if context_lines else "" + notes_block = "" + if user_notes: + combined = " | ".join(n for n in user_notes if n).strip() + if combined: + notes_block = f"Instrucciones del usuario: {combined[:500]}\n\n" + return ( f"Documento: {document_title}\n" f"{context_block}" + f"{notes_block}" f"Secciones disponibles:\n{sections_summary}\n\n" "Identifica dónde un visual añadiría valor real. Devuelve el JSON array." ) diff --git a/backend/puppeteer_config.json b/backend/puppeteer_config.json index 141bd21..cc714b8 100644 --- a/backend/puppeteer_config.json +++ b/backend/puppeteer_config.json @@ -4,6 +4,18 @@ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", - "--disable-gpu" + "--disable-gpu", + "--disable-crashpad", + "--disable-breakpad", + "--disable-features=Crashpad,CrashpadReporter", + "--no-crash-upload", + "--disable-crash-reporter", + "--disable-software-rasterizer", + "--disable-extensions", + "--mute-audio", + "--no-first-run", + "--no-default-browser-check", + "--hide-scrollbars", + "--user-data-dir=/tmp/chromium-user-data" ] } diff --git a/backend/tests/unit/test_visual_pipeline_fixes.py b/backend/tests/unit/test_visual_pipeline_fixes.py index 6880388..6df7306 100644 --- a/backend/tests/unit/test_visual_pipeline_fixes.py +++ b/backend/tests/unit/test_visual_pipeline_fixes.py @@ -18,7 +18,13 @@ import pytest from app.core.document.pipeline.base import DocumentContent, SectionContent -from app.core.document.vision_auditor import _build_sections_summary, _max_placements_for +from app.core.document.vision_auditor import ( + _build_sections_summary, + _force_placement, + _max_placements_for, + _user_requested_charts, + _user_requested_diagrams, +) from app.core.images.trusted_image_insertion import ( _translate_query_to_english, should_use_real_images, @@ -234,3 +240,97 @@ def test_snippet_length_preserved(self) -> None: content = self._make_content([body]) summary = _build_sections_summary(content) assert "A" * 100 in summary # snippet present + + +@pytest.mark.unit +class TestUserVisualIntentDetection: + """Detection of explicit user requests for charts/diagrams in user_notes.""" + + def test_charts_intent_with_accent(self) -> None: + assert _user_requested_charts(["incluye gráficas y tablas"]) is True + + def test_charts_intent_without_accent(self) -> None: + assert _user_requested_charts(["incluye graficas obligatorias"]) is True + + def test_charts_intent_tabla(self) -> None: + assert _user_requested_charts(["agrega una tabla comparativa"]) is True + + def test_charts_intent_negative(self) -> None: + assert _user_requested_charts(["solo texto narrativo"]) is False + + def test_charts_intent_none_safe(self) -> None: + assert _user_requested_charts(None) is False + assert _user_requested_charts([]) is False + + def test_diagrams_intent(self) -> None: + assert _user_requested_diagrams(["incluye diagramas explicativos"]) is True + assert _user_requested_diagrams(["un esquema de procesos"]) is True + + def test_diagrams_intent_negative(self) -> None: + assert _user_requested_diagrams(["sin nada visual"]) is False + + def test_max_placements_user_wants_both_scales_up(self) -> None: + content = DocumentContent( + document_title="t", + introduction="intro", + sections=[ + SectionContent(title=f"s{i}", body="lorem " * 200, citation_placeholders=[]) + for i in range(3) + ], + conclusion="conc", + all_citation_topics=[], + ) + # Sin user_notes: 2 placements para reporte de 3 secciones + assert _max_placements_for(content, "reporte") == 2 + # Con pedido explícito de ambos: hasta 1 por sección + assert ( + _max_placements_for( + content, + "reporte", + user_notes=["incluye graficas, diagramas y tablas obligatorias"], + ) + == 3 + ) + + +@pytest.mark.unit +class TestForcePlacement: + """_force_placement crea un placement del tipo solicitado en la mejor sección libre.""" + + def _content(self, bodies: list[str]) -> DocumentContent: + return DocumentContent( + document_title="t", + introduction="intro", + sections=[ + SectionContent(title=f"s{i}", body=b, citation_placeholders=[]) + for i, b in enumerate(bodies) + ], + conclusion="conc", + all_citation_topics=[], + ) + + def test_forces_chart_in_longest_eligible_section(self) -> None: + content = self._content( + ["palabra " * 110, "palabra " * 250, "palabra " * 180] + ) + placement = _force_placement(content, existing=[], target_type="chart", topic="X") + assert placement is not None + assert placement.type == "chart" + # Sección más larga = índice 1 (250 palabras) + assert placement.section_index == 1 + + def test_skips_sections_with_existing_visual(self) -> None: + bodies = [ + "palabra " * 200 + "\n\n:::chart\n{}\n:::", + "palabra " * 250, + ] + content = self._content(bodies) + placement = _force_placement(content, existing=[], target_type="mermaid", topic="X") + assert placement is not None + # La primera sección ya tiene chart → debe elegir la segunda + assert placement.section_index == 1 + + def test_returns_none_when_no_eligible_section(self) -> None: + content = self._content(["short", "tiny"]) # both < 100 words + placement = _force_placement(content, existing=[], target_type="chart", topic="X") + assert placement is None