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
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ test: ## Run unit tests only (fast, no IO)
test-all: ## Run all tests (unit + integration)
uv run pytest -v

lint: ## Run linter (ruff check)
lint: ## Run linter (ruff check + mypy)
uv run ruff check src/ tests/
uv run mypy src/

format: ## Format code (ruff format + fix)
uv run ruff format src/ tests/
Expand All @@ -37,7 +38,7 @@ run-api: ## Start the FastAPI server
uv run uvicorn researchos.infrastructure.api.main:app --reload --port 8000

run-bot: ## Start the Telegram bot
uv run python -m researchos.infrastructure.bot.main
uv run python scripts/run_telegram_bot.py

# ── Docker ──
docker-up: ## Start local stack (api + chroma)
Expand Down
6 changes: 3 additions & 3 deletions docs/essays/2026-W33-grafo-vs-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

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.
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 aunque podrían ciclarse y bifurcarse con python puro tienen un costo de desarrollo manual además del control y observación del flujo de la información, cosa que LangGraph ya hace por sí mismo.

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.
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. Aún más, al conocer el flujo de la información de antemano, es más sencillo ir entendiendo la estructura y modo funcional de la librería sobre este grafo de 2 nodos, centrándome en el cómo orquestar con la librería más allá de la lógica en sí misma.

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.
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 inversión en infraestructura tendrá sus beneficios en el futuro.
94 changes: 94 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,97 @@ Regla simple para ResearchOS:
cambiaron en la migración 0.x → 1.x) pero no verifiqué la API real todavía.

---

**Fecha:** 18/08/2026

### ¿Qué aprendí?

- **Un wrapper es una función de orden superior: recibe una función y
devuelve otra función.** Lo practiqué primero en
`taller_retorno_researchos.ipynb` con `with_exclamation(fn)`, que define
`wrapped(text)` cerrando sobre `fn` y devuelve `wrapped` sin ejecutarlo.
Es el mismo molde que `with_logging(answer_fn: AnswerFn) -> AnswerFn` en
`scripts/run_telegram_bot.py`: recibe un `AnswerFn`, devuelve otro.
- **Lo que importa no es que envuelva, sino cuándo corre cada parte.** En el
segundo experimento del notebook, `print("CONSTRUYENDO")` corre una sola
vez -- cuando se llama `with_exclamation(greet)` -- y `print("EJECUTANDO")`
corre una vez por cada llamada a la función envuelta. Esa separación
construcción-una-vez / ejecución-por-llamada es exactamente por qué
`with_logging` puede abrir el `logging.FileHandler` y hacer
`query_logger.addHandler(...)` en el cuerpo de la función envolvente: ese
código corre una sola vez, al armar `with_logging(answer)` en el
composition root, no en cada pregunta que llega por Telegram.
- **El wrapper no necesita saber nada de Telegram.** `with_logging` solo
conoce la firma `AnswerFn` (`Callable[[str], Awaitable[str]]`) — le es
indiferente si la función que envuelve viene de `answer_query` con
vector-only o con hybrid+rerank. Mismo patrón de composition root que ya
veníamos usando (`retrieve_hybrid_rerank`), aplicado ahora para agregar un
efecto secundario (logging) en vez de una estrategia de recuperación.

**LangGraph: estado, nodo y edge, con evidencia de mis propios experimentos**

- **Estado.** Es la estructura de datos explícita (`TypedDict`, `dataclass` o
modelo Pydantic) que se pasa por todo el grafo. Cada nodo recibe el
estado (o un subconjunto, si se declaran `InputState`/`OutputState`/
`PrivateState` separados) y devuelve un **update parcial** — no el estado
completo. Probé esto con `InputState`/`OverallState`/`PrivateState`/
`OutputState` en un grafo de 3 nodos donde cada uno lee de un canal y
escribe en otro, y funcionó exactamente así: `node_1` escribe en
`OverallState`, `node_2` lee de ahí y escribe en `PrivateState`, `node_3`
lee de `PrivateState` y arma el `OutputState` final.
- **Nodo.** Una función que recibe estado y devuelve una actualización de
estado. Nada más — no decide a dónde ir después (eso es trabajo del edge),
salvo que sea un nodo tipo `Command` (que mi tutor y yo ya distinguimos
esta semana para T19: si el nodo solo calcula, va con conditional edge).
- **Edge.** Conexión entre nodos. Puede ser fija (`add_edge`, siempre va de
A a B) o condicional (`add_conditional_edges`, una función del estado
decide el destino, como `should_continue` devolviendo `"tool_node"` o
`END` según si el último mensaje tiene `tool_calls`).
- **El ciclo es lo que hace posible el multi-tool-call, y lo comprobé
rompiéndolo.** Con el edge `tool_node → llm_call` presente, le pedí al
agente derivar una expresión, multiplicar y dividir el resultado — hizo
las tres cosas en secuencia porque después de cada tool call volvía a
`llm_call` a decidir el siguiente paso. Al comentar ese edge, el agente
ejecutó `multiply` una sola vez y terminó ahí — sin el edge de retorno,
`tool_node` no tiene a dónde ir y el grafo termina implícitamente. Es la
demostración empírica de por qué T22 (reescritura de query + reintento)
necesita un ciclo real, no solo un conditional edge de ida.
- **El orden en que declaro los edges no afecta el grafo compilado.**
Definí primero el `add_edge("tool_node", "llm_call")` y después el
conditional edge, y también al revés — mismo comportamiento en ambos
casos. El grafo se arma con la suma de todas las llamadas a
`add_edge`/`add_conditional_edges` antes de `compile()`, no importa en
qué secuencia se llamaron.
- **`TypedDict` no valida en runtime; Pydantic sí, en cada actualización de
estado.** Me había quedado la duda de qué significa "validación
recursiva" con Pydantic como estado — sí es lo que sospechaba: cada vez
que un nodo devuelve un update, si el estado es un modelo Pydantic, se
valida contra los tipos declarados en ese momento, no solo al construir
el estado inicial. Con `TypedDict` esa verificación no existe en
ejecución — es solo información para el type checker.
- **Un reducer decide cómo se combina el valor viejo con el nuevo, no lo
reemplaza por default.** Sin anotación, una clave se sobrescribe. Con
`Annotated[list[str], operator.add]` (o un reducer propio como
`append_strings(left, right)`), el update se acumula en vez de pisar el
valor anterior — así es como `messages` en `MessagesState` va creciendo
turno a turno en vez de perder el historial.
- **Ya tengo un primer borrador del estado para T18**, como `dataclass`:
`query: str`, `documents: list[Document]`, `answer: str`,
`messages: Annotated[list, add_messages]`, `rewritten_query: str` — con
`rewritten_query` ya pensando en T22 antes de empezar T18.

### Errores interesantes

- En el notebook, re-ejecutar la celda de `greet = with_exclamation(greet)`
varias veces sin reiniciar el kernel apiló wrappers uno sobre otro (el
output mostró tres `"EJECUTANDO"` y `"!!!"` en vez de uno) — cada
ejecución envolvía el `greet` ya envuelto de la ejecución anterior, no el
original. No es un bug de la función, es un recordatorio de que el
estado de un notebook persiste entre celdas y `x = f(x)` no es idempotente
si se re-corre la celda.

### ¿Qué no entendí bien?

- El mecanismo de flujo de la información ya que el patrón me muestra que la función que envuelve recibe los mismo argumentos de la función que quiero envolver, pero aún así, no asimilo muy bien cómo fluye la información ya que estoy acostrumbrado a un patrón más lineal (spaguetti)

---
41 changes: 41 additions & 0 deletions docs/work_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,44 @@
de preguntas en el ritual normal

---

## 2026-08-18

### Trabajo desarrollado
- `scripts/run_telegram_bot.py`: agregado `with_logging(answer_fn) -> AnswerFn`,
un wrapper que registra cada query entrante en `data/raw/queries.jsonl`
(timestamp UTC + texto) — insumo real para T24 (eval V1 vs V2 sin data
leakage). Corregida de paso la duplicación de `AnswerFn`: ahora se importa
de `telegram_bot.py` en vez de redefinirse
- `mypy` conectado a `make lint` — ya estaba como dependencia dev y con
config básica desde antes, pero nunca se ejecutaba. Corrida completa: 38
errores encontrados; arreglados los de configuración/ruido (`rank_bm25` y
`fitz` sin stubs de tipos) y dos `var-annotated` en `retrieval_service.py`;
quedan 34 errores reales (gaps de manejo de `None` en 6 archivos) sin
tocar, a la espera de decidir alcance
- `make run-bot` corregido: apuntaba a un módulo inexistente
(`researchos.infrastructure.bot.main`); ahora corre el script real
(`scripts/run_telegram_bot.py`)
- Verificado manualmente que el bot arranca sin errores con el wrapper de
logging activo: `data/raw/queries.jsonl` se crea al construir
`with_logging`, embedder/Chroma/BM25 se construyen sin fallas. Falta
confirmar con un mensaje real desde Telegram
- `notebooks/201-jmmz-langraph-study.ipynb`: práctica de `StateGraph` —
nodos, edges fijos y condicionales, ciclos (verificado quitando y
reordenando edges), reducers, y esquemas de estado separados
(`InputState`/`OutputState`/`PrivateState`)
- `docs/learnings.md`: entrada de hoy documenta el patrón wrapper/decorador
(con ejemplo propio del taller de retorno) y los conceptos de estado,
nodo y edge de LangGraph con evidencia de los experimentos del notebook 201

### Próximos pasos
- Decidir qué hacer con los 34 errores de mypy restantes (`arxiv.py`,
`telegram_bot.py`, `anthropic_llm.py`, `chroma.py`, `embedder.py`,
`ingestion_service.py`) — arreglar ahora, un subconjunto, o registrar
como deuda en `ROADMAP.md`
- Confirmar el logging de queries con un mensaje real por Telegram
- Arrancar T18 (LangGraph fundamentals) con el borrador de estado ya
escrito en el notebook 201 (`query`, `documents`, `answer`, `messages`,
`rewritten_query`)

---
Loading
Loading