Código fuente del curso
Las 11 lecciones, en orden. Cada fichero es ejecutable y autocontenido; puedes leerlo aquí o descargarlo.
01_minimo.pydescargar
""" 01 - Tool calling al desnudo (sin internet). Objetivo: VER el baile entre el programa y el modelo. Se usa una herramienta tonta -una calculadora- para que no haya magia oculta. El flujo es SIEMPRE el mismo, da igual que la herramienta sea sumar o buscar en Google: 1. El programa describe las herramientas que existen (nombre, para qué sirven, parámetros). 2. Se le pasa la pregunta del usuario + esa lista de herramientas al modelo. 3. El modelo NO ejecuta nada. Si cree que necesita una herramienta, devuelve un JSON: "llama a 'sumar' con a=25, b=17". 4. El programa ejecuta la función de verdad. 5. Se le devuelve el resultado al modelo (como un mensaje de rol 'tool'). 6. El modelo redacta la respuesta final en lenguaje natural. """ import ollama MODELO = "qwen2.5:14b-instruct-q4_K_M" # --- PASO 0: las funciones reales de Python que el modelo podrá "pedir" --------- def sumar(a: int, b: int) -> int: print(f" [Python ejecuta de verdad] sumar({a}, {b})") return a + b # Mapa nombre-de-herramienta -> función real. Se usa para despachar. FUNCIONES = {"sumar": sumar} # --- PASO 1: describir las herramientas al modelo ------------------------------- # Esto es un "menú". El modelo lee la descripción y los parámetros para decidir # si la necesita y con qué valores llamarla. La descripción IMPORTA mucho: # es lo único que el modelo tiene para entender cuándo usarla. TOOLS = [ { "type": "function", "function": { "name": "sumar", "description": "Suma dos números enteros y devuelve el resultado.", "parameters": { "type": "object", "properties": { "a": {"type": "integer", "description": "Primer sumando"}, "b": {"type": "integer", "description": "Segundo sumando"}, }, "required": ["a", "b"], }, }, } ] def main(): # El historial de la conversación. Se van añadiendo mensajes aquí. messages = [ {"role": "user", "content": "¿Cuánto es 25 + 17? Usa la herramienta."} ] print("PASO 2: se envía la pregunta + herramientas al modelo...") respuesta = ollama.chat(model=MODELO, messages=messages, tools=TOOLS) messages.append(respuesta.message) # se guarda lo que respondió el modelo # PASO 3: ¿el modelo ha pedido usar una herramienta? if not respuesta.message.tool_calls: print("El modelo respondió directamente, sin herramientas:") print(respuesta.message.content) return print("PASO 3: el modelo NO contesta, pide ejecutar herramientas:") for tc in respuesta.message.tool_calls: print(f" -> quiere llamar a '{tc.function.name}' con {dict(tc.function.arguments)}") # PASO 4: el código ejecuta la función real funcion = FUNCIONES[tc.function.name] resultado = funcion(**tc.function.arguments) # PASO 5: se devuelve el resultado al modelo como mensaje 'tool' messages.append({ "role": "tool", "name": tc.function.name, "content": str(resultado), }) # PASO 6: el modelo redacta la respuesta final ya con el resultado en mano print("\nPASO 6: el modelo redacta la respuesta final con el resultado...") final = ollama.chat(model=MODELO, messages=messages, tools=TOOLS) print("\nRESPUESTA FINAL:") print(final.message.content) if __name__ == "__main__": main()
02_busqueda_web.pydescargar
""" 02 - Tool calling REAL: el modelo busca en internet lo que no sabe. Mismo mecanismo que 01, pero: - La herramienta ahora es 'buscar_web' (DuckDuckGo, sin API key). - Se usa un BUCLE: el modelo puede pedir varias búsquedas seguidas antes de responder. Un agente de verdad casi nunca resuelve en un solo turno. Cómo se "entera de lo que no sabe": El modelo fue entrenado con fecha de corte. Si le preguntas algo posterior o muy específico, no lo sabe -> se describe una herramienta de búsqueda y él decide llamarla. Se le devuelven los resultados y responde basándose en ellos. """ import ollama from ddgs import DDGS # Generalista razona mejor CUÁNDO buscar. Puede cambiarse a "qwen2.5-coder:14b-instruct-q4_K_M" # si las preguntas son técnicas/de código. MODELO = "qwen2.5:14b-instruct-q4_K_M" # --- La función real de búsqueda ------------------------------------------------ def buscar_web(query: str) -> str: print(f" [Python busca en DuckDuckGo] '{query}'") with DDGS() as ddgs: resultados = list(ddgs.text(query, max_results=5)) if not resultados: return "No se encontraron resultados." # Se devuelve texto compacto: título + resumen + url. Esto es lo que "lee" el modelo. return "\n\n".join( f"[{i+1}] {r['title']}\n{r['body']}\nFuente: {r['href']}" for i, r in enumerate(resultados) ) FUNCIONES = {"buscar_web": buscar_web} TOOLS = [ { "type": "function", "function": { "name": "buscar_web", "description": ( "Busca información actualizada en internet. Úsala SIEMPRE que la " "pregunta trate sobre hechos recientes, fechas, versiones, precios, " "noticias o cualquier cosa que no sepas con certeza." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Términos de búsqueda, concisos y en el idioma adecuado.", } }, "required": ["query"], }, }, } ] SYSTEM = ( "Eres un asistente útil. Si no estás seguro de un dato o puede haber cambiado " "tras tu entrenamiento, USA la herramienta buscar_web antes de responder. " "Cita las fuentes (URLs) que uses en tu respuesta final." ) def responder(pregunta: str, max_turnos: int = 5): messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] for turno in range(max_turnos): respuesta = ollama.chat(model=MODELO, messages=messages, tools=TOOLS) messages.append(respuesta.message) # Si no pide herramientas, ya tiene la respuesta final. if not respuesta.message.tool_calls: print("\nRESPUESTA FINAL:\n") print(respuesta.message.content) return # Se ejecuta cada herramienta pedida y se le devuelve el resultado. for tc in respuesta.message.tool_calls: funcion = FUNCIONES.get(tc.function.name) if funcion is None: resultado = f"Error: herramienta desconocida '{tc.function.name}'" else: resultado = funcion(**tc.function.arguments) messages.append({ "role": "tool", "name": tc.function.name, "content": str(resultado), }) print("Se alcanzó el máximo de turnos sin respuesta final.") if __name__ == "__main__": # Una pregunta que el modelo NO puede saber de memoria: responder("¿Qué versión estable de Python es la más reciente y cuándo salió?")
03_busqueda_searxng.pydescargar
""" 03 - Tool calling 100% soberano: el modelo busca a través de un SearXNG local. Idéntico a 02_busqueda_web.py salvo una cosa: la función buscar_web ya no llama a DuckDuckGo, sino a una instancia local de SearXNG en http://localhost:8888. Eso demuestra lo importante del tool calling: el modelo y el bucle NO cambian. Solo cambia el CUERPO de la herramienta. Hoy SearXNG; mañana podría ser una base de datos de expedientes o cualquier API interna. El patrón es el mismo. Requisito: SearXNG levantado -> cd searxng && docker compose up -d """ import requests import ollama MODELO = "qwen2.5:14b-instruct-q4_K_M" SEARXNG_URL = "http://localhost:8888/search" # --- La función real: ahora pega a SearXNG, todo dentro de casa ----------------- def buscar_web(query: str) -> str: print(f" [Python consulta SearXNG local] '{query}'") resp = requests.get( SEARXNG_URL, params={"q": query, "format": "json"}, timeout=20, ) resp.raise_for_status() resultados = resp.json().get("results", [])[:5] if not resultados: return "No se encontraron resultados." return "\n\n".join( f"[{i+1}] {r.get('title','')}\n{r.get('content','')}\nFuente: {r.get('url','')}" for i, r in enumerate(resultados) ) FUNCIONES = {"buscar_web": buscar_web} TOOLS = [ { "type": "function", "function": { "name": "buscar_web", "description": ( "Busca información actualizada en internet a través de un metabuscador " "privado. Úsala SIEMPRE que la pregunta trate sobre hechos recientes, " "fechas, versiones, precios, noticias o algo que no sepas con certeza." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Términos de búsqueda, concisos.", } }, "required": ["query"], }, }, } ] SYSTEM = ( "Eres un asistente útil. Si no estás seguro de un dato o puede haber cambiado " "tras tu entrenamiento, USA la herramienta buscar_web antes de responder. " "Cita las fuentes (URLs) que uses en tu respuesta final." ) def responder(pregunta: str, max_turnos: int = 5): messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] for _ in range(max_turnos): respuesta = ollama.chat(model=MODELO, messages=messages, tools=TOOLS) messages.append(respuesta.message) if not respuesta.message.tool_calls: print("\nRESPUESTA FINAL:\n") print(respuesta.message.content) return for tc in respuesta.message.tool_calls: funcion = FUNCIONES.get(tc.function.name) resultado = ( funcion(**tc.function.arguments) if funcion else f"Error: herramienta desconocida '{tc.function.name}'" ) messages.append({ "role": "tool", "name": tc.function.name, "content": str(resultado), }) print("Se alcanzó el máximo de turnos sin respuesta final.") if __name__ == "__main__": responder("¿Qué versión estable de Python es la más reciente y cuándo salió?")
04_agente.pydescargar
""" 04 - Agente de 2 herramientas (soberano, modelo que cabe en GPU). Mejoras sobre el 03: (C.1) System prompt afinado: se le dice cómo razonar sobre los resultados (p. ej. ante varias versiones, la más reciente es el número más alto) y se le pide que LEA la fuente antes de afirmar fechas/datos. (C.2) Segunda herramienta 'leer_pagina(url)': descarga el contenido real de una página. Así el modelo no depende de los títulos sueltos de la búsqueda; busca -> elige la mejor fuente -> la lee entera -> responde. Modelo: qwen2.5-coder:14b-instruct-q4_K_M (~9 GB -> cabe entero en la GPU de un M4 Pro de 24 GB, sin volcado a CPU, inferencia rápida). Requisito: SearXNG levantado -> cd searxng && docker compose up -d """ import json import re import requests import ollama from bs4 import BeautifulSoup MODELO = "qwen2.5:14b-instruct-q4_K_M" # generalista: tool calling nativo fiable SEARXNG_URL = "http://localhost:8888/search" # --- Herramienta 1: buscar ----------------------------------------------------- def buscar_web(query: str) -> str: print(f" [buscar_web] '{query}'") resp = requests.get(SEARXNG_URL, params={"q": query, "format": "json"}, timeout=20) resp.raise_for_status() resultados = resp.json().get("results", [])[:5] if not resultados: return "No se encontraron resultados." return "\n\n".join( f"[{i+1}] {r.get('title','')}\n{r.get('content','')}\nURL: {r.get('url','')}" for i, r in enumerate(resultados) ) # --- Herramienta 2: leer el contenido real de una página ----------------------- def leer_pagina(url: str) -> str: print(f" [leer_pagina] {url}") try: resp = requests.get(url, timeout=20, headers={"User-Agent": "Mozilla/5.0"}) resp.raise_for_status() except Exception as e: return f"Error al descargar la página: {e}" soup = BeautifulSoup(resp.text, "lxml") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() texto = " ".join(soup.get_text(separator=" ").split()) return texto[:4000] # se recorta para no saturar el contexto FUNCIONES = {"buscar_web": buscar_web, "leer_pagina": leer_pagina} TOOLS = [ { "type": "function", "function": { "name": "buscar_web", "description": ( "Busca en internet y devuelve una lista de resultados con título, " "resumen y URL. Úsala para localizar fuentes relevantes." ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Términos de búsqueda."} }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "leer_pagina", "description": ( "Descarga y devuelve el texto real de una página web dada su URL. " "Úsala tras buscar, para leer la mejor fuente y confirmar datos " "concretos (fechas, versiones, cifras) antes de responder." ), "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "URL de la página a leer."} }, "required": ["url"], }, }, }, ] SYSTEM = ( "Eres un asistente riguroso con acceso a internet mediante herramientas.\n" "Procedimiento:\n" "1. Si la pregunta trata de hechos recientes, versiones, fechas o cifras, " "usa buscar_web.\n" "2. No te fíes solo de los títulos/resúmenes: elige la fuente más fiable " "(prioriza sitios oficiales) y usa leer_pagina para leer su contenido real.\n" "3. Si aparecen varios números de versión, la MÁS RECIENTE es la de número " "más alto (p. ej. 3.14 es más nueva que 3.12).\n" "4. Responde de forma concreta y cita las URLs usadas.\n" "Nunca inventes fechas ni datos: si no los confirmaste leyendo, dilo." ) def llamadas_fugadas_en_texto(contenido: str): """Algunos modelos (qwen, coder...) escriben la llamada como texto en vez de usar el campo tool_calls. Aquí se rescatan esos JSON {"name":..,"arguments":..} -con o sin etiquetas <tool_call>- para que el agente sea robusto.""" if not contenido: return [] llamadas = [] for bloque in re.findall(r'\{[^{}]*"name"[^{}]*\{[^{}]*\}[^{}]*\}', contenido): try: obj = json.loads(bloque) if "name" in obj and "arguments" in obj: llamadas.append((obj["name"], obj["arguments"])) except json.JSONDecodeError: continue return llamadas def responder(pregunta: str, max_turnos: int = 8): messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] for _ in range(max_turnos): respuesta = ollama.chat( model=MODELO, messages=messages, tools=TOOLS, options={"temperature": 0}, # determinista, evita tokens basura ) messages.append(respuesta.message) # 1) Llamadas por el canal correcto (campo estructurado tool_calls) pendientes = [ (tc.function.name, tc.function.arguments) for tc in (respuesta.message.tool_calls or []) ] # 2) Si no hay, se rescatan llamadas fugadas al texto if not pendientes: pendientes = llamadas_fugadas_en_texto(respuesta.message.content) # Sin llamadas en ningún canal -> es la respuesta final if not pendientes: print("\nRESPUESTA FINAL:\n") print(respuesta.message.content) return for nombre, args in pendientes: funcion = FUNCIONES.get(nombre) resultado = ( funcion(**args) if funcion else f"Error: herramienta desconocida '{nombre}'" ) messages.append({"role": "tool", "name": nombre, "content": str(resultado)}) print("Se alcanzó el máximo de turnos sin respuesta final.") if __name__ == "__main__": responder("¿Qué versión estable de Python es la más reciente y cuándo salió?")
05_multiherramienta.pydescargar
""" 05 - Cuatro herramientas: ¿sabe el modelo ELEGIR la adecuada? Se le dan 4 herramientas muy distintas y preguntas de distinta naturaleza. Se observa qué herramienta(s) elige en cada caso. La traza [--> usa: X] deja VER cada decisión del modelo. Herramientas: - buscar_web(query) -> internet (SearXNG local) - leer_pagina(url) -> contenido real de una URL - calcular(expresion) -> aritmética, en local, sin red - fecha_y_hora() -> fecha/hora del sistema, sin argumentos Requisito: SearXNG levantado -> cd searxng && docker compose up -d """ import ast import json import operator import re from datetime import datetime import requests import ollama from bs4 import BeautifulSoup MODELO = "qwen2.5:14b-instruct-q4_K_M" SEARXNG_URL = "http://localhost:8888/search" # --- Herramienta 1: buscar ----------------------------------------------------- def buscar_web(query: str) -> str: resp = requests.get(SEARXNG_URL, params={"q": query, "format": "json"}, timeout=20) resp.raise_for_status() resultados = resp.json().get("results", [])[:5] if not resultados: return "No se encontraron resultados." return "\n\n".join( f"[{i+1}] {r.get('title','')}\n{r.get('content','')}\nURL: {r.get('url','')}" for i, r in enumerate(resultados) ) # --- Herramienta 2: leer página ------------------------------------------------ def leer_pagina(url: str) -> str: try: resp = requests.get(url, timeout=20, headers={"User-Agent": "Mozilla/5.0"}) resp.raise_for_status() except Exception as e: return f"Error al descargar la página: {e}" soup = BeautifulSoup(resp.text, "lxml") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() return " ".join(soup.get_text(separator=" ").split())[:4000] # --- Herramienta 3: calcular (evaluador aritmético seguro, sin eval) ----------- _OPS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod, ast.USub: operator.neg, } def _eval(nodo): if isinstance(nodo, ast.Constant): return nodo.value if isinstance(nodo, ast.BinOp): return _OPS[type(nodo.op)](_eval(nodo.left), _eval(nodo.right)) if isinstance(nodo, ast.UnaryOp): return _OPS[type(nodo.op)](_eval(nodo.operand)) raise ValueError("expresión no permitida") def calcular(expresion: str) -> str: try: return str(_eval(ast.parse(expresion, mode="eval").body)) except Exception as e: return f"Error al calcular '{expresion}': {e}" # --- Herramienta 4: fecha y hora ----------------------------------------------- def fecha_y_hora() -> str: return datetime.now().strftime("%Y-%m-%d %H:%M:%S (%A)") FUNCIONES = { "buscar_web": buscar_web, "leer_pagina": leer_pagina, "calcular": calcular, "fecha_y_hora": fecha_y_hora, } TOOLS = [ {"type": "function", "function": { "name": "buscar_web", "description": "Busca en internet y devuelve resultados (título, resumen, URL). " "Úsala para hechos recientes, noticias, versiones, datos que no sepas.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Términos de búsqueda."}}, "required": ["query"]}}}, {"type": "function", "function": { "name": "leer_pagina", "description": "Descarga el texto real de una página web dada su URL. Úsala tras buscar, " "para confirmar datos concretos en la fuente.", "parameters": {"type": "object", "properties": {"url": {"type": "string", "description": "URL a leer."}}, "required": ["url"]}}}, {"type": "function", "function": { "name": "calcular", "description": "Evalúa una expresión aritmética (+,-,*,/,**,%) y devuelve el resultado. " "Úsala SIEMPRE para cálculos numéricos en lugar de calcular tú mismo.", "parameters": {"type": "object", "properties": {"expresion": {"type": "string", "description": "Expresión, ej. '1234 * 5678'."}}, "required": ["expresion"]}}}, {"type": "function", "function": { "name": "fecha_y_hora", "description": "Devuelve la fecha y hora actuales del sistema. Úsala cuando necesites " "saber 'hoy', 'ahora', el día de la semana o para cálculos con fechas.", "parameters": {"type": "object", "properties": {}}}}, ] SYSTEM = ( "Eres un asistente riguroso con varias herramientas. Elige la adecuada a cada " "pregunta. No calcules ni adivines fechas de memoria: usa calcular y fecha_y_hora. " "Para hechos de internet usa buscar_web y confirma en la fuente con leer_pagina. " "Si una tarea requiere varios pasos, encadena varias herramientas. " "Responde de forma concreta." ) def llamadas_fugadas_en_texto(contenido: str): if not contenido: return [] llamadas = [] for bloque in re.findall(r'\{[^{}]*"name"[^{}]*\{[^{}]*\}[^{}]*\}', contenido): try: obj = json.loads(bloque) if "name" in obj and "arguments" in obj: llamadas.append((obj["name"], obj["arguments"])) except json.JSONDecodeError: continue return llamadas def responder(pregunta: str, max_turnos: int = 8): print(f"\n{'='*70}\nPREGUNTA: {pregunta}\n{'='*70}") messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] for _ in range(max_turnos): respuesta = ollama.chat( model=MODELO, messages=messages, tools=TOOLS, options={"temperature": 0}, ) messages.append(respuesta.message) pendientes = [(tc.function.name, tc.function.arguments) for tc in (respuesta.message.tool_calls or [])] if not pendientes: pendientes = llamadas_fugadas_en_texto(respuesta.message.content) if not pendientes: print(f"\nRESPUESTA FINAL:\n{respuesta.message.content}") return for nombre, args in pendientes: print(f" --> usa: {nombre}({dict(args)})") funcion = FUNCIONES.get(nombre) resultado = (funcion(**args) if funcion else f"Error: herramienta desconocida '{nombre}'") messages.append({"role": "tool", "name": nombre, "content": str(resultado)}) print("Se alcanzó el máximo de turnos sin respuesta final.") if __name__ == "__main__": # Cuatro preguntas que deberían activar herramientas distintas: responder("¿Cuánto es 1234 * 5678 + 99?") # -> calcular responder("¿Qué día de la semana es hoy?") # -> fecha_y_hora responder("¿Cuál es la última versión estable de Python?") # -> buscar_web (+ leer_pagina) responder("¿Cuántos años hace que naciste si naces en 1991 " # -> fecha_y_hora + calcular "y estamos en el año actual?")
06_solapadas.pydescargar
""" 06 - EXPERIMENTO: descripciones solapadas. ¿Se confunde el modelo? A propósito se meten TRES herramientas de búsqueda casi sinónimas, con descripciones que se pisan. Las tres hacen lo mismo (consultan SearXNG), pero se quiere ver QUÉ elige el modelo y si es CONSISTENTE entre preguntas parecidas. Hipótesis: con descripciones solapadas el modelo elige de forma arbitraria o inconsistente, porque no hay un disparador claro que las distinga. Cada herramienta marca su resultado con [origen: NOMBRE] para ver cuál usó. Requisito: SearXNG levantado -> cd searxng && docker compose up -d """ import json import re import requests import ollama MODELO = "qwen2.5:14b-instruct-q4_K_M" SEARXNG_URL = "http://localhost:8888/search" def _buscar(nombre_origen: str, query: str) -> str: resp = requests.get(SEARXNG_URL, params={"q": query, "format": "json"}, timeout=20) resp.raise_for_status() resultados = resp.json().get("results", [])[:3] cuerpo = " | ".join(r.get("title", "")[:50] for r in resultados) or "sin resultados" return f"[origen: {nombre_origen}] {cuerpo}" # Tres funciones distintas... que hacen EXACTAMENTE lo mismo. def buscar_web(query: str) -> str: return _buscar("buscar_web", query) def buscar_datos(query: str) -> str: return _buscar("buscar_datos", query) def buscar_general(query: str) -> str: return _buscar("buscar_general", query) FUNCIONES = { "buscar_web": buscar_web, "buscar_datos": buscar_datos, "buscar_general": buscar_general, } # Descripciones SOLAPADAS a propósito: las tres sirven para casi todo. TOOLS = [ {"type": "function", "function": { "name": "buscar_web", "description": "Busca información actualizada en internet.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Términos de búsqueda."}}, "required": ["query"]}}}, {"type": "function", "function": { "name": "buscar_datos", "description": "Busca datos y hechos en internet. Úsala para versiones, fechas y cifras.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Términos de búsqueda."}}, "required": ["query"]}}}, {"type": "function", "function": { "name": "buscar_general", "description": "Búsqueda general en internet para responder cualquier pregunta.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Términos de búsqueda."}}, "required": ["query"]}}}, ] SYSTEM = "Eres un asistente con acceso a internet. Usa una herramienta de búsqueda para responder." def llamadas_fugadas_en_texto(contenido: str): if not contenido: return [] out = [] for b in re.findall(r'\{[^{}]*"name"[^{}]*\{[^{}]*\}[^{}]*\}', contenido): try: o = json.loads(b) if "name" in o and "arguments" in o: out.append((o["name"], o["arguments"])) except json.JSONDecodeError: continue return out def responder(pregunta: str, max_turnos: int = 5): print(f"\n{'='*70}\nPREGUNTA: {pregunta}\n{'='*70}") messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] elegidas = [] for _ in range(max_turnos): r = ollama.chat(model=MODELO, messages=messages, tools=TOOLS, options={"temperature": 0}) messages.append(r.message) pend = [(tc.function.name, tc.function.arguments) for tc in (r.message.tool_calls or [])] if not pend: pend = llamadas_fugadas_en_texto(r.message.content) if not pend: print(f" herramientas elegidas: {elegidas}") return for nombre, args in pend: elegidas.append(nombre) print(f" --> elige: {nombre}") f = FUNCIONES.get(nombre) res = f(**args) if f else f"Error: '{nombre}' desconocida" messages.append({"role": "tool", "name": nombre, "content": str(res)}) if __name__ == "__main__": # Preguntas parecidas: ¿elegirá siempre la misma herramienta? ¿la coherente? responder("¿Cuál es la última versión estable de Python?") # 'versiones' apunta a buscar_datos responder("¿Cuál es la última versión estable de Node.js?") # idéntica naturaleza responder("¿Quién ganó el último Mundial de fútbol?") # no es 'versión/fecha/cifra' responder("¿Cuántos habitantes tiene Madrid?") # 'cifra' -> ¿buscar_datos?
07_expedientes.pydescargar
""" 07 - Una herramienta que consulta una base de datos de expedientes. Mismo patrón de siempre; solo cambia el cuerpo de la herramienta: en vez de internet, consulta una BD. Aquí es SQLite de demo (expedientes.db); en producción se cambia SOLO la función de conexión por un Oracle/Postgres real (ver NOTA). Seguridad: NO se deja que el modelo escriba SQL libre. Se exponen consultas ACOTADAS con parámetros (los filtros usan enum). Así el modelo elige el QUÉ, pero el CÓMO (el SQL) lo controla el código. Es la regla de oro: prompt sugiere, código garantiza. Requisitos: - .venv/bin/python crear_expedientes_db.py (crea la BD de demo) - Ollama corriendo con qwen2.5:14b-instruct-q4_K_M """ import json import re import sqlite3 from pathlib import Path import ollama MODELO = "qwen2.5:14b-instruct-q4_K_M" DB = Path(__file__).parent / "expedientes.db" ESTADOS = ["abierto", "en_tramite", "cerrado", "archivado"] TIPOS = ["inspeccion", "licencia", "incidente", "sancion"] # --- NOTA: para una BD real, cambia SOLO esta función -------------------------- # def _conectar(): # import oracledb # o psycopg2 para Postgres # return oracledb.connect(user=..., password=..., dsn=...) # y ajusta los nombres de tabla/columnas en las consultas de abajo. def _conectar(): con = sqlite3.connect(DB) con.row_factory = sqlite3.Row return con # --- Herramienta 1: buscar con filtros (SQL parametrizado, seguro) ------------- def buscar_expedientes(estado: str = None, tipo: str = None, anio: int = None, responsable: str = None, limite: int = 10) -> str: sql = "SELECT codigo, titulo, tipo, estado, fecha_apertura, responsable, instalacion FROM expedientes WHERE 1=1" params = [] if estado: sql += " AND estado = ?"; params.append(estado) if tipo: sql += " AND tipo = ?"; params.append(tipo) if anio: sql += " AND fecha_apertura LIKE ?"; params.append(f"{anio}-%") if responsable: sql += " AND responsable LIKE ?"; params.append(f"%{responsable}%") sql += " ORDER BY fecha_apertura DESC LIMIT ?"; params.append(min(int(limite), 50)) con = _conectar() filas = con.execute(sql, params).fetchall() con.close() if not filas: return "No se encontraron expedientes con esos criterios." return "\n".join( f"{f['codigo']} | {f['estado']} | {f['tipo']} | {f['fecha_apertura']} | " f"{f['responsable']} | {f['instalacion']} | {f['titulo']}" for f in filas ) # --- Herramienta 2: detalle de un expediente concreto -------------------------- def detalle_expediente(codigo: str) -> str: con = _conectar() f = con.execute("SELECT * FROM expedientes WHERE codigo = ?", [codigo]).fetchone() con.close() if not f: return f"No existe el expediente {codigo}." return json.dumps(dict(f), ensure_ascii=False, indent=2) FUNCIONES = {"buscar_expedientes": buscar_expedientes, "detalle_expediente": detalle_expediente} TOOLS = [ {"type": "function", "function": { "name": "buscar_expedientes", "description": "Busca expedientes en la base de datos aplicando filtros opcionales " "(estado, tipo, año de apertura, responsable). Devuelve una lista resumida. " "Úsala para preguntas como 'expedientes abiertos de 2025' o 'inspecciones de Marta'.", "parameters": {"type": "object", "properties": { "estado": {"type": "string", "enum": ESTADOS, "description": "Estado del expediente."}, "tipo": {"type": "string", "enum": TIPOS, "description": "Tipo de expediente."}, "anio": {"type": "integer", "description": "Año de apertura, ej. 2025."}, "responsable": {"type": "string", "description": "Nombre (o parte) del responsable."}, "limite": {"type": "integer", "description": "Máximo de resultados (por defecto 10)."}, }}}}, {"type": "function", "function": { "name": "detalle_expediente", "description": "Devuelve TODOS los datos de un expediente concreto a partir de su código " "(ej. 'EXP-2025-008'). Úsala cuando ya conoces el código y quieres el detalle.", "parameters": {"type": "object", "properties": { "codigo": {"type": "string", "description": "Código del expediente, ej. EXP-2025-008."}, }, "required": ["codigo"]}}}, ] SYSTEM = ( "Eres un asistente del registro de expedientes. Responde SOLO con datos " "obtenidos de las herramientas; nunca inventes expedientes ni datos. " "Usa buscar_expedientes para localizar y detalle_expediente para profundizar " "en uno concreto. Responde de forma clara y en español." ) def llamadas_fugadas_en_texto(contenido: str): if not contenido: return [] out = [] for b in re.findall(r'\{[^{}]*"name"[^{}]*\{[^{}]*\}[^{}]*\}', contenido): try: o = json.loads(b) if "name" in o and "arguments" in o: out.append((o["name"], o["arguments"])) except json.JSONDecodeError: continue return out def responder(pregunta: str, max_turnos: int = 6): print(f"\n{'='*70}\nPREGUNTA: {pregunta}\n{'='*70}") messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] for _ in range(max_turnos): r = ollama.chat(model=MODELO, messages=messages, tools=TOOLS, options={"temperature": 0}) messages.append(r.message) pend = [(tc.function.name, tc.function.arguments) for tc in (r.message.tool_calls or [])] if not pend: pend = llamadas_fugadas_en_texto(r.message.content) if not pend: print(f"\nRESPUESTA FINAL:\n{r.message.content}") return for nombre, args in pend: print(f" --> {nombre}({dict(args)})") f = FUNCIONES.get(nombre) res = f(**args) if f else f"Error: '{nombre}' desconocida" messages.append({"role": "tool", "name": nombre, "content": str(res)}) if __name__ == "__main__": responder("¿Qué expedientes de tipo incidente están abiertos?") responder("Dame los expedientes abiertos en 2026.") responder("¿Cuántos expedientes lleva Marta Ruiz y de qué tipos?") responder("Dame el detalle del expediente EXP-2025-008.")
crear_expedientes_db.pydescargar
""" Crea y puebla una BD de DEMO de expedientes en SQLite (expedientes.db). Sin dependencias externas: sqlite3 viene en la stdlib. Esquema genérico de un registro de expedientes administrativos (inspecciones de establecimientos municipales). En producción, se sustituiría esto por un Oracle/Postgres real; el resto del agente NO cambia. """ import sqlite3 from pathlib import Path DB = Path(__file__).parent / "expedientes.db" EXPEDIENTES = [ # codigo, titulo, tipo, estado, fecha_apertura, responsable, instalacion ("EXP-2024-001", "Inspección periódica de seguridad e higiene", "inspeccion", "cerrado", "2024-02-11", "Marta Ruiz", "Restaurante El Puerto"), ("EXP-2024-014", "Renovación de licencia de actividad", "licencia", "en_tramite","2024-05-03", "Luis Sáenz", "Hotel Central"), ("EXP-2024-027", "Incidente en sistema de refrigeración", "incidente","abierto", "2024-09-21", "Marta Ruiz", "Mercado Municipal"), ("EXP-2025-002", "Inspección no programada", "inspeccion", "cerrado", "2025-01-15", "Ana Belén Gil", "Cafetería La Plaza"), ("EXP-2025-008", "Expediente sancionador por exceso de ruido", "sancion","en_tramite","2025-03-30","Luis Sáenz", "Discoteca Némesis"), ("EXP-2025-019", "Modificación de licencia de obras", "licencia", "abierto", "2025-06-12", "Ana Belén Gil","Centro Comercial Vega"), ("EXP-2025-031", "Inspección periódica de seguridad e higiene", "inspeccion", "archivado", "2025-08-07", "Marta Ruiz", "Polideportivo Norte"), ("EXP-2026-003", "Incidente por fuga de agua", "incidente", "abierto", "2026-02-19", "Luis Sáenz", "Gimnasio Atlas"), ("EXP-2026-006", "Renovación de licencia de actividad", "licencia","en_tramite", "2026-04-02", "Ana Belén Gil","Panadería San Juan"), ] def main(): con = sqlite3.connect(DB) cur = con.cursor() cur.execute("DROP TABLE IF EXISTS expedientes") cur.execute(""" CREATE TABLE expedientes ( id INTEGER PRIMARY KEY AUTOINCREMENT, codigo TEXT UNIQUE NOT NULL, titulo TEXT NOT NULL, tipo TEXT NOT NULL, -- inspeccion|licencia|incidente|sancion estado TEXT NOT NULL, -- abierto|en_tramite|cerrado|archivado fecha_apertura TEXT NOT NULL, -- ISO YYYY-MM-DD responsable TEXT NOT NULL, instalacion TEXT NOT NULL ) """) cur.executemany( "INSERT INTO expedientes " "(codigo, titulo, tipo, estado, fecha_apertura, responsable, instalacion) " "VALUES (?,?,?,?,?,?,?)", EXPEDIENTES, ) con.commit() n = cur.execute("SELECT COUNT(*) FROM expedientes").fetchone()[0] con.close() print(f"BD creada en {DB} con {n} expedientes.") if __name__ == "__main__": main()
mcp_expedientes_server.pydescargar
""" Servidor MCP de expedientes. Es el MISMO tool de 07_expedientes.py, pero expuesto por el protocolo MCP en lugar de hardcodeado dentro del agente. Comparación directa: 07 (no-MCP): el dict TOOLS y FUNCIONES viven DENTRO del agente. MCP: las tools viven AQUÍ, en un proceso aparte. El agente las descubre por el protocolo (tools/list) y las ejecuta (tools/call). Lo notable: aquí NO se escribe ningún esquema JSON a mano. FastMCP lo genera solo, a partir de los type hints (incluido el Literal -> enum) y del docstring de cada función. Compáralo con el TOOLS escrito a mano de 07. Este servidor no se ejecuta directamente: lo lanza el cliente (08_cliente_mcp.py) como subproceso y habla con él por stdin/stdout (transporte stdio). """ import sqlite3 from pathlib import Path from typing import Literal from mcp.server.fastmcp import FastMCP DB = Path(__file__).parent / "expedientes.db" mcp = FastMCP("expedientes") def _conectar(): con = sqlite3.connect(DB) con.row_factory = sqlite3.Row return con @mcp.tool() def buscar_expedientes( estado: Literal["abierto", "en_tramite", "cerrado", "archivado"] | None = None, tipo: Literal["inspeccion", "licencia", "incidente", "sancion"] | None = None, anio: int | None = None, responsable: str | None = None, limite: int = 10, ) -> str: """Busca expedientes en la base de datos aplicando filtros opcionales (estado, tipo, año de apertura, responsable). Devuelve una lista resumida. Úsala para preguntas como 'expedientes abiertos de 2025' o 'inspecciones de Marta'.""" sql = ("SELECT codigo, titulo, tipo, estado, fecha_apertura, responsable, " "instalacion FROM expedientes WHERE 1=1") params = [] if estado: sql += " AND estado = ?"; params.append(estado) if tipo: sql += " AND tipo = ?"; params.append(tipo) if anio: sql += " AND fecha_apertura LIKE ?"; params.append(f"{anio}-%") if responsable: sql += " AND responsable LIKE ?"; params.append(f"%{responsable}%") sql += " ORDER BY fecha_apertura DESC LIMIT ?"; params.append(min(int(limite), 50)) con = _conectar() filas = con.execute(sql, params).fetchall() con.close() if not filas: return "No se encontraron expedientes con esos criterios." return "\n".join( f"{f['codigo']} | {f['estado']} | {f['tipo']} | {f['fecha_apertura']} | " f"{f['responsable']} | {f['instalacion']} | {f['titulo']}" for f in filas ) @mcp.tool() def detalle_expediente(codigo: str) -> str: """Devuelve TODOS los datos de un expediente concreto a partir de su código (ej. 'EXP-2025-008'). Úsala cuando ya conoces el código y quieres el detalle.""" import json con = _conectar() f = con.execute("SELECT * FROM expedientes WHERE codigo = ?", [codigo]).fetchone() con.close() if not f: return f"No existe el expediente {codigo}." return json.dumps(dict(f), ensure_ascii=False, indent=2) if __name__ == "__main__": mcp.run() # transporte stdio por defecto
08_cliente_mcp.pydescargar
""" 08 - El agente de expedientes, ahora hablando MCP. Mismo bucle de agente que 07, pero con DOS cambios que son toda la gracia de MCP: (A) El catálogo TOOLS ya NO se escribe a mano. Se DESCUBRE preguntándole al servidor (session.list_tools()). El servidor decide qué ofrece. (B) La ejecución ya NO usa el dict FUNCIONES local. Se delega al servidor por el protocolo (session.call_tool(nombre, args)). El LLM y el bucle de decisión son idénticos a 07. Lo único que cambió es DE DÓNDE vienen las herramientas y CÓMO se ejecutan. Eso es, exactamente, lo que aporta MCP. Es asíncrono porque el cliente MCP lo es. Lanza el servidor como subproceso y habla con él por stdio. Requisitos: - .venv/bin/python crear_expedientes_db.py (la BD que usa el servidor) - Ollama con qwen2.5:14b-instruct-q4_K_M """ import asyncio import json import re import sys from pathlib import Path import ollama from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client MODELO = "qwen2.5:14b-instruct-q4_K_M" SERVER = Path(__file__).parent / "mcp_expedientes_server.py" SYSTEM = ( "Eres un asistente del registro de expedientes. Responde SOLO con datos " "obtenidos de las herramientas; nunca inventes expedientes ni datos. " "Usa buscar_expedientes para localizar y detalle_expediente para profundizar " "en uno concreto. Responde de forma clara y en español." ) def llamadas_fugadas_en_texto(contenido: str): if not contenido: return [] out = [] for b in re.findall(r'\{[^{}]*"name"[^{}]*\{[^{}]*\}[^{}]*\}', contenido): try: o = json.loads(b) if "name" in o and "arguments" in o: out.append((o["name"], o["arguments"])) except json.JSONDecodeError: continue return out async def responder(session: ClientSession, ollama_tools, pregunta: str, max_turnos=6): print(f"\n{'='*70}\nPREGUNTA: {pregunta}\n{'='*70}") cliente = ollama.AsyncClient() messages = [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": pregunta}, ] for _ in range(max_turnos): r = await cliente.chat(model=MODELO, messages=messages, tools=ollama_tools, options={"temperature": 0}) messages.append(r.message) pend = [(tc.function.name, tc.function.arguments) for tc in (r.message.tool_calls or [])] if not pend: pend = llamadas_fugadas_en_texto(r.message.content) if not pend: print(f"\nRESPUESTA FINAL:\n{r.message.content}") return for nombre, args in pend: print(f" --> (MCP) call_tool: {nombre}({dict(args)})") # (B) Ejecutar la herramienta YA NO es FUNCIONES[nombre](**args): # se delega al servidor MCP por el protocolo. resultado = await session.call_tool(nombre, dict(args)) texto = "\n".join(c.text for c in resultado.content if hasattr(c, "text")) messages.append({"role": "tool", "name": nombre, "content": texto}) async def main(): # Se lanza el servidor MCP como subproceso y se conecta por stdio. params = StdioServerParameters(command=sys.executable, args=[str(SERVER)]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # (A) DESCUBRIMIENTO: se pregunta al servidor qué herramientas tiene. # Esto sustituye al dict TOOLS escrito a mano de 07. lista = await session.list_tools() print("Herramientas DESCUBIERTAS en el servidor MCP:") for t in lista.tools: print(f" - {t.name}: {t.description.splitlines()[0]}") # Se adapta el esquema MCP al formato que espera Ollama. # (inputSchema ya es JSON Schema estándar -> encaja directo) ollama_tools = [ {"type": "function", "function": { "name": t.name, "description": t.description, "parameters": t.inputSchema, }} for t in lista.tools ] await responder(session, ollama_tools, "¿Qué expedientes de tipo incidente están abiertos?") await responder(session, ollama_tools, "¿Cuántos expedientes lleva Marta Ruiz y de qué tipos?") await responder(session, ollama_tools, "Dame el detalle del expediente EXP-2025-008.") if __name__ == "__main__": asyncio.run(main())
09_langgraph.pydescargar
""" 09 - El MISMO agente de expedientes, ahora con LangGraph (el framework). Cierra la comparativa de las tres formas de construir el bucle: - 07: a mano, dict TOOLS + FUNCIONES + while. - 08: MCP, las tools en un servidor aparte. - 09: LangGraph, el bucle como un GRAFO de estado (este archivo). Mapa de equivalencias con el 'responder()' hecho a mano: el código a mano LangGraph --------------------- -------------------------------- escribir el dict TOOLS --> @tool (genera el esquema del docstring/hints) ollama.chat(tools=...) --> ChatOllama(...).bind_tools(tools) FUNCIONES[nombre](**args) --> ToolNode(tools) el while de responder() --> StateGraph con nodos y aristas "¿pidió herramienta?" --> tools_condition (arista condicional) la lista messages --> el estado (MessagesState) max_turnos --> los ciclos del grafo (con checkpoints, etc.) Conclusión del curso: LangGraph no inventa nada nuevo; formaliza EXACTAMENTE el bucle que ya se construyó. A cambio de una dependencia pesada, ahorra el boilerplate y da gratis persistencia, streaming, human-in-the-loop... Requisitos: - .venv/bin/python crear_expedientes_db.py - Ollama con qwen2.5:14b-instruct-q4_K_M """ import json import sqlite3 from pathlib import Path from typing import Annotated, Literal, Optional from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.tools import tool from langchain_ollama import ChatOllama from langgraph.graph import START, StateGraph, MessagesState from langgraph.prebuilt import ToolNode, tools_condition MODELO = "qwen2.5:14b-instruct-q4_K_M" DB = Path(__file__).parent / "expedientes.db" def _conectar(): con = sqlite3.connect(DB) con.row_factory = sqlite3.Row return con # --- Herramientas: misma lógica que 07, pero el esquema lo genera @tool -------- @tool def buscar_expedientes( estado: Optional[Literal["abierto", "en_tramite", "cerrado", "archivado"]] = None, tipo: Optional[Literal["inspeccion", "licencia", "incidente", "sancion"]] = None, anio: Optional[int] = None, responsable: Optional[str] = None, limite: int = 10, ) -> str: """Busca expedientes en la base de datos aplicando filtros opcionales (estado, tipo, año de apertura, responsable). Devuelve una lista resumida. Úsala para preguntas como 'expedientes abiertos de 2025' o 'inspecciones de Marta'.""" sql = ("SELECT codigo, titulo, tipo, estado, fecha_apertura, responsable, " "instalacion FROM expedientes WHERE 1=1") params = [] if estado: sql += " AND estado = ?"; params.append(estado) if tipo: sql += " AND tipo = ?"; params.append(tipo) if anio: sql += " AND fecha_apertura LIKE ?"; params.append(f"{anio}-%") if responsable: sql += " AND responsable LIKE ?"; params.append(f"%{responsable}%") sql += " ORDER BY fecha_apertura DESC LIMIT ?"; params.append(min(int(limite), 50)) con = _conectar() filas = con.execute(sql, params).fetchall() con.close() if not filas: return "No se encontraron expedientes con esos criterios." return "\n".join( f"{f['codigo']} | {f['estado']} | {f['tipo']} | {f['fecha_apertura']} | " f"{f['responsable']} | {f['instalacion']} | {f['titulo']}" for f in filas ) @tool def detalle_expediente(codigo: str) -> str: """Devuelve TODOS los datos de un expediente concreto a partir de su código (ej. 'EXP-2025-008'). Úsala cuando ya conoces el código y quieres el detalle.""" con = _conectar() f = con.execute("SELECT * FROM expedientes WHERE codigo = ?", [codigo]).fetchone() con.close() if not f: return f"No existe el expediente {codigo}." return json.dumps(dict(f), ensure_ascii=False, indent=2) TOOLS = [buscar_expedientes, detalle_expediente] SYSTEM = ( "Eres un asistente del registro de expedientes. Responde SOLO con datos " "obtenidos de las herramientas; nunca inventes expedientes ni datos. " "Usa buscar_expedientes para localizar y detalle_expediente para profundizar " "en uno concreto. Responde de forma clara y en español." ) # El modelo, con las herramientas 'enganchadas' (equivale a ollama.chat(tools=...)) llm = ChatOllama(model=MODELO, temperature=0).bind_tools(TOOLS) # --- El bucle, ahora como GRAFO ------------------------------------------------ def nodo_agente(state: MessagesState): """Nodo que llama al modelo. Equivale a la llamada ollama.chat de responder().""" return {"messages": [llm.invoke(state["messages"])]} # Construcción del grafo: dos nodos (agente y tools) y una arista condicional. grafo = StateGraph(MessagesState) grafo.add_node("agente", nodo_agente) grafo.add_node("tools", ToolNode(TOOLS)) # = FUNCIONES[nombre](**args) grafo.add_edge(START, "agente") # tools_condition = "¿el último mensaje pidió herramienta? -> tools, si no -> END" grafo.add_conditional_edges("agente", tools_condition) grafo.add_edge("tools", "agente") # tras ejecutar, vuelve al modelo app = grafo.compile() def responder(pregunta: str): print(f"\n{'='*70}\nPREGUNTA: {pregunta}\n{'='*70}") estado = app.invoke({"messages": [SystemMessage(SYSTEM), HumanMessage(pregunta)]}) # Traza: se muestran las herramientas que se ejecutaron por el camino for m in estado["messages"]: for tc in getattr(m, "tool_calls", None) or []: print(f" --> (LangGraph) {tc['name']}({tc['args']})") print(f"\nRESPUESTA FINAL:\n{estado['messages'][-1].content}") if __name__ == "__main__": responder("¿Qué expedientes de tipo incidente están abiertos?") responder("¿Cuántos expedientes lleva Marta Ruiz y de qué tipos?") responder("Dame el detalle del expediente EXP-2025-008.") # --- Alternativa "con pilas incluidas": todo el grafo en UNA línea --------- # from langgraph.prebuilt import create_react_agent # app = create_react_agent(llm_sin_bind, TOOLS, prompt=SYSTEM) # Hace exactamente lo de arriba (nodos + arista condicional) por ti.
