72 horas, un café y una hipótesis incómoda
Era jueves por la tarde cuando miramos el dashboard y aceptamos algo que llevábamos semanas negando: los usuarios no estaban descubriendo el producto. Tenemos 54 herramientas legales — finiquitos, validadores de RUT, comparadores de contratos, análisis de causas — y la curva de adopción se estancaba en las 3 que estaban pinned en el sidebar.
La causa raíz no era de diseño. Era de fricción semántica: un abogado quiere escribir "generar finiquito para Juan Pérez con indemnización proporcional", no hacer 4 clicks en un árbol de hubs.
Decidimos resolverlo con un atajo que no existía todavía en LegalTech en español: un AI Command Palette estilo Linear/Raycast, pero que entiende derecho chileno y ejecuta intenciones. Nos dimos 72 horas.
Spoiler: lo logramos. Y lo que aprendimos en el camino es la razón de este post.
La decisión irreversible: UX primero, IA segundo
El instinto inicial fue abrir la consola y ponerse a escribir un prompt gigante. Lo frenamos a tiempo. La primera decisión — y posiblemente la más importante — fue definir el contrato de UX antes de tocar el LLM:
Cmd+K(oCtrl+K) abre el palette desde cualquier pantalla.- El usuario escribe en lenguaje natural.
- Mientras piensa, mostramos sugerencias deterministas (búsqueda fuzzy sobre 54 acciones conocidas).
- Tras 400ms de silencio, disparamos al LLM para enriquecer la interpretación.
- El resultado se muestra como una acción ejecutable única: "Crear finiquito con Juan Pérez prellenado" → 1 click → el form aparece con los campos ya cargados.
Este diseño tiene una consecuencia técnica enorme: el LLM no tiene permitido hablar con el usuario. No chatea, no pide aclaraciones, no devuelve prosa. Devuelve un JSON estructurado que representa una acción o se calla. Esta restricción fue nuestra mejor amiga.
Arquitectura: tres capas, ninguna opcional
┌──────────────────────────────────────────────────────┐
│ Frontend (cmdk + CommandPalette.js) │
│ • Fuzzy matching local (54 acciones) │
│ • Debounce 400ms │
│ • Skeleton mientras LLM responde │
└───────────────────────┬──────────────────────────────┘
│ POST /api/command-palette/interpret
▼
┌──────────────────────────────────────────────────────┐
│ Backend (command_palette.py + FastAPI) │
│ • Validación de intención con JSON Schema │
│ • Llamada al LLM via Emergent LLM Key │
│ • Fallback determinista si LLM falla │
│ • Logging en MongoDB (query, intent, confidence) │
└───────────────────────┬──────────────────────────────┘
│ { action, target, params, confidence }
▼
┌──────────────────────────────────────────────────────┐
│ React Router + prefill │
│ • navigate(target, { state: { prefill: params } }) │
│ • Las páginas leen el state y llenan el form │
└──────────────────────────────────────────────────────┘
La capa que más iteramos fue la del medio. Aquí viven las decisiones caras.
Por qué un LLM y no una taxonomía de regex
Nuestra tentación inicial fue clasificar con reglas. "Si la query empieza con 'generar' o 'crear' → es un prefill". Funciona para el 60% de los casos. El otro 40% es donde el producto cobra vida: "tengo un problema con un finiquito que firmé hace 3 meses" debería llevar al triage legal, no a un generador.
Entrenar un clasificador propio era overkill para un MVP. Probamos tres modelos comerciales bajo un mismo benchmark interno (~800 queries reales del logging de los primeros 7 días):
| Modelo | Latencia p50 | Precisión | Costo/1k queries | |------------------------------|--------------|-----------|------------------| | LLM ligero genérico | ~900ms | 78% | $0.15 | | LLM ultra-rápido alternativo | ~1100ms | 71% | $0.10 | | Frontier reasoning model | ~780ms | 91% | $0.38 |
La diferencia no fue el precio. Fue la estabilidad del JSON. El modelo que elegimos respeta el schema incluso cuando la query es ambigua; los otros dos tendían a envolver el JSON en markdown o a añadir campos fantasmas. Cada campo fantasma son 20 líneas de defensa en el backend.
Mantenemos el proveedor abstraído detrás de la Emergent LLM Key, así podemos cambiar de modelo con una línea cuando el veredicto cambie en 6 meses:
from emergentintegrations import LlmChat, UserMessage
chat = LlmChat(
api_key=os.environ["EMERGENT_LLM_KEY"],
session_id=f"cmdk-{user_id}",
system_message=SYSTEM_PROMPT,
).with_model(*os.environ["CMDK_MODEL"].split(":")) # provider:model_id desde env
Aclaración sobre el moat: el modelo no es lo que nos hace defendibles
Un atajo Cmd+K con un LLM genérico se construye en una tarde. Lo que tomó 72 horas — y nos llevó al estado actual del feature — vive en una capa que no estamos compartiendo en este post: el intent resolver.
En palabras simples, es un mapa curado de keywords (verbos chilenos de acción + sustantivos legales) emparejados con rutas + parámetros del producto, evaluados con un scoring por longest-match: gana la entrada que matchea con la palabra clave más específica. Esto explica por qué "redactá un finiquito para Juan" navega al generador y "finiquito qué es" lleva al triage informativo, sin pasar por el LLM en el 70% de los casos.
Lo que sí podemos abrir:
- El principio de diseño: keywords curadas + scoring determinista + LLM como capa de enriquecimiento.
- La forma del schema que valida la salida del LLM (más abajo).
- Los errores que evitamos y los KPIs que usamos.
Lo que NO publicamos:
- El array completo del intent map (verbos + sinónimos + sustantivos chilenos legaltech).
- Las regex de normalización (acentos + jerga chilena).
- Los pesos del scoring y los umbrales de confianza.
No es por dramatismo. Es porque ese contenido se construye iterando con data real de uso, y replicarlo desde cero toma 4-6 semanas que ningún competidor está dispuesto a invertir si nuestro stack ya es público. Es el ejemplo clásico de moat que no está en el código sino en la curaduría.
El system prompt: menos es más
El prompt final tiene 47 líneas. Las primeras 20 son el catálogo de acciones (las 54 herramientas con su target route). Las siguientes 15 son ejemplos con input/output. Las últimas 12 son restricciones estrictas:
Responde SOLO con JSON válido. No expliques.
Si la confianza < 0.5, usa action="unknown".
Nunca inventes rutas fuera del catálogo.
Si el usuario pide algo ilegal o fuera de scope legal, action="reject".
Los parámetros deben ser extraídos literalmente del input;
no inferir nombres, RUTs o cifras que no estén escritas.
La última restricción nos salvó de un bug serio. Durante testing, el LLM inventó un RUT cuando la query mencionaba a "Juan Pérez" sin número. Prellenó el form con un RUT aleatorio válido en dígito verificador. Catastrófico. Añadir la restricción más un test E2E (palette-no-hallucinated-params.spec.ts) cerró el agujero.
El JSON Schema que nos ahorró 3 reescrituras
Antes de parsear la respuesta del LLM la validamos con Pydantic:
class CmdKIntent(BaseModel):
action: Literal["prefill", "navigate", "chat", "validate_rut", "reject", "unknown"]
target: str | None = None # ruta del frontend
params: dict[str, Any] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0)
rationale: str = Field(max_length=120)
@field_validator("target")
@classmethod
def target_must_be_known(cls, v):
if v is not None and v not in KNOWN_ROUTES:
raise ValueError(f"unknown route: {v}")
return v
Cuando el LLM devuelve basura, Pydantic revienta y el endpoint responde action=unknown con fallback a búsqueda fuzzy. Esta capa convirtió un sistema que a veces funcionaba en uno que nunca rompe la UX, aunque ocasionalmente sea menos mágico.
Observabilidad desde la primera hora
Aprendimos tarde en otros proyectos que sin métricas, mejorar un producto LLM es adivinar. Esta vez cada evento se loggea en MongoDB desde el primer despliegue:
await db.command_palette_events.insert_one({
"query": query,
"query_hash": hashlib.sha256(query.encode()).hexdigest()[:16],
"action": intent.action,
"target": intent.target,
"params_keys": list(intent.params.keys()), # sin valores (PII)
"confidence": intent.confidence,
"source": "llm" if used_llm else "fuzzy",
"logged_execution": False, # flip a True si el usuario clickeó
"timestamp": datetime.now(timezone.utc),
})
Cuando el usuario ejecuta la sugerencia disparamos /api/command-palette/log-execution. Esto nos da el KPI más importante del sistema: suggestion acceptance rate. Hoy está en 71% — por cada 100 queries, 71 terminan en click.
El dashboard de admin (/admin/cmdk-analytics) muestra top intents, distribución por acción y confidence vs acceptance. Los próximos deltas al prompt se decidirán con esta data, no con corazonadas.
La página SEO que salió gratis
Cuando teníamos tres semanas de datos, miramos los top intents y notamos algo: son exactamente las búsquedas que la gente hace en Google sobre temas legales en Chile. "calcular finiquito", "validar RUT", "plazo para renunciar".
En una tarde lanzamos /insights, una página pública que renderiza las top consultas agregadas (anonimizadas, Art. 11 Ley 21.719). Long-tail SEO sin escribir una sola palabra: el contenido lo generan los usuarios al usar el producto. En dos semanas ya traía tráfico orgánico al funnel.
Es un ejemplo perfecto de un "bonus" no planificado: una decisión arquitectónica temprana (loggear estructurado) abrió una palanca de growth que nunca estuvo en la propuesta original.
Los 4 errores que estuvimos a punto de cometer
- Streaming de tokens en el palette. El UX parecía cool. No lo es. Streaming en un dropdown es mareante; el usuario quiere un resultado, no ver cómo se construye. Lo descartamos tras una prueba de 30 minutos.
- Darle al LLM acceso a ejecutar acciones. Durante 20 minutos consideramos que el LLM llamara endpoints directamente. Es un anti-patrón: pierdes control de autorización, rate limiting y auditoría. El LLM propone; el frontend dispone.
- Un único prompt gigante con toda la taxonomía legal chilena. Empezamos así. 4k tokens de sistema. Cortamos a 900 tokens enfocados en enrutamiento, y delegamos el conocimiento legal al chat específico de cada herramienta. Baja latencia, menor costo, mejor precisión.
- Skipear los hooks de seguridad. Tentados por velocidad. No lo hicimos, y esa decisión nos ahorró un incidente real: el linter de seguridad open-source
legaltech-security-hooksdetectó unrandom.choiceen el code path de generación de tokens de logging y lo bloqueó antes del commit.
Qué funcionó y qué sigue
Lo que brilló:
- 72h reales (no 72h de "arquitectura" y 3 semanas de "pulido"), gracias a acotar scope de forma brutal.
- La restricción de que el LLM solo habla en JSON: el sistema es testeable como cualquier API clásica.
- Observabilidad desde el día uno: nos permitió sumar
/insightssin esfuerzo extra.
Lo que queda pendiente:
- Fine-tuning sobre un dataset chileno de queries legales — hoy el frontier model que usamos es brillante pero genérico; un modelo especializado podría bajar costo y latencia 3x.
- Multi-turn context: hoy cada query es atómica. Queremos soportar "lo mismo pero para Marta" reutilizando contexto reciente.
- Ejecución one-shot: si la confianza > 0.9 y la acción es segura, ejecutar sin requerir el segundo click.
TL;DR para el ingeniero que va a construir el suyo
- Diseña la UX antes del prompt. El LLM no habla, el LLM enruta.
- Valida la salida del LLM con un schema estricto. Cualquier desvío → fallback determinista.
- Loggea cada evento desde la primera línea de código. La analítica es tu siguiente feature gratis.
- Empieza con un sprint corto y un scope incómodamente pequeño. 72h es suficiente para un MVP real si resistes la tentación de meter todo.
- Mide
suggestion acceptance rate, noaccuracy. Es el único KPI que correlaciona con retención.
Si quieres ver el código: el paquete está abierto en nuestro repo (backend/routes/command_palette.py y frontend/src/components/CommandPalette.js). Los pre-commit hooks que mencionamos también están publicados como legaltech-security-hooks.
Preguntas, bugs o ideas → ingenieria@notaryos.cl. Leemos todo.