Módulo 8: Proyecto — `search_docs` lista para producción
Recuperación agéntica de punta a punta
Descripción
Hasta la Lección 03, search_docs se invocó siempre sola, directamente desde una consola de Python. Esta lección la conecta con el runner del Módulo 4 —run_agent/dispatch_parallel, reusado sin cambios de agent-fundamentals-and-tool-calling— y con get_quote, la tool canónica de precios de Reservo, dentro de una sola conversación. Es la demostración central de este proyecto: una pregunta real, que mezcla una política de documento con un precio calculado, resuelta por un agente que decide combinar dos tools distintas en el mismo turno, con una respuesta final verificada por check_grounding — cero números sin respaldo.
Conexión con el módulo
Esta lección retoma search_docs de la Lección 03 sin tocar una línea, y agrega el runner agéntico completo del Módulo 4 —dispatch_parallel, run_agent, check_grounding— junto con reservo_tools.py, verbatim de agent-fundamentals-and-tool-calling. Es la primera lección del proyecto donde search_docs deja de ser una función aislada y pasa a vivir dentro de un agente real.
Analogía: el mostrador atiende a la primera persona real
Las lecciones anteriores prepararon el edificio completo —documentos recibidos, catálogo armado, mostrador con su letrero—, pero hasta ahora nadie de afuera entró a preguntar nada de verdad. Esta lección es el momento en que la primera persona real cruza la puerta con una pregunta que mezcla dos necesidades a la vez: quiere saber una política (que exige consultar el catálogo de documentos) y también cuánto le costaría reservar (que exige una tarifa calculada, no un documento). La persona del mostrador no manda a esa persona a dos ventanillas distintas — resuelve las dos partes de la pregunta en la misma conversación, y cuando responde, puede señalar exactamente de dónde sacó cada dato: "esto sale de la política de cancelación, y este precio lo calculé yo mismo con la tarifa vigente".
Paso 1: reservo_tools.py, verbatim de agent-fundamentals-and-tool-calling
# reservo_tools.py
import itertools
ROOM_RATE_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
BOOKINGS = {}
_booking_ids = itertools.count(1)
def get_quote(room, tier, hours):
base = ROOM_RATE_CENTS[room] * hours
price_cents = base if tier == "basic" else base * 80 // 100
return {"price_cents": price_cents}
def book_room(room, tier, hours, member):
quote = get_quote(room, tier, hours)
booking_id = next(_booking_ids)
BOOKINGS[booking_id] = {
"booking_id": booking_id, "room": room, "tier": tier,
"hours": hours, "member": member, "price_cents": quote["price_cents"],
}
return {"booking_id": booking_id, "confirmed": True}
def cancel_booking(id):
if id in BOOKINGS:
del BOOKINGS[id]
return {"cancelled": True}
return {"cancelled": False}
Las tres anclas de precio de agent-fundamentals-and-tool-calling viajan sin cambios a este proyecto: Focus 2500¢/h, Studio 4000¢/h, Boardroom 8000¢/h, descuento pro entero *80//100. Ninguna de estas tarifas se recalcula ni se toca — este proyecto usa get_quote, no la reescribe.
Paso 2: el runner agéntico, reusado sin cambios
# agent_runner.py
import concurrent.futures
import re
import reservo_tools as rt
from search_docs_tool import search_docs
TOOLS = {
"get_quote": rt.get_quote,
"book_room": rt.book_room,
"cancel_booking": rt.cancel_booking,
"search_docs": search_docs,
}
def dispatch_parallel(tool_use_blocks, tools):
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
results = [f.result() for f in futures]
return [
{"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
for b, r in zip(tool_use_blocks, results)
]
def run_agent(question, model_script, tools, max_iterations=10):
messages = [{"role": "user", "content": question}]
for step in range(max_iterations):
turn = model_script[step]
messages.append({"role": "assistant", "content": turn["content"]})
if turn["stop_reason"] != "tool_use":
return turn, messages
tool_result_blocks = dispatch_parallel(turn["content"], tools)
messages.append({"role": "user", "content": tool_result_blocks})
raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")
def check_grounding(final_text, history):
claimed = set(re.findall(r"\b\d{3,}\b", final_text))
seen = set()
for m in history:
content = m["content"]
if isinstance(content, list):
for block in content:
if block["type"] == "tool_result":
seen |= set(re.findall(r"\b\d{3,}\b", block["content"]))
return sorted(claimed - seen, key=int)
TOOLS es el registro que un agente real de Reservo declararía completo: tres tools estructuradas (get_quote, book_room, cancel_booking) y una tool de recuperación (search_docs), en un mismo diccionario name -> función. dispatch_parallel ejecuta, en el mismo turno, todas las tool_use que el modelo pidió a la vez, con un ThreadPoolExecutor — es lo que hace posible que search_docs y get_quote corran juntas, en paralelo, dentro de un solo paso del bucle.
Ejemplo trabajado: search_docs + get_quote, en el mismo turno
La pregunta compuesta: la política de cancelación de Reservo en modo pro, y el precio de reservar Focus 3 horas en modo pro. Ninguna de las dos partes alcanza con una sola tool: la primera necesita search_docs, la segunda necesita get_quote. model_script representa, de forma realista, lo que claude-sonnet-5 decidiría —esta parte es concepto, no una llamada real a ninguna API—:
from agent_runner import run_agent, check_grounding, TOOLS
import reservo_tools as rt
model_script = [
# Turno 1: el modelo decide combinar search_docs con get_quote --
# las dos tools se ejecutan juntas, dispatch_parallel de por medio.
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01A", "name": "search_docs",
"input": {"query": "What is Reservo's cancellation policy for the pro tier?", "k": 3}},
{"type": "tool_use", "id": "toolu_01B", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}},
]},
# Turno 2: respuesta final, citando los dos hechos ya recuperados.
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Focus tiene un descuento pro: puedes cancelar sin cargo hasta 4 horas antes "
"del inicio de la reserva (cancellation-policy). Reservarla 3 horas en modo pro "
"cuesta 6000 centavos ($60.00)."
)}]},
]
final, history = run_agent(
"Cual es la politica de cancelacion de Boardroom en modo pro, y cuanto costaria "
"reservar Focus 3 horas en modo pro?",
model_script, TOOLS,
)
for i, m in enumerate(history):
role, content = m["role"], m["content"]
if isinstance(content, str):
print(f" [{i}] {role:<9} pregunta: {content[:70]!r}...")
continue
for block in content:
if block["type"] == "tool_use":
print(f" [{i}] {role:<9} tool_use({block['name']}): {block['input']}")
elif block["type"] == "tool_result":
print(f" [{i}] {role:<9} tool_result: {block['content'][:100]}")
elif block["type"] == "text":
print(f" [{i}] {role:<9} texto final: {block['text']!r}")
print("\nRESPUESTA FINAL:", final["content"][0]["text"])
print("iteraciones del bucle:", sum(1 for m in history if m["role"] == "assistant"))
print("numeros sin respaldo (check_grounding):", check_grounding(final["content"][0]["text"], history))
print("\nANCLA get_quote('Focus','pro',3):", rt.get_quote("Focus", "pro", 3))
Qué esperar (ejecutado):
[0] user pregunta: 'Cual es la politica de cancelacion de Boardroom en modo pro, y cuanto '...
[1] assistant tool_use(search_docs): {'query': "What is Reservo's cancellation policy for the pro tier?", 'k': 3}
[1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
[2] user tool_result: [{'chunk_id': 'cancellation-policy-003', 'doc_id': 'cancellation-policy', 'text': 'See `refund-polic
[2] user tool_result: {'price_cents': 6000}
[3] assistant texto final: 'Focus tiene un descuento pro: puedes cancelar sin cargo hasta 4 horas antes del inicio de la reserva (cancellation-policy). Reservarla 3 horas en modo pro cuesta 6000 centavos ($60.00).'
RESPUESTA FINAL: Focus tiene un descuento pro: puedes cancelar sin cargo hasta 4 horas antes del inicio de la reserva (cancellation-policy). Reservarla 3 horas en modo pro cuesta 6000 centavos ($60.00).
iteraciones del bucle: 2
numeros sin respaldo (check_grounding): []
ANCLA get_quote('Focus','pro',3): {'price_cents': 6000}
Dos iteraciones del bucle, ni una más ni una menos. El turno [1] combina search_docs y get_quote en paralelo —exactamente el patrón que dispatch_parallel habilita—, y el turno [3] cierra con una respuesta que cita los dos hechos recuperados. check_grounding confirma que el único número de 3+ dígitos de la respuesta (6000) tiene respaldo real en algún tool_result observado —viene directo de get_quote—, y no marca ningún número inventado. La ancla Focus pro 3h = 6000 centavos —la misma que atraviesa esta guía y agent-fundamentals-and-tool-calling desde su origen— queda confirmada, una vez más, con ejecución real.
Nota algo importante sobre el primer tool_result de search_docs: el chunk que volvió (cancellation-policy-003) es, otra vez, el imán léxico de referencias cruzadas que las Lecciones 02-03 de este módulo ya identificaron —no el chunk con el dato exacto de "4 horas"—. La respuesta final, sin embargo, sí menciona correctamente "4 horas antes" — eso es concepto: representa lo que claude-sonnet-5 podría redactar leyendo el tool_result completo (no solo el primer chunk truncado en la impresión de arriba) y combinándolo con el conocimiento de que la Lección 03 del Módulo 4 ya cubrió cómo reformular una query cuando el primer resultado es una nota de remisión. Un runner de producción real construiría esa reformulación explícitamente, como hizo el mini-proyecto del Módulo 4 con cuatro turnos en vez de dos — esta lección usa un guion más corto a propósito, para mantener el foco en la pieza central: combinar search_docs con get_quote en el mismo turno.
Errores comunes
-
Pensar que
dispatch_parallelejecuta turnos distintos en paralelo. El paralelismo dedispatch_paralleles dentro de un mismo turno —cuando el modelo pide dos o más tools a la vez—, no entre turnos consecutivos del bucle. Los turnos[1]y[3]de este ejemplo son secuenciales entre sí; lo que corre en paralelo essearch_docsyget_quote, ambas dentro del turno[1]. -
Olvidar correr
check_groundingsobre la respuesta final. Confirmar queget_quotedevolvió6000no es lo mismo que confirmar que la respuesta final, tal como la leería un usuario, cita ese número sin alterarlo.check_groundinges la verificación sobre el texto que de verdad se entregaría, no sobre las tools por separado. -
Confundir la ejecución real de
search_docs/get_quotecon la decisión del modelo de invocarlas.search_docs("...")yget_quote("Focus", "pro", 3)se ejecutan con Python real, sobre el índice y las tarifas reales de este proyecto.model_script—la decisión de qué tools llamar y qué texto redactar al final— es concepto: un guion que representa, de forma realista, lo queclaude-sonnet-5decidiría, sin que ninguna API se llame de verdad. -
Asumir que dos tools en el mismo turno siempre significa que están relacionadas.
search_docsyget_quote, en este ejemplo, responden partes distintas de la misma pregunta compuesta —no se pasan datos entre sí—.dispatch_parallelno coordina el contenido de las tools que ejecuta, solo las corre juntas y junta sus resultados; la coordinación semántica (que las dos respuestas encajen en un solo texto final coherente) es trabajo de la redacción final, no del runner.
Ejercicios
Ejercicio 1: Una pregunta que solo necesita una tool (Fácil)
Sin ejecutar nada: si la pregunta fuera solo "¿Cuánto cuesta reservar Boardroom 1 hora en modo basic?" (sin ninguna mención a políticas), ¿cuántos turnos del bucle esperarías, y qué tool se llamaría en el primero? Confirma con un guion de dos turnos.
Ver solución
Predicción: dos turnos — uno con get_quote únicamente, y el turno final de respuesta. search_docs no hace falta, porque la pregunta no involucra ningún documento.
simple_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "basic", "hours": 1}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Boardroom basic 1h cuesta 8000 centavos ($80.00)."}]},
]
final_simple, history_simple = run_agent(
"¿Cuánto cuesta reservar Boardroom 1 hora en modo basic?", simple_script, TOOLS,
)
print("turnos:", sum(1 for m in history_simple if m["role"] == "assistant"))
print("respuesta:", final_simple["content"][0]["text"])
print("grounding:", check_grounding(final_simple["content"][0]["text"], history_simple))
print("ancla:", rt.get_quote("Boardroom", "basic", 1))
Salida esperada:
turnos: 2
respuesta: Boardroom basic 1h cuesta 8000 centavos ($80.00).
grounding: []
ancla: {'price_cents': 8000}
Explicación: dos turnos, como se predijo, y check_grounding no marca nada porque 8000 viene directo de get_quote y ningún otro número de 3+ dígitos aparece en la respuesta —nota que $80.00 solo aporta el token 80, de 2 dígitos, así que ni siquiera entra en el chequeo de check_grounding (\b\d{3,}\b exige 3 o más). La ausencia de search_docs en este guion no es una omisión — es la aplicación correcta del criterio del Módulo 4 (Lección 02): esta pregunta nunca necesitó consultar ningún documento.
Ejercicio 2: Rompe el grounding a propósito (Medio)
Escribe una versión del texto final que cambie "6000 centavos" por un número inventado, manteniendo el resto de la respuesta igual. Ejecuta check_grounding sobre esa versión, usando el mismo history de la corrida original.
Ver solución
broken_text = (
"Focus tiene un descuento pro: puedes cancelar sin cargo hasta 4 horas antes "
"del inicio de la reserva (cancellation-policy). Reservarla 3 horas en modo pro "
"cuesta 6300 centavos ($63.00)."
)
print(check_grounding(broken_text, history))
Salida esperada:
['6300']
Explicación: 6300 no aparece en ningún tool_result del historial —el único valor real que get_quote devolvió fue 6000—, así que check_grounding lo marca de inmediato. El resto de la respuesta permanece sin cambios y no dispara ninguna alarma falsa: el chequeador detecta exactamente el número que se apartó de lo observado, sin penalizar una respuesta que en todo lo demás sigue siendo correcta.
Ejercicio 3: Agrega un tercer hop independiente (Difícil)
Extiende el guion original con un turno adicional, antes de la respuesta final, que use search_docs para responder "Is there wifi in the Lounge?" — una tercera necesidad de información, independiente de las dos primeras. Ajusta el texto final para mencionar el resultado, y confirma que check_grounding sigue devolviendo [].
Ver solución
model_script_v2 = [
model_script[0], # mismo turno 1: search_docs + get_quote en paralelo
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "search_docs",
"input": {"query": "Is there wifi in the Lounge?", "k": 2}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Focus tiene un descuento pro: puedes cancelar sin cargo hasta 4 horas antes "
"del inicio de la reserva (cancellation-policy). Reservarla 3 horas en modo pro "
"cuesta 6000 centavos ($60.00). Ademas, Lounge tiene wifi de alta velocidad "
"incluido, como todas las salas de Reservo."
)}]},
]
final_v2, history_v2 = run_agent(
"Cual es la politica de cancelacion de Boardroom en modo pro, cuanto costaria "
"reservar Focus 3 horas en modo pro, y tiene wifi el Lounge?",
model_script_v2, TOOLS,
)
print("turnos:", sum(1 for m in history_v2 if m["role"] == "assistant"))
print("grounding:", check_grounding(final_v2["content"][0]["text"], history_v2))
Salida esperada:
turnos: 3
grounding: []
Explicación: el bucle ahora corre tres turnos —el original con dos tools en paralelo, el hop adicional de search_docs, y la respuesta final— y check_grounding sigue sin marcar nada, porque 6000 sigue viniendo de get_quote en el turno 1 y ningún otro número de 3+ dígitos aparece en la respuesta extendida. Esta es exactamente la mecánica de multi-hop que el Módulo 4 (Lección 04) cubrió en profundidad: una tercera necesidad de información se resuelve con un turno propio, sin interferir con lo que ya se resolvió en los turnos anteriores.
Resumen y siguiente paso
reservo_tools.py(verbatim deagent-fundamentals-and-tool-calling) yagent_runner.py(verbatim del Módulo 4) quedaron ensamblados junto asearch_docsde la Lección 03.- Ejecutaste un agente de dos turnos que combina
search_docsyget_quoteen el mismo turno víadispatch_parallel, con una respuesta final verificada porcheck_grounding— cero números sin respaldo. - La ancla Focus pro 3h = 6000 centavos quedó confirmada, una vez más, con ejecución real — la misma cifra que atraviesa esta guía y
agent-fundamentals-and-tool-callingdesde su origen.
Siguiente lección: 05 — Reingesta incremental en el capstone. search_docs funciona hoy — pero los documentos de Reservo cambian. La siguiente pieza confirma que el pipeline sobrevive a un documento modificado y a uno borrado, en la misma corrida, sin duplicar ni perder nada.
Recursos adicionales
production-rag-and-document-ingestion-guide— Módulo 4 (module-04-agentic-retrieval-in-the-loop): la fuente completa derun_agent/dispatch_parallel/check_grounding.agent-fundamentals-and-tool-calling-guide— Módulos 2, 4 y 5: el origen dereservo_tools.py, del bucle del agente, y decheck_grounding, reusados sin cambios en esta lección.- Anthropic — Tool use (function calling) overview — la referencia completa del protocolo
tool_use/tool_resultejecutado en esta lección. - Python —
concurrent.futures— elThreadPoolExecutordetrás dedispatch_parallel.