Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/essays/2026-W34-grafos-y-clean-architecture.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions docs/essays/prompts/2026-W34.md
Original file line number Diff line number Diff line change
@@ -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.
155 changes: 153 additions & 2 deletions docs/interview_prep/bank.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)_
Expand Down
Loading
Loading