From 311424643bf58116ee11bf320d3316498762c69f Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 10:05:44 -0500 Subject: [PATCH 01/11] fix(tests): mock PDF bytes in memory instead of reading unversioned fixture test_extract_text_pdf read data/samples/sample_pdf.pdf directly, which is gitignored (*.pdf) and fails on a clean clone. Build a minimal valid PDF in memory with fitz instead, since _extract_text needs real extractable text to exercise the actual code path. --- tests/unit/application/test_ingestion_service.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/unit/application/test_ingestion_service.py b/tests/unit/application/test_ingestion_service.py index 533d8da..d85dc7e 100644 --- a/tests/unit/application/test_ingestion_service.py +++ b/tests/unit/application/test_ingestion_service.py @@ -2,18 +2,23 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +import fitz import pytest from researchos.application.services.ingestion_service import extract_text_pdf from researchos.domain.models import Paper -from researchos.paths import SAMPLES_DIR + + +def _fake_pdf_bytes() -> bytes: + """Build a minimal valid PDF in memory — no disk, no fixture file.""" + doc = fitz.open() + doc.new_page().insert_text((72, 72), "Test PDF content") + return doc.tobytes() @pytest.mark.unit @pytest.mark.asyncio async def test_extract_text_pdf(): - local_pdf = SAMPLES_DIR / "sample_pdf.pdf" - paper = Paper( source_id="1", source="arxiv", @@ -26,7 +31,7 @@ async def test_extract_text_pdf(): ) mock_response = MagicMock() - mock_response.content = local_pdf.read_bytes() + mock_response.content = _fake_pdf_bytes() mock_response.raise_for_status = MagicMock() mock_client = MagicMock() From e97cd995e56116f2654e64ff7c8d1362693e14c7 Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 10:06:01 -0500 Subject: [PATCH 02/11] fix(bot): split long replies to respect Telegram's 4096-char limit Telegram rejects messages over MessageLimit.MAX_TEXT_LENGTH. _split_message breaks on the last whitespace before the limit and _handle_message sends the resulting chunks in sequence. The restriction lives in the channel, not the engine, so only telegram_bot.py changes. --- .../infrastructure/bot/telegram_bot.py | 22 +++++++++++- .../unit/infrastructure/test_telegram_bot.py | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/unit/infrastructure/test_telegram_bot.py diff --git a/src/researchos/infrastructure/bot/telegram_bot.py b/src/researchos/infrastructure/bot/telegram_bot.py index 32bba6e..15eda43 100644 --- a/src/researchos/infrastructure/bot/telegram_bot.py +++ b/src/researchos/infrastructure/bot/telegram_bot.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from telegram import Update +from telegram.constants import MessageLimit from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters logger = logging.getLogger(__name__) @@ -11,6 +12,24 @@ AnswerFn = Callable[[str], Awaitable[str]] +def _split_message(text: str, limit: int = MessageLimit.MAX_TEXT_LENGTH) -> list[str]: + """Split text into chunks that fit Telegram's per-message character limit. + + Breaks on the last whitespace before ``limit`` when possible, so words + aren't cut in half. Falls back to a hard cut if a single token exceeds + the limit on its own. + """ + chunks = [] + while len(text) > limit: + split_at = text.rfind(" ", 0, limit) + if split_at <= 0: + split_at = limit + chunks.append(text[:split_at]) + text = text[split_at:].lstrip() + chunks.append(text) + return chunks + + class TelegramBot: def __init__(self, token: str, answer_fn: AnswerFn) -> None: self.token_telegram = token @@ -22,7 +41,8 @@ async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TY logger.info("Mensaje recibido: %s", raw_text[:80]) answer_llm = await self.answer_fn(raw_text) - await update.message.reply_text(answer_llm) + for chunk in _split_message(answer_llm): + await update.message.reply_text(chunk) def run(self) -> None: print(self.token_telegram) diff --git a/tests/unit/infrastructure/test_telegram_bot.py b/tests/unit/infrastructure/test_telegram_bot.py new file mode 100644 index 0000000..27807a4 --- /dev/null +++ b/tests/unit/infrastructure/test_telegram_bot.py @@ -0,0 +1,35 @@ +import pytest + +from researchos.infrastructure.bot.telegram_bot import _split_message + + +@pytest.mark.unit +def test_split_message_under_limit_returns_single_chunk(): + text = "short answer" + assert _split_message(text, limit=100) == [text] + + +@pytest.mark.unit +def test_split_message_breaks_on_whitespace(): + text = "aaaa bbbb cccc dddd" + chunks = _split_message(text, limit=10) + + assert chunks == ["aaaa bbbb", "cccc dddd"] + assert all(len(chunk) <= 10 for chunk in chunks) + + +@pytest.mark.unit +def test_split_message_reassembles_to_original(): + text = "word " * 500 + chunks = _split_message(text, limit=4096) + + assert " ".join(chunks) == text + assert all(len(chunk) <= 4096 for chunk in chunks) + + +@pytest.mark.unit +def test_split_message_hard_cut_when_no_whitespace(): + text = "a" * 25 + chunks = _split_message(text, limit=10) + + assert chunks == ["a" * 10, "a" * 10, "a" * 5] From 14468e69821b0d23b71b1dfd4f36c77c43504474 Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 10:06:09 -0500 Subject: [PATCH 03/11] feat(scripts): add CLI wrapper for arXiv paper ingestion ingest_papers() already does all the work; the script only parses --query/--max-results/--collection, calls ensure_dirs(), and drives the coroutine with asyncio.run(). Was a TODO stub since V1 week 1-2. --- scripts/ingest_documents.py | 54 +++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/scripts/ingest_documents.py b/scripts/ingest_documents.py index d424427..c239879 100644 --- a/scripts/ingest_documents.py +++ b/scripts/ingest_documents.py @@ -1,14 +1,52 @@ -"""Script: Ingest documents into the vector store. +"""Script: Ingest arXiv papers into the vector store. + +Thin CLI wrapper around ``ingest_papers`` — fetches papers matching a query, +downloads and chunks them, and upserts the resulting documents into Chroma. +The service does all the work; this script only parses arguments and drives it. Usage: - uv run python scripts/ingest_documents.py + uv run python scripts/ingest_documents.py --query "LLM agents reasoning" --max-results 5 + uv run python scripts/ingest_documents.py --query "chaos and fluids" \ + --max-results 2 --collection hydraulics This is an operational script, not part of the installable package. """ -# TODO: Implement in V1 Week 1-2 -# 1. Fetch papers from arXiv API -# 2. Parse PDFs with PyMuPDF -# 3. Chunk text -# 4. Generate embeddings -# 5. Upsert into vector store +import argparse +import asyncio +import sys + +if sys.platform == "linux": + __import__("pysqlite3") + sys.modules["sqlite3"] = sys.modules.pop("pysqlite3") + +from researchos.application.services.ingestion_service import ingest_papers +from researchos.paths import ensure_dirs + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments for the ingestion run.""" + parser = argparse.ArgumentParser(description="Ingest arXiv papers into the vector store.") + parser.add_argument("--query", required=True, help="arXiv search query, e.g. 'LLM agents'") + parser.add_argument( + "--max-results", type=int, default=5, help="Max papers to fetch. Default: 5" + ) + parser.add_argument( + "--collection", default="papers", help="Chroma collection name. Default: 'papers'" + ) + return parser.parse_args() + + +async def main() -> None: + """Parse arguments and run the ingestion pipeline.""" + args = parse_args() + ensure_dirs() + await ingest_papers( + query=args.query, + max_results=args.max_results, + collection_name=args.collection, + ) + + +if __name__ == "__main__": + asyncio.run(main()) From a24de87fae633e89e6e73ff041b1970d6fab20cf Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 10:07:01 -0500 Subject: [PATCH 04/11] docs(architecture): add ADR-004, fix stale Telegram/ingest examples in bank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-004 documents the AnswerFn/RetrieveFn pattern: Callable type aliases for single-behavior dependencies instead of a Protocol or a strategy flag. CA-004 described the Telegram bot as "planned for V2" and referenced telegram.py/store=... — both stale now that the bot is implemented with the retrieve-injection pattern. CA-005 referenced a script filename that was never real (ingest_papers.py instead of ingest_documents.py). --- docs/architecture.md | 13 ++++++++++++ docs/interview_prep/bank.md | 21 ++++++++++++------- .../by_topic/clean_architecture.md | 21 ++++++++++++------- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c015ae3..999dabf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,4 +39,17 @@ --- +## ADR-004: Function-type aliases for single-behavior dependencies + +**Date:** 2026-08 +**Status:** Accepted + +**Context:** `rag_service.answer_query` needed to depend on "something that answers" (for the Telegram adapter) and, one layer down, on "something that retrieves" (to swap vector-only, hybrid, and hybrid+rerank without touching the service). A `Protocol` is built for contracts with several named methods that share state; here each dependency is a single anonymous behavior — one parameter, one return. A `strategy="hybrid"` flag was also considered and rejected: it produces parameters that are conditionally required depending on another parameter's value, which a type checker cannot express, so a wrong combination only fails at runtime. + +**Decision:** Model single-behavior dependencies as `Callable` type aliases (`AnswerFn = Callable[[str], Awaitable[str]]`, `RetrieveFn = Callable[[str], Awaitable[list[Document]]]`) instead of a `Protocol` or a strategy flag. The concrete choice of implementation is captured as a closure built once in the composition root (`scripts/run_telegram_bot.py`) and passed down — the consuming code (`TelegramBot`, `answer_query`) only knows the function signature. + +**Consequences:** Lighter than a `Protocol` for the common case of one behavior, still statically checkable via the `Callable` signature. Swapping retrieval strategy (vector-only → hybrid+rerank) required zero changes to `TelegramBot` — only the closure built in the composition root changed. Rule going forward: one behavior → function-type alias; several related behaviors sharing state → `Protocol` or a class. + +--- + diff --git a/docs/interview_prep/bank.md b/docs/interview_prep/bank.md index 7275535..a0db16e 100644 --- a/docs/interview_prep/bank.md +++ b/docs/interview_prep/bank.md @@ -102,18 +102,23 @@ respuesta. **Respuesta esperada:** El bot va en `infrastructure/bot/slack.py`. Flujo: (1) llega evento HTTP de Slack al webhook expuesto en el bot; (2) el bot -extrae texto y contexto (user_id, channel_id); (3) el bot llama a -`rag_service.answer_query(text=..., llm=..., store=...)` — llamada -agnóstica al canal; (4) `rag_service` orquesta hybrid_search + rerank + -generación; (5) devuelve el string; (6) el bot publica la respuesta en -Slack usando el SDK. Motor no sabe que existe Slack. +extrae texto y contexto (user_id, channel_id); (3) el bot llama a una +función `answer_fn(query)` inyectada — típicamente un closure armado en el +composition root que envuelve `rag_service.answer_query(query, llm, +retrieve)`; (4) `answer_query` ejecuta `retrieve(query)` (hybrid+rerank u +otra estrategia) y genera con el LLM; (5) devuelve el string; (6) el bot +publica la respuesta en Slack usando el SDK. Motor no sabe que existe Slack. **Trampa común:** Meter el bot en `application/`. Los SDKs de Slack o Telegram son dependencias externas — pertenecen a infrastructure. El bot importa del motor, no al revés. -**Ejemplo en el proyecto:** Planeado para V2 — -`infrastructure/bot/telegram.py` seguirá el mismo patrón. +**Ejemplo en el proyecto:** Implementado en V1 real — +`infrastructure/bot/telegram_bot.py` (`TelegramBot(token, answer_fn)`, con +`AnswerFn = Callable[[str], Awaitable[str]]`). El closure que satisface +`answer_fn` se arma en `scripts/run_telegram_bot.py`, no en el bot. Un canal +nuevo (Slack) seguiría exactamente el mismo patrón sin tocar +`TelegramBot`. --- @@ -126,7 +131,7 @@ deberías tocar y por qué? clase `QdrantVectorStore` que implementa el Protocol `VectorStore`; `tests/integration/test_qdrant.py`. Modificar: `config.py` para agregar la opción `Literal["chroma", "qdrant"]` en Settings; el composition root -donde se instancia el store (scripts como `scripts/ingest_papers.py`). +donde se instancia el store (scripts como `scripts/ingest_documents.py`). No tocar: nada en `domain/` (las abstracciones no cambian); nada en `application/services/*` (programan contra Protocols); ni los tests unitarios de application (los mocks de conftest.py siguen sirviendo). diff --git a/docs/interview_prep/by_topic/clean_architecture.md b/docs/interview_prep/by_topic/clean_architecture.md index e974125..85433a8 100644 --- a/docs/interview_prep/by_topic/clean_architecture.md +++ b/docs/interview_prep/by_topic/clean_architecture.md @@ -83,18 +83,23 @@ respuesta. **Respuesta esperada:** El bot va en `infrastructure/bot/slack.py`. Flujo: (1) llega evento HTTP de Slack al webhook expuesto en el bot; (2) el bot -extrae texto y contexto (user_id, channel_id); (3) el bot llama a -`rag_service.answer_query(text=..., llm=..., store=...)` — llamada -agnóstica al canal; (4) `rag_service` orquesta hybrid_search + rerank + -generación; (5) devuelve el string; (6) el bot publica la respuesta en -Slack usando el SDK. Motor no sabe que existe Slack. +extrae texto y contexto (user_id, channel_id); (3) el bot llama a una +función `answer_fn(query)` inyectada — típicamente un closure armado en el +composition root que envuelve `rag_service.answer_query(query, llm, +retrieve)`; (4) `answer_query` ejecuta `retrieve(query)` (hybrid+rerank u +otra estrategia) y genera con el LLM; (5) devuelve el string; (6) el bot +publica la respuesta en Slack usando el SDK. Motor no sabe que existe Slack. **Trampa común:** Meter el bot en `application/`. Los SDKs de Slack o Telegram son dependencias externas — pertenecen a infrastructure. El bot importa del motor, no al revés. -**Ejemplo en el proyecto:** Planeado para V2 — -`infrastructure/bot/telegram.py` seguirá el mismo patrón. +**Ejemplo en el proyecto:** Implementado en V1 real — +`infrastructure/bot/telegram_bot.py` (`TelegramBot(token, answer_fn)`, con +`AnswerFn = Callable[[str], Awaitable[str]]`). El closure que satisface +`answer_fn` se arma en `scripts/run_telegram_bot.py`, no en el bot. Un canal +nuevo (Slack) seguiría exactamente el mismo patrón sin tocar +`TelegramBot`. --- @@ -107,7 +112,7 @@ deberías tocar y por qué? clase `QdrantVectorStore` que implementa el Protocol `VectorStore`; `tests/integration/test_qdrant.py`. Modificar: `config.py` para agregar la opción `Literal["chroma", "qdrant"]` en Settings; el composition root -donde se instancia el store (scripts como `scripts/ingest_papers.py`). +donde se instancia el store (scripts como `scripts/ingest_documents.py`). No tocar: nada en `domain/` (las abstracciones no cambian); nada en `application/services/*` (programan contra Protocols); ni los tests unitarios de application (los mocks de conftest.py siguen sirviendo). From 317be9a35ea3b8337d762cc7c28f85c593e30ce1 Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 10:09:38 -0500 Subject: [PATCH 05/11] docs(roadmap): restructure with v3.0 plan windows, log ingestion layering debt Replaces relative-week sections with calendar-date windows per version (V1 real through V6), matching the new plan. Marks T11-T16 done for the Telegram bot work and today's fixes. Logs a new debt item: ingestion_ service.py imports httpx/fitz/arxiv.py at module level, violating the application-depends-only-on-domain rule that rag_service.py already follows via RetrieveFn injection. --- ROADMAP.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b8b228..8217bbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,18 @@ # ResearchOS — Roadmap de aprendizaje +> Roadmap v3.0 (10/08/2026). Ventanas por fecha calendario en vez de semanas +> relativas — reemplaza el plan de marzo 2026. Detalle completo (objetivos +> SMART, distribución semanal de horas, sistema de consolidación conceptual) +> vive en los documentos personales de planificación; este archivo es solo +> el checklist de tareas por versión. + ## Abril 2026 — V1 pipeline RAG básico - [x] T1: AnthropicLLM provider (completado 30 mar) - [x] T2: Cliente arXiv API (completado 13 abr) -- [x] T3: Servicio de ingesta PDFs (completado 15 abr) +- [x] T3: Servicio de ingesta PDFs (completado 15 abr) — nota: el servicio + (`ingestion_service.py`) quedó completo en esta fecha, pero el CLI que + lo envuelve (`scripts/ingest_documents.py`) quedó como stub sin + implementar hasta el 12/08/2026 (ver T16) - [x] T4: Chunking fijo (completado 17 abr) - [x] T5: Integración end-to-end (completado 18 abr) @@ -14,7 +23,58 @@ - [x] T9: Dataset de evaluación (20 preguntas) (completado 18 abr) - [x] T10: Script de evaluación comparativa (completado 01 jun) -## Junio 2026 — V2 LangGraph -- [ ] T11: Refactor a LangGraph -- [ ] T12: Primer briefing matutino -- [ ] T13: Comparativa V1 vs V2 +## V1 real — Cierre con Telegram (10/08 – 14/08/2026) · Hito: 14/08/2026 +- [x] T11: Bot de Telegram como adapter (`infrastructure/bot/telegram_bot.py`, `AnswerFn` inyectado, completado 10 ago) +- [x] T12: Composition root `scripts/run_telegram_bot.py` con wiring hybrid+rerank (completado 11 ago) +- [x] T13: `answer_query` inyecta `retrieve: RetrieveFn` en vez de `store: VectorStore` (completado 11 ago) +- [x] T14: Fix — `test_extract_text_pdf` sin depender de un PDF no versionado (completado 12 ago) +- [x] T15: Fix — límite de 4096 caracteres por mensaje de Telegram (completado 12 ago) +- [x] T16: `scripts/ingest_documents.py` implementado — CLI delgado sobre `ingest_papers` (completado 12 ago) +- [ ] T17: Merge de la rama de V1 real a `main` + +## Deuda técnica conocida +- [ ] `ingestion_service.py` (`application/`) importa `httpx`, `fitz` y + `infrastructure.data.arxiv.search_papers` **a nivel de módulo** — viola + la regla "application solo depende de domain" (ADR-001, y CA-001/CA-002 + del banco de preguntas). Detectado el 12/08/2026 al construir el + diagrama de arquitectura de V1 real. `rag_service.py` ya recibió este + tratamiento (inyecta `retrieve`/`llm` en vez de instanciar); pendiente + decidir si `ingestion_service.py` se refactoriza igual — candidato + natural: V2, al tocar el pipeline de ingesta para LangGraph. + +## V2 — Agente LangGraph + Briefing matutino (17/08 – 02/10/2026) · Hito: 02/10/2026 +- [ ] T18: LangGraph fundamentals — grafo mínimo `retrieve → generate` +- [ ] T19: Primera tool tipada: `search_papers()` +- [ ] T20: Tools adicionales: `fetch_paper()`, `search_news()`, `query_vector_store()`, `explain_concept()` +- [ ] T21: Briefing matutino con APScheduler (7am — top 5 papers + 3 noticias + 1 concepto) +- [ ] T22: Memoria conversacional con SQLite checkpointer +- [ ] T23: Dockerización (`docker-compose` con bot + Chroma + scheduler) +- [ ] T24: Eval V1 vs V2 con queries reales acumuladas del bot + +## V3 — Observabilidad + Evals + Testing (05/10 – 06/11/2026) · Hito: 06/11/2026 +- [ ] T25: Langfuse self-hosted instrumentando el agente +- [ ] T26: Deuda de testing pagada — separación `make test` / `make test-all` +- [ ] T27: Dataset de evaluación a 40 preguntas sin data leakage +- [ ] T28: Métricas RAGAS con Gemini Flash como juez externo +- [ ] T29: 1–2 tools migradas a MCP (FastMCP) + +## V4 — Guardrails + Despliegue + Vertex AI (09/11/2026 – 08/01/2027) · Hito: 08/01/2027 +- [ ] T30: Guardrails AI + PII masking con Presidio +- [ ] T31: Despliegue en Cloud Run + Secret Manager +- [ ] T32: CI/CD en GitHub Actions con evals bloqueando deploys degradados +- [ ] T33: Exploración de Vertex AI Agent Engine +- [ ] T34: Apertura del canal público de Telegram + +## V5 — Consolidación + Portfolio (11/01 – 12/02/2027) · Hito: 12/02/2027 +- [ ] T35: Router inteligente por dominio + Vertex AI Search +- [ ] T36: App Streamlit con streaming +- [ ] T37: API REST autenticada (FastAPI) +- [ ] T38: Documentación técnica con MkDocs +- [ ] T39: Video demo + post de LinkedIn + +## V6 — Laboratorio de exploración (desde 15/02/2027, sin deadline) +- [ ] Módulos independientes de 1–2 semanas (multi-agente, MCP a fondo, + comparación de frameworks, RAG avanzado, voice interface, fine-tuning, + infra avanzada, evals como producto, Vertex AI + ADK, CI/CD + alternativo, comparación de IDEs agénticos) — detalle en el roadmap + de aprendizaje personal, sin checklist fijo por diseño From 8509258698e919f9c2e5b6b165ea095561c0288a Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 10:09:46 -0500 Subject: [PATCH 06/11] chore(skills): add legend_edge helper for floating legend arrows Legend arrow samples are floating edges (no source/target node), and mxGraph requires them to declare sourcePoint/targetPoint explicitly or draw.io silently fails to render the line. legend_edge emits those attributes so legend samples always show up. --- .../arquitectura-drawio/references/estilo.md | 24 ++++++++++--------- .../arquitectura-drawio/scripts/drawio_kit.py | 21 +++++++++++++--- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.claude/skills/arquitectura-drawio/references/estilo.md b/.claude/skills/arquitectura-drawio/references/estilo.md index b36ea78..27db747 100644 --- a/.claude/skills/arquitectura-drawio/references/estilo.md +++ b/.claude/skills/arquitectura-drawio/references/estilo.md @@ -103,19 +103,21 @@ rojo = orquestador/crítico, blanco = almacén de apoyo, amarillo = salida). Añ extra que apliquen (iconos sueltos = fuentes externas; flechas animadas = sentido del flujo). Dibújala en una franja al pie (dentro del `pageHeight`) con **edges de muestra reales** — mismo `style` -que en el diagrama, con `sourcePoint`/`targetPoint` en la geometría en lugar de `source`/`target` — y -**chips** de color usando el `fillColor`/`strokeColor` de cada arquetipo: +que en el diagrama — y **chips** de color usando el `fillColor`/`strokeColor` de cada arquetipo: +```python +# Línea de muestra (sin nodos): usa Page.legend_edge(x1, x2, y, style) — NO Page.edge() +p.legend_edge(960, 1005, 756, EDGE["data"]) # swatch verde = flujo de datos +# Chip de color de caja: un node() normal con el estilo del arquetipo +p.node("Paso determinista", 960, 780, 240, 38, STYLE["det"]) ``` -# Línea de muestra (sin nodos): reutiliza el EDGE real, solo cambia los puntos - - - - - -# Chip de color de caja (usa el fillColor/strokeColor del arquetipo) -rounded=1;arcSize=6;absoluteArcSize=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366; -``` + +**Por qué `legend_edge` y no `edge`:** una muestra de flecha en la leyenda no conecta dos nodos reales, +así que es un edge "flotante" — mxGraph exige que declare sus extremos con +``/`` dentro de `mxGeometry`. Sin el atributo `as=` +draw.io no sabe dónde dibujar el segmento y la muestra queda invisible (bug real detectado: el XML +parseaba y el linter de traslapes no lo atrapaba, pero la línea no se veía en draw.io). `legend_edge` +ya emite esos atributos — no construyas el `` a mano. ## Claridad y anti-traslape (lo más importante para que se entienda) diff --git a/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py b/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py index e875017..21c5929 100644 --- a/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py +++ b/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py @@ -66,9 +66,8 @@ "user": "shape=mxgraph.ios7.icons.user;html=1;strokeColor=#0080F0;strokeWidth=2;" "verticalLabelPosition=bottom;verticalAlign=top;labelPosition=center;align=center;" f"fontColor={FONT};fontSize=11;", - "zone": f"""rounded=1;{ARC_ZONE}whiteSpace=wrap;html=1;fillColor=none; - strokeColor={BLUE};dashed=1;""" - f"verticalAlign=top;align=left;fontColor={BLUE};fontSize=13;fontStyle=1;" + "zone": f"rounded=1;{ARC_ZONE}whiteSpace=wrap;html=1;fillColor=none;strokeColor={BLUE};" + f"dashed=1;verticalAlign=top;align=left;fontColor={BLUE};fontSize=13;fontStyle=1;" "spacingLeft=12;spacingTop=6;", "note": "text;whiteSpace=wrap;html=1;fontColor=#9E9E9E;fontSize=11;align=left;", "caption": f"text;whiteSpace=wrap;html=1;fontColor={FONT};" @@ -157,6 +156,22 @@ def edge( ) return cid + def legend_edge(self, x1: float, x2: float, y: float, style: str) -> str: + """Muestra de flecha para la leyenda: un edge SIN nodo origen/destino + (`source`/`target`). mxGraph exige que un edge flotante declare sus + extremos con ``/``; + sin ese atributo `as=`, draw.io no sabe dónde dibujar la línea y la + muestra no se ve (aunque el resto del diagrama sea válido).""" + cid = self._id("lge") + self.cells.append( + f'' + f'' + f'' + f'' + f"" + ) + return cid + def _xml(self) -> str: body = "".join(self.cells) return ( From 1a69792c7e3e19e43dc7c45581101559993e4ead Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 16:51:06 -0500 Subject: [PATCH 07/11] fix(bot): stop printing the Telegram token on startup Co-Authored-By: Claude Opus 5 (1M context) --- src/researchos/infrastructure/bot/telegram_bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/researchos/infrastructure/bot/telegram_bot.py b/src/researchos/infrastructure/bot/telegram_bot.py index 15eda43..34b7ac9 100644 --- a/src/researchos/infrastructure/bot/telegram_bot.py +++ b/src/researchos/infrastructure/bot/telegram_bot.py @@ -45,7 +45,6 @@ async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TY await update.message.reply_text(chunk) def run(self) -> None: - print(self.token_telegram) app = ApplicationBuilder().token(self.token_telegram).build() app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message)) app.run_polling() From 90adf628eb6655c678e613d7c55b148b123a309a Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 16:51:07 -0500 Subject: [PATCH 08/11] feat(skills): add page helpers and prefer multicolor logos in drawio_kit Co-Authored-By: Claude Opus 5 (1M context) --- .../arquitectura-drawio/scripts/drawio_kit.py | 72 +++++++++- .../arquitectura-drawio/scripts/ejemplo.py | 135 +++++++++--------- .../arquitectura-drawio/scripts/glyph.py | 116 +++++++++++++-- 3 files changed, 240 insertions(+), 83 deletions(-) diff --git a/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py b/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py index 21c5929..a9382e4 100644 --- a/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py +++ b/.claude/skills/arquitectura-drawio/scripts/drawio_kit.py @@ -42,8 +42,12 @@ # --- Arquetipos de nodo (copiar-pegar tal cual en draw.io con Ctrl+E) --------- STYLE = { + # `kitRole=banner` (como `kitRole=zone`) es un marcador propio que draw.io ignora y que + # permite a check_layout saber que esta caja no es un componente, así no la mide contra + # el estándar de tamaño de la casa. "banner": "rounded=0;whiteSpace=wrap;html=1;fillColor=#4DA1F5;strokeColor=none;" - "shadow=1;fontColor=#ffffff;fontSize=15;fontStyle=1;align=center;verticalAlign=middle;", + "shadow=1;fontColor=#ffffff;fontSize=15;fontStyle=1;align=center;verticalAlign=middle;" + "kitRole=banner;", "card": f"rounded=1;{ARC}whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#dddddd;" f"shadow=1;strokeWidth=1;fontColor={FONT};fontSize=12;", "llm": f"rounded=1;{ARC}whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" @@ -66,9 +70,13 @@ "user": "shape=mxgraph.ios7.icons.user;html=1;strokeColor=#0080F0;strokeWidth=2;" "verticalLabelPosition=bottom;verticalAlign=top;labelPosition=center;align=center;" f"fontColor={FONT};fontSize=11;", + # `kitRole=zone` es un marcador propio: draw.io ignora las claves de estilo que no + # conoce, y a cambio check_layout puede identificar los contenedores sin adivinar por + # heurística (antes: "punteado + sin relleno" — que también matchea una anotación + # punteada cualquiera y la excluía en silencio de todos los chequeos). "zone": f"rounded=1;{ARC_ZONE}whiteSpace=wrap;html=1;fillColor=none;strokeColor={BLUE};" f"dashed=1;verticalAlign=top;align=left;fontColor={BLUE};fontSize=13;fontStyle=1;" - "spacingLeft=12;spacingTop=6;", + "spacingLeft=12;spacingTop=6;kitRole=zone;", "note": "text;whiteSpace=wrap;html=1;fontColor=#9E9E9E;fontSize=11;align=left;", "caption": f"text;whiteSpace=wrap;html=1;fontColor={FONT};" "fontSize=11;align=center;fontStyle=1;", @@ -99,9 +107,11 @@ def _esc(text: str) -> str: class Page: """Una página () del archivo. No instanciar directo: usar Diagram.page().""" - def __init__(self, name: str, prefix: str) -> None: + def __init__(self, name: str, prefix: str, width: int = 1654, height: int = 1169) -> None: self.name = name self.prefix = prefix + self.width = width + self.height = height self.cells: list[str] = [] self._n = 0 @@ -129,6 +139,52 @@ def node(self, label, x, y, w, h, style, ident=None, icon=None) -> str: ) return cid + def zone(self, title, x, y, w, h) -> str: + """Contenedor/banda (recuadro azul punteado con el título arriba a la izquierda). + + Dibújalo ANTES que su contenido para que quede detrás. Usa siempre este método en + vez de `node(..., STYLE["zone"])`: marca la celda como contenedor para el linter.""" + return self.node(title, x, y, w, h, STYLE["zone"]) + + def banner(self, title: str, subtitle: str = "", x=40, y=20, w=1574) -> None: + """Cabecera de página: barra azul con el título y, debajo, una línea de contexto. + + El subtítulo es donde se dice para qué sirve la página y a qué otras remite — en + un diagrama multipágina es lo que evita que el lector crea que está viendo el todo.""" + self.node(title, x, y, w, 36, STYLE["banner"]) + if subtitle: + self.node(subtitle, x, y + 38, w, 20, STYLE["note"] + "spacingLeft=2;") + + def legend(self, y, edges, chips, nota="", x=40, edge_col=450, chip_col=620) -> None: + """Leyenda obligatoria (ver SKILL.md): muestras de flecha REALES + chips de caja. + + `edges` es [(EDGE[...], "qué significa ese color"), ...] y `chips` + [(STYLE[...], "qué tipo de componente es"), ...]. Las muestras se dibujan con el + mismo `style` del diagrama para que color y animación coincidan de verdad. + + Devuelve None; comprueba con check_layout que la franja entre en el pageHeight.""" + self.node("Leyenda", x, y, 200, 22, STYLE["caption"] + "align=left;") + for i, (edge_style, texto) in enumerate(edges): + yy = y + 34 + i * 32 + self.legend_edge(x + 20, x + 88, yy + 11, edge_style) + self.node(texto, x + 102, yy, edge_col, 22, STYLE["note"]) + for i, (chip_style, texto) in enumerate(chips): + xx = chip_col + (i % 3) * 340 + yy = y + 32 + (i // 3) * 62 + # `kitRole=legend`: los chips reutilizan el estilo de cada arquetipo, así que sin + # marcarlos check_layout los mediría como si fueran componentes del diagrama. + chip = chip_style + "kitRole=legend;" + if "cylinder" in chip_style or "verticalLabelPosition=bottom" in chip_style: + # Estas formas llevan la etiqueta DEBAJO y su huella real es ~1.7x el ancho: + # como chip invadirían al vecino. Se dibujan pequeñas y mudas, con el texto al lado. + self.node("", xx, yy + 6, 66, 34, chip) + self.node(texto, xx + 78, yy, 222, 46, STYLE["note"] + "verticalAlign=middle;") + else: + self.node(texto, xx, yy, 300, 46, chip) + if nota: + filas = (len(chips) - 1) // 3 + 1 if chips else 0 + self.node(nota, chip_col, y + 32 + filas * 62 + 20, 980, 56, STYLE["note"]) + def edge( self, src, dst, style=EDGE["flow"], label="", exit=None, entry=None, points=None ) -> str: @@ -178,7 +234,7 @@ def _xml(self) -> str: f'' f'' + f'pageWidth="{self.width}" pageHeight="{self.height}" math="0" shadow="0">' f'{body}' f"" ) @@ -190,8 +246,12 @@ class Diagram: def __init__(self) -> None: self.pages: list[Page] = [] - def page(self, name: str) -> Page: - p = Page(name, f"p{len(self.pages) + 1}") + def page(self, name: str, width: int = 1654, height: int = 1169) -> Page: + """Crea una página. El tamaño por defecto es A3 apaisado a 96 dpi (1654x1169), + que es lo que cabe legible en pantalla; súbelo solo si el contenido lo pide de + verdad — un lienzo más grande suele ser síntoma de que faltaba partir en páginas + (ver el criterio de partición en SKILL.md).""" + p = Page(name, f"p{len(self.pages) + 1}", width, height) self.pages.append(p) return p diff --git a/.claude/skills/arquitectura-drawio/scripts/ejemplo.py b/.claude/skills/arquitectura-drawio/scripts/ejemplo.py index 6adddfb..e1365e1 100644 --- a/.claude/skills/arquitectura-drawio/scripts/ejemplo.py +++ b/.claude/skills/arquitectura-drawio/scripts/ejemplo.py @@ -1,81 +1,86 @@ -"""Plantilla de uso de drawio_kit — AGNÓSTICA de tecnología. +"""Plantilla de uso de drawio_kit — AGNÓSTICA de tecnología y MULTIPÁGINA. -Muestra el flujo de iconos recomendado: intentar el LOGO OFICIAL de cada tecnología -con `logo(...)` y, si no existe, caer a un GLIFO GENÉRICO con `material(...)`. -Copie este patrón para cualquier stack (JS, Python, LangChain, ADK, cloud, ...). +Copia este patrón para cualquier stack (JS, Python, LangChain, ADK, cloud, ...). Muestra +las dos formas de página que cubren casi todo diagrama de arquitectura, y que conviene NO +mezclar en el mismo lienzo (ver el criterio de partición en SKILL.md): + + página 1 · vista ESTÁTICA -> qué existe y quién depende de quién (capas, zonas) + página 2 · vista DINÁMICA -> qué pasa cuando ocurre X (secuencia numerada) + +Si tu repositorio tiene un solo flujo y pocos componentes, una sola página es la respuesta +correcta: borra la segunda. Partir de más también hace daño. python ejemplo.py # escribe ejemplo.drawio, lo valida y chequea el layout """ from drawio_kit import EDGE, STYLE, Diagram +from glyph import icon # logo oficial a color -> glifo genérico -> None si no hay red -try: - from glyph import logo, material - - def icon(name, fallback_glyph, color=None): - """Logo oficial de `name`; si no existe, el glifo genérico `fallback_glyph`.""" - try: - return logo(name, color) - except Exception: - return material(fallback_glyph, color or "#5f6368") -except Exception as exc: # sin red -> degradar sin iconos (no romper) - print(f"(aviso: sin iconos, sigo sin ellos: {exc})") - - def icon(name, fallback_glyph, color=None): - return None - +# Arquetipos usados en este ejemplo. Mantén el mapeo color->significado estable en todo +# el archivo: es lo que la leyenda promete al lector. +CARD, LLM, DET, STORE, ZONE = ( + STYLE["card"], + STYLE["llm"], + STYLE["det"], + STYLE["store"], + STYLE["zone"], +) -def istyle(uri, fs=11): - return ( - ( - f"shape=image;html=1;imageAspect=0;aspect=fixed;verticalLabelPosition=bottom;" - f"verticalAlign=top;labelPosition=center;align=center;fontColor=#4B5259;" - f"fontSize={fs};image={uri}" - ) - if uri - else STYLE["card"] - ) +LEG_EDGES = [ + (EDGE["data"], "Flujo de datos — lo que se transforma y avanza al paso siguiente"), + (EDGE["flow"], "Orquestación / control — quién invoca o ejecuta a quién"), + (EDGE["aux"], "Consumo de un recurso o servicio compartido (lectura / escritura)"), +] +LEG_CHIPS = [ + (DET, "Paso determinista (lógica propia)"), + (LLM, "Servicio LLM o sistema externo"), + (CARD, "Adapter concreto sobre un SDK"), + (STORE, "Almacén de datos"), +] +LEG_NOTA = ( + "Las flechas van animadas e indican el sentido del flujo. Los números marcan el orden de " + "ejecución: el lector sigue la numeración y no necesita trazar las flechas. Cada caja lleva " + "el logo oficial de su tecnología; las que no tienen logo usan un glifo genérico." +) +AG = icon("langchain", "smart_toy") +DB = icon("postgresql", "database") d = Diagram() -p = d.page("Ejemplo") -p.node( - "Arquitectura de ejemplo — plantilla drawio_kit (agnóstica de tecnología)", - 40, - 20, - 900, - 40, - STYLE["banner"], -) -p.node("Aplicación", 40, 90, 920, 330, STYLE["zone"], ident="app") -# Cada nodo usa el logo oficial de su tecnología; los que no lo tienen, un glifo genérico. -front = p.node("Frontend", 70, 150, 150, 60, STYLE["card"], icon=icon("react", "code", "#61DAFB")) -api = p.node("API", 280, 150, 150, 60, STYLE["card"], icon=icon("fastapi", "api", "#009688")) -agent = p.node( - "Agente LangChain", 490, 150, 160, 60, STYLE["llm"], icon=icon("langchain", "smart_toy") -) -# Langfuse NO está en las fuentes de logos -> cae al glifo genérico "visibility" -obs = p.node( - "Langfuse\n(observabilidad)", - 720, - 152, - 160, - 56, - STYLE["card"], - icon=icon("langfuse", "visibility", "#1a73e8"), -) -# Componente custom sin logo -> glifo genérico "code" -worker = p.node( - "Worker propio", 280, 270, 150, 56, STYLE["card"], icon=icon("__custom__", "code", "#cc0000") +# ── Página 1 · vista estática: qué existe y cómo se agrupa ──────────────────── +p1 = d.page("1 · Componentes") +p1.banner( + "Ejemplo — vista estática de componentes", + "Qué existe y en qué capa vive. El recorrido en tiempo de ejecución está en la " + "página «2 · Flujo».", ) -# Base de datos: cilindro estándar (etiqueta debajo) -db = p.node("PostgreSQL", 520, 280, 120, 50, STYLE["store"]) +p1.zone("frontera del sistema", 40, 100, 1574, 210) +p1.node("Frontend\nReact", 80, 150, 190, 56, CARD, icon=icon("react", "code")) +p1.node("API\nFastAPI", 330, 150, 190, 56, CARD, icon=icon("fastapi", "api")) +p1.node("Agente\nLangChain", 580, 150, 190, 56, LLM, icon=icon("langchain", "smart_toy")) +# Langfuse no está en las fuentes de logos -> cae al glifo genérico "visibility" +p1.node("Langfuse\nobservabilidad", 830, 150, 190, 56, CARD, icon=icon("langfuse", "visibility")) +p1.node("PostgreSQL", 1090, 156, 150, 44, STORE) +p1.legend(370, LEG_EDGES, LEG_CHIPS, LEG_NOTA) -p.edge(front, api, EDGE["flow"], exit=(1, 0.5), entry=(0, 0.5)) -p.edge(api, agent, EDGE["flow"], exit=(1, 0.5), entry=(0, 0.5)) -p.edge(agent, obs, EDGE["flow"], exit=(1, 0.5), entry=(0, 0.5)) -p.edge(agent, db, EDGE["data"], exit=(0.5, 1), entry=(0.5, 0)) -p.edge(api, worker, EDGE["flow"], exit=(0.5, 1), entry=(0.5, 0)) +# ── Página 2 · vista dinámica: qué pasa cuando llega una petición ───────────── +# Secuencia numerada de izquierda a derecha; si no cupiera, se sigue en una fila de +# abajo bajando por la MISMA columna (serpentina), así ninguna flecha retrocede. +p2 = d.page("2 · Flujo") +p2.banner( + "Ejemplo — flujo de una petición", + "Cuatro pasos numerados. El color de cada caja indica su tipo (ver leyenda).", +) +s1 = p2.node("① Frontend\nenvía la petición", 60, 140, 200, 56, CARD, icon=icon("react", "code")) +s2 = p2.node("② API\nvalida y enruta", 380, 140, 200, 56, CARD, icon=icon("fastapi", "api")) +s3 = p2.node("③ Agente\nrazona y decide", 700, 140, 200, 56, LLM, icon=AG) +s4 = p2.node("④ PostgreSQL\npersiste el resultado", 1020, 140, 200, 56, DET, icon=DB) +p2.edge(s1, s2, EDGE["flow"], "POST /consulta", exit=(1, 0.5), entry=(0, 0.5)) +p2.edge(s2, s3, EDGE["flow"], exit=(1, 0.5), entry=(0, 0.5)) +p2.edge(s3, s4, EDGE["data"], "resultado", exit=(1, 0.5), entry=(0, 0.5)) +p2.legend(300, LEG_EDGES, LEG_CHIPS, LEG_NOTA) +# En un repo real la salida por defecto es docs/architecture.drawio (ver SKILL.md); +# aquí se escribe al directorio actual por ser solo una plantilla de demostración. d.write("ejemplo.drawio") diff --git a/.claude/skills/arquitectura-drawio/scripts/glyph.py b/.claude/skills/arquitectura-drawio/scripts/glyph.py index c849493..20e6e85 100644 --- a/.claude/skills/arquitectura-drawio/scripts/glyph.py +++ b/.claude/skills/arquitectura-drawio/scripts/glyph.py @@ -7,10 +7,15 @@ cachea cada SVG en el temp del sistema. Entradas: - - logo(name, color) -> LOGO OFICIAL (punto de entrada recomendado). Prueba, en - orden: marca (simple-icons, ~3000 logos) y producto Google - Cloud (gcp_icon). Sirve para cualquier tecnología. - - simple_icon(slug, color) -> logo de marca puntual (simple-icons). + - icon(name, fallback, color) -> RECOMENDADO al generar: logo oficial y, si no existe, + glifo genérico; None si no hay red. Nunca lanza. + - logo(name, color) -> LOGO OFICIAL (lanza KeyError si no existe). Prefiere el + arte A COLOR: devicon (multicolor) -> producto Google Cloud + (multicolor) -> simple-icons teñido con el hex OFICIAL de + la marca. Solo sale negro si el color de marca lo es. + - devicon(slug) -> logo multicolor (devicon). Cubre el stack clásico. + - brand_hex(slug) -> color oficial de una marca (#RRGGBB) según simple-icons. + - simple_icon(slug, color) -> logo de marca puntual (simple-icons, monocromo). - material(symbol, color) -> GLIFO GENÉRICO de fallback (code, database, api, hub, settings, smart_toy...) vía Material Symbols (Apache-2.0). Úsalo cuando NO haya logo oficial (p. ej. Google ADK, un @@ -28,13 +33,18 @@ from __future__ import annotations import hashlib +import json import os +import re import sys import tempfile import urllib.parse import urllib.request SIMPLE = "https://cdn.jsdelivr.net/npm/simple-icons/icons/{}.svg" +SIMPLE_META = "https://cdn.jsdelivr.net/npm/simple-icons/_data/simple-icons.json" +DEVICON = "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/{0}/{0}-{1}.svg" +DEVICON_VARIANTS = ("original", "plain") # "original" es el arte a color; "plain", silueta MATERIAL = "https://cdn.jsdelivr.net/npm/@material-symbols/svg-400/outlined/{}.svg" CACHE = os.path.join(tempfile.gettempdir(), "drawio_glyphs") @@ -82,6 +92,46 @@ def simple_icon(slug: str, color: str | None = None) -> str: return _uri(_fetch(SIMPLE.format(slug)), color) +def devicon(slug: str) -> str: + """Logo A COLOR de una tecnología (devicon, variante `original`). + + simple-icons es monocromo por diseño: cada logo es un `` sin `fill`, así que + sale NEGRO salvo que se le pase un color, y aun así queda plano. devicon publica el + arte oficial multicolor (degradados incluidos) para el stack clásico de desarrollo + —python, docker, postgresql, react, nodejs, googlecloud, kubernetes…— que es lo que + da vida al diagrama. No cubre marcas nuevas o de nicho: ahí hay que caer a + simple-icons coloreado con el hex de marca.""" + ultimo: Exception | None = None + for variante in DEVICON_VARIANTS: + try: + return _uri(_fetch(DEVICON.format(slug, variante)), None) + except Exception as exc: + ultimo = exc + raise KeyError(f"devicon no tiene {slug!r}") from ultimo + + +def brand_hex(slug: str) -> str | None: + """Color oficial de una marca según los metadatos de simple-icons (#RRGGBB). + + Sirve para que un logo monocromo salga al menos en SU color y no en negro. El JSON + (~3300 marcas) se descarga una vez y queda cacheado como el resto.""" + try: + datos = json.loads(_fetch(SIMPLE_META)) + except Exception: + return None + iconos = datos["icons"] if isinstance(datos, dict) else datos + for entrada in iconos: + propio = entrada.get("slug") or _slug(entrada.get("title", "")) + if propio == slug.lower(): + return "#" + entrada["hex"] + return None + + +def _slug(titulo: str) -> str: + """Slug de simple-icons a partir del título (regla simple: minúsculas y alfanuméricos).""" + return re.sub(r"[^a-z0-9]", "", titulo.lower()) + + def material(symbol: str, color: str = "#5f6368") -> str: """Glifo genérico de fallback (Material Symbols). Ver SUGGESTED para nombres útiles.""" return _uri(_fetch(MATERIAL.format(symbol)), color) @@ -91,24 +141,66 @@ def globe(color: str = "#5f6368") -> str: return material("language", color) -def logo(name: str, color: str | None = None) -> str: - """Logo OFICIAL de una tecnología (punto de entrada agnóstico de stack). +def icon(name: str, fallback: str = "code", color: str | None = None) -> str | None: + """Resolución de icono **a prueba de fallos** — el punto de entrada para generar. + + Aplica la regla de la skill en una sola llamada: intenta el LOGO OFICIAL de `name` + y, si esa tecnología no tiene logo, cae al GLIFO GENÉRICO `fallback`. Si tampoco hay + red (primera ejecución sin conectividad), devuelve None en vez de romper: el kit + dibuja entonces la caja sin icono y el diagrama sigue siendo válido. + + Es lo que antes cada script copiaba de ejemplo.py; vive aquí para que la caída a + glifo sea idéntica en todos los diagramas. - Prueba, en orden: (1) marca en simple-icons (langchain, langfuse, react, nodejs, - python, docker, kubernetes, awslambda, microsoftazure, ...) y (2) producto Google - Cloud (gcp_icon: vertex_ai, bigquery, cloud_run, ...). Si NO hay logo oficial, lanza - KeyError sugiriendo un glifo genérico con material() — nunca inventa un icono. + p.node("Frontend", x, y, w, h, STYLE["card"], icon=icon("react", "code")) + p.node("Google ADK", x, y, w, h, STYLE["llm"], icon=icon("adk", "smart_toy")) + + Para forzar el glifo sin intentar el logo, pasa un `name` que no exista como marca + (convención: "__mi_componente__"). """ try: - return simple_icon(name, color) + return logo(name, color) except Exception: pass - try: # gcp_icon vive en la misma carpeta de la skill + try: + return material(fallback, color or "#5f6368") + except Exception as exc: # sin red: degradar, no romper + print(f"(aviso glyph: sin icono para {name!r} ni glifo {fallback!r}: {exc})") + return None + + +def logo(name: str, color: str | None = None) -> str: + """Logo OFICIAL de una tecnología (punto de entrada agnóstico de stack). + + **Prefiere el arte a color**, que es lo que da vida al diagrama: + + 1. devicon `original` — multicolor, con degradados (python, docker, postgresql, + react, nodejs, googlecloud, kubernetes, fastapi...). + 2. producto Google Cloud vía gcp_icon — también multicolor (vertex_ai, bigquery...). + 3. simple-icons teñido con el hex OFICIAL de la marca — cubre ~3300 marcas, pero es + monocromo por diseño; al menos sale en su color y no en negro. + + Si se pasa `color` explícito se respeta y se salta el paso 1: quien pide un color + concreto quiere ese color. Si NO hay logo oficial en ninguna fuente lanza KeyError + para que caigas a un glifo genérico — nunca inventa un icono. + """ + if color is None: # sin color pedido -> se prefiere el arte multicolor + try: + return devicon(name) + except Exception: + pass + try: # gcp_icon vive en la misma carpeta de la skill; sus iconos ya son a color from gcp_icon import data_uri as _gcp return _gcp(name) except Exception: pass + try: + # simple-icons es monocromo: si no se pidió color, se tiñe con el hex OFICIAL de la + # marca en vez de dejarlo en negro, que es lo que apagaba los diagramas. + return simple_icon(name, color or brand_hex(name)) + except Exception: + pass raise KeyError( f"sin logo oficial para {name!r}. Usa un glifo genérico de fallback, p. ej. " f"material('code'|'hub'|'database'|'smart_toy', color). Ver `list-suggested`." From 4f2c0fc2c68c5fe88069662f4ca0f2199b9998d1 Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 16:51:07 -0500 Subject: [PATCH 09/11] feat(skills): expand the drawio layout linter from four checks to eleven Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/check_labels.py | 71 +++++ .../scripts/check_layout.py | 280 +++++++++++++++++- 2 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/arquitectura-drawio/scripts/check_labels.py diff --git a/.claude/skills/arquitectura-drawio/scripts/check_labels.py b/.claude/skills/arquitectura-drawio/scripts/check_labels.py new file mode 100644 index 0000000..a6dba11 --- /dev/null +++ b/.claude/skills/arquitectura-drawio/scripts/check_labels.py @@ -0,0 +1,71 @@ +"""check_labels — detecta etiquetas mal partidas en el SCRIPT que genera un diagrama. + +Las etiquetas de un diagrama son textos largos y chocan con el límite de columnas del +repo (ruff/flake8). Al partirlas en concatenación implícita es fácil perder el espacio +final de un trozo, y draw.io las muestra pegadas: + + "... dependiendo de " "infrastructure/" -> "dependiendo de infrastructure/" OK + "... dependiendo de" "infrastructure/" -> "dependiendo deinfrastructure/" MAL + +Es un fallo invisible: el XML es válido, el layout no se traslapa y el linter de +legibilidad pasa; solo se ve al mirar el diagrama renderizado. Este chequeo lo atrapa en +el fuente, que es donde la información existe (sobre el .drawio ya solo hay texto plano y +`deinfrastructure` no se distingue de un identificador legítimo). + +CLI: + python check_labels.py build_mi_diagrama.py # sale !=0 si encuentra algo + +Como librería: + from check_labels import check + problemas = check("build_mi_diagrama.py") # -> [(línea, izquierda, derecha)] +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +# Una línea que es EXACTAMENTE un literal de string (posiblemente con coma final). +_LITERAL = re.compile(r'^\s*"((?:[^"\\]|\\.)*)"(,?)\s*$') + +# Finales/inicios donde la concatenación sin espacio es intencional y correcta: +# un token de estilo (`fillColor=#fff;`), un salto de línea explícito, un guion de +# palabra partida, o el trozo derecho empieza por puntuación. +_FIN_OK = (" ", "\\n", "-", "(", ";", ":", "/", "=") +_INI_OK = (" ", "\\n", ")", ",", ".", ";", ":", "/") + + +def check(path: str) -> list[tuple[int, str, str]]: + lineas = pathlib.Path(path).read_text(encoding="utf-8").split("\n") + fallos: list[tuple[int, str, str]] = [] + for i in range(len(lineas) - 1): + a, b = _LITERAL.match(lineas[i]), _LITERAL.match(lineas[i + 1]) + if not (a and b): + continue + if a.group(2): # coma final -> son argumentos distintos, no se concatenan + continue + izq, der = a.group(1), b.group(1) + if not izq or not der: + continue + if izq.endswith(_FIN_OK) or der.startswith(_INI_OK): + continue + fallos.append((i + 1, izq, der)) + return fallos + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("uso: python check_labels.py .py [...]") + sys.exit(2) + total = 0 + for archivo in sys.argv[1:]: + for linea, izq, der in check(archivo): + total += 1 + print(f"{archivo}:{linea} «…{izq[-24:]}» + «{der[:24]}…»") + print(f"{' ' * len(archivo)} -> quedaría «{izq[-14:]}{der[:14]}»") + if total: + print(f"\n⚠ {total} etiqueta(s) partida(s) sin espacio de separación.") + print(' Añade el espacio al FINAL del trozo izquierdo: "…de " "infrastructure/"') + sys.exit(1) + print("✓ check_labels: etiquetas bien partidas") diff --git a/.claude/skills/arquitectura-drawio/scripts/check_layout.py b/.claude/skills/arquitectura-drawio/scripts/check_layout.py index aabdaf9..0ca1cae 100644 --- a/.claude/skills/arquitectura-drawio/scripts/check_layout.py +++ b/.claude/skills/arquitectura-drawio/scripts/check_layout.py @@ -7,9 +7,31 @@ 3. FLECHAS que cruzan un nodo que no es su origen/destino (heurístico: ruteo ortogonal aproximado según exit/entry). 4. ETIQUETAS DE FLECHA que caen dentro de un nodo ajeno. + 5. ETIQUETAS DE FLECHA sobre el TÍTULO de un contenedor/zona: el contenedor se + excluye de (3) y (4) porque contiene a otros por diseño, pero su título vive + en una banda de ~26 px arriba y ahí sí se pisa (bug real: el título quedó + como «infrastructure/ — SDK[Atom XML]erno»). + 6. ETIQUETAS DE FLECHA ENCIMADAS entre sí: varios edges que cruzan el mismo + carril colocan su etiqueta a la misma altura y se solapan. + 7. TEXTO QUE DESBORDA su caja: la etiqueta necesita más líneas de las que caben, + así que draw.io la recorta o la derrama fuera del borde. + 8. NODOS FUERA DEL ÁREA DE PÁGINA (pageWidth/pageHeight): se ven en el lienzo + pero se pierden al exportar a PNG/PDF con el recorte de página. + 9. CAJAS SOBREDIMENSIONADAS y 10. ETIQUETAS DE CAJA MUY LARGAS: no son traslapes, + son economía. Una caja el doble de grande de lo normal gasta el espacio de tres + componentes, y eso baja el número de nodos hasta dejar el diagrama incompleto + para un lector técnico. Se reportan como mediana por página, no caja por caja. + 11. ROJO DISPERSO: el rojo marca deuda; concentrado en una zona rotulada se lee como + sección, rociado sobre el flujo hace que el sistema entero parezca averiado. + +Las PALABRAS PEGADAS ("dependiendo deinfrastructure/") no se detectan aquí sino en +`check_labels.py`, que lee el SCRIPT generador: sobre el .drawio ya solo queda texto +plano y no hay forma fiable de distinguir un error de un identificador CamelCase. Es una heurística (el ruteo real de draw.io difiere), pero atrapa los problemas -gruesos de traslape. Los contenedores/zonas se excluyen (contienen a otros por diseño). +gruesos de traslape. Los contenedores/zonas se excluyen de (1) a (4) por diseño; +para las etiquetas de flecha, la posición se estima sobre la polilínea ruteada +(incluidos los waypoints), no sobre el punto medio recto origen-destino. CLI: python check_layout.py archivo.drawio # reporta y sale !=0 si hay traslapes duros @@ -27,6 +49,34 @@ MIN_AREA = 90 # px² de intersección para contar un traslape de cajas LABEL_LH = 15 # alto estimado por línea de etiqueta al pie de un icono +ZONE_TITLE_H = 26 # banda superior de un contenedor donde se dibuja su título +ZONE_TITLE_CW = 7.4 # ancho estimado por carácter del título de un contenedor (fontSize 13, bold) +EDGE_LBL_CW = 6.4 # ancho estimado por carácter de una etiqueta de flecha +EDGE_LBL_H = 16 # alto estimado de una línea de etiqueta de flecha +# Métricas de texto dentro de una caja, por fontSize: (ancho medio de carácter, alto de línea). +# Son estimaciones de la fuente por defecto de draw.io (Helvetica); van holgadas a propósito +# para no llenar el reporte de falsos positivos por un par de píxeles. +TEXT_METRICS = {10: (5.5, 14), 11: (6.0, 15), 12: (6.5, 16), 13: (7.2, 17), 15: (8.2, 19)} +BOX_PAD = 16 # padding horizontal dentro de una caja con borde +ICON_PAD = 32 # desplazamiento extra que mete node(icon=) con spacingLeft=32 + +# ── Estándar de economía de caja (references/estilo.md) ────────────────────────────── +# Medido sobre los diagramas de referencia de la organización: caja de componente con +# mediana 176-200 x 52-56 px y etiqueta de 30-45 caracteres. Cajas más grandes con más +# texto no añaden información: gastan el espacio de tres componentes y bajan el número de +# nodos, que es lo que vuelve un diagrama incompleto para un lector técnico. Los umbrales +# van por encima del máximo de la referencia para avisar solo cuando la desviación es real. +BOX_W_MAX, BOX_H_MAX = 220, 70 # mediana por página a partir de la cual se avisa +BOX_LBL_MAX = 55 # mediana de caracteres por etiqueta de caja +# Caja suelta tan grande que se reporta aparte. Holgado a propósito: una rejilla de ítems +# de deuda o de notas rotuladas legítimamente usa cajas anchas, y ya la pesca la mediana. +BOX_W_ATIP, BOX_H_ATIP = 420, 130 +BANNER_FILL = "#4da1f5" # relleno del banner de la casa (no es un componente) +# Rojo = deuda/alerta. Concentrado en una zona rotulada se lee como sección; rociado sobre +# el flujo hace que el sistema entero parezca averiado. Se avisa por encima de esta fracción. +RED_MAX_FRAC = 0.25 +RED_FILLS = {"#f8cecc", "#fdf3f3"} +RE_ZONA_DEUDA = re.compile(r"deuda|debt|pendiente|sin implementar|no implementad", re.I) def _sk(style: str) -> dict: @@ -41,9 +91,34 @@ def _sk(style: str) -> dict: def _is_container(d: dict) -> bool: + """¿Es una zona/contenedor (contiene otros nodos por diseño)? + + Primero el marcador explícito que pone `Page.zone()`; la heurística vieja queda + solo como respaldo para XML escrito a mano. Confiar únicamente en la heurística + excluía en silencio de TODOS los chequeos a cualquier anotación punteada sin relleno.""" + if d.get("kitRole") == "zone": + return True return "dashed" in d and d.get("fillColor") == "none" and d.get("shape", "") == "" +def _text_overflows(cell, d, geo) -> tuple[bool, int, int]: + """¿La etiqueta necesita más alto del que tiene la caja? -> (desborda, necesita, hay). + + Las formas con `verticalLabelPosition=bottom` (cilindros, actores) dibujan el texto + FUERA de la caja, así que nunca desbordan por dentro.""" + _, _, w, h = geo + label = _label(cell).replace(" ", "\n") + if not label.strip() or d.get("verticalLabelPosition") == "bottom": + return (False, 0, int(h)) + cw, lh = TEXT_METRICS.get(int(d.get("fontSize", 12)), (6.5, 16)) + es_texto_suelto = d.get("_bare_text", False) # nodo `text;` sin borde: no lleva padding + pad = (0 if es_texto_suelto else BOX_PAD) + (ICON_PAD if d.get("spacingLeft") == "32" else 0) + util = max(w - pad, 40) + lineas = sum(max(1, -(-len(ln) * cw // util)) for ln in label.split("\n")) + necesita = int(lineas * lh + (0 if es_texto_suelto else 8)) + return (necesita > h, necesita, int(h)) + + def _label(cell) -> str: return re.sub(r"<[^>]+>", "", cell.get("value") or "") @@ -93,6 +168,56 @@ def _route(p0, p1, ex): return [p0, (p0[0], my), (p1[0], my), p1] +def _path_midpoint(pts): + """Punto al 50 % de la LONGITUD de la polilínea ruteada — que es donde draw.io + dibuja la etiqueta de un edge con `relative=1`. Usar el punto medio recto + origen→destino (como se hacía antes) da una posición muy distinta en cuanto el + edge tiene waypoints, y deja pasar etiquetas que en el render sí se pisan.""" + segs = [ + (pts[i], pts[i + 1], abs(pts[i + 1][0] - pts[i][0]) + abs(pts[i + 1][1] - pts[i][1])) + for i in range(len(pts) - 1) + ] + total = sum(s[2] for s in segs) + if total == 0: + return pts[0] + walked = 0.0 + for (ax, ay), (bx, by), ln in segs: + if walked + ln >= total / 2: + t = 0 if ln == 0 else (total / 2 - walked) / ln + return (ax + (bx - ax) * t, ay + (by - ay) * t) + walked += ln + return pts[-1] + + +def _es_componente(d: dict) -> bool: + """¿Es una caja de componente del diagrama (y no cromo: banner, zona, leyenda, nota)? + + Todos los arquetipos de componente de `STYLE` llevan `shadow=1`; el cromo no. Los + elementos que reutilizan un estilo de arquetipo pero no son componentes (el banner, + los chips de la leyenda) van marcados con `kitRole`, así que basta con excluirlos. + El color de banner de la casa se reconoce además por su relleno, para que los diagramas + hechos antes del marcador —o escritos a mano copiando `estilo.md`— tampoco lo cuenten.""" + return ( + d.get("shadow") == "1" + and not d.get("kitRole") + and not d.get("shape") + and not d.get("_bare_text") + and (d.get("fillColor") or "").lower() != BANNER_FILL + ) + + +def _mediana(xs): + return sorted(xs)[len(xs) // 2] if xs else 0 + + +def _edge_label_box(text: str, mid): + """Caja aproximada que ocupa la etiqueta de una flecha, centrada en `mid`.""" + lines = text.split("\n") or [""] + w = max(len(ln) for ln in lines) * EDGE_LBL_CW + h = EDGE_LBL_H * len(lines) + return (mid[0] - w / 2, mid[1] - h / 2, w, h) + + def check(path: str) -> list[dict]: t = ET.parse(path) issues: list[dict] = [] @@ -110,6 +235,7 @@ def check(path: str) -> list[dict]: gid = c.get("id") geo[gid] = tuple(float(g.get(k, 0)) for k in ("x", "y", "width", "height")) d = _sk(style) + d["_bare_text"] = style.startswith("text;") sty[gid] = d val[gid] = c kind[gid] = ( @@ -118,6 +244,93 @@ def check(path: str) -> list[dict]: else ("text" if style.startswith("text;") else "node") ) + # 7 y 8: texto que no cabe en su caja / nodos fuera del área de página + modelo = diag.find("mxGraphModel") + pw = float(modelo.get("pageWidth", 1654)) if modelo is not None else 1654 + ph = float(modelo.get("pageHeight", 1169)) if modelo is not None else 1169 + for gid, (gx, gy, gw, gh) in geo.items(): + if gx < 0 or gy < 0 or gx + gw > pw or gy + gh > ph: + issues.append( + { + "tipo": "nodo-fuera-de-pagina", + "pagina": page, + "detalle": f"«{_label(val[gid])[:24]}» en ({gx:.0f},{gy:.0f}) " + f"{gw:.0f}x{gh:.0f} excede {pw:.0f}x{ph:.0f}", + } + ) + if kind[gid] == "container": + continue + desborda, necesita, hay = _text_overflows(val[gid], sty[gid], geo[gid]) + if desborda: + issues.append( + { + "tipo": "texto-desborda-caja", + "pagina": page, + "detalle": f"«{_label(val[gid])[:24]}» necesita ~{necesita} px " + f"de alto y tiene {hay}", + } + ) + + # 9, 10 y 11: economía de caja y uso del rojo (agregados por página, no por caja, + # para que el reporte sea accionable en vez de una lista de 50 líneas). + comps = [i for i in geo if _es_componente(sty[i])] + if comps: + anchos = [geo[i][2] for i in comps] + altos = [geo[i][3] for i in comps] + largos = [len(_label(val[i]).replace(" ", "\n")) for i in comps] + mw, mh, ml = _mediana(anchos), _mediana(altos), _mediana(largos) + if mw > BOX_W_MAX or mh > BOX_H_MAX: + issues.append( + { + "tipo": "caja-sobredimensionada", + "pagina": page, + "detalle": f"caja de componente mediana {mw:.0f}x{mh:.0f}; el estándar " + f"de la casa es ~180x56. Con cajas así caben {len(comps)} componentes " + f"donde cabrían ~{int(len(comps) * (mw * mh) / (180 * 56))}", + } + ) + if ml > BOX_LBL_MAX: + issues.append( + { + "tipo": "etiqueta-de-caja-muy-larga", + "pagina": page, + "detalle": f"mediana de {ml} caracteres por caja (estándar 30-45); " + f"lleva el detalle a la etiqueta de flecha, a la zona o a la nota al pie", + } + ) + for i in comps: + _, _, w, h = geo[i] + if w > BOX_W_ATIP or h > BOX_H_ATIP: + issues.append( + { + "tipo": "caja-sobredimensionada", + "pagina": page, + "detalle": f"«{_label(val[i])[:24]}» mide {w:.0f}x{h:.0f} — " + f"¿es un componente o una nota disfrazada?", + } + ) + zonas_deuda = [ + geo[i] + for i in geo + if kind[i] == "container" and RE_ZONA_DEUDA.search(_label(val[i]) or "") + ] + rojos = [i for i in comps if (sty[i].get("fillColor") or "").lower() in RED_FILLS] + sueltos = [ + i + for i in rojos + if not any(_inter(geo[i], z) > 0.5 * geo[i][2] * geo[i][3] for z in zonas_deuda) + ] + if sueltos and len(sueltos) > RED_MAX_FRAC * len(comps): + issues.append( + { + "tipo": "rojo-disperso", + "pagina": page, + "detalle": f"{len(sueltos)} de {len(comps)} cajas en rojo fuera de una " + f"zona de deuda ({len(sueltos) / len(comps) * 100:.0f}%): el flujo entero " + f"se lee como averiado. Concentra el rojo en una zona rotulada", + } + ) + # 1 y 2: traslape de nodos / etiquetas (excluye contenedores) solid = [i for i in geo if kind[i] != "container"] fp = {i: _footprint(val[i], sty[i], geo[i]) for i in solid} @@ -141,6 +354,7 @@ def point(gid, fx, fy, geo=geo): # geo=geo: enlaza el geo de ESTA página (B023 x, y, w, h = geo[gid] return x + fx * w, y + fy * h + edge_labels: list[tuple[str, tuple]] = [] for c in diag.iter("mxCell"): if c.get("edge") != "1": continue @@ -168,21 +382,59 @@ def point(gid, fx, fy, geo=geo): # geo=geo: enlaza el geo de ESTA página (B023 f"cruza «{_label(val[gid])[:20]}»", } ) - if c.get("value"): - mid = ((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2) + if _label(c).strip(): + text = _label(c).replace(" ", "\n") + mid = _path_midpoint(pts) + box = _edge_label_box(text, mid) + edge_labels.append((text, box)) for gid in geo: - if gid in (s, tg) or kind[gid] == "container": - continue + # OJO: aquí NO se excluyen `s` ni `tg`. La etiqueta de una flecha se + # dibuja en el punto medio del recorrido, que casi siempre cae en el + # HUECO entre sus dos extremos; si ese hueco es más angosto que la + # etiqueta, esta se monta sobre las cajas que conecta. Es el traslape + # más frecuente de todos y excluir los extremos lo hacía invisible. gx, gy, gw, gh = geo[gid] - if gx <= mid[0] <= gx + gw and gy <= mid[1] <= gy + gh: + if kind[gid] == "container": + # el cuerpo del contenedor es zona de paso legítima; su TÍTULO no. + # El título es corto y va alineado a la izquierda: acotar el rect al + # ancho real del texto evita falsos positivos en el resto de la banda. + title = _label(val[gid]) + tw = min(gw, len(title) * ZONE_TITLE_CW + 24) + if title and _inter(box, (gx, gy, tw, ZONE_TITLE_H)) > 0: + issues.append( + { + "tipo": "etiqueta-flecha-sobre-titulo-zona", + "pagina": page, + "detalle": f"etiqueta «{text[:16]}» pisa el título " + f"«{_label(val[gid])[:26]}»", + } + ) + # Se compara la CAJA de la etiqueta contra el nodo, no solo su punto + # medio: una etiqueta de 130 px centrada justo en el borde de una caja + # la invade sin que su centro llegue a caer dentro. + elif _inter(box, (gx, gy, gw, gh)) > MIN_AREA: + propio = " (extremo de la propia flecha: el hueco es muy angosto)" issues.append( { "tipo": "etiqueta-flecha-sobre-nodo", "pagina": page, - "detalle": f"etiqueta «{_label(c)[:16]}» " - f"cae en «{_label(val[gid])[:20]}»", + "detalle": f"etiqueta «{text[:16]}» invade " + f"«{_label(val[gid])[:20]}»" + f"{propio if gid in (s, tg) else ''}", } ) + + # 6: etiquetas de flecha encimadas entre sí (varios edges en el mismo carril) + for i, (ta, ba) in enumerate(edge_labels): + for tb, bb in edge_labels[i + 1 :]: + if _inter(ba, bb) > MIN_AREA: + issues.append( + { + "tipo": "etiquetas-flecha-encimadas", + "pagina": page, + "detalle": f"«{ta[:20]}» ∩ «{tb[:20]}»", + } + ) return issues @@ -208,6 +460,14 @@ def summarize(path: str) -> dict: print("---") for it in res["issues"][:40]: print(f" [{it['tipo']}] {it['detalle']}") - # traslapes duros -> exit !=0 (útil como gate en iteración) - hard = {"nodos-superpuestos", "flecha-cruza-nodo"} + # Problemas duros -> exit !=0 (útil como gate en iteración). "nodo-fuera-de-pagina" entra + # aquí porque es geometría exacta, no heurística: el nodo se pierde al exportar. + # "texto-desborda-caja" queda fuera a propósito: la métrica de texto es estimada y no + # conviene que un par de píxeles bloqueen la iteración — pero hay que atenderlo igual. + hard = { + "nodos-superpuestos", + "flecha-cruza-nodo", + "nodo-fuera-de-pagina", + "etiqueta-flecha-sobre-nodo", + } sys.exit(1 if any(it["tipo"] in hard for it in res["issues"]) else 0) From 712c4d31716d42b971328d38d3c95e504125ba37 Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 16:51:08 -0500 Subject: [PATCH 10/11] docs(skills): add repo-analysis workflow and page-split criteria Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/arquitectura-drawio/SKILL.md | 280 ++++++++++++++++-- .../arquitectura-drawio/references/estilo.md | 89 +++++- .../arquitectura-drawio/references/iconos.md | 65 +++- 3 files changed, 388 insertions(+), 46 deletions(-) diff --git a/.claude/skills/arquitectura-drawio/SKILL.md b/.claude/skills/arquitectura-drawio/SKILL.md index e176e7b..e576270 100644 --- a/.claude/skills/arquitectura-drawio/SKILL.md +++ b/.claude/skills/arquitectura-drawio/SKILL.md @@ -1,6 +1,6 @@ --- name: arquitectura-drawio -description: Genera y edita diagramas de arquitectura en draw.io (.drawio) con el estilo visual de la organización — paleta, cajas por categoría, edges ortogonales animados theme-aware y logos oficiales de la tecnología que sea (GCP, AWS, Azure, LangChain, React, Node, Python, Docker…) con glifo genérico de fallback. Agnóstica del stack. Usar cuando se pida crear, rediseñar o exportar una arquitectura/diagrama en draw.io, o convertir una descripción o boceto en un .drawio con el look de la casa. +description: Lee un repositorio, deriva su arquitectura real y la dibuja en draw.io (.drawio) con el estilo visual de la organización — paleta, cajas por categoría, edges ortogonales animados theme-aware y logos oficiales de la tecnología que sea (GCP, AWS, Azure, LangChain, React, Node, Python, Docker…) con glifo genérico de fallback. Incluye el criterio para decidir con el usuario si conviene una página o varias, y un linter de legibilidad. Agnóstica del stack. Usar cuando se pida crear, rediseñar, documentar o exportar una arquitectura/diagrama en draw.io, diagramar un repositorio, o convertir una descripción o boceto en un .drawio con el look de la casa. --- # Arquitecturas draw.io con el estilo de la organización @@ -15,21 +15,206 @@ Un `.drawio` es XML de mxGraph (texto plano). Lo mejor es **generarlo directamen `scripts/drawio_kit.py`, que ya trae los tokens de estilo de la casa, asegura IDs únicos, escapa el XML y valida el resultado. No hace falta ningún MCP ni servicio online. +## Qué entrega esta skill (y qué NO) — leer antes de prometer nada + +**El resultado es un borrador de alta fidelidad, no un entregable final.** Un diagrama de arquitectura +publicable siempre necesita una pasada humana. Decirlo por adelantado evita la frustración de esperar +que salga perfecto de una corrida. + +La razón es estructural, no de esfuerzo: quien genera el diagrama **no ve el resultado**. Se coloca +cada caja por coordenadas y se rutea cada flecha a ciegas, mientras que el render depende de métricas +de fuente reales, del algoritmo de ruteo de draw.io y de si el SVG de cada icono cargó. `check_layout` +cubre la parte geométrica —y cubre bastante: los diagramas de referencia de la organización lo pasan +casi limpios— pero **no ve** el ancho real del texto, ni dónde acaba draw.io dibujando una etiqueta, +ni un icono en blanco, ni si el conjunto está equilibrado. + +Reparto realista del trabajo: + +| Lo hace bien la skill | Lo tiene que hacer una persona | +|---|---| +| Inventario completo y fiel de componentes leyendo el repo | Confirmar que las conexiones reflejan la intención del sistema | +| Estilo, paleta, iconos y leyenda consistentes con la casa | Ajuste fino de posiciones y ruteo tras ver el render | +| Detectar traslapes geométricos y texto que no cabe | Juzgar el equilibrio visual y qué sobra o falta | +| Estructura de páginas y densidad razonable de arranque | Decidir qué se enfatiza para la audiencia concreta | + +Al entregar, **dilo explícitamente**: esto es un insumo inicial, revisalo en draw.io y ajustá. +No presentes el resultado como terminado ni prometas que no hay traslapes sin un export confirmado. + +### Con qué modelo lanzar esta skill + +Generar una arquitectura desde un repo pide tres cosas a la vez: leer y sintetizar código, razonar +sobre geometría sin verla, e iterar contra los linters hasta que queden en verde. Es una tarea de +razonamiento largo y **conviene el modelo más capaz disponible con esfuerzo alto**; con un modelo +intermedio el patrón observado es diagramas de pocas cajas —el inventario se queda en los nombres de +módulo— y traslapes que no se corrigen porque no se itera contra el linter. + +Sea cual sea el modelo, lo no negociable es el bucle: correr `check_layout` **hasta exit 0**, y no +dar el diagrama por bueno sin un export visual confirmado por el usuario. + ## Flujo de trabajo -1. **Entender la arquitectura**: nodos, capas, flujos, qué herramienta es cada nodo. Si es ambiguo, - preguntar antes de dibujar. -2. **Generar** con `drawio_kit` (ver `scripts/ejemplo.py` como plantilla). Escribir un script corto - en el scratchpad que: - - agregue `scripts/` de esta skill a `sys.path` e importe `Diagram, STYLE, EDGE` y, para - iconos, `glyph.logo` (logo oficial de cualquier tecnología) y `glyph.material` (fallback); - - defina nodos con coordenadas en grilla y edges con **anclajes explícitos** - (`exit=`/`entry=`) — clave para que no se traslapen las flechas; - - llame `Diagram.write(ruta)`, que valida (IDs únicos, edges íntegros, XML) y avisa si supera 500 KB. -3. **Agregar la leyenda** (obligatorio, ver sección *Leyenda*): un bloque que explique qué significa - cada color de flecha y de caja antes de dar por terminado el diagrama. -4. **Revisar** abriendo el `.drawio` en VS Code con la extensión *Draw.io Integration* - (`hediet.vscode-drawio`). Iterar con el usuario y confirmar que los iconos rendericen. +**No empieces a dibujar hasta haber completado los pasos 1 y 2.** El error caro no es un diagrama +feo: es un diagrama bonito que muestra una arquitectura que no es la del repositorio, o que mete en +un lienzo tres preguntas distintas. + +### 1. Levantar el inventario leyendo el repositorio + +Cuando el diagrama sale de un repo (lo normal), no preguntes al usuario lo que puedes leer. Recorre +estas fuentes y anota qué extraes de cada una: + +| Dónde miras | Qué sacas | +|---|---| +| Manifiestos: `pyproject.toml`, `package.json`, `go.mod`, `pom.xml`, `requirements.txt` | El stack real, y con él **qué logo lleva cada caja** | +| `README`, `docs/`, ADRs, `CLAUDE.md`/`AGENTS.md` | La **intención** declarada y el vocabulario del equipo (úsalo en las etiquetas) | +| Puntos de entrada: `main`/`__main__`, `scripts/`, `cmd/`, `CMD` del Dockerfile, targets del Makefile, workflows de CI | **Cuántos flujos independientes hay** — el insumo del paso 2 | +| Composition roots, contenedores de DI, módulos de *wiring* | **Quién construye e inyecta a quién** (el grafo real, no el declarado) | +| Interfaces, Protocols, puertos, clases abstractas | Los **contratos** y qué implementación concreta cumple cada uno | +| Clientes de SDK, llamadas HTTP, colas, drivers de BD | Las **fronteras del sistema**: qué es externo y qué es propio | +| Tests | Qué se ejecuta de verdad; lo que no aparece suele ser código muerto o aspiracional | + +Reglas de fidelidad: + +- **Dibuja el estado REAL, no el ideal.** Si el código viola su propia arquitectura, el diagrama lo + muestra. Un diagrama que solo describe la intención es peor que no tener diagrama, porque nadie + descubre la deuda mirándolo. +- Cuando encuentres deuda o violaciones, **pregunta explícitamente** si el usuario quiere el estado + real (marcado en rojo con `EDGE["warn"]`), el estado objetivo, o ambos en páginas distintas. +- Marca lo que existe pero está **vacío o sin implementar** — decir "este paquete está creado pero + vacío hasta V2" es información, y evita que el lector lo crea funcional. +- Si algo del código contradice la documentación, gana el código; señálalo al usuario. + +### 2. Decidir el encuadre y OFRECER OPCIONES (paso obligatorio) + +Cuenta primero, decide después. Con el inventario en mano: + +- **N** = nodos-componente (sin notas ni leyenda). +- **F** = flujos independientes, entendiendo por independiente "con disparador distinto": CLI, + petición HTTP, webhook, cron, mensaje de cola. + +Criterio de partición: + +| Señal | Qué hacer | +|---|---| +| N ≤ 12 y F = 1 | **Una sola página.** Partir de más también hace daño: obliga al lector a saltar entre pestañas para entender algo que cabía junto. | +| N > 55 o más de ~40 flechas en una página | Partir. | +| Conviven la vista **estática** (qué existe, quién depende de quién) y la **dinámica** (qué pasa cuando ocurre X) | Separarlas siempre. Responden preguntas distintas y comparten muy pocas flechas; juntas obligan a que cada paso cruce de banda. | +| F ≥ 2 | Una página por flujo. | +| Al bocetar, más de 3 flechas cruzan más de una banda | Señal de que sobra una vista en esa página. | + +> **La zona buena para una arquitectura técnica es 25–50 nodos por página, no 8.** Medido sobre los +> diagramas de referencia de la organización: 52 nodos / 33 flechas en el de MLOps, 37 / 19 y 30 / 2 +> en el de un sistema multiagente, 130–200 en los de proceso de negocio. Un diagrama de 8 cajas para +> un público técnico **no está simplificado, está incompleto**: no dice qué SDK hay detrás de cada +> adapter, ni dónde vive la configuración, ni qué contratos median, ni qué es externo al proceso. +> +> Esa densidad **no se consigue con cajas más grandes sino con más cajas y menos texto en cada una**. +> Las medidas de la referencia: caja de **~180×56 px** (no 300×88) y etiqueta de **~30–45 caracteres** +> (no un párrafo). El detalle se lleva a las etiquetas de flecha, a las zonas que agrupan y a una nota +> al pie — no dentro de la caja. Si tus cajas necesitan 90 px de alto, estás metiendo prosa donde +> debería ir un nombre. + +Para repositorios grandes, encuadra por **niveles de zoom al estilo C4** (contexto → contenedores → +componentes → código) en vez de inventar cortes: es vocabulario estándar y le da al usuario una +escala reconocible para elegir. + +Después **presenta 2–3 encuadres al usuario y espera su elección antes de dibujar**. Cada opción +lleva el reparto de páginas y el conteo de nodos por página, y el trade-off dicho en voz alta: +más páginas = más fiel al detalle técnico pero más salto entre vistas; menos páginas = una lectura +de golpe pero menos precisión. + +En la misma tanda, resuelve las dos decisiones que cambian el diagrama **más que cualquier regla de +layout**, y que no se pueden inferir del código: el **nivel de detalle** y el **público**. + +### Qué significa "público técnico" (no es una etiqueta, es una lista) + +Si el usuario dice que la audiencia es técnica, el diagrama tiene que responder **qué, cómo, cuándo y +por qué** se conecta cada cosa. En la práctica, cada una de estas debe estar en el lienzo, y su +ausencia es lo que hace que un diagrama se sienta simplista: + +- **Qué SDK o librería concreta hay detrás de cada adapter** (`chromadb PersistentClient`, + `rank_bm25 BM25Okapi`, `AsyncAnthropic`), no solo "vector store". +- **Qué contrato media** entre dos piezas: el Protocol, la interfaz, el tipo de la función inyectada. +- **Qué tipo de dato viaja** por cada flecha (`list[Paper]`, `list[Document]`, `str`). +- **Qué es externo al proceso** y por qué canal se le habla (HTTP, gRPC, cola, disco). +- **Dónde vive el estado**: almacenes, índices en memoria, caché, y quién los escribe y quién los lee. +- **Qué es concurrente o diferido**: `asyncio.gather`, colas, reintentos, jobs programados. +- **Dónde está la configuración y los secretos**, y quién los inyecta. +- **Cuándo ocurre cada flujo**: disparado por el usuario, por un cron, al arrancar el proceso. +- **Qué NO existe todavía**: paquetes vacíos, contratos sin implementación, deuda registrada. + +Un diagrama técnico con 8 cajas no cumple ninguna de estas. Si al terminar el inventario te salen +menos de ~20 nodos para un sistema real, es que te quedaste en los nombres de módulo y no bajaste a +las herramientas, los contratos y las fronteras. Volvé al paso 1. + +Para público de negocio, en cambio, se sube de nivel: cajas por capacidad, sin SDKs ni tipos, y las +fronteras del sistema como lo importante. + +### 3. Generar + +**Archivo de salida: `docs/architecture.drawio`.** Es la ruta por defecto salvo que el usuario pida +otra. Un repositorio normalmente tiene una sola arquitectura, y las distintas vistas van como páginas +dentro de ese archivo, no como archivos sueltos: así el diagrama tiene una ubicación previsible y no +se acumulan versiones con nombres inventados. Solo se usa otro nombre cuando de verdad hay diagramas +de sistemas distintos en el mismo repo. + +**Si el archivo ya existe, no lo sobrescribas sin avisar.** Puede tener retoques manuales que no están +en ningún script (ver la sección de alcance). Ábrelo, mira qué páginas tiene, y confirma con el +usuario si se reemplaza entero, se añade una página o se conserva una copia. + +Escribir un script con `drawio_kit` (ver `scripts/ejemplo.py`, que ya es multipágina) que: + +- agregue `scripts/` de esta skill a `sys.path` e importe `Diagram, STYLE, EDGE` y `glyph.icon` + (logo oficial de cualquier tecnología con caída automática a glifo genérico); +- use `Page.zone()` para las bandas, `Page.banner()` para la cabecera y `Page.legend()` para la + leyenda, en vez de reimplementarlos — así todos los diagramas salen iguales; +- defina nodos con coordenadas en grilla y edges con **anclajes explícitos** (`exit=`/`entry=`) — + clave para que no se traslapen las flechas; +- aplique los **patrones de composición** de `references/estilo.md` (serpentina, pasos numerados, + corredores reservados) antes de pelear con waypoints; +- llame `Diagram.write(ruta)`, que valida (IDs únicos, edges íntegros, XML), corre el linter de + legibilidad y avisa si supera 500 KB. + +**El script es andamiaje, no fuente de verdad.** Sirve para colocar decenas de cajas por coordenadas +sin escribir XML a mano, pero en cuanto una persona retoca el `.drawio` en draw.io —y casi siempre lo +hace, ver la sección de alcance— volver a ejecutarlo **destruye ese trabajo manual**. A partir de ese +momento la fuente de verdad es el `.drawio`. + +Por defecto, **déjalo en un temporal y no lo versiones**; entrega el `.drawio` y menciona que el +script existe por si lo quieren. Guardarlo en el repo solo compensa en un caso concreto: que el +diagrama se vaya a **regenerar entero y periódicamente** desde el código (por ejemplo en CI, o en un +repo que cambia rápido y donde nadie va a retocar a mano). Si es ese caso, ponlo en +`docs/build_architecture.py`, junto al diagrama; documenta en el docstring cómo ejecutarlo y por qué +ese encuadre de páginas, y **avisa en el propio archivo que regenerar pisa los ajustes manuales**. + +Pregúntaselo al usuario en vez de decidirlo tú. + +Ojo con el lint del repo al escribirlo: las etiquetas largas chocan con el límite de columnas. +Pártelas en concatenación implícita **conservando el espacio final** de cada trozo (`"...de "` +`"infrastructure/"`), o saldrán palabras pegadas en el diagrama. Es un fallo invisible —el XML es +válido y el linter de layout pasa— así que hay un chequeo dedicado sobre el fuente: + +```bash +python scripts/check_labels.py .py +``` + +### 4. Verificar + +Los linters no sustituyen ver el diagrama. Es obligatorio cerrar así: + +```bash +python scripts/check_layout.py docs/architecture.drawio # nodos/etiquetas/flechas/texto/página +python scripts/check_labels.py .py # etiquetas partidas sin espacio +``` + +Y luego, **como asistente no puedes renderizar el `.drawio`: pídele al usuario un export y espera +su confirmación.** Si el diagrama es multipágina, pídeselo explícitamente **con todas las páginas** +(*File → Export as → PNG* con **All Pages** marcado, o cambiando de pestaña antes de exportar): el +export por defecto saca solo la página activa, y es fácil creer que verificaste tres vistas cuando +te mandaron tres copias de la primera. + +Revisar y editar el `.drawio` en VS Code con la extensión *Draw.io Integration* +(`hediet.vscode-drawio`). Confirmar con el usuario que los iconos rendericen y que no haya palabras +pegadas ni texto recortado — cosas que el linter no ve. Alternativa sin scripts: escribir el XML a mano siguiendo `references/estilo.md` (útil para retoques puntuales). Estructura mínima por página: `` @@ -37,36 +222,55 @@ puntuales). Estructura mínima por página: `.drawio # nodos/etiquetas/flechas superpuestos +python scripts/check_layout.py docs/architecture.drawio ``` -`drawio_kit.write()` ya lo ejecuta y avisa. Confirma también visualmente en draw.io. +Detecta once cosas. Ocho son traslapes: nodos superpuestos, etiquetas sobre nodos, flechas que +cruzan nodos, etiquetas de flecha sobre nodos, sobre el título de una zona o encimadas entre sí, +texto que desborda su caja y nodos fuera del área de página. Las otras tres son **economía**: +**cajas sobredimensionadas**, **etiquetas de caja muy largas** y **rojo disperso** fuera de una +zona de deuda — no rompen nada, pero son lo que separa un diagrama correcto de uno a la altura +del estándar de la casa. `drawio_kit.write()` ya lo ejecuta y avisa. + +**El linter no ve todo.** No sabe de fuentes reales, iconos que no cargan ni palabras pegadas por un +literal mal partido. Un diagrama que pasa el linter puede seguir siendo ilegible: cierra siempre con +la verificación visual del paso 4. ## Leyenda (obligatorio) @@ -85,15 +289,27 @@ libre) y cubre, como mínimo: - **Cualquier otra convención relevante**: iconos sueltos = fuentes/actores externos, que las flechas van animadas e indican el sentido del flujo, líneas punteadas vs. sólidas, etc. -Dibuja las muestras de flecha como edges reales (con su mismo `style`) para que el color y la animación -coincidan con el diagrama, y usa chips de color con el `fillColor`/`strokeColor` de cada arquetipo de -caja. Mantén la leyenda dentro del `pageHeight` y pásala por el linter como el resto. +Usa **`Page.legend(y, edges, chips, nota)`**, que ya dibuja las muestras como edges reales (mismo +`style`, así el color y la animación coinciden de verdad) y los chips con el `fillColor` de cada +arquetipo, resolviendo además el caso de las formas con etiqueta inferior. No la reimplementes: si +cada diagrama arma su leyenda a mano, dejan de parecerse entre sí, que es justo lo que la skill +evita. En multipágina, **repite la leyenda en cada página** — nadie garantiza que el lector entre por +la primera. Mantenla dentro del `pageHeight` y pásala por el linter como el resto. ## Reglas - **Todos los conectores van animados** (`flowAnimation=1`); el kit lo garantiza en `EDGE`. - **Incluye siempre una leyenda descriptiva** de colores de flecha y de caja (ver sección *Leyenda*); un diagrama sin leyenda está incompleto. +- **Nunca dibujes sin haber ofrecido opciones de encuadre** (paso 2) cuando el diagrama sale de un + repositorio: la decisión de una página o varias es del usuario, no tuya. +- **El diagrama refleja el código, no la intención**; la deuda técnica se marca, no se esconde. +- **La salida por defecto es `docs/architecture.drawio`**, con las distintas vistas como páginas del + mismo archivo; si ya existe, confirmar antes de sobrescribir. +- **El script generador es andamiaje**: por defecto no se versiona, porque regenerar pisa los ajustes + manuales que la persona haga sobre el `.drawio`. +- **Un diagrama no está verificado hasta que el usuario confirma un export visual**, y en multipágina + el export debe incluir todas las páginas. - **Esquinas con radio fijo** (no la curva grande por defecto de `rounded=1`, que tapa el texto): dos niveles, ya incluidos en `STYLE` (`ARC` y `ARC_ZONE`) — cajas de componente `absoluteArcSize=1` (muy sutil) y contenedores/zonas azules punteados `absoluteArcSize=2` (esquina algo más marcada). diff --git a/.claude/skills/arquitectura-drawio/references/estilo.md b/.claude/skills/arquitectura-drawio/references/estilo.md index 27db747..e9f688e 100644 --- a/.claude/skills/arquitectura-drawio/references/estilo.md +++ b/.claude/skills/arquitectura-drawio/references/estilo.md @@ -22,7 +22,35 @@ Pasteles estándar de draw.io para categorizar cajas: azul `#dae8fc` (LLM/servic ## Tipografía y tamaños Fuente por defecto de draw.io. `fontSize` 12 en nodos, 15 en banners, 10–11 en detalles/almacenes. -Tamaños de caja habituales: tarjeta **160×60** o **170×60**, almacén **≈190×44**, actor **48×60**. + +Tamaños medidos en los diagramas de referencia de la organización, que son el estándar a igualar: + +| Elemento | Tamaño | Etiqueta | +|---|---|---| +| Caja de componente | **~180×56** (mediana real 176–200 × 52–56) | **30–45 caracteres**, 2–3 líneas cortas | +| Almacén (cilindro) | ≈170×44 | Nombre + una línea de detalle | +| Actor | 48×60 | Dos palabras | +| Zona / banda | ancho completo | Título corto alineado a la izquierda | + +**Cajas pequeñas y muchas, no pocas y grandes.** Una caja de 300×88 con cuatro líneas de prosa es +media nota al pie disfrazada de componente: ocupa el espacio de tres cajas reales y obliga a bajar +el número de nodos, que es justo lo que vuelve el diagrama incompleto para un lector técnico. Si el +texto no cabe en 45 caracteres, el detalle va a la etiqueta de la flecha, a la zona que agrupa o a +una nota al pie de la página. `check_layout` avisa cuando la mediana de la página se pasa. + +## El rojo se concentra, no se rocía + +El rojo (`STYLE["warn"]`, `#f8cecc`) marca deuda técnica o violación de capas. **Va agrupado en una +zona rotulada** —«Deuda técnica», «Piezas sin implementar»— donde el lector lo interpreta como una +sección del diagrama. Repartido sobre los pasos de un flujo produce el efecto contrario al buscado: +si cuatro de los diez pasos de un pipeline salen en rojo, el sistema entero se lee como averiado +cuando lo que está mal puede ser solo dónde viven unos imports. + +En un flujo, colorea cada paso por **lo que es** (su capa) y saca la deuda a una nota al pie o a una +página aparte; marca en rojo únicamente el punto exacto de la violación. Y cuida que el texto de la +leyenda cubra **todos** los usos que le das al color: si el rojo también señala «duplica el wiring», +la leyenda no puede decir solo «viola ADR-001». `check_layout` avisa si más del 25 % de las cajas de +una página están en rojo fuera de una zona de deuda. ## Esquinas redondeadas fijas (dos niveles) @@ -134,23 +162,70 @@ Reglas de diseño: - **Almacenes de apoyo** pegados **junto** al nodo que los consume (edge corto), no al otro extremo. - **Flechas que cruzarían un nodo** → usa `points=[(x,y),...]` en `edge()` para sacar la ruta por encima/alrededor (un carril libre), o reubica los nodos. No dejes una flecha atravesando un icono. -- **Etiquetas de flecha** (`202`, `Post`): ponlas donde el tramo esté libre; si caen sobre un nodo, +- **Etiquetas de flecha: el hueco manda.** Es el traslape más frecuente y el más invisible al + escribir el script. La etiqueta se dibuja en el punto medio del recorrido, o sea **dentro del hueco + entre las dos cajas que conecta**; si el hueco es más angosto que la etiqueta, esta se monta sobre + ambas. Regla operativa: **hueco ≥ 8 px × nº de caracteres de la etiqueta, + 20 px de aire.** + `search_papers(query)` son 20 caracteres → necesita ~180 px de separación, no 40. Si no tienes ese + espacio, tienes tres salidas y ninguna es dejarlo así: acortar la etiqueta, moverla a un tramo + vertical largo con un waypoint, o quitarla y llevar ese dato dentro de la caja destino. +- **Etiquetas de flecha sobre otros nodos**: ponlas donde el tramo esté libre; si caen sobre un nodo, mueve el nodo o añade un waypoint para desplazar el punto medio. - **Contenedor/zona**: dibújalo primero (queda detrás), `fillColor=none`; el título va en una esquina (`align=left`), no centrado sobre el paso de las flechas. +### Patrones de composición (resuelven el 90 % de los enredos) + +Antes de pelear con waypoints, prueba a cambiar la disposición. Estos patrones vienen de rehacer +diagramas que habían quedado ilegibles: + +- **Serpentina para secuencias largas.** Si un flujo no cabe en una fila, no vuelvas al margen + izquierdo con una flecha de retorno gigante: alterna el sentido por fila (fila 1 →, fila 2 ←, + fila 3 →) y **baja de fila por la misma columna**, con un tramo vertical corto. Así ninguna + flecha retrocede ni cruza otra. Es lo que permite meter 12 pasos en una página sin un solo cruce. +- **Pasos numerados** ① ② ③ en la etiqueta de cada caja. El lector sigue números, no flechas; y de + paso te libera de dibujar los retornos, que son la mitad del enredo en un diagrama de secuencia. +- **Corredores reservados.** Deja carriles (verticales u horizontales) sin ningún nodo, y rutea por + ahí las flechas largas con `points=`. Un par de corredores en los márgenes convierte un ruteo + imposible en uno trivial. Reserva el carril **antes** de colocar los nodos, no después. +- **Codifica la capa en el COLOR de la caja, no en bandas**, cuando la página es de flujo. Dibujar + bandas por capa *y* un flujo encima obliga a que cada paso cruce de banda: es la receta exacta + para que las etiquetas caigan sobre los títulos. Las bandas son para la vista estática; en la + dinámica, el color ya dice la capa y la leyenda lo traduce. +- **Sustituye un haz N:M por una tabla.** Una relación aburrida y densa (qué implementa qué + contrato, qué servicio consume qué cola) son diez flechas cruzadas o una caja de texto con dos + columnas. Gana la caja: se lee mejor y no gasta presupuesto de flechas. +- **Cuidado con las formas de etiqueta inferior** (cilindro `store`, actor `user`): su huella real + es la caja **más ~1.7× su ancho** de texto debajo. No las pongas de vecinas apretadas, no las uses + como chip de leyenda tal cual (el kit ya lo resuelve en `Page.legend`) y no rutees una flecha justo + por debajo. + ### Verificar con el linter (obligatorio antes de entregar) `drawio_kit.write()` corre `check_layout` y avisa. Para el detalle: ```bash -python scripts/check_layout.py .drawio +python scripts/check_layout.py docs/architecture.drawio ``` -Reporta **nodos-superpuestos**, **etiqueta-sobre-nodo**, **flecha-cruza-nodo** y -**etiqueta-flecha-sobre-nodo**. Itera reubicando nodos / añadiendo waypoints hasta que no queden -traslapes **duros** (nodos-superpuestos, flecha-cruza-nodo). Es heurístico (el ruteo real de draw.io -difiere), así que confirma también visualmente en draw.io. +Reporta ocho tipos de problema: **nodos-superpuestos**, **etiqueta-sobre-nodo**, +**flecha-cruza-nodo**, **etiqueta-flecha-sobre-nodo**, **etiqueta-flecha-sobre-titulo-zona**, +**etiquetas-flecha-encimadas**, **texto-desborda-caja** y **nodo-fuera-de-pagina**. Itera +reubicando nodos / añadiendo waypoints hasta que no queden problemas **duros** (nodos-superpuestos, +flecha-cruza-nodo, nodo-fuera-de-pagina, que hacen salir con código ≠ 0). + +Dos notas sobre por qué el linter mira lo que mira: + +- Las **zonas se excluyen** de los chequeos de cruce porque contienen nodos por diseño, pero su + **título** sí se comprueba: vive en una banda de ~26 px arriba y ahí es donde aterrizaban las + etiquetas de flecha (bug real: el título quedó como `infrastructure/ — SDK[Atom XML]erno`). Para + que esa detección funcione, dibuja las zonas con `Page.zone()`, que marca la celda como contenedor. +- La posición de una etiqueta de flecha se estima **sobre la polilínea ruteada**, no en el punto + medio recto origen→destino: con waypoints, ambos puntos no tienen nada que ver. + +Sigue siendo heurístico (el ruteo real de draw.io difiere) y **no sustituye la revisión visual**: +el linter no ve fuentes, ni iconos que no cargan, ni palabras pegadas. Confirma siempre con un +export real (ver SKILL.md, sección de verificación). ## Etiquetas: pasar texto crudo (evitar doble escape) diff --git a/.claude/skills/arquitectura-drawio/references/iconos.md b/.claude/skills/arquitectura-drawio/references/iconos.md index 3cc0f45..897787c 100644 --- a/.claude/skills/arquitectura-drawio/references/iconos.md +++ b/.claude/skills/arquitectura-drawio/references/iconos.md @@ -7,23 +7,74 @@ React, Node, Python, TypeScript, Docker, Kubernetes, PostgreSQL, FastAPI, etc. L > **Regla:** para cada componente usa SIEMPRE el **logo oficial de la tecnología** que representa; > si no existe, cae al **glifo genérico**. Nunca inventes un icono ni dejes una caja vacía. -## 1ª opción — logo oficial de la tecnología (recomendado) +## Al generar: `icon()` — la regla completa en una llamada (recomendado) -Punto de entrada único en `scripts/glyph.py`, sirve para cualquier tecnología: +```python +from glyph import icon + +p.node("Frontend React", x, y, w, h, STYLE["card"], icon=icon("react", "code")) +p.node("PostgreSQL", x, y, w, h, STYLE["card"], icon=icon("postgresql", "database")) +p.node("Google ADK", x, y, w, h, STYLE["llm"], icon=icon("adk", "smart_toy")) +p.node("Worker propio", x, y, w, h, STYLE["card"], icon=icon("__worker__", "code")) +``` + +`icon(name, fallback, color)` intenta el **logo oficial** y, si esa tecnología no lo tiene, cae al +**glifo genérico** `fallback`. Si no hay red devuelve `None` y el kit dibuja la caja sin icono, en +vez de romper la generación. Para forzar el glifo sin intentar el logo, usa un `name` que no exista +como marca (convención: `"__mi_componente__"`). + +> **No le pases `color` a un logo de marca salvo que quieras forzarlo.** El tercer argumento existe +> para los glifos genéricos, donde el color codifica la categoría. En un logo, pasar color **apaga el +> arte multicolor**: se salta devicon y tiñe la silueta de simple-icons de un solo tono. +> `icon("python", "code")` da el logo azul-y-amarillo real; `icon("python", "code", "#3776AB")` da +> una silueta plana azul. + +## Por qué un icono sale a color o en negro + +Los diagramas apagados casi siempre vienen de aquí: **simple-icons es monocromo por diseño**. Cada +logo es un único `` sin `fill`, así que renderiza NEGRO salvo que se le pase un color, y aun +así queda plano. Es un set de siluetas de marca, no de logos a color. + +`logo()` prueba las fuentes en este orden, para que el color sea lo normal y el negro la excepción: + +| Orden | Fuente | Aspecto | Cobertura | +|---|---|---|---| +| 1 | **devicon** `original` | **Multicolor**, con degradados | Stack clásico: python, docker, postgresql, react, nodejs, kubernetes, fastapi, googlecloud… | +| 2 | **gcp_icon** | **Multicolor** (arte oficial de Google) | Productos Google Cloud: vertex_ai, bigquery, cloud_run… | +| 3 | **simple-icons + `brand_hex()`** | Monocromo, pero en el **color oficial de la marca** | ~3300 marcas: telegram `#26A5E4`, huggingface `#FFD21E`, pydantic `#E92063`… | + +El paso 3 solo queda oscuro cuando el color de marca **es** oscuro: Anthropic es `#191919`, así que +su logo es casi negro por definición y está bien así. + +```bash +python scripts/glyph.py logo python # devicon multicolor +python scripts/glyph.py logo telegram # simple-icons teñido con su #26A5E4 +``` + +**Peso:** el arte multicolor pesa entre 2 y 3 veces más que la silueta (python 1,6 → 3,1 KB; docker +2,0 → 5,6 KB; postgresql 5,7 → 10,6 KB), porque lleva varios `path` y a veces degradados. Teñir un +simple-icons no cuesta nada (+25 bytes). En un diagrama normal la diferencia son unas decenas de KB, +muy lejos del límite de 500 KB; si alguna vez aprieta, la palanca es reducir iconos repetidos, no +volver al monocromo. + +## 1ª opción por dentro — logo oficial de la tecnología + +Si quieres controlar el fallo tú mismo: ```python from glyph import logo p.node("LangChain service", x, y, w, h, istyle(logo("langchain"))) -p.node("Frontend React", x, y, w, h, istyle(logo("react", "#61DAFB"))) +p.node("Frontend React", x, y, w, h, istyle(logo("react"))) # multicolor p.node("PostgreSQL", x, y, w, h, istyle(logo("postgresql"))) p.node("Vertex AI", x, y, w, h, istyle(logo("vertex_ai"))) # cae a producto GCP ``` -`logo(name, color)` prueba, en orden: **marca** (simple-icons, ~3000 logos oficiales) y luego -**producto Google Cloud** (`gcp_icon`). Si ninguno tiene el logo, lanza un error que te guía al -glifo genérico (no inventa iconos). `color` es opcional (respeta el color de marca). +`logo(name, color)` prueba las tres fuentes de la tabla de arriba en orden, priorizando el arte a +color. Si ninguna tiene el logo, lanza un error que te guía al glifo genérico (no inventa iconos). +`color` es opcional y, si lo pasas, **salta devicon** y tiñe la silueta de simple-icons: úsalo solo +cuando quieras un color concreto por encima del arte de marca. -- Slugs de marca típicos: `langchain`, `react`, `nodejs`, `typescript`, `python`, `docker`, +- Slugs típicos (sirven igual en devicon y simple-icons): `langchain`, `react`, `nodejs`, `typescript`, `python`, `docker`, `kubernetes`, `fastapi`, `postgresql`, `redis`, `awslambda`, `amazonsqs`, `microsoftazure`, `apache`, `openai`, `huggingface`. Catálogo completo: . - Productos GCP: `vertex_ai`, `bigquery`, `cloud_run`, `cloud_storage`, `cloud_vision_api`, From 1c1a591e0783d5ebec0bd89d1fdc0d2c9d2a543e Mon Sep 17 00:00:00 2001 From: John Mario Montoya Zapata Date: Wed, 12 Aug 2026 16:51:27 -0500 Subject: [PATCH 11/11] docs(architecture): add V1 architecture diagram with three views --- docs/architecture.drawio | 1097 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1097 insertions(+) create mode 100644 docs/architecture.drawio diff --git a/docs/architecture.drawio b/docs/architecture.drawio new file mode 100644 index 0000000..c6bc698 --- /dev/null +++ b/docs/architecture.drawio @@ -0,0 +1,1097 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +