diff --git a/docs/essays/2026-W34-grafos-y-clean-architecture.md b/docs/essays/2026-W34-grafos-y-clean-architecture.md new file mode 100644 index 0000000..1886c09 --- /dev/null +++ b/docs/essays/2026-W34-grafos-y-clean-architecture.md @@ -0,0 +1,9 @@ +Dónde vive el grafo: por qué un reducer siendo "solo un Callable" cambió la decisión de capas de LangGraph + +Decisiones arquitectónicas como la selección de herramientas, las conexiones de las mismas, desarrollos cloud u on-premise, manejo de costos, y otras, son con frecuencia matizadas y están en función de la filosofía que se desarrolle dentro del proyecto y la misma organización. Como punto de partida, desde mi investigación he encontrado referentes que me han llevado a implementar clean architecture dentro de proyecto de gen AI con la filosofía de mantener cada cosa en su lugar para que en le futuro la evolución del mismo proyecto sea atómica y estable. + +Ahora bien, LangGraph como librería misma expone ejemplos que podrían conducir a generación de código spaguetty y poca separación de capas (aunque esto eventualmente tiene sus bondades como el aislamiento mismo de las soluciones ocn LangGrapg, lo que haría parecerlo una opción de "escribo un único módulo, y si necesito modificar solo toco ese módulo o de plano lo borro"). Sin embargo, y para mantenerme dentro de Clean Architecture he decidido y el argumento va más o menos así: Primero notemos que para la definición de estados LangGraph nos da la posibilidad de tener valores por default y además de customizar los reducers de esos campos, es decir, podemos definir cuál es la lógica de actualización que tendrá un campo siendo por default un reemplazo (operation.add), sin embargo, es común encontrar que se use el reducer add_messages importado desde langgraph.graph el cual se encarga de acumular los mensajes en lugar de sobreescribirlos; ahora bien, dada esta práctica uno se ve tentado a hacer esta importación dentro del módulo que contenga la definición del estado (domain/ en mi caso o application/ si hubiese decidio encapsular todo en un único módulo bajo la premisa de que la solución de grafo es una orquestación) rompiendo así la filosofía de clean architecture, pero ahondando en qué es un reducer, éste no es más que un Callabe[[T, T], T], es decir, un objeto llamable que recibe en este caso 2 argumentos del mismo tipo y retorna 1 del mismo tipo, así pues add_messages recibe el mensaje anterior, el mensaje nuevo y retorna una fución de ambos mensajes. Este comportamiento es completamente replicable manualmente por lo que al ser una lógica sencilla pude tomar la decisión de separar los nodos en la capa de application/ (se encargan de orquestar qué hace un nodo en sí mismo), la definición de estado en domain/ (sin tener que importar desde librerías y cuando requiera un comportamiento particular como el de add_messages yo mismo puedo definirilo customizado) y la definición del grafo en infrastructure/ puesto que define qué es el grafo y para ello usa librerías externas. + +Esa decisión de separación trae consigo sus consecuencias, como que quien mantenga la solución de código debe mantener la separación de capas y por tanto se aleja un poco de la práctica normalizada, pero es un trade-offs al que estoy dispuesto. Además, en esa exploración pude entender que add_messages al final permite mantener una identificación de cada mensaje y no por ello asegura separación de hilos entre usuarios, si no únicamente una identificación única de cada mensaje + +Así pues, las decisiones de no importar en nodes se resument en la capacidad de mantener la separación de capas y la capacidad de replicabilidad de la lógica de los reducers, mientras que en caso de la definición del grafo sí usamos la librería estandar. diff --git a/docs/essays/prompts/2026-W34.md b/docs/essays/prompts/2026-W34.md new file mode 100644 index 0000000..18139ad --- /dev/null +++ b/docs/essays/prompts/2026-W34.md @@ -0,0 +1,52 @@ +# Tema propuesto — Semana 2026-W34 + +## Título sugerido +Dónde vive el grafo: por qué un reducer siendo "solo un Callable" cambió la decisión de capas de LangGraph + +## Por qué este tema +El ensayo de W33 ya cubrió *por qué* construir un grafo en vez de seguir con +el pipeline lineal — este tema es distinto: *dónde vive físicamente* ese +grafo una vez decidido construirlo, que es exactamente lo que resolviste +esta semana en ADR-005 (`docs/architecture.md`). Evaluaste tres opciones +reales (todo en `application/` como excepción documentada, nodos puros en +`application/` + ensamblado en `infrastructure/`, o todo en +`infrastructure/orchestration/`) y el hallazgo que inclinó la decisión no +fue una preferencia estética sino un dato técnico concreto: un reducer es +"solo" un `Callable[[T, T], T]`, así que el estado y los nodos no +necesitan importar `langgraph` en absoluto — solo `StateGraph`/`START`/ +`END`/`compile()` sí. Esa misma semana casi cometés el error inverso: +`docs/learnings.md` del 19/08 registra que casi justificás una excepción a +la regla de capas sobre la premisa equivocada de que `add_messages` +aislaba usuarios (en realidad lo hace el `thread_id` del checkpointer). +Ese "casi" es material rico para el ensayo: una decisión de capas que casi +se toma mal por una analogía plausible pero incorrecta. + +## Preguntas guía para arrancar la escritura +- ¿Por qué "un reducer es un `Callable[[T, T], T]`" es el dato que decide + en qué capa vive cada pieza del grafo, y no solo un detalle de tipado? +- De las tres opciones evaluadas en ADR-005, ¿qué se pierde concretamente + con la opción que *no* elegiste (todo en una sola capa) — no en teoría, + sino en un escenario real de este proyecto (T19, T22, T24)? +- ¿Cómo casi te llevó la analogía "add_messages aísla conversaciones" a + justificar una excepción a la regla de capas? ¿Qué pregunta concreta + te hizo verificarlo antes de actuar sobre esa premisa? +- Si mañana tuvieras que explicarle a alguien sin contexto de LangGraph + por qué `nodes.py` no importa `langgraph` pero `research_graph.py` sí, + ¿qué le dirías en dos oraciones? + +## Longitud +400–500 palabras. Escribí sin abrir código ni `docs/architecture.md`. +Después, comparalo contra el ADR real y anotá qué detalle de las tres +opciones evaluadas no recordaste — probablemente sea la señal de qué parte +de la decisión todavía no terminás de asimilar. + +## Alternativas si este no resuena +1. **Composition root: por qué extraer `_wiring.py` un día y no el + anterior** — la regla "se extrae cuando las copias deben cambiar + juntas, no cuando se ven iguales" (aplicada a `scripts/_wiring.py`) como + caso de estudio de cuándo el DRY dogmático se equivoca. +2. **Un test que pasa no es un test correcto** — a partir de la corrección + del tutor sobre `generate_node`: un mock con retorno fijo desacopla la + aserción de lo que realmente recibió el LLM, y qué heurística general + deja eso para decidir *qué* verificar en un test, no solo *que* algo + se devuelva. diff --git a/docs/interview_prep/bank.md b/docs/interview_prep/bank.md index fca1799..4ec0230 100644 --- a/docs/interview_prep/bank.md +++ b/docs/interview_prep/bank.md @@ -7,10 +7,10 @@ proyecto. Meta: 80–100 preguntas al final de V5. - [Clean Architecture (CA)](#clean-architecture) — 5 preguntas - [Protocols (PR)](#protocols) — 4 preguntas -- [RAG y retrieval (RG)](#rag-y-retrieval) — 4 preguntas +- [RAG y retrieval (RG)](#rag-y-retrieval) — 5 preguntas - [Async y concurrencia (AS)](#async-y-concurrencia) — 3 preguntas - [Testing (TS)](#testing) — 0 preguntas -- [LangGraph y agentes (LG)](#langgraph-y-agentes) — 3 preguntas +- [LangGraph y agentes (LG)](#langgraph-y-agentes) — 6 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 @@ -345,6 +345,33 @@ razón fue leakage por construir queries desde el corpus. --- +#### [RG-005] Nivel: avanzado +**Pregunta:** `build_dependencies()` reconstruye el índice BM25 cargando +**todos** los documentos de Chroma en memoria en cada arranque del bot, +porque BM25 no persiste. ¿Qué problema anticipás cuando el corpus +crezca, y cómo lo abordarías? + +**Respuesta esperada:** Hoy es instantáneo porque el corpus es pequeño +(cientos de documentos), pero T21 (briefing matutino) va a ingerir papers +todos los días, así que ese arranque va a crecer linealmente con el tiempo +sin que nada lo frene. El síntoma no es un bug — es una decisión de diseño +(BM25 en memoria, sin persistencia) que funciona mientras una asunción +implícita (corpus chico) sea cierta, y deja de serlo silenciosamente. +Abordajes posibles: persistir el índice BM25 serializado junto a Chroma y +reconstruirlo solo si el corpus cambió; o mover la reconstrucción a un +proceso de fondo desacoplado del arranque del bot, para que un arranque +lento no bloquee la disponibilidad del canal. + +**Trampa común:** Descartarlo como "no es un problema hoy" sin dejarlo +anotado. Las asunciones de escala que dejan de cumplirse silenciosamente +son más peligrosas que un error explícito — no hay señal hasta que duele. + +**Ejemplo en el proyecto:** `scripts/_wiring.py:38-67` (`build_dependencies`); +pendiente anotado en `docs/work_log.md` del 20/08, ligado a issue +[#9](https://github.com/johnma96/researchos/issues/9) (T21). + +--- + ## Async y concurrencia #### [AS-001] Nivel: intermedio @@ -559,6 +586,130 @@ 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`. +--- + +#### [LG-004] Nivel: básico +**Pregunta:** Un nodo de LangGraph, ¿recibe todo el estado del grafo o +solo los campos que necesita? ¿Qué debe devolver? ¿Hay alguna forma de que +un nodo declare explícitamente un esquema distinto al del grafo? + +**Respuesta esperada:** Por default, en `nodes.py` todos los nodos reciben +el mismo esquema del grafo — no existe una vista parcial automática. La +firma es uniforme: `(ResearchContext) -> dict`. Lo que varía nodo a nodo es +qué campos lee internamente y qué claves incluye en el dict que devuelve. +Ese dict es una actualización **parcial** — LangGraph la fusiona con el +estado existente; el nodo no reconstruye el objeto completo. Ahora bien, +LangGraph sí permite que un nodo declare explícitamente en su firma un +esquema *distinto* — `input_schema`/tipos como `InputState`, +`PrivateState`, `OutputState` ("multiple schemas" en la documentación +oficial): un nodo puede leer un esquema y escribir en otro, y de hecho +**cualquier nodo puede escribir a cualquier canal del estado del grafo** +aunque no forme parte de su esquema declarado. Esto no es "recibir menos +estado" — es una forma de tipar más estrictamente la comunicación +*interna* entre nodos (un canal privado que no es parte del input/output +público del grafo), no un mecanismo para reducir lo que el nodo puede +tocar en tiempo de ejecución. + +**Trampa común:** Intentar que un nodo devuelva el estado completo +reconstruido "por seguridad" — innecesario, y con reducers de acumulación +puede duplicar datos que ya estaban. Trampa relacionada: pensar que +declarar `PrivateState` en la firma de un nodo *restringe* en runtime qué +puede leer o escribir — no lo hace; es tipado para claridad y chequeo +estático, LangGraph igual permite escribir a cualquier canal. Y ojo con +streaming: los canales privados no se ocultan automáticamente en +`stream_mode="values"` — hay que pasar `output_keys` explícito si se +quiere restringir qué se expone. + +**Ejemplo en el proyecto:** +`src/researchos/application/agents/research_agent/nodes.py:33,53` — +`retrieve_node` devuelve `{"documents": ...}`, `generate_node` devuelve +`{"answer": ...}`; ambos usan el mismo `ResearchContext` porque el grafo +de T18 es lineal y no necesita estado privado entre nodos. El caso de +esquemas múltiples (`InputState`/`PrivateState`/`OutputState`) todavía no +aplica en el repo — sería relevante si T22 necesita pasar un +`rewritten_query` intermedio que no forma parte del output público del +grafo. + +--- + +#### [LG-005] Nivel: intermedio +**Pregunta:** Un nodo de LangGraph solo recibe el estado como parámetro — +no admite argumentos extra. Tu `generate_node` necesita un `LLMProvider`. +¿Qué opciones tenés para inyectarlo y cuál elegiste? + +**Respuesta esperada:** Tres opciones: (1) meter el `LLMProvider` en el +estado del grafo — se descarta porque en T22 el checkpointer tiene que +serializar el estado en cada paso, y un cliente HTTP no es serializable; +(2) un global de módulo — se descarta porque acopla `application/` a una +instancia concreta de infraestructura, violando la regla de capas; (3) una +fábrica que recibe la dependencia y devuelve el nodo, capturándola en un +closure. Se eligió la tercera: `make_generate_node(llm)` construye el nodo +una sola vez, en el composition root, y el nodo en sí sigue siendo +`(ResearchContext) -> dict` sin saber que existe LangGraph. + +**Trampa común:** Pensar que el estado es "el lugar natural" para +cualquier dependencia porque "así viaja con el grafo". El estado se +serializa (checkpointing); una dependencia con I/O no debería. + +**Ejemplo en el proyecto:** +`src/researchos/application/agents/research_agent/nodes.py:21,39` — +`make_retrieve_node` y `make_generate_node`. + +--- + +#### [LG-006] Nivel: avanzado +**Pregunta:** ¿Qué es exactamente un reducer en LangGraph? Un colega +propone anotar `messages: Annotated[list, add_messages]` para que cada +usuario del bot tenga su propia conversación aislada. ¿Es correcto? + +**Respuesta esperada:** Un reducer es simplemente un `Callable[[T, T], T]` +— el quickstart oficial usa `operator.add`. `Callable[[Arg1, Arg2], +Return]` es el type hint para "algo invocable que toma esos argumentos y +devuelve eso" — acá `Callable[[T, T], T]` dice "una función que toma dos +valores del mismo tipo (el viejo y el nuevo) y devuelve uno de ese tipo": +literalmente la firma de "combinar A y B en uno". No es magia de +LangGraph, es una función corriente; sin reducer, una clave del estado se +sobreescribe en cada update, con uno se acumula o fusiona según la lógica +que definas. `Annotated[list, add_messages]` usa `Annotated[Tipo, +metadata]`, que envuelve un tipo con metadata adicional que no cambia el +tipo en runtime (`messages` sigue siendo `list`) pero que herramientas +como LangGraph sí leen: al construir el grafo, LangGraph inspecciona esa +metadata por campo y, si encuentra un reducer anotado, lo usa para +fusionar; si no, aplica el default (sobreescribir). `add_messages` en sí +es un reducer sincrónico (no async) que recibe la lista vieja y la lista +de mensajes nuevos y hace *upsert* por `id`: mensajes con `id` nuevo se +appendean, mensajes con `id` ya existente reemplazan al anterior en su +posición — así se editan o corrigen mensajes sin duplicarlos, todo +**dentro de un mismo hilo**. Esto es ortogonal a `Awaitable`: los nodos que +producen esos mensajes nuevos sí son `async` — su firma real es +`Callable[[ResearchContext], Awaitable[dict[str, Any]]]`, o sea "invocar +el nodo devuelve algo *awaitable* (una corrutina) que al resolverse +entrega el dict" — pero el reducer que combina esos valores una vez +producidos es una función sync corriente, sin `await` de por medio. Sobre +el aislamiento entre usuarios: eso lo da el `thread_id` que maneja el +checkpointer (T22) — cada `thread_id` tiene su propio estado persistido +por separado. `add_messages` nunca ve mensajes de otro hilo; no hay ningún +mecanismo de reducer que "separe" usuarios, porque la separación ocurre un +nivel antes, en qué estado se carga para ejecutar el grafo. + +**Trampa común:** Confundir "reducer que combina valores" con "mecanismo +que aísla sesiones". Son conceptos ortogonales — casi se justificó una +excepción a la regla de capas del proyecto sobre esa premisa equivocada +antes de verificarla contra la documentación. Trampa secundaria: pensar +que `Annotated` cambia el tipo en runtime — no lo hace; es puramente +metadata que frameworks conscientes de ella (como LangGraph) eligen leer. + +**Ejemplo en el proyecto:** `docs/architecture.md` ADR-005 (línea sobre +"a reducer is just a Callable[[T, T], T]"); +`src/researchos/application/agents/research_agent/nodes.py:10` — `NodeFn = +Callable[[ResearchContext], Awaitable[dict[str, Any]]]`, mismo patrón de +tipado que el reducer pero para el nodo, no para la fusión. `messages` +deliberadamente no está en `ResearchContext` todavía — llega en T22 +(issue [#10](https://github.com/johnma96/researchos/issues/10)) una vez +que se conoce la semántica de fusión necesaria. + +--- + ## Observabilidad y evals _(sin preguntas todavía)_ diff --git a/docs/interview_prep/by_topic/langgraph_agentes.md b/docs/interview_prep/by_topic/langgraph_agentes.md index 42c27f0..42861e1 100644 --- a/docs/interview_prep/by_topic/langgraph_agentes.md +++ b/docs/interview_prep/by_topic/langgraph_agentes.md @@ -1,6 +1,6 @@ # LangGraph y agentes — banco de preguntas -3 preguntas. Fuente: `docs/interview_prep/bank.md`. +6 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é @@ -85,3 +85,125 @@ 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`. + +--- + +#### [LG-004] Nivel: básico +**Pregunta:** Un nodo de LangGraph, ¿recibe todo el estado del grafo o +solo los campos que necesita? ¿Qué debe devolver? ¿Hay alguna forma de que +un nodo declare explícitamente un esquema distinto al del grafo? + +**Respuesta esperada:** Por default, en `nodes.py` todos los nodos reciben +el mismo esquema del grafo — no existe una vista parcial automática. La +firma es uniforme: `(ResearchContext) -> dict`. Lo que varía nodo a nodo es +qué campos lee internamente y qué claves incluye en el dict que devuelve. +Ese dict es una actualización **parcial** — LangGraph la fusiona con el +estado existente; el nodo no reconstruye el objeto completo. Ahora bien, +LangGraph sí permite que un nodo declare explícitamente en su firma un +esquema *distinto* — `input_schema`/tipos como `InputState`, +`PrivateState`, `OutputState` ("multiple schemas" en la documentación +oficial): un nodo puede leer un esquema y escribir en otro, y de hecho +**cualquier nodo puede escribir a cualquier canal del estado del grafo** +aunque no forme parte de su esquema declarado. Esto no es "recibir menos +estado" — es una forma de tipar más estrictamente la comunicación +*interna* entre nodos (un canal privado que no es parte del input/output +público del grafo), no un mecanismo para reducir lo que el nodo puede +tocar en tiempo de ejecución. + +**Trampa común:** Intentar que un nodo devuelva el estado completo +reconstruido "por seguridad" — innecesario, y con reducers de acumulación +puede duplicar datos que ya estaban. Trampa relacionada: pensar que +declarar `PrivateState` en la firma de un nodo *restringe* en runtime qué +puede leer o escribir — no lo hace; es tipado para claridad y chequeo +estático, LangGraph igual permite escribir a cualquier canal. Y ojo con +streaming: los canales privados no se ocultan automáticamente en +`stream_mode="values"` — hay que pasar `output_keys` explícito si se +quiere restringir qué se expone. + +**Ejemplo en el proyecto:** +`src/researchos/application/agents/research_agent/nodes.py:33,53` — +`retrieve_node` devuelve `{"documents": ...}`, `generate_node` devuelve +`{"answer": ...}`; ambos usan el mismo `ResearchContext` porque el grafo +de T18 es lineal y no necesita estado privado entre nodos. El caso de +esquemas múltiples (`InputState`/`PrivateState`/`OutputState`) todavía no +aplica en el repo — sería relevante si T22 necesita pasar un +`rewritten_query` intermedio que no forma parte del output público del +grafo. + +--- + +#### [LG-005] Nivel: intermedio +**Pregunta:** Un nodo de LangGraph solo recibe el estado como parámetro — +no admite argumentos extra. Tu `generate_node` necesita un `LLMProvider`. +¿Qué opciones tenés para inyectarlo y cuál elegiste? + +**Respuesta esperada:** Tres opciones: (1) meter el `LLMProvider` en el +estado del grafo — se descarta porque en T22 el checkpointer tiene que +serializar el estado en cada paso, y un cliente HTTP no es serializable; +(2) un global de módulo — se descarta porque acopla `application/` a una +instancia concreta de infraestructura, violando la regla de capas; (3) una +fábrica que recibe la dependencia y devuelve el nodo, capturándola en un +closure. Se eligió la tercera: `make_generate_node(llm)` construye el nodo +una sola vez, en el composition root, y el nodo en sí sigue siendo +`(ResearchContext) -> dict` sin saber que existe LangGraph. + +**Trampa común:** Pensar que el estado es "el lugar natural" para +cualquier dependencia porque "así viaja con el grafo". El estado se +serializa (checkpointing); una dependencia con I/O no debería. + +**Ejemplo en el proyecto:** +`src/researchos/application/agents/research_agent/nodes.py:21,39` — +`make_retrieve_node` y `make_generate_node`. + +--- + +#### [LG-006] Nivel: avanzado +**Pregunta:** ¿Qué es exactamente un reducer en LangGraph? Un colega +propone anotar `messages: Annotated[list, add_messages]` para que cada +usuario del bot tenga su propia conversación aislada. ¿Es correcto? + +**Respuesta esperada:** Un reducer es simplemente un `Callable[[T, T], T]` +— el quickstart oficial usa `operator.add`. `Callable[[Arg1, Arg2], +Return]` es el type hint para "algo invocable que toma esos argumentos y +devuelve eso" — acá `Callable[[T, T], T]` dice "una función que toma dos +valores del mismo tipo (el viejo y el nuevo) y devuelve uno de ese tipo": +literalmente la firma de "combinar A y B en uno". No es magia de +LangGraph, es una función corriente; sin reducer, una clave del estado se +sobreescribe en cada update, con uno se acumula o fusiona según la lógica +que definas. `Annotated[list, add_messages]` usa `Annotated[Tipo, +metadata]`, que envuelve un tipo con metadata adicional que no cambia el +tipo en runtime (`messages` sigue siendo `list`) pero que herramientas +como LangGraph sí leen: al construir el grafo, LangGraph inspecciona esa +metadata por campo y, si encuentra un reducer anotado, lo usa para +fusionar; si no, aplica el default (sobreescribir). `add_messages` en sí +es un reducer sincrónico (no async) que recibe la lista vieja y la lista +de mensajes nuevos y hace *upsert* por `id`: mensajes con `id` nuevo se +appendean, mensajes con `id` ya existente reemplazan al anterior en su +posición — así se editan o corrigen mensajes sin duplicarlos, todo +**dentro de un mismo hilo**. Esto es ortogonal a `Awaitable`: los nodos que +producen esos mensajes nuevos sí son `async` — su firma real es +`Callable[[ResearchContext], Awaitable[dict[str, Any]]]`, o sea "invocar +el nodo devuelve algo *awaitable* (una corrutina) que al resolverse +entrega el dict" — pero el reducer que combina esos valores una vez +producidos es una función sync corriente, sin `await` de por medio. Sobre +el aislamiento entre usuarios: eso lo da el `thread_id` que maneja el +checkpointer (T22) — cada `thread_id` tiene su propio estado persistido +por separado. `add_messages` nunca ve mensajes de otro hilo; no hay ningún +mecanismo de reducer que "separe" usuarios, porque la separación ocurre un +nivel antes, en qué estado se carga para ejecutar el grafo. + +**Trampa común:** Confundir "reducer que combina valores" con "mecanismo +que aísla sesiones". Son conceptos ortogonales — casi se justificó una +excepción a la regla de capas del proyecto sobre esa premisa equivocada +antes de verificarla contra la documentación. Trampa secundaria: pensar +que `Annotated` cambia el tipo en runtime — no lo hace; es puramente +metadata que frameworks conscientes de ella (como LangGraph) eligen leer. + +**Ejemplo en el proyecto:** `docs/architecture.md` ADR-005 (línea sobre +"a reducer is just a Callable[[T, T], T]"); +`src/researchos/application/agents/research_agent/nodes.py:10` — `NodeFn = +Callable[[ResearchContext], Awaitable[dict[str, Any]]]`, mismo patrón de +tipado que el reducer pero para el nodo, no para la fusión. `messages` +deliberadamente no está en `ResearchContext` todavía — llega en T22 +(issue [#10](https://github.com/johnma96/researchos/issues/10)) una vez +que se conoce la semántica de fusión necesaria. diff --git a/docs/interview_prep/by_topic/rag_retrieval.md b/docs/interview_prep/by_topic/rag_retrieval.md index 51888b1..f0665ec 100644 --- a/docs/interview_prep/by_topic/rag_retrieval.md +++ b/docs/interview_prep/by_topic/rag_retrieval.md @@ -1,6 +1,6 @@ # RAG y retrieval — banco de preguntas -4 preguntas. Fuente: `docs/interview_prep/bank.md`. +5 preguntas. Fuente: `docs/interview_prep/bank.md`. #### [RG-001] Nivel: básico **Pregunta:** Dame un ejemplo concreto de una query donde BM25 supera a @@ -98,3 +98,30 @@ lo que ya sabías que estaba, no calidad de retrieval. **Ejemplo en el proyecto:** El `learnings.md` del 01/06/2026 documenta exactamente este problema al observar scores 1.000/1.000 en el eval — la razón fue leakage por construir queries desde el corpus. + +--- + +#### [RG-005] Nivel: avanzado +**Pregunta:** `build_dependencies()` reconstruye el índice BM25 cargando +**todos** los documentos de Chroma en memoria en cada arranque del bot, +porque BM25 no persiste. ¿Qué problema anticipás cuando el corpus +crezca, y cómo lo abordarías? + +**Respuesta esperada:** Hoy es instantáneo porque el corpus es pequeño +(cientos de documentos), pero T21 (briefing matutino) va a ingerir papers +todos los días, así que ese arranque va a crecer linealmente con el tiempo +sin que nada lo frene. El síntoma no es un bug — es una decisión de diseño +(BM25 en memoria, sin persistencia) que funciona mientras una asunción +implícita (corpus chico) sea cierta, y deja de serlo silenciosamente. +Abordajes posibles: persistir el índice BM25 serializado junto a Chroma y +reconstruirlo solo si el corpus cambió; o mover la reconstrucción a un +proceso de fondo desacoplado del arranque del bot, para que un arranque +lento no bloquee la disponibilidad del canal. + +**Trampa común:** Descartarlo como "no es un problema hoy" sin dejarlo +anotado. Las asunciones de escala que dejan de cumplirse silenciosamente +son más peligrosas que un error explícito — no hay señal hasta que duele. + +**Ejemplo en el proyecto:** `scripts/_wiring.py:38-67` (`build_dependencies`); +pendiente anotado en `docs/work_log.md` del 20/08, ligado a issue +[#9](https://github.com/johnma96/researchos/issues/9) (T21). diff --git a/docs/interview_prep/weekly_drafts/2026-W34.done.md b/docs/interview_prep/weekly_drafts/2026-W34.done.md new file mode 100644 index 0000000..7ce5884 --- /dev/null +++ b/docs/interview_prep/weekly_drafts/2026-W34.done.md @@ -0,0 +1,312 @@ +# Borrador de preguntas — 2026-W34 + +Semana del T18 (LangGraph fundamentals, issue #6, cerrado vía PR #16). +Commits: ADR-005, `ResearchContext`/`RetrieveFn`/`AnswerFn` movidos a +`domain/`, `nodes.py`, `research_graph.py`, `scripts/_wiring.py`, bot +conectado al grafo, y dos correcciones de tutor (versión en un +`type: ignore`, test de `generate_node` insuficiente). Fuente: `git log +--since='7 days ago'`, `docs/learnings.md` (19/08, 20/08), `docs/architecture.md` +ADR-005. + +**Curación final (21/08):** de los 9 candidatos, se marcaron 4 y se +movieron al banco maestro: node factories para inyección de dependencias +→ `LG-005`; nodo recibe estado completo + esquemas múltiples → `LG-004` +(ampliada a pedido del usuario con el mecanismo de "multiple schemas" de +LangGraph); reducer como `Callable[[T,T],T]` vs. malentendido de +`add_messages` → `LG-006` (ampliada con `Callable`, `Annotated` y +`Awaitable`, y verificada la mecánica de `add_messages` contra el código +fuente instalado); BM25 reconstruido en memoria en cada arranque → `RG-005`. +Sin marcar (quedan disponibles para una semana futura si el tema vuelve a +aparecer): fábrica de nodos + composition root (CA), test insuficiente de +mocks (TS), manejo de `type: ignore` con versión (PC), y matiz sobre +criterios de aceptación literales vs. su intención (LG). + +--- + +- [X] **Tema: LangGraph y agentes** — Nivel: intermedio + - **Pregunta:** Un nodo de LangGraph solo recibe el estado como parámetro — + no admite argumentos extra. Tu `generate_node` necesita un + `LLMProvider`. ¿Qué opciones tenés para inyectarlo y cuál elegiste? + - **Respuesta esperada** (3–5 oraciones): Tres opciones: (1) meter el + `LLMProvider` en el estado del grafo — se descarta porque en T22 el + checkpointer tiene que serializar el estado en cada paso, y un cliente + HTTP no es serializable; (2) un global de módulo — se descarta porque + acopla `application/` a una instancia concreta de infraestructura, + violando la regla de capas; (3) una fábrica que recibe la dependencia y + devuelve el nodo, capturándola en un closure. Se eligió la tercera: + `make_generate_node(llm)` construye el nodo una sola vez, en el + composition root, y el nodo en sí sigue siendo `(ResearchContext) -> + dict` sin saber que existe LangGraph. + - **Trampa común:** Pensar que el estado es "el lugar natural" para + cualquier dependencia porque "así viaja con el grafo". El estado se + serializa (checkpointing); una dependencia con I/O no debería. + - **Ejemplo en el proyecto:** `src/researchos/application/agents/research_agent/nodes.py:21,39` + — `make_retrieve_node` y `make_generate_node`. + - **Generada desde:** commit `eadc1c4`; "learnings del 19/08". + +--- + +- [x] **Tema: LangGraph y agentes** — Nivel: avanzado + - **Pregunta:** ¿Qué es exactamente un reducer en LangGraph? Un colega + propone anotar `messages: Annotated[list, add_messages]` para que cada + usuario del bot tenga su propia conversación aislada. ¿Es correcto? + - **Respuesta esperada** (extendida — cubre 3 construcciones de tipado y + la mecánica de `add_messages`): + Un reducer es simplemente un `Callable[[T, T], T]` — el quickstart + oficial usa `operator.add`. `Callable[[Arg1, Arg2], Return]` es el type + hint para "algo invocable que toma esos argumentos y devuelve eso" — acá + `Callable[[T, T], T]` dice "una función que toma dos valores del mismo + tipo (el viejo y el nuevo) y devuelve uno de ese tipo": literalmente la + firma de "combinar A y B en uno". No es magia de LangGraph, es una + función corriente; sin reducer, una clave del estado se sobreescribe en + cada update, con uno se acumula o fusiona según la lógica que definas. + `Annotated[list, add_messages]` usa `Annotated[Tipo, metadata]`, que + envuelve un tipo con metadata adicional que no cambia el tipo en + runtime (`messages` sigue siendo `list`) pero que herramientas como + LangGraph sí leen: al construir el grafo, LangGraph inspecciona esa + metadata por campo y, si encuentra un reducer anotado, lo usa para + fusionar; si no, aplica el default (sobreescribir). `add_messages` en sí + es un reducer sincrónico (no async) que recibe la lista vieja y la lista + de mensajes nuevos y hace *upsert* por `id`: mensajes con `id` nuevo se + appendean, mensajes con `id` ya existente reemplazan al anterior en su + posición — así se editan o corrigen mensajes sin duplicarlos, todo + **dentro de un mismo hilo**. Esto es ortogonal a `Awaitable`: los nodos + que producen esos mensajes nuevos sí son `async` — su firma real es + `Callable[[ResearchContext], Awaitable[dict[str, Any]]]`, o sea "invocar + el nodo devuelve algo *awaitable* (una corrutina) que al resolverse + entrega el dict" — pero el reducer que combina esos valores una vez + producidos es una función sync corriente, sin `await` de por medio. + Sobre el aislamiento entre usuarios: eso lo da el `thread_id` que + maneja el checkpointer (T22) — cada `thread_id` tiene su propio estado + persistido por separado. `add_messages` nunca ve mensajes de otro + hilo; no hay ningún mecanismo de reducer que "separe" usuarios, porque + la separación ocurre un nivel antes, en qué estado se carga para + ejecutar el grafo. + - **Trampa común:** Confundir "reducer que combina valores" con + "mecanismo que aísla sesiones". Son conceptos ortogonales — casi se + justificó una excepción a la regla de capas del proyecto sobre esa + premisa equivocada antes de verificarla contra la documentación. Trampa + secundaria: pensar que `Annotated` cambia el tipo en runtime — no lo + hace; es puramente metadata que frameworks conscientes de ella (como + LangGraph) eligen leer. + - **Ejemplo en el proyecto:** `docs/architecture.md` ADR-005 (línea sobre + "a reducer is just a Callable[[T, T], T]"); + `src/researchos/application/agents/research_agent/nodes.py:10` — `NodeFn + = Callable[[ResearchContext], Awaitable[dict[str, Any]]]`, mismo patrón + de tipado que el reducer pero para el nodo, no para la fusión. + `messages` deliberadamente no está en `ResearchContext` todavía — llega + en T22 (issue [#10](https://github.com/johnma96/researchos/issues/10)) + una vez que se conoce la semántica de fusión necesaria. + - **Generada desde:** "learnings del 19/08". + +--- + +- [X] **Tema: LangGraph y agentes** — Nivel: básico + - **Pregunta:** Un nodo de LangGraph, ¿recibe todo el estado del grafo o + solo los campos que necesita? ¿Qué debe devolver? ¿Hay alguna forma de + que un nodo declare explícitamente un esquema distinto al del grafo? + - **Respuesta esperada** (3–6 oraciones): Por default, en `nodes.py` + todos los nodos reciben el mismo esquema del grafo — no existe una + vista parcial automática. La firma es uniforme: `(ResearchContext) -> + dict`. Lo que varía nodo a nodo es qué campos lee internamente y qué + claves incluye en el dict que devuelve. Ese dict es una actualización + **parcial** — LangGraph la fusiona con el estado existente; el nodo no + reconstruye el objeto completo. Ahora bien, LangGraph sí permite que un + nodo declare explícitamente en su firma un esquema *distinto* — + `input_schema`/tipos como `InputState`, `PrivateState`, `OutputState` + ("multiple schemas" en la documentación oficial): un nodo puede leer un + esquema y escribir en otro, y de hecho **cualquier nodo puede escribir + a cualquier canal del estado del grafo** aunque no forme parte de su + esquema declarado. Esto no es "recibir menos estado" — es una forma de + tipar más estrictamente la comunicación *interna* entre nodos (un canal + privado que no es parte del input/output público del grafo), no un + mecanismo para reducir lo que el nodo puede tocar en tiempo de + ejecución. + - **Trampa común:** Intentar que un nodo devuelva el estado completo + reconstruido "por seguridad" — innecesario, y con reducers de + acumulación puede duplicar datos que ya estaban. Trampa relacionada: + pensar que declarar `PrivateState` en la firma de un nodo *restringe* + en runtime qué puede leer o escribir — no lo hace; es tipado para + claridad y chequeo estático, LangGraph igual permite escribir a + cualquier canal. Y ojo con streaming: los canales privados no se + ocultan automáticamente en `stream_mode="values"` — hay que pasar + `output_keys` explícito si se quiere restringir qué se expone. + - **Ejemplo en el proyecto:** + `src/researchos/application/agents/research_agent/nodes.py:33,53` — + `retrieve_node` devuelve `{"documents": ...}`, `generate_node` devuelve + `{"answer": ...}`; ambos usan el mismo `ResearchContext` porque el grafo + de T18 es lineal y no necesita estado privado entre nodos. El caso de + esquemas múltiples (`InputState`/`PrivateState`/`OutputState`) todavía + no aplica en el repo — sería relevante si T22 necesita pasar un + `rewritten_query` intermedio que no forma parte del output público del + grafo. + - **Generada desde:** commit `eadc1c4`; "learnings del 19/08"; docs + oficiales de LangGraph, sección "Multiple schemas" + (`docs.langchain.com/oss/python/langgraph/graph-api#multiple-schemas`). + +--- + +- [ ] **Tema: LangGraph y agentes** — Nivel: intermedio + - **Pregunta:** `ResearchContext` es un `dataclass`, no un `BaseModel` de + Pydantic, aunque el resto del proyecto usa "Pydantic models para todos + los datos". ¿Por qué la excepción, y por qué `query: str` no tiene + default? + - **Respuesta esperada** (3–5 oraciones): LangGraph acepta por igual + `TypedDict`, `dataclass` o `BaseModel` como esquema de estado — no hay + requisito técnico. Se eligió dataclass porque Pydantic revalida el + modelo completo en **cada** actualización parcial de un nodo (no solo + al construirlo), un costo que no aporta nada aquí porque no hay + validación cruzada entre campos. Sobre `query` sin default: es una + decisión de fallo temprano — sin default, construir + `ResearchContext()` sin query lanza `TypeError` inmediatamente. Con + `query: str = ""` el objeto se construiría igual y el error aparecería + mucho después (embedding de string vacío, retrieval basura, respuesta + rara), obligando a rastrear hacia atrás para encontrar el origen. + - **Trampa común:** Asumir que "todo dato del dominio debe ser Pydantic" + como regla sin excepciones. La regla del proyecto es una heurística + (validar en los bordes), no un dogma — y hay que poder justificar por + qué un caso concreto se aparta de ella. + - **Ejemplo en el proyecto:** `src/researchos/domain/models.py:98-104` — + docstring de `ResearchContext` documentando explícitamente la + excepción. + - **Generada desde:** commit `a6b0cae`; "learnings del 19/08". + +--- + +- [ ] **Tema: Clean Architecture** — Nivel: avanzado + - **Pregunta:** `run_telegram_bot.py` y `run_research_graph.py` tenían el + mismo bloque de construcción de embedder/Chroma/BM25/LLM duplicado. Un + día antes, con un solo consumidor, se decidió **no** extraerlo. Un día + después, con dos, sí. ¿Cuál es el criterio para decidir cuándo extraer + una duplicación y cuándo dejarla? + - **Respuesta esperada** (3–5 oraciones): El criterio no es "se ven + iguales" — es si las copias **deben cambiar juntas**. Con un solo + consumidor, extraer una abstracción es prematuro: no hay evidencia de + qué parte es realmente compartida versus coincidencia temporal. Con dos + consumidores que necesitan la misma conexión a Chroma y la misma + reconstrucción de BM25, esa parte sí debe evolucionar en sincronía — + extraerla a `build_dependencies()` evita que diverjan por accidente. + Un composition root típicamente mezcla tres cosas distintas + (construcción de dependencias, composición del motor, arranque del + canal); solo la primera resultó común a ambos scripts, y por eso es la + única que se extrajo — `retrieve_hybrid_rerank`, duplicada palabra por + palabra, se dejó intacta porque es una elección de composición, no de + infraestructura. + - **Trampa común:** Extraer apenas se detecta similitud textual (DRY + dogmático). Dos bloques de código idénticos que cambian por razones + distintas no son la misma abstracción — forzarla acopla cosas que + debían poder evolucionar por separado. + - **Ejemplo en el proyecto:** `scripts/_wiring.py` + (`build_dependencies()`) — extraído; `retrieve_hybrid_rerank` en + `scripts/run_telegram_bot.py` y `scripts/run_research_graph.py` — no + extraído, a propósito. + - **Generada desde:** commit `91e3417`; "learnings del 20/08". + +--- + +- [ ] **Tema: Testing** — Nivel: intermedio + - **Pregunta:** Un test de `generate_node` verificaba que la respuesta + devuelta fuera la del LLM mockeado y que hubo una llamada. Un tutor + señaló que ese test pasaría igual aunque el nodo ignorara + `state.documents` por completo. ¿Por qué, y cómo se corrige? + - **Respuesta esperada** (3–5 oraciones): El test verificaba la salida + del mock (`"This is a mock response."`), no lo que el nodo le envió al + mock. Un mock configurado con respuesta fija devuelve esa respuesta sin + importar qué mensajes recibió — así que un nodo que arme el prompt sin + los documentos recuperados pasa el test exactamente igual que uno + correcto. La corrección es inspeccionar los argumentos de la llamada + capturada (`mock_llm.calls[0]`) y afirmar sobre el contenido real: + que el texto de los documentos aparezca en el mensaje, no solo que el + nodo haya devuelto algo con forma correcta. + - **Trampa común:** Confundir "el test pasó" con "el comportamiento es + correcto". Un mock con retorno fijo desacopla la aserción de lo que + realmente le llega — hay que mirar explícitamente los argumentos + capturados, no solo el resultado final. + - **Ejemplo en el proyecto:** + `tests/unit/application/test_research_agent_nodes.py:46` — + `test_passes_query_and_documents_to_llm`, agregado después de la + observación del tutor; verificado manualmente rompiendo el nodo a + propósito y confirmando que el test nuevo falla mientras el anterior + seguía en verde. + - **Generada desde:** commit `7d96404`. + +--- + +- [ ] **Tema: Proceso y colaboración** — Nivel: intermedio + - **Pregunta:** `mypy` marca un error real en una llamada a + `StateGraph.add_node()` que, tras investigar, resulta ser una + limitación de los stubs de LangGraph y no un bug del código propio. + ¿Cómo lo manejás en vez de simplemente silenciarlo? + - **Respuesta esperada** (3–5 oraciones): Primero se reproduce el error + en un caso mínimo fuera del proyecto (una función async simple pasada a + un `StateGraph` de juguete), probando variantes razonables (pasar + `input_schema` explícito) para descartar que sea un error propio de + tipado. Confirmado que persiste, se busca si ya está reportado + upstream — en este caso, `langchain-ai/langgraph#5000` sigue abierto, + sin versión objetivo. Recién ahí se silencia con + `# type: ignore[call-overload]`, pero anotando la versión exacta de + las herramientas (`langgraph==1.2.11`, `mypy==1.20.1`) y el número de + issue en el comentario. + - **Trampa común:** Poner un `type: ignore` desnudo, sin versión ni + referencia. Nadie sabe después si sigue siendo necesario — un ignore + sin fecha de caducidad implícita nunca se revisa, y se acumula como + deuda invisible indefinidamente. + - **Ejemplo en el proyecto:** + `src/researchos/infrastructure/orchestration/research_graph.py:42-43`. + - **Generada desde:** commit `7d96404`. + +--- + +- [ ] **Tema: LangGraph y agentes** — Nivel: avanzado + - **Pregunta:** El criterio de aceptación de T18 pedía que + `git diff --stat` no tocara `telegram_bot.py`. Al cerrar el issue, el + diff completo de la rama sí lo toca. ¿El criterio quedó incumplido? + - **Respuesta esperada** (3–5 oraciones): No — hay que distinguir qué + verifica el criterio de lo que literalmente mide. El cambio en + `telegram_bot.py` es una línea de import (`AnswerFn` ahora viene de + `domain/interfaces` en vez de definirse localmente), consecuencia de un + refactor de un día antes (mover los alias de tipo a domain), no de + conectar el grafo. El propósito real del criterio —que cablear el + grafo no requiera tocar la *lógica* del adaptador de Telegram— sí se + cumple: `TelegramBot` sigue dependiendo solo de `AnswerFn` y no sabe + que existe un grafo. Verificar un criterio de aceptación exige entender + qué riesgo intenta prevenir, no solo ejecutar el comando literal que lo + describe. + - **Trampa común:** Tratar un criterio de aceptación como un comando a + ejecutar en vez de como una propiedad a razonar. Un diff que "toca" un + archivo no es automáticamente una violación si el motivo del cambio es + ortogonal a lo que el criterio buscaba prevenir. + - **Ejemplo en el proyecto:** issue + [#6](https://github.com/johnma96/researchos/issues/6) (T18), commits + `a6b0cae` (movimiento de alias, día anterior) y `2adb756` (conexión del + grafo, sin tocar la lógica del adaptador). + - **Generada desde:** commit `2adb756`; "learnings del 20/08". + +--- + +- [x] **Tema: RAG y retrieval** — Nivel: avanzado + - **Pregunta:** `build_dependencies()` reconstruye el índice BM25 cargando + **todos** los documentos de Chroma en memoria en cada arranque del bot, + porque BM25 no persiste. ¿Qué problema anticipás cuando el corpus + crezca, y cómo lo abordarías? + - **Respuesta esperada** (3–5 oraciones): Hoy es instantáneo porque el + corpus es pequeño (cientos de documentos), pero T21 (briefing matutino) + va a ingerir papers todos los días, así que ese arranque va a crecer + linealmente con el tiempo sin que nada lo frene. El síntoma no es un + bug — es una decisión de diseño (BM25 en memoria, sin persistencia) + que funciona mientras una asunción implícita (corpus chico) sea + cierta, y deja de serlo silenciosamente. Abordajes posibles: + persistir el índice BM25 serializado junto a Chroma y reconstruirlo + solo si el corpus cambió; o mover la reconstrucción a un proceso de + fondo desacoplado del arranque del bot, para que un arranque lento no + bloquee la disponibilidad del canal. + - **Trampa común:** Descartarlo como "no es un problema hoy" sin dejarlo + anotado. Las asunciones de escala que dejan de cumplirse silenciosamente + son más peligrosas que un error explícito — no hay señal hasta que + duele. + - **Ejemplo en el proyecto:** `scripts/_wiring.py:38-67` + (`build_dependencies`); pendiente anotado en `docs/work_log.md` del + 20/08, ligado a issue [#9](https://github.com/johnma96/researchos/issues/9) (T21). + - **Generada desde:** commit `91e3417`; "learnings del 20/08". + +---