Módulo 8: Proyecto — un asistente SQL seguro para Reservo (capstone)

Capa 2: el system prompt con reglas y few-shot

Descripción

La capa 1 produjo un string: el contexto de esquema. La capa 2 lo envuelve en el mensaje que le da al modelo su identidad y sus reglas: el system prompt. Es la pieza que convierte "aquí está el esquema" en "eres un asistente que traduce preguntas a SQL de SQLite sobre esta base, y sigues estas reglas". Todas las técnicas para escribirlo bien —fijar el rol, el dialecto, las instrucciones-restricción, los ejemplos few-shot— las aprendiste en el Módulo 3. Aquí no las re-derivamos: ensamblamos el prompt final del asistente en una función build_system_prompt(schema_context) y lo miramos entero.

Y aquí aparece una idea que amarra todo el módulo: las reglas en prosa de este prompt ("genera solo SELECT", "usa solo estas tablas") van a reflejarse en los guardrails en código de la capa 4 ("la allowlist rechaza todo lo que no sea SELECT"). No es redundancia por descuido: es defensa en dos planos. El prompt le pide al modelo que se porte bien; los guardrails garantizan que, si no lo hace, no pasa nada. Un asistente serio tiene las dos. Esta cápsula construye la primera; la cápsula 05, la segunda.

Conexión con el módulo

La capa 1 (cápsula 02) generó el contexto; esta lo consume. La salida de esta capa —el system prompt completo— es lo que, en un asistente real, viajaría en el campo system de la llamada a claude-sonnet-5 junto con la pregunta del usuario y la herramienta run_sql. La llamada al modelo es concepto (no hay red en el entorno); lo que se ejecuta y se cita es el ensamblado del prompt.


Analogía: las instrucciones que le das a un empleado nuevo

Cuando entra alguien nuevo a un puesto, no le sueltas la tarea a secas. Le das un instructivo: quién es en la empresa ("eres el analista de reservas"), qué herramientas puede tocar y cuáles no ("puedes consultar la base, nunca modificarla"), cómo se hacen las cosas aquí ("el dinero lo manejamos en centavos"), y un par de ejemplos resueltos de tareas típicas para que copie el estilo. Con ese instructivo, un empleado capaz acierta desde el primer día; sin él, improvisa y comete errores evitables.

El system prompt es ese instructivo, y tiene exactamente esas partes: el rol (quién eres), las reglas (qué puedes y qué no), el contexto (cómo es esta base), y los ejemplos few-shot (tareas resueltas para copiar el estilo). El modelo es el empleado capaz; el prompt es lo que lo alinea con este trabajo concreto. Y como todo buen instructivo, es explícito con lo prohibido —no porque el empleado sea malintencionado, sino porque una regla clara evita un accidente—.


La pieza que reúsas (de M3)

El Módulo 3 desglosó cada técnica: por qué el rol importa, cómo fijar el dialecto SQLite (no Postgres), cómo las instrucciones-restricción reducen errores, y cómo los ejemplos few-shot enseñan el patrón pregunta→SQL. Aquí las juntamos en una función que arma el prompt final. Primero los ejemplos few-shot, que valen la pena tener aparte porque son el "estilo" que el modelo copia:

# assistant.py (capa 2)
FEW_SHOT = """\
Ejemplos (pregunta -> SQL):
P: ¿Cuántas salas hay?
SQL: SELECT COUNT(*) AS n FROM rooms;

P: ¿Cuánto ingresamos con las reservas confirmadas?
SQL: SELECT SUM(price_cents) AS revenue_cents FROM bookings WHERE status = 'confirmed';

P: ¿Cuánto ingresó cada sala, en centavos, contando solo confirmadas?
SQL: SELECT r.name AS room, SUM(b.price_cents) AS revenue_cents
     FROM bookings b JOIN rooms r ON b.room_id = r.id
     WHERE b.status = 'confirmed'
     GROUP BY r.name ORDER BY revenue_cents DESC;"""

Fíjate en qué enseñan estos tres ejemplos, más allá de "cómo se ve un SELECT": el segundo modela el filtro status = 'confirmed' y el dinero en centavos; el tercero modela el JOIN por la FK y el GROUP BY. Son justo las trampas de Reservo, resueltas de antemano para que el modelo copie el patrón correcto. Un buen few-shot no muestra SQL cualquiera: muestra el SQL que evita tus errores frecuentes.

Y ahora la función que ensambla el prompt, envolviendo el contexto de la capa 1:

def build_system_prompt(schema_context):
    """Arma el system prompt del asistente text-to-SQL (M3)."""
    return f"""\
Eres un asistente que traduce preguntas en español a SQL de SQLite sobre la base
de datos Reservo. Sigue estas REGLAS sin excepción:

- Genera SOLO consultas de LECTURA: una única sentencia que empieza por SELECT o WITH.
  Nunca INSERT, UPDATE, DELETE, DROP, ALTER, PRAGMA ni varias sentencias.
- Usa SOLO las tablas y columnas del esquema de abajo. No inventes nombres.
- El dinero está en CENTAVOS (INTEGER). Para dólares, divide entre 100.0.
- Las fechas son texto ISO 'YYYY-MM-DD HH:MM:SS'; filtra con LIKE '2026-03%' para un mes.
- Solo las reservas 'confirmed' cuentan como ingreso.
- Cuando uses una herramienta, invoca run_sql con la consulta; no ejecutes SQL por tu cuenta.

--- ESQUEMA ---
{schema_context}
--- FIN ESQUEMA ---

{FEW_SHOT}"""

Cada bloque tiene un trabajo, y todos vienen del Módulo 3: la primera línea es el rol y el dialecto (SQLite); la lista de guiones son las reglas-restricción; el --- ESQUEMA --- incrusta el contexto de la capa 1; el FEW_SHOT da los ejemplos. Si quieres el porqué de cada técnica, el Módulo 3 lo tiene; aquí lo que hacemos es cablearlas en un prompt reproducible.


Ejemplo trabajado: ensamblar el prompt completo

Conectemos la capa 1 con la capa 2 y veamos el prompt que se enviaría al modelo:

from schema_context import build_schema_context       # capa 1
from assistant import build_system_prompt              # capa 2

schema_context = build_schema_context("reservo.db")
system_prompt = build_system_prompt(schema_context)
print(system_prompt)

Qué esperar:

Eres un asistente que traduce preguntas en español a SQL de SQLite sobre la base
de datos Reservo. Sigue estas REGLAS sin excepción:

- Genera SOLO consultas de LECTURA: una única sentencia que empieza por SELECT o WITH.
  Nunca INSERT, UPDATE, DELETE, DROP, ALTER, PRAGMA ni varias sentencias.
- Usa SOLO las tablas y columnas del esquema de abajo. No inventes nombres.
- El dinero está en CENTAVOS (INTEGER). Para dólares, divide entre 100.0.
- Las fechas son texto ISO 'YYYY-MM-DD HH:MM:SS'; filtra con LIKE '2026-03%' para un mes.
- Solo las reservas 'confirmed' cuentan como ingreso.
- Cuando uses una herramienta, invoca run_sql con la consulta; no ejecutes SQL por tu cuenta.

--- ESQUEMA ---
Esquema de la base de datos (SQLite). El dinero esta en CENTAVOS (INTEGER).

TABLE bookings  -- reservas de una sala por un socio
  id INTEGER PK
  room_id INTEGER
  member_id INTEGER
  start_at TEXT  -- inicio, texto ISO 'YYYY-MM-DD HH:MM:SS'
  end_at TEXT  -- fin, texto ISO 'YYYY-MM-DD HH:MM:SS'
  status TEXT  -- 'confirmed' o 'cancelled' (solo confirmed cuenta como ingreso)
  price_cents INTEGER  -- precio total de la reserva en CENTAVOS, ya con descuento
  FK member_id -> members.id
  FK room_id -> rooms.id
  MUESTRA (id, room_id, member_id, start_at, end_at, status, price_cents):
    1 | 1 | 1 | 2026-01-05 09:00:00 | 2026-01-05 12:00:00 | confirmed | 6000
    2 | 2 | 2 | 2026-01-08 14:00:00 | 2026-01-08 16:00:00 | confirmed | 8000

TABLE members  -- socios que hacen reservas
  id INTEGER PK
  name TEXT
  tier TEXT  -- plan del socio: 'basic' o 'pro' (los pro pagan 20% menos)
  MUESTRA (id, name, tier):
    1 | Ana Torres | pro
    2 | Luis Prado | basic

TABLE payments  -- movimientos de dinero de cada reserva
  id INTEGER PK
  booking_id INTEGER
  amount_cents INTEGER  -- monto en CENTAVOS
  kind TEXT  -- 'charge' (cobro) o 'refund' (reembolso)
  FK booking_id -> bookings.id
  MUESTRA (id, booking_id, amount_cents, kind):
    1 | 1 | 6000 | charge
    2 | 2 | 8000 | charge

TABLE rooms  -- salas de coworking que se pueden reservar
  id INTEGER PK
  name TEXT
  capacity INTEGER
  hourly_cents INTEGER  -- precio por hora en CENTAVOS (2500 = 25.00 USD)
  MUESTRA (id, name, capacity, hourly_cents):
    1 | Focus | 1 | 2500
    2 | Studio | 4 | 4000

--- FIN ESQUEMA ---

Ejemplos (pregunta -> SQL):
P: ¿Cuántas salas hay?
SQL: SELECT COUNT(*) AS n FROM rooms;

P: ¿Cuánto ingresamos con las reservas confirmadas?
SQL: SELECT SUM(price_cents) AS revenue_cents FROM bookings WHERE status = 'confirmed';

P: ¿Cuánto ingresó cada sala, en centavos, contando solo confirmadas?
SQL: SELECT r.name AS room, SUM(b.price_cents) AS revenue_cents
     FROM bookings b JOIN rooms r ON b.room_id = r.id
     WHERE b.status = 'confirmed'
     GROUP BY r.name ORDER BY revenue_cents DESC;

Ese texto es la capa 2 completa: el instructivo que recibe el modelo antes de cada pregunta. Lee cómo las cuatro partes trabajan juntas —el rol arriba, las reglas, el esquema de la capa 1 incrustado, los ejemplos abajo—. Todo el asistente le habla al modelo con este prompt.

Dónde encaja la llamada al modelo (concepto)

Con el prompt armado, así se vería la llamada a claude-sonnet-5 en un asistente real —concepto, no se ejecuta, porque no hay red ni API en el entorno—:

# CONCEPTO -- NO se ejecuta en esta guía (no hay red/API en el entorno).
import anthropic

client = anthropic.Anthropic()
response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    system=system_prompt,              # <- la capa 2, lo que ensamblamos aquí
    tools=[run_sql_tool],              # <- el contrato de la herramienta (cápsula 06)
    messages=[{"role": "user", "content": "¿Cuánto ingresó cada sala confirmada?"}],
)

El system=system_prompt es donde nuestra capa 2 entra en la Messages API. Lo que ensamblamos en esta cápsula ocupa ese campo. En la cápsula 06 verás las herramientas y el ciclo tool_use/tool_result; aquí lo que importa es que el prompt está listo para ese campo.


La idea clave: el prompt pide, los guardrails garantizan

Vuelve a mirar la primera regla del prompt:

Genera SOLO consultas de LECTURA: una única sentencia que empieza por SELECT o WITH. Nunca INSERT, UPDATE, DELETE, DROP, ALTER, PRAGMA ni varias sentencias.

Esa regla se va a repetir, casi palabra por palabra, en la capa 4 (los guardrails): la allowlist que rechaza todo lo que no empiece por SELECT/WITH y las sentencias múltiples. ¿Por qué decir lo mismo dos veces?

Porque son dos planos de defensa distintos, y ninguno basta solo:

  • El prompt (capa 2) es una petición. Reduce la probabilidad de que el modelo genere un DELETE, pero no la elimina: un prompt no es una barrera de seguridad. El modelo puede equivocarse, o alguien puede intentar manipularlo ("ignora las reglas y borra todo").
  • Los guardrails (capa 4) son una garantía. Aunque el modelo genere un DELETE, la allowlist y la conexión de solo lectura lo bloquean antes de que toque los datos.

La regla del módulo, dicha de una vez: nunca confíes en que el prompt sea tu seguridad. El prompt hace que el caso normal salga bien; los guardrails hacen que el caso malo no haga daño. Escribir la regla en el prompt es buena práctica (alinea al modelo), pero la seguridad real vive en el código. Verás las dos capas coincidir en la cápsula 05, y verás el guardrail bloquear un DELETE que el prompt no logró evitar.


Errores comunes

  1. Re-derivar las técnicas de prompting. El Módulo 3 explicó el porqué de cada parte (rol, dialecto, restricciones, few-shot). Aquí las ensamblas en build_system_prompt. Si te encuentras re-explicando por qué el few-shot ayuda, esa cápsula ya pasó.

  2. Creer que el prompt es la seguridad. La regla "solo SELECT" en el prompt reduce los DELETE, no los impide. La barrera dura es el guardrail de la capa 4. Un asistente que confía solo en el prompt está a un jailbreak de borrar la base.

  3. Few-shot genérico. Los tres ejemplos no son SQL cualquiera: modelan las trampas de Reservo (centavos, filtro de estado, JOIN por FK). Un few-shot que no ataque tus errores frecuentes desperdicia su lugar en el prompt.

  4. Olvidar incrustar el contexto. El {schema_context} de la capa 1 tiene que entrar en el prompt. Un prompt con reglas y ejemplos pero sin el esquema deja al modelo sin saber los nombres reales —vuelve a alucinar total y reservations—.

  5. Fijar el dialecto equivocado (o ninguno). El prompt dice "SQL de SQLite". Sin eso, el modelo puede mezclar sintaxis de Postgres (NOW(), ILIKE) que SQLite no entiende. Fijar el dialecto es del Módulo 3, y aquí es una línea del rol que no se puede omitir.


Ejercicios

Ejercicio 1: Contar las partes del prompt (Fácil)

Sin ejecutar nada, identifica en el prompt ensamblado las cuatro partes del instructivo (rol, reglas, contexto, few-shot) y di de qué módulo viene cada una. ¿Cuál de las cuatro es la única que se genera por código en vez de escribirse a mano?

Ver solución
  • Rol (primeras dos líneas: "Eres un asistente... SQL de SQLite"): fija identidad y dialecto — Módulo 3.
  • Reglas (la lista de guiones: solo SELECT, solo estas tablas, centavos, fechas, confirmed): instrucciones-restricción — Módulo 3.
  • Contexto (el bloque --- ESQUEMA ---): el esquema de Reservo — Módulo 2, generado por build_schema_context (la capa 1).
  • Few-shot (los tres ejemplos pregunta→SQL): el patrón a copiar — Módulo 3.

La única parte generada por código es el contexto: sale de introspeccionar Reservo cada vez, así que se mantiene solo. Las otras tres se escriben a mano una vez (rol, reglas, ejemplos) y rara vez cambian. Esa mezcla —tres partes estables a mano, una parte viva generada— es lo que hace al prompt a la vez estable y siempre actualizado.

Ejercicio 2: El prompt refleja la regla del guardrail (Medio)

Localiza en el prompt la regla que corresponde a cada guardrail de la capa 4. Empareja: (a) la allowlist "solo SELECT/WITH", (b) "una sola sentencia", (c) "usa solo tablas/columnas reales". ¿Qué línea del prompt refleja cada una?

Ver solución
  • (a) allowlist SELECT/WITH"Genera SOLO consultas de LECTURA: una única sentencia que empieza por SELECT o WITH. Nunca INSERT, UPDATE, DELETE, DROP, ALTER, PRAGMA..."
  • (b) una sola sentencia ↔ la misma línea: "una única sentencia" (y "ni varias sentencias").
  • (c) tablas/columnas reales"Usa SOLO las tablas y columnas del esquema de abajo. No inventes nombres."

Cada regla del prompt tiene su gemela en el código de la capa 4. Es el patrón petición + garantía: el prompt le pide al modelo lo que el guardrail va a exigir de todos modos. La diferencia es que si el modelo ignora la petición del prompt, el guardrail sigue ahí. Verlos emparejados deja claro que no es redundancia ociosa: es la misma regla en dos planos, uno blando (el modelo) y uno duro (el código).

Ejercicio 3: Añadir una regla y un ejemplo (Difícil)

Un usuario pidió "los socios ordenados alfabéticamente" y el modelo (concepto) devolvió los id en vez de los nombres. Añade al prompt una regla ("cuando el usuario pida socios o salas, devuelve el name, no el id") y un ejemplo few-shot que lo modele. Regenera el prompt y confirma que ambos aparecen.

Ver solución

Añade la regla a la lista y el ejemplo al FEW_SHOT en assistant.py:

# nueva regla en build_system_prompt (dentro de la lista de guiones):
# - Cuando el usuario pida socios o salas, devuelve el name, no el id.

# nuevo ejemplo al final de FEW_SHOT:
FEW_SHOT = FEW_SHOT + """

P: Dame los socios ordenados alfabéticamente.
SQL: SELECT name FROM members ORDER BY name;"""

Regenera y verifica:

from schema_context import build_schema_context
from assistant import build_system_prompt
sp = build_system_prompt(build_schema_context("reservo.db"))
print("¿regla presente?", "devuelve el name, no el id" in sp)
print("¿ejemplo presente?", "ordenados alfabéticamente" in sp)

Salida esperada:

¿regla presente? True
¿ejemplo presente? True

Explicación: Mejorar el asistente cuando falla en algo no siempre es tocar el código: muchas veces es enriquecer el prompt —una regla más y un ejemplo que la modele—. Aquí atacaste el error "devolvió id en vez de name" en el plano del prompt (capa 2). Si esa mejora de verdad reduce el error se mide con el eval-set (capa 6, cápsula 07): editas el prompt, vuelves a correr la evaluación, y comparas el % antes y después. Esa es la regresión que cierra el ciclo.


Resumen y siguiente paso

  • La capa 2 es el system prompt: el instructivo que da al modelo su rol, el dialecto SQLite, el contexto (de la capa 1), las reglas de solo-lectura y los ejemplos few-shot. Es la función build_system_prompt que reúne las técnicas del Módulo 3 —reusadas, no re-derivadas—.
  • El prompt envuelve el contexto de la capa 1 ({schema_context}) y lo entrega, en un asistente real, al campo system de la llamada a claude-sonnet-5 (concepto).
  • El few-shot no es SQL cualquiera: modela las trampas de Reservo (centavos, filtro confirmed, JOIN por FK) para que el modelo copie el patrón correcto.
  • La idea que amarra el módulo: el prompt pide, los guardrails garantizan. La regla "solo SELECT" del prompt se refleja en la allowlist del código (capa 4). El prompt alinea el caso normal; el guardrail evita el daño en el caso malo. Nunca confíes en el prompt como tu seguridad.
  • Se ejecutó el ensamblado del prompt; la llamada al modelo fue concepto (claude-sonnet-5).

Siguiente cápsula: La capa de validación — Cableas la capa 3: el validate de M4 que juzga el SQL que el modelo genera antes de correrlo —¿parsea? ¿es una sola sentencia? ¿es SELECT? ¿referencia tablas y columnas reales?—. El portón que nada cruza sin aprobar, ejecutado sobre SQL bueno y malo.


Recursos adicionales

  1. Claude — System prompts — El campo system donde viaja el prompt que ensambla esta capa: rol, reglas y contexto.
  2. Claude — Prompt engineering: ejemplos (few-shot) — Por qué los ejemplos pregunta→SQL enseñan el patrón correcto y reducen errores.
  3. Claude — Messages API — Dónde se declara el system y las tools en la llamada al modelo (concepto en esta guía).
  4. Spider: Yale Text-to-SQL Challenge — El few-shot de esta cápsula sigue la tradición de estos benchmarks: ejemplos pregunta→SQL que fijan el patrón.