diff --git a/docs/essays/2026-W33-grafo-vs-pipeline.md b/docs/essays/2026-W33-grafo-vs-pipeline.md new file mode 100644 index 0000000..e083c79 --- /dev/null +++ b/docs/essays/2026-W33-grafo-vs-pipeline.md @@ -0,0 +1,11 @@ +# Por qué un grafo que no aporta nada hoy es la decisión correcta de hoy + +Cuando construímos soluciones dentro de ML es bastante común que como científico de datos tendamos a crear procesos síncronos, lineas y continuos de manera que cada paso está completamente determinado y el proceso fluye desde el punto A hasta el punto B en una serie de sucesos predecibles y replicables. + +Ahora bien, en la transición a AI engineer me he encontrado con la necesidad de pensar en sistemas que se retroalimenten, que tengan la capacidad de volver sobre sí mismos, "repensarse" y luego seleccionar otros caminos si es el caso, además de tener que manejar sistemas con muchas bifurcaciones y caminos posibles. Así pues, surge como herramienta langGraph y en particular la filosofía de grafos: Aplicado al desarrollo que tenemos, un grafo permite estructuralmente volver hacia atrás si es necesario y además bifurcarse, de manera que el sistema se retro-alimenta y se mueve en flujos que no podrían ser posibles con una cadena de funciones ya que como lo mencioné arriba, éstas son secuenciales. + +Este hecho lleva a cuestionar por qué es necesario en este punto integrar LangGraph cuando mi pipeline actualmnete solo tiene 2 pasos "recuperar->generate", pues la realidad es que ahora mismo un grafo no aporta mucho valor ya que el sistema es secuencial, sin embargo, previendo que en próximas tareas quiero que haya retroalimentación y que además espero bifurcar decisiones entonces introducir un grafo en este momento me ayuda a que la construcción de la V2 y futuras versiones esté sobre esta filosofía. Sí podría continuar como lo tengo ahora pero más adelante tendría que hacer mayor refactorización para alcanzar las funcionalidades que espero que el sistema tenga. + +Una ventaja adicional es que LangGraph al ser una librería de bajo nivel tiene implementaciones que permiten conocer el estado explícito del sistema, si mantuviera el pipeline entonces sí podría inspeccionar el estado pero tendría que implementar manualmente estrategias para conocer esos estados, lo cual en temas de observabilidad da una ventaja importante con langGraph. + +Dicho esto, no es que no pueda continuar con un pipeline secuencia, sino que hacerlo ahora mismo es una decisión estratégica de cara a la evolución de funcionalidades que espero alcanzar con mi sistema, por lo que esta sobre-ingeniería tendrá sus beneficios en el futuro. diff --git a/docs/essays/prompts/2026-W33.md b/docs/essays/prompts/2026-W33.md new file mode 100644 index 0000000..4aa8568 --- /dev/null +++ b/docs/essays/prompts/2026-W33.md @@ -0,0 +1,39 @@ +# Tema propuesto — Semana 2026-W33 + +## Título sugerido +Por qué un grafo que no aporta nada hoy es la decisión correcta de hoy + +## Por qué este tema +Esta semana arrancaste LangGraph y tu primera respuesta a "¿qué gana un grafo +sobre tu pipeline `retrieve → generate`?" se quedó corta: te centraste en el +estado explícito y dejaste afuera las dos razones de fondo (bifurcaciones +condicionales y ciclos), y además afirmaste algo técnicamente falso ("no puedo +conocer el estado intermedio en un pipeline lineal") — ambos corregidos en +`learnings.md` del 13/08 y ahora en el banco como LG-002. Es el primer ciclo +de ensayos del proyecto (0 ejecutados hasta hoy según el diagnóstico del +10/08), y este es el hueco más caro de dejar sin consolidar: T18 arranca el +lunes 17/08 y si la justificación no está firme, vas a construir el grafo sin +poder defender por qué, ni en el código ni en una entrevista. + +## Preguntas guía para arrancar la escritura +- ¿Qué puede hacer un grafo que una cadena de funciones no puede hacer + *estructuralmente*, no solo "más cómodamente"? +- Si tu pipeline de dos pasos no tiene ramas ni ciclos hoy, ¿dónde vive + exactamente el beneficio de montar el grafo en T18 en vez de esperar a T19? +- ¿Por qué "no puedo inspeccionar el estado intermedio de un pipeline lineal" + es una afirmación falsa, y cuál es la diferencia real que sí importa? +- Si tuvieras que defender esta decisión ante alguien que dice "eso es + sobre-ingeniería para dos pasos", ¿qué le respondés? + +## Longitud +400–500 palabras. Escribí sin abrir código ni `learnings.md`. Después de +escribir, compará contra la entrada del 13/08 y anotá qué no recordaste sin +mirar. + +## Alternativas si este no resuena +1. **De Protocol a alias de función: cuándo un contrato pesado sobra** — sobre + `AnswerFn`/`RetrieveFn` (ADR-004) y el criterio de "un comportamiento → + función, varios comportamientos con estado → Protocol". +2. **Mockear la dependencia no es lo mismo que un test hermético** — sobre el + fix real de T14: `httpx` mockeado no evitó que `_download_pdf` escribiera + al `data/papers/` real del repo. diff --git a/docs/interview_prep/bank.md b/docs/interview_prep/bank.md index a0db16e..fca1799 100644 --- a/docs/interview_prep/bank.md +++ b/docs/interview_prep/bank.md @@ -6,11 +6,11 @@ proyecto. Meta: 80–100 preguntas al final de V5. ## Índice por tema - [Clean Architecture (CA)](#clean-architecture) — 5 preguntas -- [Protocols (PR)](#protocols) — 3 preguntas +- [Protocols (PR)](#protocols) — 4 preguntas - [RAG y retrieval (RG)](#rag-y-retrieval) — 4 preguntas - [Async y concurrencia (AS)](#async-y-concurrencia) — 3 preguntas - [Testing (TS)](#testing) — 0 preguntas -- [LangGraph y agentes (LG)](#langgraph-y-agentes) — 0 preguntas +- [LangGraph y agentes (LG)](#langgraph-y-agentes) — 3 preguntas - [Observabilidad y evals (OB)](#observabilidad-y-evals) — 0 preguntas - [Guardrails y seguridad (GR)](#guardrails-y-seguridad) — 0 preguntas - [LLM providers y SDKs (LM)](#llm-providers-y-sdks) — 0 preguntas @@ -189,9 +189,10 @@ su código. "rigor" adicional de ABC (enforcement al instanciar) es innecesario si usás mypy en CI — y a cambio pagás con acoplamiento por herencia. -**Ejemplo en el proyecto:** `application/services/rag_service.py` recibe -`llm: LLMProvider` y `store: VectorStore` — no sabe si son AnthropicLLM, -GeminiLLM, ChromaVectorStore o mocks; solo sabe qué métodos puede llamar. +**Ejemplo en el proyecto:** `application/services/ingestion_service.py` +recibe `store: VectorStore | None` — no sabe si es `ChromaVectorStore` o un +mock; solo sabe qué métodos puede llamar. (`rag_service.answer_query` ya no +recibe `store` directamente — inyecta `retrieve: RetrieveFn`, ver PR-004.) --- @@ -217,6 +218,32 @@ mocks de LLMProvider usados en `tests/unit/application/`. --- +#### [PR-004] Nivel: intermedio +**Pregunta:** `rag_service.answer_query` recibe `retrieve: RetrieveFn`, un +alias `Callable[[str], Awaitable[list[Document]]]`, en vez de un Protocol. +¿Por qué no un Protocol acá, si ya se usa `VectorStore` y `LLMProvider` en +el resto del proyecto? + +**Respuesta esperada:** Un Protocol tiene sentido cuando hay varios métodos +relacionados que comparten estado (`VectorStore` con `search` + `upsert`). +Acá la dependencia es un solo comportamiento anónimo — un parámetro, un +retorno — y envolver eso en una clase con un único método es ceremonia sin +beneficio. El alias de función sigue siendo estáticamente verificable +(mypy valida la firma) pero es más liviano. Regla: un comportamiento → +alias de función; varios comportamientos relacionados → Protocol o clase. + +**Trampa común:** Pensar que "más formal siempre es mejor" y usar Protocol +por defecto. La complejidad debe ser proporcional al número de +comportamientos que la dependencia agrupa, no una preferencia estilística +fija. + +**Ejemplo en el proyecto:** +`src/researchos/application/services/rag_service.py:8` — +`RetrieveFn = Callable[[str], Awaitable[list[Document]]]`; documentado en +ADR-004 de `docs/architecture.md`. + +--- + ## RAG y retrieval #### [RG-001] Nivel: básico @@ -448,7 +475,89 @@ _(sin preguntas todavía)_ ## LangGraph y agentes -_(sin preguntas todavía)_ +#### [LG-001] Nivel: básico +**Pregunta:** ¿Cuál es la diferencia entre LangChain y LangGraph, y por qué +`create_agent` de LangChain se apoya en LangGraph por debajo? + +**Respuesta esperada:** LangGraph es orquestación de bajo nivel — control +de flujo explícito, human-in-the-loop, ejecución duradera y persistencia +de estado. LangChain es construcción de agentes de alto nivel, con modelos +y herramientas ya integrados en abstracciones como `create_agent`. Desde +la migración de LangGraph 0.x a 1.x (que deprecó `create_react_agent`), +`create_agent` de LangChain usa LangGraph internamente para ese control de +flujo — LangChain no reemplaza a LangGraph, se apoya en él. + +**Trampa común:** Tratarlos como alternativas competidoras ("¿uso +LangChain o LangGraph?") en vez de verlos como capas — LangGraph es la +base de orquestación, LangChain es la abstracción de más alto nivel +construida encima. + +**Ejemplo en el proyecto:** `notebooks/201-jmmz-langraph-study.ipynb`; +versiones fijadas en `pyproject.toml` +(`langgraph>=1.2.11`, `langchain>=1.3.15`, `langchain-anthropic>=1.5.6`). + +--- + +#### [LG-002] Nivel: intermedio +**Pregunta:** Tu pipeline de V1 es dos pasos lineales +(`retrieve → generate`). ¿Qué gana un grafo sobre eso, y por qué vale la +pena montarlo en T18 si hoy, aislado, no aporta ningún beneficio? + +**Respuesta esperada:** El estado explícito es el mecanismo, no el fin — +permite leer el estado de un nodo no adyacente y persistir para retomar +tras un fallo — pero la razón de fondo es que el grafo habilita +bifurcaciones condicionales (T19: ir a una tool si la recuperación local +es pobre) y ciclos (T22: recuperar, evaluar, reescribir query, recuperar +de nuevo), algo que una cadena de funciones no puede hacer porque va en +una sola dirección. Para dos pasos sin ramas ni ciclos, un grafo es +sobrecosto puro: la justificación de T18 no está en T18 mismo, sino en que +T19 y T22 no se pueden construir sin el grafo ya montado — es +infraestructura que se paga por adelantado. + +**Trampa común:** (a) Decir que un pipeline lineal "no permite conocer el +estado intermedio" — sí permite, con logging manual; la diferencia real es +que el grafo lo da por construcción, no por instrumentación. (b) +Justificar T18 por "mejor arquitectura" en vez de por secuenciación, sin +reconocer que hoy es sobrecosto sin beneficio inmediato. + +**Ejemplo en el proyecto:** +`src/researchos/application/services/rag_service.py` (`answer_query`, el +pipeline lineal candidato a convertirse en el grafo); `ROADMAP.md` muestra +T19 y T22 dependiendo de T18. + +--- + +#### [LG-003] Nivel: avanzado +**Pregunta:** En LangGraph, un nodo de clasificación puede enrutar con una +conditional edge (el nodo devuelve `dict`, un router aparte decide el +destino) o devolviendo `Command(goto=...)` (el nodo decide y enruta en un +solo retorno). Para tu T19 (decidir si ir a una tool de arXiv o responder +directo), ¿cuál usarías y por qué? + +**Respuesta esperada:** Conditional edge. El criterio real no es cuál es +más simple, sino si el nodo *calcula* algo que sirve solo para decidir o si +esa información ya se necesita en el estado de todas formas. En T19 los +documentos recuperados van al estado igual — el nodo de generación los +necesita — así que no hay campo transitorio que `Command` evitaría +persistir. Con conditional edge el router queda como función pura, +testeable con estados fabricados sin ejecutar el nodo completo ni golpear +el LLM, y la estructura de ruteo queda declarada en `add_conditional_edges`, +visible en el builder. + +**Trampa común:** Pensar que `Command` es "la forma moderna" y usarla por +defecto. `Command` es obligatorio en un caso real y puntual: enrutar desde +un subgrafo hacia el grafo padre (`Command(graph=Command.PARENT)`), porque +las edges no cruzan fronteras de subgrafo — eso es multi-agente (V6), no +T19. Fuera de ese caso, la pregunta correcta es si el nodo calcula algo +transitorio que solo sirve para decidir (ahí `Command` evita ensuciar el +esquema de estado) o si el nodo solo decide sobre información que ya está +en el estado (ahí conditional edge). + +**Ejemplo en el proyecto:** Aplica directamente al criterio de aceptación +de T19 ([#7](https://github.com/johnma96/researchos/issues/7)): "test del +grafo verificando que la conditional edge enruta correctamente en ambos +casos" ya asume el patrón de router-como-función-pura que impone un +conditional edge, no `Command`. ## Observabilidad y evals diff --git a/docs/interview_prep/by_topic/langgraph_agentes.md b/docs/interview_prep/by_topic/langgraph_agentes.md new file mode 100644 index 0000000..42c27f0 --- /dev/null +++ b/docs/interview_prep/by_topic/langgraph_agentes.md @@ -0,0 +1,87 @@ +# LangGraph y agentes — banco de preguntas + +3 preguntas. Fuente: `docs/interview_prep/bank.md`. + +#### [LG-001] Nivel: básico +**Pregunta:** ¿Cuál es la diferencia entre LangChain y LangGraph, y por qué +`create_agent` de LangChain se apoya en LangGraph por debajo? + +**Respuesta esperada:** LangGraph es orquestación de bajo nivel — control +de flujo explícito, human-in-the-loop, ejecución duradera y persistencia +de estado. LangChain es construcción de agentes de alto nivel, con modelos +y herramientas ya integrados en abstracciones como `create_agent`. Desde +la migración de LangGraph 0.x a 1.x (que deprecó `create_react_agent`), +`create_agent` de LangChain usa LangGraph internamente para ese control de +flujo — LangChain no reemplaza a LangGraph, se apoya en él. + +**Trampa común:** Tratarlos como alternativas competidoras ("¿uso +LangChain o LangGraph?") en vez de verlos como capas — LangGraph es la +base de orquestación, LangChain es la abstracción de más alto nivel +construida encima. + +**Ejemplo en el proyecto:** `notebooks/201-jmmz-langraph-study.ipynb`; +versiones fijadas en `pyproject.toml` +(`langgraph>=1.2.11`, `langchain>=1.3.15`, `langchain-anthropic>=1.5.6`). + +--- + +#### [LG-002] Nivel: intermedio +**Pregunta:** Tu pipeline de V1 es dos pasos lineales +(`retrieve → generate`). ¿Qué gana un grafo sobre eso, y por qué vale la +pena montarlo en T18 si hoy, aislado, no aporta ningún beneficio? + +**Respuesta esperada:** El estado explícito es el mecanismo, no el fin — +permite leer el estado de un nodo no adyacente y persistir para retomar +tras un fallo — pero la razón de fondo es que el grafo habilita +bifurcaciones condicionales (T19: ir a una tool si la recuperación local +es pobre) y ciclos (T22: recuperar, evaluar, reescribir query, recuperar +de nuevo), algo que una cadena de funciones no puede hacer porque va en +una sola dirección. Para dos pasos sin ramas ni ciclos, un grafo es +sobrecosto puro: la justificación de T18 no está en T18 mismo, sino en que +T19 y T22 no se pueden construir sin el grafo ya montado — es +infraestructura que se paga por adelantado. + +**Trampa común:** (a) Decir que un pipeline lineal "no permite conocer el +estado intermedio" — sí permite, con logging manual; la diferencia real es +que el grafo lo da por construcción, no por instrumentación. (b) +Justificar T18 por "mejor arquitectura" en vez de por secuenciación, sin +reconocer que hoy es sobrecosto sin beneficio inmediato. + +**Ejemplo en el proyecto:** +`src/researchos/application/services/rag_service.py` (`answer_query`, el +pipeline lineal candidato a convertirse en el grafo); `ROADMAP.md` muestra +T19 y T22 dependiendo de T18. + +--- + +#### [LG-003] Nivel: avanzado +**Pregunta:** En LangGraph, un nodo de clasificación puede enrutar con una +conditional edge (el nodo devuelve `dict`, un router aparte decide el +destino) o devolviendo `Command(goto=...)` (el nodo decide y enruta en un +solo retorno). Para tu T19 (decidir si ir a una tool de arXiv o responder +directo), ¿cuál usarías y por qué? + +**Respuesta esperada:** Conditional edge. El criterio real no es cuál es +más simple, sino si el nodo *calcula* algo que sirve solo para decidir o si +esa información ya se necesita en el estado de todas formas. En T19 los +documentos recuperados van al estado igual — el nodo de generación los +necesita — así que no hay campo transitorio que `Command` evitaría +persistir. Con conditional edge el router queda como función pura, +testeable con estados fabricados sin ejecutar el nodo completo ni golpear +el LLM, y la estructura de ruteo queda declarada en `add_conditional_edges`, +visible en el builder. + +**Trampa común:** Pensar que `Command` es "la forma moderna" y usarla por +defecto. `Command` es obligatorio en un caso real y puntual: enrutar desde +un subgrafo hacia el grafo padre (`Command(graph=Command.PARENT)`), porque +las edges no cruzan fronteras de subgrafo — eso es multi-agente (V6), no +T19. Fuera de ese caso, la pregunta correcta es si el nodo calcula algo +transitorio que solo sirve para decidir (ahí `Command` evita ensuciar el +esquema de estado) o si el nodo solo decide sobre información que ya está +en el estado (ahí conditional edge). + +**Ejemplo en el proyecto:** Aplica directamente al criterio de aceptación +de T19 ([#7](https://github.com/johnma96/researchos/issues/7)): "test del +grafo verificando que la conditional edge enruta correctamente en ambos +casos" ya asume el patrón de router-como-función-pura que impone un +conditional edge, no `Command`. diff --git a/docs/interview_prep/by_topic/protocols.md b/docs/interview_prep/by_topic/protocols.md index 2d2b844..442b772 100644 --- a/docs/interview_prep/by_topic/protocols.md +++ b/docs/interview_prep/by_topic/protocols.md @@ -1,6 +1,6 @@ # Protocols — banco de preguntas -3 preguntas. Fuente: `docs/interview_prep/bank.md`. +4 preguntas. Fuente: `docs/interview_prep/bank.md`. #### [PR-001] Nivel: básico **Pregunta:** ¿Qué es un `Protocol` de Python y en qué se diferencia de @@ -41,9 +41,10 @@ su código. "rigor" adicional de ABC (enforcement al instanciar) es innecesario si usás mypy en CI — y a cambio pagás con acoplamiento por herencia. -**Ejemplo en el proyecto:** `application/services/rag_service.py` recibe -`llm: LLMProvider` y `store: VectorStore` — no sabe si son AnthropicLLM, -GeminiLLM, ChromaVectorStore o mocks; solo sabe qué métodos puede llamar. +**Ejemplo en el proyecto:** `application/services/ingestion_service.py` +recibe `store: VectorStore | None` — no sabe si es `ChromaVectorStore` o un +mock; solo sabe qué métodos puede llamar. (`rag_service.answer_query` ya no +recibe `store` directamente — inyecta `retrieve: RetrieveFn`, ver PR-004.) --- @@ -66,3 +67,29 @@ señal de mypy sobre si tu test está usando el Protocol correctamente. **Ejemplo en el proyecto:** `tests/conftest.py` tiene `MockVectorStore` y mocks de LLMProvider usados en `tests/unit/application/`. + +--- + +#### [PR-004] Nivel: intermedio +**Pregunta:** `rag_service.answer_query` recibe `retrieve: RetrieveFn`, un +alias `Callable[[str], Awaitable[list[Document]]]`, en vez de un Protocol. +¿Por qué no un Protocol acá, si ya se usa `VectorStore` y `LLMProvider` en +el resto del proyecto? + +**Respuesta esperada:** Un Protocol tiene sentido cuando hay varios métodos +relacionados que comparten estado (`VectorStore` con `search` + `upsert`). +Acá la dependencia es un solo comportamiento anónimo — un parámetro, un +retorno — y envolver eso en una clase con un único método es ceremonia sin +beneficio. El alias de función sigue siendo estáticamente verificable +(mypy valida la firma) pero es más liviano. Regla: un comportamiento → +alias de función; varios comportamientos relacionados → Protocol o clase. + +**Trampa común:** Pensar que "más formal siempre es mejor" y usar Protocol +por defecto. La complejidad debe ser proporcional al número de +comportamientos que la dependencia agrupa, no una preferencia estilística +fija. + +**Ejemplo en el proyecto:** +`src/researchos/application/services/rag_service.py:8` — +`RetrieveFn = Callable[[str], Awaitable[list[Document]]]`; documentado en +ADR-004 de `docs/architecture.md`. diff --git a/docs/interview_prep/weekly_drafts/2026-W33.done.md b/docs/interview_prep/weekly_drafts/2026-W33.done.md new file mode 100644 index 0000000..9b2fe97 --- /dev/null +++ b/docs/interview_prep/weekly_drafts/2026-W33.done.md @@ -0,0 +1,233 @@ +# Borrador semanal — 2026-W33 (10/08 – 14/08) + +Generado desde `git log --since='7 days ago'`, `work_log.md` y `learnings.md` +del 10, 11, 12 y 13/08. Semana de cierre de V1 real (Telegram, hybrid+rerank, +fixes de clon limpio) y arranque conceptual de V2 (LangGraph). + +Marcá con `[x]` las que quieras mover al banco maestro y decime "movés las +marcadas al banco". + +--- + +## Curación final (14/08) + +De los 9 candidatos de abajo, se curaron 4 preguntas al banco maestro —no +tal cual estaban escritas, sino ajustadas en conversación con el usuario: + +- **LG-001** — la opción "C" (LangChain vs. LangGraph, capas) reemplazó a + los candidatos 1 y 2 de este draft como pregunta básica independiente. +- **LG-002** — candidatos 1 y 2 de este draft ("qué gana un grafo" + + "por qué T18 si hoy no aporta nada") fusionados en una sola pregunta + intermedia, a pedido del usuario. +- **PR-004** — candidato 3 de este draft, sin cambios. +- **LG-003** — nueva, no estaba en este draft. Salió de una conversación + posterior con el tutor sobre `conditional edge` vs. `Command(goto=...)` + en LangGraph, aplicada a T19. + +Los candidatos 4–9 (PR-005, CA-006, TS-001/002/003, IN-001) no se curaron +esta semana — quedan disponibles para semanas futuras, no se descartaron. + +--- + +- [ ] **Tema: LangGraph y agentes** — Nivel: básico + - **Pregunta:** Tu pipeline de V1 es `retrieve → generate`, dos pasos + lineales. ¿Qué gana concretamente un grafo (LangGraph) sobre encadenar + esas dos funciones? + - **Respuesta esperada** (3–5 oraciones): El estado explícito es el + mecanismo, no el fin: permite que un nodo lea lo que hizo otro no + adyacente sin recorrer toda la cadena, y se puede persistir para + retomar tras un fallo. Pero la razón de fondo es que el grafo permite + bifurcaciones condicionales (ir a una tool si la recuperación local es + pobre, T19) y ciclos (recuperar, evaluar, reescribir query, recuperar de + nuevo, T22) — algo que una cadena de funciones, al ir en una sola + dirección, no puede hacer estructuralmente. Para un pipeline de dos + pasos sin ramas ni ciclos, el grafo es sobrecosto puro; se justifica + porque habilita lo que viene después, no por sí mismo. + - **Trampa común:** Decir que un pipeline lineal "no permite conocer el + estado intermedio". Sí permite — con un `logger.debug` o devolviendo los + documentos junto con la respuesta. La diferencia real es que en el + grafo la inspeccionabilidad viene por construcción (cada nodo ya es una + unidad nombrada con entrada/salida declaradas), no por instrumentación + manual que hay que recordar agregar. Quedarse solo en el argumento del + estado, sin mencionar ramas y ciclos, es una respuesta incompleta. + - **Ejemplo en el proyecto:** + `src/researchos/application/services/rag_service.py` — `answer_query` + (2 pasos: retrieve → generate) es el pipeline lineal candidato a + convertirse en el grafo de T18. + - **Generada desde:** learnings del 13/08 (conversación con el tutor) + +- [ ] **Tema: LangGraph y agentes** — Nivel: intermedio + - **Pregunta:** Si LangGraph no aporta nada a un pipeline de dos pasos sin + ramas, ¿por qué T18 (montar el grafo) es el primer issue de V2 en vez de + esperar a T19? + - **Respuesta esperada** (3–5 oraciones): Porque T19 (rama condicional + hacia una tool de arXiv) y T22 (ciclo de reescritura de query) necesitan + que el grafo ya exista para poder agregar la bifurcación o el ciclo — + no se puede insertar una conditional edge en una cadena de llamadas de + Python. T18 es infraestructura que se paga por adelantado: el costo + (una dependencia nueva sin beneficio inmediato) se asume en un issue + para que los dos siguientes puedan enfocarse en la capacidad real en + vez de en el andamiaje. + - **Trampa común:** Justificar T18 por sus propios méritos ("el grafo es + mejor arquitectura"). La justificación correcta es que habilita T19 y + T22, reconociendo explícitamente que hoy, aislado, es sobrecosto sin + beneficio — es un argumento de secuenciación, no de superioridad + técnica per se. + - **Ejemplo en el proyecto:** `ROADMAP.md` — T18 depende de "Nada" pero + T19 y T22 dependen de T18; issues [#6](https://github.com/johnma96/researchos/issues/6), + [#7](https://github.com/johnma96/researchos/issues/7) y + [#10](https://github.com/johnma96/researchos/issues/10). + - **Generada desde:** learnings del 13/08 + +- [ ] **Tema: Protocols** — Nivel: intermedio + - **Pregunta:** `rag_service.answer_query` recibe `retrieve: RetrieveFn`, + un alias `Callable[[str], Awaitable[list[Document]]]`, en vez de un + Protocol. ¿Por qué no un Protocol acá, si ya se usa `VectorStore` y + `LLMProvider` en el resto del proyecto? + - **Respuesta esperada** (3–5 oraciones): Un Protocol tiene sentido cuando + hay varios métodos relacionados que comparten estado (`VectorStore` con + `search` + `upsert`). Acá la dependencia es un solo comportamiento + anónimo — un parámetro, un retorno — y envolver eso en una clase con un + único método es ceremonia sin beneficio. El alias de función sigue + siendo estáticamente verificable (mypy valida la firma) pero es más + liviano. Regla: un comportamiento → alias de función; varios + comportamientos relacionados → Protocol o clase. + - **Trampa común:** Pensar que "más formal siempre es mejor" y usar + Protocol por defecto. La complejidad debe ser proporcional al número de + comportamientos que la dependencia agrupa, no una preferencia + estilística fija. + - **Ejemplo en el proyecto:** + `src/researchos/application/services/rag_service.py:8` — + `RetrieveFn = Callable[[str], Awaitable[list[Document]]]`; documentado + en ADR-004 de `docs/architecture.md`. + - **Generada desde:** commit `8b6d643` / learnings del 10–11/08 + +- [ ] **Tema: Protocols** — Nivel: avanzado + - **Pregunta:** Antes de inyectar `retrieve: RetrieveFn` se consideró un + parámetro `strategy: Literal["vector", "hybrid"]` junto con `store` y + `retrievers` opcionales. ¿Por qué esa alternativa es peor? + - **Respuesta esperada** (3–5 oraciones): Con `strategy`, `store` y + `retrievers` se vuelven condicionalmente obligatorios según el valor de + un tercer parámetro — algo que el type checker no puede expresar. Se + puede llamar `answer_query(strategy="hybrid", store=chroma)` — pasando + solo `store`, sin `retrievers` — y el código compila perfecto pero + explota en runtime dentro de la función. Inyectar la función correcta ya + resuelta (el closure) elimina esa combinación inválida por + construcción: no hay parámetro que pueda faltar porque no hay rama que + elegir en tiempo de llamada. + - **Trampa común:** Ver el flag de estrategia como "más flexible" porque + permite elegir en runtime. La flexibilidad de un string literal es + ilusoria si las combinaciones inválidas no se detectan hasta la + ejecución — el costo se paga en bugs de producción, no en el diseño. + - **Ejemplo en el proyecto:** `docs/learnings.md`, entrada del 11/08, + sección sobre por qué `strategy="hybrid"` habría sido peor que + `RetrieveFn`. + - **Generada desde:** learnings del 11/08 + +- [ ] **Tema: Testing con mocks** — Nivel: intermedio + - **Pregunta:** `test_extract_text_pdf` mockeaba `httpx.AsyncClient` y + generaba un PDF válido en memoria con `fitz`, y aun así seguía fallando + en un clon limpio. ¿Por qué mockear la llamada HTTP no bastó para que el + test fuera hermético? + - **Respuesta esperada** (3–5 oraciones): `_download_pdf` hace dos cosas: + pide el PDF por HTTP (mockeado correctamente) y después escribe los + bytes a disco con `open(local_pdf_path, "wb")` — esa escritura real a + `data/papers/` no estaba mockeada. Mockear una dependencia no cubre + automáticamente todos los efectos secundarios del código bajo prueba; + hay que identificar cada punto de I/O real, no solo el más obvio. La + solución fue parchar `PAPERS_DIR` a `tmp_path` de pytest, aislando + también la escritura a disco. + - **Trampa común:** Asumir que "ya mockeé la dependencia externa" es + sinónimo de "el test es hermético". Un test puede seguir tocando el + sistema de archivos real aunque la red esté perfectamente simulada. + - **Ejemplo en el proyecto:** + `tests/unit/application/test_ingestion_service.py:21-22` — + `monkeypatch.setattr("...ingestion_service.PAPERS_DIR", tmp_path)`. + - **Generada desde:** commit `9491967` + +- [ ] **Tema: Testing con mocks** — Nivel: básico + - **Pregunta:** ¿Cómo testearías `_split_message`, la función que parte + respuestas largas de Telegram en trozos de máximo 4096 caracteres? + - **Respuesta esperada** (3–5 oraciones): Es una función pura (texto in, + lista de strings out) sin dependencias externas, así que no necesita + mocks — solo casos de entrada representativos: texto bajo el límite + (debe devolver un solo elemento), texto que corta justo en un espacio + antes del límite, y el caso borde de un texto sin espacios que fuerza un + corte duro para no quedar en loop infinito. Verificar además que + concatenar los trozos reconstruye el texto original. + - **Trampa común:** Intentar mockear algo en un test de una función pura. + Si la función no tiene I/O ni dependencias inyectadas, mockear es una + señal de que se está complicando el test innecesariamente. + - **Ejemplo en el proyecto:** + `tests/unit/infrastructure/test_telegram_bot.py` — cuatro casos sobre + `_split_message` en + `src/researchos/infrastructure/bot/telegram_bot.py:15`. + - **Generada desde:** commit `c5c656d` + +- [ ] **Tema: Ingesta** — Nivel: básico + - **Pregunta:** `_download_pdf` escribía a `data/papers/{nombre}.pdf` y + fallaba con `FileNotFoundError` en un clon limpio porque el directorio + no existía. ¿Por qué no está bien que el servicio dependa de que alguien + haya llamado `ensure_dirs()` antes? + - **Respuesta esperada** (3–5 oraciones): Un servicio que escribe a una + ruta fija no debería asumir que su entorno de ejecución ya está + preparado por un tercero — eso acopla su corrección al orden en que se + invocan las cosas, algo invisible en la firma de la función. La función + que escribe el archivo es responsable de garantizar su propia + precondición: `local_pdf_path.parent.mkdir(parents=True, + exist_ok=True)` antes del `open()` hace el servicio robusto sin + importar si `ensure_dirs()` corrió antes o no. + - **Trampa común:** Arreglarlo solo en el script de arranque (llamando + `ensure_dirs()` ahí) y no en el servicio. Eso deja el bug latente para + el próximo caller que no lo sepa — el fix correcto vive en la función + que tiene la precondición, no en cada invocador. + - **Ejemplo en el proyecto:** + `src/researchos/application/services/ingestion_service.py:77`. + - **Generada desde:** commit `9491967` + +- [ ] **Tema: Clean Architecture** — Nivel: intermedio + - **Pregunta:** `ROADMAP.md` registra como deuda que `ingestion_service.py` + (`application/`) importa `httpx`, `fitz` y + `infrastructure.data.arxiv.search_papers` a nivel de módulo. El servicio + funciona correctamente hoy. ¿Por qué de todas formas es un problema? + - **Respuesta esperada** (3–5 oraciones): El código funcionando no es el + criterio — la regla de dependencia protege intercambiabilidad y + testabilidad futuras, no la corrección presente. Si mañana se quiere + probar `ingestion_service` sin instalar `httpx`/`fitz` reales, o cambiar + cómo se descarga el PDF, hay que tocar código de `application` en vez de + solo reemplazar una implementación de `infrastructure`. Es exactamente + el mismo problema que `rag_service.py` ya resolvió: `answer_query` recibe + `retrieve: RetrieveFn` inyectado en vez de instanciar Chroma — + `ingestion_service.py` sigue haciendo lo que `rag_service.py` dejó de + hacer. + - **Trampa común:** Decir "no importa, funciona" o "se arregla cuando dé + tiempo". La deuda documentada explícitamente en `ROADMAP.md` (con fecha + de detección y versión candidata para pagarla) es la práctica correcta + — nombrarla y fecharla, no dejarla implícita. + - **Ejemplo en el proyecto:** `ROADMAP.md`, sección "Deuda técnica + conocida"; contraste con + `src/researchos/application/services/rag_service.py`, que sí inyecta + `retrieve`. + - **Generada desde:** deuda registrada en `ROADMAP.md` el 12/08, reforzada + por el fix de `ingestion_service.py` del 13/08 (commit `9491967`) + +- [ ] **Tema: Testing con mocks** — Nivel: intermedio + - **Pregunta:** Uno de los tests de `_split_message` no compara contra una + lista exacta de chunks esperados, sino que verifica + `" ".join(chunks) == text`. ¿Por qué preferir esa forma de aserción? + - **Respuesta esperada** (3–5 oraciones): Verificar una lista exacta + acopla el test a la implementación particular del algoritmo de corte — + cualquier ajuste menor (dónde cae el espacio, el orden de prioridad de + los cortes) rompe el test aunque el comportamiento siga siendo correcto. + Verificar la invariante real que importa — que unir los trozos + reconstruye el texto original sin pérdida ni duplicación — prueba la + propiedad que de verdad interesa y es más resistente a refactors del + algoritmo interno. + - **Trampa común:** Pensar que un test más "específico" (lista exacta) es + un test más riguroso. Un test que verifica la invariante correcta es más + riguroso donde importa (no perder contenido) y menos frágil donde no + importa (los detalles exactos del corte). + - **Ejemplo en el proyecto:** + `tests/unit/infrastructure/test_telegram_bot.py`, + `test_split_message_reassembles_to_original`. + - **Generada desde:** commit `c5c656d`