Manual de administración
Guía operativa para administradores: configurar modelos LLM, autorar casos de uso, gestionar equipos y permisos. Mezcla pasos de UI con cambios en código (YAML / .md / .env) cuando la UI todavía no cubre el caso.
.env) requieren acceso al repositorio y reinicio de servicio.
En desarrollo local: docker compose build app && docker compose up -d app.
En producción (GCP): ./deploy.sh deploy rebuild + push imagen + nueva revisión Cloud Run (frontend y backend van en la misma imagen). Ver Despliegue.
Las acciones marcadas UI se realizan desde la app sin redeploy. Las marcadas código requieren editar archivos.
Modelos LLM — visión general
La plataforma usa un sistema de cadenas de modelos: cada caso de uso declara una lista ordenada de modelos preferidos. En tiempo de ejecución la cadena se resuelve aplicando filtros (permisos del usuario + claves API configuradas), y un wrapper resiliente (ResilientProvider) la recorre saltando al siguiente modelo si el actual falla con un error transitorio (HTTP 429/503/504, timeout, proveedor sin clave).
La resolución sigue este orden de capas, la primera que coincide gana (no se acumulan):
model_chain_by_phase[phase]— override por fase (extracción, razonamiento, drafting, validación). Hoy el bucle del agente no emite fronteras de fase, así que esta capa está reservada para uso futuro.model_chain_by_tool[tool_name]— override por herramienta. Reservado: las herramientas del agente actuales no invocan al LLM por su cuenta.model_chaindel caso de uso — la cadena base.
Después se concatena la cadena global de fallback (siempre presente). Esto resuelve el caso "el caso de uso pide modelos que no están configurados → caer al fallback general".
Proveedores y claves API código
Las claves se guardan en .env en la raíz del repo y se inyectan al contenedor vía docker-compose.yml. Variables soportadas:
Las claves viven como secretos de Google Secret Manager en el proyecto GCP (kebab-case: claude-api-key, deepseek-api-key, etc.) y se montan al servicio Cloud Run vía --set-secrets. El comando ./deploy.sh secrets sync sincroniza el .env.deploy local al Secret Manager (idempotente: crea versión nueva por cada secret que difiere; el SA tiene roles/secretmanager.secretAccessor). Proveedores soportados:
| Variable de env (en runtime) | Proveedor | Estado |
|---|---|---|
| — | Google (Gemini) | Funcional vía Vertex AI + ADC (sin API key — auth con la SA app-sa con roles/aiplatform.user). Migrado 2026-05-14. |
LEGAL_AI_CLAUDE_API_KEY | Anthropic (Claude) | Funcional (secret claude-api-key en prod) |
LEGAL_AI_DEEPSEEK_API_KEY | DeepSeek | Funcional vía SDK OpenAI-compatible (secret deepseek-api-key) |
LEGAL_AI_OPENAI_API_KEY | OpenAI | Esqueleto — clase definida pero sin SDK conectado |
Gemini ya NO usa API key: la migración a Vertex AI (commit e13ca71) reemplazó LEGAL_AI_GEMINI_API_KEY por autenticación ADC. La disponibilidad del provider Gemini se gate por LEGAL_AI_GOOGLE_CLOUD_PROJECT (env var no-secret).
Si una variable no está seteada, su proveedor queda no disponible: cualquier modelo de ese proveedor que aparezca en una cadena se filtra automáticamente al resolver. Si la clave existe pero el modelo falla en runtime, ResilientProvider registra un evento llm_chain_hop en los logs y salta al siguiente modelo.
.env no basta: docker-compose.yml debe pasarla al contenedor con una línea como LEGAL_AI_DEEPSEEK_API_KEY: ${LEGAL_AI_DEEPSEEK_API_KEY:-}. Si agregas un proveedor nuevo, agrega ambos.
.env.deploy local, luego corre ./deploy.sh secrets sync — crea una versión nueva del secreto en Secret Manager. Cloud Run referencia :latest así que la nueva versión la recoge en el próximo cold start; para forzar adopción inmediata corre ./deploy.sh deploy (nueva revision). Las versiones previas quedan en historial para rollback (gcloud secrets versions list NAME).
Por qué las claves no se editan desde la UI
Decisión explícita de diseño: las claves API viven solo en variables de entorno (cargadas por Settings al boot vía pydantic-settings). No hay endpoint PUT /api/admin/api-keys, ni columna api_key en ninguna tabla. Para cambiar una clave: editar .env y reiniciar la app.
Permitir edición desde UI obligaría a persistirlas en DB (no hay otra forma de modificarlas en runtime sin restart). Eso introduce trade-offs concretos:
| Aspecto | Hoy: claves en .env | Alternativa: claves en DB editables por UI |
|---|---|---|
| Cambio de clave | Editar .env, redeploy del contenedor (~30s). |
UI → guardar → siguiente turno usa la nueva clave. |
| Superficie de exposición | Plaintext sólo en archivos del host y memoria del contenedor. .env está en .gitignore. |
Plaintext en la tabla — visible para cualquier acceso a DB (replicas, dumps, herramientas de ops). Mitigable con encriptación en reposo + KMS, pero implementar bien es trabajo real. |
| Backups y replicas | Las claves no aparecen en pg_dump. Restaurar un backup en otro entorno no expone las claves de producción. |
Cada dump contiene credenciales activas. Gestionar quién puede leerlos y por cuánto tiempo se vuelve un problema operacional. |
| Rotación | Operación de ops: editar archivo + redeploy. No hay UI history; trazabilidad vía git/CI. | Self-service, con audit log fácil de exponer (quién cambió, cuándo). |
| Multi-tenant (clave por cliente) | No soportado: una clave global por proveedor. | Trivial: tabla con tenant_id + clave + proveedor. |
| Validación atómica | Settings se cargan al boot. Si una clave es inválida, el proveedor queda no-disponible y la cadena lo filtra silenciosamente; las claves presentes hacen raise ProviderNotConfiguredError ante intento de uso, lo que dispara el fallback de la cadena. |
Hay que validar al guardar (round-trip al proveedor) o aceptar que un usuario guarde una clave rota y descubra el error en producción. |
| Integración con secret managers | 12-factor estándar. Funciona out-of-the-box con Kubernetes Secrets, AWS Secrets Manager, GCP Secret Manager (montar como env var). | Bypass del secret manager — la DB se vuelve el sistema de gestión de secretos, lo cual no es su propósito. |
| Rollback ante incidente | Revertir el commit del .env (vía Git) o restaurar el archivo desde backup. Operación reversible y auditable. |
Restaurar la fila en DB, pero en el ínterin cualquier sesión nueva ya usó la nueva clave. |
El balance hoy se inclina al modelo env-only por tres razones acumulativas:
- La app es single-tenant — no necesita una clave por cliente. Si el roadmap pasa a SaaS multi-tenant, esa razón cae y vale revisar.
- La rotación es infrecuente — pasa cuando rota una API key (raro), no es flujo diario que valga UI dedicada.
- El costo de hacerlo bien es alto — encriptación en reposo, KMS, audit log de cambios, validación al guardar. Más trabajo del que ahorra hasta tener un volumen real de operación.
Si en algún momento se decide migrar a DB-editables, la implementación correcta debería incluir: encriptación con clave en KMS, audit log inmutable, validación round-trip al guardar, fallback automático a env si la fila DB está ausente, y mecanismo de rollback. No es trivial.
El manifiesto de modelos código
Fuente única de verdad: src/permissions/llm_models_manifest.yaml. Define qué modelos existen, a qué proveedor pertenecen, en qué tier están y cuál es su sustituto inmediato. Cualquier modelo no registrado aquí no se puede referenciar en las cadenas.
items:
- id: claude-opus-4-7
provider: anthropic
label: "Claude Opus 4.7"
category: "Anthropic"
tier: high
next: claude-sonnet-4-6
- id: gemini-2.5-flash
provider: google
label: "Gemini 2.5 Flash"
category: "Google"
tier: mid
next: gemini-2.5-flash-lite
- id: gemini-2.5-flash-lite
provider: google
label: "Gemini 2.5 Flash Lite"
category: "Google"
tier: low
next: null
- id: deepseek-v4-flash
provider: deepseek
label: "DeepSeek V4 Flash"
category: "DeepSeek"
tier: low
next: gemini-2.5-flash
tiers:
high: [claude-opus-4-7, gemini-2.5-pro, gpt-5, deepseek-v4-pro]
mid: [claude-sonnet-4-6, gemini-2.5-flash, gpt-5-mini]
low: [claude-haiku-4-5, gemini-2.5-flash-lite, gpt-5-nano, deepseek-v4-flash]
global_fallback_chain:
- gemini-2.5-flash-lite # validado para chat común y análisis estándar
- gemini-2.5-flash # hop cuando lite rebota o caso pide más
- deepseek-v4-flash # salvavidas cross-vendor
Agregar un modelo nuevo
- Editar
src/permissions/llm_models_manifest.yamly agregar una entrada enitemsconid,provider(uno deanthropic | google | openai | deepseek),label,category,tier(high | mid | low) ynext(otro id conocido onull). - Agregar el id al array correspondiente del bloque
tiers. El orden dentro del tier importa: define el orden por defecto cuando alguien usatier:high. - Si el proveedor todavía no tiene clase implementada (ej. OpenAI hoy), saltar al paso 5 — el catálogo lo aceptará pero las llamadas fallarán con
ProviderNotConfiguredErrorhasta que se conecte el SDK. -
Reiniciar la app:
docker compose build app && docker compose up -d app. Redesplegar:./deploy.sh deploy. El manifest viaja dentro de la imagen Docker, así que cambios en YAML requieren rebuild. - Validar con el script de auditoría:
PYTHONPATH=. python scripts/audit-permission-catalog.py --check-chains. ImprimeCHAIN AUDIT — OKsi todo está consistente.
next: apuntando a un id desconocido, miembros de tier que no existen, ciclos en el grafo de sustitución, o entradas sin provider. Errores tipográficos no llegan a producción.
Tiers (alta / media / baja)
Los tiers son grupos por capacidad. Existen tres: high (modelos premium con razonamiento profundo), mid (modelos estándar con buen balance) y low (modelos económicos y rápidos para tareas simples). Una cadena puede referenciar un tier completo con la sintaxis tier:<nombre>:
model_chain:
- tier:high # se expande a [claude-opus-4-7, gemini-2.5-pro, gpt-5, deepseek-v4-pro]
- tier:mid # se expande a [claude-sonnet-4-6, gemini-2.5-flash, gpt-5-mini]
El orden dentro del tier es el orden literal del array en el manifiesto. Para reordenar dentro de un tier, edita el array tiers.<nombre>. Para reordenar solo en un caso de uso particular, lista los modelos explícitamente en lugar de usar tier: (ver Reordenar modelos).
Cadena global de fallback
global_fallback_chain en el manifiesto. Esta lista siempre se concatena al final de la cadena resuelta de cada caso de uso. Asegura que si la cadena del caso de uso solo referencia modelos no configurados, el sistema cae a una opción que sí está disponible.
global_fallback_chain:
- gemini-2.5-flash-lite
- gemini-2.5-flash
- deepseek-v4-flash
Decisión (2026-05-11): cascada Google barata-a-cara con DeepSeek-flash como red de seguridad cross-vendor. gemini-2.5-flash-lite validado en pruebas reales para chat conversacional, consultas cortas y análisis estándar. gemini-2.5-pro no está en el fallback ni es preferido en ningún caso de uso — queda como override-only desde el picker del chat para tareas que ameriten razonamiento más profundo.
Ejemplo de resolución concreta: caso de uso pide [claude-opus-4-7, gpt-5], solo está configurada Gemini. El resolver descarta los dos por env-filter, concatena global_fallback_chain, queda [gemini-2.5-flash-lite, gemini-2.5-flash, deepseek-v4-flash] (más cualquier hop adicional del grafo).
Grafo de sustitución (next:)
Cada modelo tiene un next: opcional que apunta a un sucesor "inmediato" para casos donde la cadena explícita se agota. Después de aplicar la cadena del caso de uso + el fallback global, el resolver hace una caminata del grafo desde el último modelo de la cadena resuelta y agrega los sucesores no vistos (filtrados por permisos y entorno).
En la práctica, la cadena de fallback global ya cubre la mayoría de los casos. El grafo agrega redundancia adicional. Si un modelo está aislado y nunca debe sustituirse, ponle next: null.
Cadenas por caso de uso código
Cada caso de uso (src/casos_de_uso/id.md) declara su cadena en el frontmatter YAML. Tres campos opcionales:
# Cadena base (la usual):
model_chain:
- gemini-2.5-flash
- gemini-2.5-flash-lite
# Override por herramienta (reservado, no activado en runtime):
model_chain_by_tool:
search_laws: [tier:low]
risk_matrix_compute: [tier:high]
# Override por fase (reservado, no activado en runtime):
model_chain_by_phase:
drafting: [claude-opus-4-7]
extraction: [tier:low]
Los tres aceptan tanto IDs literales (claude-opus-4-7) como referencias de tier (tier:high). El validador rechaza cualquier referencia desconocida al cargar el caso de uso (warning en logs, caso queda fuera del catálogo hasta corregir).
Reordenar modelos dentro de un caso de uso
Tres patrones según qué tan flexible necesitas ser:
Patrón A — referencia al tier (orden del manifiesto)
model_chain:
- tier:low # → [claude-haiku-4-5, gemini-2.5-flash, gpt-5-nano, deepseek-v4-flash]
El orden lo decide el manifest. No tienes control fino dentro del tier.
Patrón B — lista explícita (tu orden)
model_chain:
- deepseek-v4-flash
- gemini-2.5-flash
- gpt-5-nano
- claude-haiku-4-5
El resolver respeta el orden tal cual lo escribes.
Patrón C — mezcla (cabeza explícita + cola por tier)
model_chain:
- deepseek-v4-flash # primero, prioridad propia de este caso
- tier:low # resto del tier; deepseek-v4-flash deduplicado
El resolver dedupe preservando el primero encontrado, así que la cabeza explícita gana ante empates.
Override por herramienta — reservado
El campo model_chain_by_tool existe en el schema y se valida, pero las herramientas actuales no invocan al LLM por su cuenta — todas las llamadas pasan por el provider único del agente. Cuando alguna herramienta gane su propio call site al LLM (ejemplo futuro: un extractor que llama a Haiku para procesar un anexo grande), este override empezará a tener efecto.
Override por fase — reservado
Mismo estado: el campo model_chain_by_phase existe pero el bucle del agente hoy no marca fronteras de fase. La infra (resolve_chain(phase=...)) está lista; falta declarar dónde se activa cada fase. TODOs en el código (src/agent/agent.py, src/extraction/amount_extractor.py, src/extraction/outcome_classifier.py) marcan los puntos de futura conexión.
Verificar qué modelo respondió
Tres ventanas de observabilidad disponibles hoy:
1 — Auditoría declarativa (cadenas configuradas)
PYTHONPATH=. python scripts/audit-permission-catalog.py --check-chains
Imprime, por cada caso de uso, su cadena cruda (antes de aplicar permisos y filtros de entorno). Útil para confirmar que un cambio en YAML llegó al sistema.
2 — Logs estructurados del runtime
Cada llamada a resolve_chain emite un log INFO con campos raw_chain, fallback_env, chosen. Cada salto de fallback emite un WARN con from_model, to_model, error_kind. Tail típico:
docker compose logs -f app | grep -E "resolve_chain|llm_chain_hop|llm_chain_exhausted"
# Cloud Run streaming logs (project default = activo; agrega --project=<id> si difiere)
./deploy.sh logs | grep -E "resolve_chain|llm_chain_hop|llm_chain_exhausted"
# o directo:
gcloud run services logs tail altlegal-ai --region=europe-southwest1 \
| grep -E "resolve_chain|llm_chain_hop|llm_chain_exhausted"
# o desde Logs Explorer en la consola GCP con filtro:
# resource.type="cloud_run_revision"
# resource.labels.service_name="altlegal-ai"
# textPayload =~ "resolve_chain|llm_chain_hop|llm_chain_exhausted"
3 — Eventos en el stream del chat
El stream SSE del chat ahora emite dos eventos relevantes:
{"type": "model_preferred", "model_id": "..."}— al inicio de cada turno, con el primer modelo de la cadena.{"type": "done", "model_used": "...", ...}— al final del turno, con el modelo que efectivamente respondió (post-fallback). Si el primero falló y el segundo respondió,model_used ≠ model_preferredy se debe inspeccionarllm_chain_hopen los logs para ver el motivo.
4 — Pill de modelo en el header del chat UI
La cabecera del chat muestra un pill con el nombre corto del modelo activo (lee los eventos del punto 3). Tres estados visuales:
- Neutro (gris) — preferido del caso corriendo normal.
- Accent (rojo) — el usuario eligió un override desde el picker. Se aplica solo a esa sesión.
- Warning (amarillo) — la cadena cayó al siguiente modelo porque el preferido falló. El tooltip explica cuál falló.
Click en el pill abre un dropdown con la chain resuelta para el caso de uso (consume GET /api/chat/sessions/{id}/available-models). Cada opción muestra el label del manifest + tier hint (profundo / balanceado / rápido). Al elegir un modelo, SendMessageRequest.model_override viaja en el siguiente turno y LegalAgent._init_chain lo prepende a la chain. El override se limpia al cambiar de sesión o al re-elegir el preferido.
Lo que aún no hace esta app
Decisiones de scope tomadas en la v1 del sistema de cadenas. Cada uno tiene un plan de evolución registrado en el spec (docs/superpowers/specs/2026-05-01-llm-model-priority-fallback-design.md):
| Capacidad | Estado | Workaround |
|---|---|---|
UI de admin para editar model_chain de un caso de uso | No implementado (Fase 2) | Editar el archivo .md del caso de uso y hacer commit + redeploy. |
| Override de cadenas por tenant (multi-cliente) | Reservado (tabla model_chain_overrides nombrada pero no creada) | — |
| Provider OpenAI conectado a SDK real | Esqueleto — siempre lanza ProviderNotConfiguredError | Usar Gemini, Claude o DeepSeek hasta conectar el SDK. |
| Re-resolución por herramienta / por fase en runtime | Infra lista, call sites no conectados | El campo se valida y se cachea pero no se aplica. Definir cuál tool/phase debe activarlo es trabajo futuro. |
| Fallback por costo / latencia (degradar si modelo es muy caro o lento) | No implementado | Solo se hace fallback en errores duros (429/503/504/timeout/proveedor sin clave). |
| Cuotas de uso por modelo / por usuario / por equipo | No implementado | Confiar en los límites del proveedor upstream. |
| Bloqueo de modelo por proyecto sensible (ej. forzar Sonnet en proyectos con datos sensibles) | No implementado | Editar el caso de uso correspondiente; o crear un caso de uso dedicado a "alta sensibilidad" con su propia cadena. |
| Adjuntos PDF / DOCX en proveedor DeepSeek | No soportado por DeepSeek upstream | Usar Gemini o Claude para sesiones con documentos adjuntos. La cadena se filtra automáticamente en estos casos. |
UI muestra model_used debajo del turno | El backend ya emite el evento; falta render en frontend | Inspeccionar en DevTools → Network → SSE. |
| Streaming con fallback mid-stream | Diseño explícito: si ya se emitieron tokens, NO se hace fallback (se propaga el error) | Esto es por diseño para evitar emisiones duplicadas. El usuario verá un error con respuesta parcial. |
Casos de uso — qué son y dónde viven
Un caso de uso es una receta pre-configurada para una tarea recurrente: un prompt master, una cadena de modelos preferidos, una whitelist de herramientas, restricciones de temperatura y tokens, y metadatos para mostrarlo en el catálogo (icono, chips, sugerencias iniciales). Al activarlo en una sesión, el agente queda restringido a operar dentro de ese marco.
Los archivos viven en src/casos_de_uso/id.md. Cada archivo tiene dos secciones:
- Frontmatter YAML entre
---al inicio: metadatos, configuración del modelo, whitelist de herramientas, datos para el catálogo. - Cuerpo Markdown: el prompt completo del agente (rol, misión, instrucciones, formato de respuesta esperado).
Frontmatter — campos
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
id | string | Sí | Identificador único, slug en minúsculas-con-guiones (ej. pdp-eipd). 3–64 chars, regex ^[a-z0-9][a-z0-9-]{2,63}$. Inmutable post-crear (rompe sesiones existentes). |
nombre | string | Sí | Etiqueta visible (ej. "Evaluación de Impacto en Protección de Datos"). |
categoria | string | Sí | Agrupación libre (ej. "Protección de datos", "Litigios"). |
descripcion | string ≤160 | Sí | Una línea para el catálogo. |
icono | enum | Sí | Uno de los iconos conocidos: shield-check, scale-balance, document-check, pen-draft, gavel, etc. Lista completa en src/casos_de_uso/schema.py:KNOWN_ICONS. |
model_chain | list | No | Cadena de modelos preferidos. Si se omite, se usa la cadena global de fallback. |
model_chain_by_tool | dict | No | Override por herramienta. reservado |
model_chain_by_phase | dict | No | Override por fase. reservado |
temperatura | float 0–2 | No | Temperatura del LLM. null usa el default del proveedor. |
max_output_tokens | int 256–16384 | No | Cap de tokens de salida. |
herramientas_permitidas | list<tool_id> | No | Whitelist de herramientas del agente. null = todas. Lista completa en KNOWN_TOOL_NAMES. |
mensaje_inicial_usuario | string | No | Mensaje pre-rellenado en el input al crear sesión. |
chips | list 1–3 | Sí | Etiquetas cortas en el catálogo. |
sugerencias_iniciales | list 3 | Sí | Tres ejemplos de prompts mostrados al crear sesión. |
placeholder_input | string | Sí | Placeholder del input principal. |
orden | int | No (default 100) | Orden de aparición en el catálogo. |
habilitado | bool | No (default true) | false oculta el caso del catálogo sin borrarlo. |
trayectoria | string | No | Identificador de la trayectoria multi-paso a la que pertenece (si aplica). |
paso | int 1–999 | No | Número de paso dentro de la trayectoria. |
inputs_requeridos / outputs_producidos | list<artifact_type> | No | Tipos de artefactos que el paso consume / produce. Drives la lógica de "siguiente paso desbloqueado". |
subprompts | list<string> (2–20) | No | Multi-turno (ver sección dedicada). Si está set, el chat ejecuta cada entrada como un sub-turno LLM separado y acumula los outputs en un solo entregable. Requiere exactamente 1 tipo en outputs_producidos. Si es null, el caso es single-turn (default). |
Cuerpo del prompt
Después del frontmatter, todo el resto del archivo es el prompt master del agente. Convención del repo: secciones numeradas en español (Rol, Misión, Corpus, Reglas, Formato de respuesta, etc.). Ejemplos en cualquier pdp-*.md. El cuerpo se inyecta literal como system_prompt en cada turno de la sesión asociada.
---
id: pdp-eipd
nombre: PDP — Evaluación de Impacto
# ... resto del frontmatter ...
---
## 1. Rol
Eres un consultor especializado en protección de datos personales bajo la
Ley N° 21.719. Operas como evaluador metódico que produce informes EIPD
estructurados...
## 2. Misión
- Identificar los tratamientos de datos personales que justifican una EIPD.
- Cuantificar riesgos por tipo de titular y categoría de dato.
...
Crear / editar un caso de uso en código código
- Copiar
src/casos_de_uso/TEMPLATE.mdasrc/casos_de_uso/<id>.md. El nombre del archivo no necesita coincidir con el id, pero por convención sí. - Editar el frontmatter respetando los tipos.
iddebe ser único entre todos los archivos del directorio. - Escribir el cuerpo del prompt.
- Validar la sintaxis del frontmatter:
Si hay errores de validación se imprimen en stderr y el caso queda fuera del catálogo.PYTHONPATH=. python -c "from src.casos_de_uso.loader import load_all, list_enabled_use_cases; load_all(); print(len(list_enabled_use_cases()), 'casos cargados')" - Reiniciar la app o usar el endpoint
POST /api/casos-de-uso/reload(admin only) para recargar sin redeploy.
Editar desde la UI UI
La app expone editor admin para casos de uso vía la API /api/admin/casos-de-uso/<id>. Permite:
- GET — recuperar frontmatter + cuerpo en JSON.
- PUT — sobrescribir el archivo. Versionado automático: cada PUT crea una entrada en el historial;
POST /api/admin/casos-de-uso/<id>/rollback/<version>revierte. - GET
/api/admin/casos-de-uso/vocab— devuelve listas válidas (icons, herramientas, artifact types) para poblar selectores del editor.
El editor en la UI vive en el panel de configuración → pestaña "Casos de uso" (cuando está habilitado por permisos). Los cambios se persisten directamente al archivo .md. Nota: editar archivos en disco desde la UI implica que para preservar los cambios entre rebuilds, hay que commitear el archivo modificado al repositorio.
Pills informativas en el listado
El listado tabular de casos de uso muestra pills al lado del nombre cuando aplican:
- tier · high — el caso configura un modelo high-tier (
claude-opus-4-7,gemini-2.5-pro,gpt-5,deepseek-v4-pro) como primary delmodel_chain. Útil para identificar de un vistazo cuáles pasos usan un modelo más caro/potente que el default global. Útil también para auditar costos: si un caso simple aparece con la pill, probablemente sobreconfiguraste y conviene bajarlo a tier mid o low. - multi-turno · N — el caso declara
subprompts(multi-turno por chunk). N indica cuántos sub-turnos. Ver sección "Multi-turno" más abajo.
Los criterios para mostrar las pills se resuelven en cliente (web/app.js función _renderList dentro del módulo de casos de uso) leyendo el frontmatter ya cargado del backend; no requieren llamada extra.
.md están dentro de la imagen Docker en runtime. Editar desde la UI los modifica en el container, pero un rebuild los pisa con la versión del repo. Tras editar en UI:
docker compose cp altlegal-ai-app-1:/app/src/casos_de_uso/<id>.md src/casos_de_uso/
y commitear.
.md ya no son source-of-truth: la tabla tenant_altlegal.casos_de_uso en Cloud SQL es la verdad. Edits desde la UI persisten directamente en DB y sobreviven redeploys. El backup en el repo (backup/casos_de_uso_md/) sirve solo como fallback de re-hidratación manual si la tabla queda vacía por un drop/restore. Para evolucionar un caso: edita desde la UI; opcionalmente exporta el .md y commitealo al backup si quieres versionar el snapshot.
Herramientas permitidas
El campo herramientas_permitidas es una whitelist contra el set KNOWN_TOOL_NAMES definido en src/casos_de_uso/schema.py. Si se declara, el agente solo puede invocar esas herramientas durante la sesión. Si se omite (null), todas están disponibles.
Herramientas disponibles hoy:
- Causas judiciales:
query_cases,get_case_detail,get_case_timeline,get_document_text,court_statistics,get_case_amounts. - Investigación:
search_laws,acquire_norma,fetch_url,discover_sectoral_norms. - Documentos:
search_documents. - PDP:
anonymize_with_dlp,extract_structured_form,build_data_map_table,build_rat_table,risk_matrix_compute. - Entregables:
generate_google_doc,assemble_chapter_document,submit_artifact. - Gestión:
list_clients,list_client_matters,get_matter_status.
Trayectorias y pasos
Una trayectoria agrupa casos de uso ordenados que avanzan un proyecto del cliente (ej. la trayectoria consultoria-pdp-21719 tiene 19 pasos). Para hacer que un caso de uso sea un paso de trayectoria:
trayectoria: consultoria-pdp-21719
paso: 50
inputs_requeridos:
- ficha-proyecto
outputs_producidos:
- mapa-de-datos
inputs_requeridos determina qué artefactos previos deben existir aprobados para que el paso esté "desbloqueado" en la UI del proyecto. outputs_producidos determina qué artefactos pasan a estar disponibles para pasos posteriores. Tipos válidos en KNOWN_ARTIFACT_TYPES (src/proyecto_artifacts/schema.py).
Multi-turno con subprompts
Algunos pasos producen entregables tan extensos que un solo turno LLM no alcanza — el modelo trunca por el cap de max_output_tokens, o pierde calidad cuando el output supera ~4 mil tokens. Para esos casos, declarás una lista de subprompts en el frontmatter: el chat ejecuta cada entrada como un sub-turno LLM separado, acumula los outputs y al final emite UN solo entregable consolidado del tipo declarado en outputs_producidos.
Cuándo usarlo
- Entregables con secciones canónicas independientes — ej. la Sección III del Informe PDP tiene 9 ejes (3.1 a 3.9), cada uno requiere razonamiento profundo. Cada eje queda como sub-turno.
- Outputs sobre ~6 mil palabras donde la coherencia inter-sección es razonable pero no esencial.
- NO usar para entregables cortos (one-shot está bien), ni para tareas conversacionales (el formato multi-turno asume entregable estructurado).
Schema
subprompts:
- "Redacta el eje 3.1 — Políticas generales y específicas. Estructura: Diagnóstico → Recomendaciones → Tabla de prioridad/complejidad/medios."
- "Redacta el eje 3.2 — Procedimientos y manuales operativos. Mismo formato."
- "Redacta el eje 3.3 — Reglamento Interno (RIOHS). Mismo formato."
# … hasta 20 entradas
outputs_producidos:
- seccion-iii-brechas # exactamente UN tipo (validado por el schema)
Reglas y validaciones
- 2 a 20 entradas. Menos de 2 no tiene sentido (es un single-turn) y más de 20 indica que el caso debería partirse en pasos separados.
- Cada entrada ≤ 4096 caracteres. Si necesitas más, factoriza al cuerpo del prompt master que reciben todos los sub-turnos.
outputs_producidosdebe declarar exactamente 1 tipo — el handler emite UN artefacto consolidado, no varios. Validado enschema.py:_subprompts_requires_one_output.- Cada sub-turno hereda el mismo system prompt (cuerpo del
.md), las mismasherramientas_permitidas, la mismamodel_chainy los mismos artefactos previos del proyecto. Solo cambia el "user message" de cada sub-turno (la entrada desubprompts).
Comportamiento en runtime
- El handler de chat detecta el campo y bifurca a
_multi_turn_streamensrc/chat/routes.py. - Cada sub-turno crea un
LegalAgentnuevo (mismo DB session) — esto previene acumulación de estado entre sub-turnos del provider. - Antes de acumular el output de un sub-turno se aplica
_strip_postproc_warningpara remover el banner "⚠️ Entregable no registrado" que el postprocesador agrega cuando el sub-turno no llamasubmit_artifact(lo cual es lo esperado: solo el último sub-turno o un follow-up del usuario invoca submit). - El stream emite eventos
subturn_startcon{n, total}antes de cada sub-turno ysubturn_endal cerrar — el frontend usa esto para pintar el separador▍ Turno N/total. - El historial inter-sub-turno se truncan a los últimos 2000 caracteres del output anterior (suficiente para mantener coherencia sin saturar el contexto).
- Citas de cada sub-turno se acumulan en una lista única emitida en el
doneconsolidado.
Ejemplos en el repo
pdp-informe-seccion-iii.md— 12 sub-prompts (9 ejes + 3 sub-brechas del eje 3.9). Tiempo típico: 7–11 minutos.pdp-informe-seccion-iv.md— 5 sub-prompts (3 tablas + Conclusión + Anexo). Tiempo típico: 4–6 minutos.
UI esperada
Para que el abogado entienda por qué la respuesta llega en bloques, documenta el caso en web/ayuda-consultoria-pdp.html con la pill multi-turn — N sub-prompts y describe los ejes. Ver paso 130 / 140 como referencia.
Validación y reload
- Boot: el loader registra cada archivo válido en el catálogo. Errores de schema se imprimen como WARN en logs y el caso queda fuera.
- Reload sin redeploy:
POST /api/casos-de-uso/reload(requiere admin). Re-lee todo el directorio y reemplaza el catálogo en memoria. Sesiones existentes mantienen su prompt original (cargado al crear). - Auditoría de chains:
scripts/audit-permission-catalog.py --check-chainsdetecta referencias a modelos / tiers desconocidos en cualquiermodel_chain*.
Equipos y permisos — modelo conceptual
El sistema combina cuatro entidades:
- Usuario (
users) — identidad federada vía Google OAuth. Atributos clave:email,role(admin|user),profile(abogado|paralegal| null). - Equipo (
teams) — unidad funcional del estudio. Ejemplos: Gerencia, Administración, Corporativo, Litigios. Un usuario puede pertenecer a varios. - Perfil — rol jurídico transversal (
abogado,paralegal). Cada usuario tiene a lo sumo uno. - Política (
permission_policies) — regla de allow/deny sobre un tipo de recurso, con un alcance (team / profile / user_override).
General (Default).
Todo usuario autenticado se agrega automáticamente a este team en su primer login (auto-creado al boot vía migración 027). Las políticas que crees aquí aplican como baseline transversal: si un user nuevo del dominio no se asigna a ningún team especializado, hereda lo que esté configurado en General.
Este team no se puede archivar ni renombrar — la integridad del baseline depende de que siempre esté presente. Solo la descripción es editable. Resuelve el problema clásico donde un user sin team / profile heredaba implícitamente "todo permitido" porque las capas restrictivas vacías devolvían el universo.
Primer paso post-deploy: abre Equipos y permisos → "General" → Editar políticas, y configura los denies que aplican a todo el estudio (ej.
deny menu_option=admin para que la pestaña Admin solo la vean los role='admin'; deny llm_model=claude-opus-* para reservar Opus a perfiles específicos).
Tipos de recurso permisionables
resource_type | Qué controla | Fuente del catálogo |
|---|---|---|
tool | Herramientas del agente | Introspección (KNOWN_TOOL_NAMES) |
paso_caso_uso | Casos de uso con paso (parte de una trayectoria) | Introspección |
caso_uso_transversal | Casos de uso sin paso (ej. consulta libre) | Introspección |
menu_option | Entradas del menú lateral (Historial, Favoritos, Documentos, etc.) | Manifest curado (menu_manifest.yaml) |
llm_model | Modelos LLM | Manifest (llm_models_manifest.yaml) |
documento_tipo | Tipos de documento (reservado, sin items en v1) | Stub |
Políticas y precedencia
Una política tiene cuatro campos clave:
scope_type:team|profile|user_overridescope_ref: el id del team / profile / user al que aplicaresource_type: tipo de recurso (tabla arriba)mode:allow|denyresource_ids: array de ids o globs (ej.["claude-opus-*"])
Resolución para un usuario en runtime:
- user_override gana siempre. Una política con
scope_type='user_override',scope_ref=<email>tiene precedencia absoluta. Si diceallow [X], X queda permitido aunque su team lo deny. - profile luego. Aplica si el usuario tiene ese profile.
- team al final. Si el usuario pertenece a varios teams, sus políticas se combinan: cualquier deny gana entre teams (la unión de denies).
- Default cerrado: si no hay ninguna política
allowque cubra el recurso, queda denegado. La excepción es admin.
Glob expansion: las policies pueden contener wildcards (*, ?, [...]) que se expanden contra el catálogo del resource_type al resolver. Ejemplo: "claude-opus-*" matchea cualquier id que empiece con claude-opus- en el manifest de modelos.
CRUD desde la UI UI
Configuración → pestaña "Equipos y permisos". Solo visible para usuarios con role=admin.
Gestión de equipos
- Crear equipo: botón "Nuevo equipo" → nombre + descripción.
- Agregar miembros: dentro de un equipo, "Agregar miembro" → buscar usuario por email. Solo usuarios con sesión previa aparecen.
- Eliminar miembro: ícono X junto al usuario.
- Soft-delete del equipo: botón "Eliminar". Conserva auditoría; las políticas del equipo dejan de aplicar.
Asignación de perfil
Dentro de un equipo, para cada miembro se puede setear el profile (abogado | paralegal | sin perfil) directamente con un selector inline.
Editor de políticas
Para cada equipo (o profile, o user_override), aparece un editor con un selector de tipo de recurso → checkboxes con los items del catálogo + campo de "globs adicionales". Al guardar, se hace upsert: si ya existía una policy con ese (scope, resource_type), se reemplaza. Borrar todos los checkboxes y los globs equivale a "todo denegado por default" para ese recurso/scope.
invalidate_all() en el resolver de permisos y de cadenas LLM. Los efectos son inmediatos para todas las sesiones nuevas; sesiones en curso terminan con su provider ya construido.
Patrones glob
Útil para reglas amplias sin enumerar cada id. Ejemplos prácticos:
# Negar a paralegales el acceso a cualquier modelo Opus (futuro o presente):
scope: profile=paralegal
resource_type: llm_model
mode: deny
resource_ids: ["claude-opus-*"]
# Permitir a un equipo solo herramientas de causas judiciales:
scope: team=Litigios
resource_type: tool
mode: allow
resource_ids: ["query_cases", "get_case_*", "court_statistics", "search_documents"]
# Permitir a un usuario específico todos los pasos de PDP:
scope: user_override=ana@altlegal.cl
resource_type: paso_caso_uso
mode: allow
resource_ids: ["pdp-*"]
Auditoría
Hay dos capas de auditoría complementarias:
- Catálogo de permisos vs políticas existentes — el script
scripts/audit-permission-catalog.pydetecta inconsistencias estáticas (orphans + cadenas LLM rotas). - Histórico de mutaciones — toda creación / edición / borrado de team, miembro, perfil o política queda registrada en
tenant_altlegal.audit_events(eventospolicy_upserted,policy_deleted,team_*,profile_updated,user_role_changed). Visible en Configuración → Actividad → Eventos. Detalle en Actividad — telemetría.
Modos del script:
- Default (
--check-orphans): listaresource_idsen políticas que no existen en el catálogo. Detecta typos y referencias a items removidos.PYTHONPATH=. python scripts/audit-permission-catalog.py - --check-chains: valida cadenas
model_chain*en YAMLs de casos de uso contra el manifest. Imprime cadenas efectivas por caso.PYTHONPATH=. python scripts/audit-permission-catalog.py --check-chains
Ambos retornan exit code 1 si encuentran inconsistencias — útiles para CI o git hooks.
Bypass de admin
Usuarios con role='admin' bypasean toda la lógica de permisos: ven todos los menús, todas las herramientas, todos los modelos, todos los pasos. La cadena LLM resuelta para un admin no aplica filtros de permisos (sí aplica el filtro de claves API configuradas — un admin sin claves de Anthropic igual no puede llamar a Claude).
Cambiar el rol de un usuario hoy es operación de DB:
docker compose exec db psql -U postgres -d legal_ai -c \
"UPDATE tenant_altlegal.users SET role='admin' WHERE email='dtorres@altlegal.cl';"
# Conectar via Cloud SQL Auth Proxy (corre detrás de IAM, no expone la DB pública):
gcloud sql connect altlegal-ai-pg --user=postgres --database=legal_ai \
--quiet << 'EOF'
UPDATE tenant_altlegal.users SET role='admin' WHERE email='dtorres@altlegal.cl';
EOF
El usuario debe cerrar sesión y volver a entrar para que el cambio surta efecto.
Impersonación — "Ver como otro usuario" UI
Para debuggear permisos sin loguearse como otro usuario, un admin puede iniciar una sesión de solo lectura que se comporta como si fuera otro user. Útil para validar "¿el paralegal Y realmente no puede aprobar artefactos? ¿el team Z ve el menú Configuración?" sin interrumpir al usuario afectado.
Cómo se inicia
- Configuración → Usuarios.
- Junto a cada user (excepto tú mismo) aparece el botón "Ver como".
- Click → confirm modal. Acepta para iniciar.
- La página recarga. Aparece un banner amarillo arriba: "Estás viendo como X (sesión real: Y). Modo lectura.".
- Mientras dure la impersonación, todos los menús, casos de uso, modelos LLM, sesiones, etc. se filtran como los vería el target.
- Para terminar: click en Salir en el banner.
Modo lectura forzado
Mientras hay una sesión de impersonación activa, un middleware del backend bloquea cualquier request HTTP que no sea GET / HEAD / OPTIONS con respuesta 423 Locked. Excepción: los endpoints del propio sistema de impersonación (POST /api/admin/impersonate/{id} y DELETE /api/admin/impersonate) siempre pasan.
Esto previene que un admin haga mutaciones (crear sesiones, aprobar artefactos, subir documentos, cambiar permisos) bajo la identidad de otro user — escenario que ensuciaría el audit log y abriría una puerta a auditorías ambiguas.
Mecánica interna
- Cookie separada
altlegal_impersonate, firmada con el mismo secret que la sesión normal pero con salt distinto. Payload:{user_id: int}. TTL: 1 h (más corto que la sesión, porque es para una tarea puntual). get_current_userensrc/auth/dependencies.pyhace el swap: si el caller real es admin Y la cookie de impersonación apunta a un user válido, devuelve el target con atributos dinámicos_impersonated_by_email/_impersonated_by_id. Si el caller real NO es admin, la cookie se ignora (defensa en profundidad ante tampering).get_real_admin_userdependency separada que bypassa el swap — usada por los endpoints de impersonación para que un admin impersonando pueda llamar aDELETE /admin/impersonate.- Audit log: dos eventos nuevos en
AuditEventType—impersonation_startedyimpersonation_ended. El actor es siempre el admin real;target_ides el user impersonado.
Limitaciones conocidas
- Si quieres cambiar de target durante una sesión de impersonación, primero debes salir — el modo lectura oculta el menú Usuarios para targets non-admin.
- Las cachés in-process (permisos, bundle de chat) se rehidratan al hacer reload completo después de iniciar la impersonación. No hay invalidación más fina.
- WebSockets / SSE iniciados antes de la impersonación siguen corriendo bajo la identidad original hasta cerrarse — pero el middleware bloquea el siguiente
POST /api/chat/sessions/{id}/message, así que en práctica cualquier acción de chat queda capturada.
ACL por documento — cómo funciona
Sistema de acceso granular a documentos individuales (Phase 4 — 2026-05-02; profile-scope agregado 2026-05-10). Ortogonal al sistema de permisos por tipo de recurso: aquí cada documento tiene su propia lista de equipos, perfiles o personas que pueden verlo y usarlo.
Default opt-in restrictivo: al crearse, un documento es visible para cualquier usuario autenticado del estudio (preserva el comportamiento histórico). Para volverlo confidencial, el propietario activa Restringir acceso y elige quién puede acceder. Spec: docs/superpowers/specs/2026-05-02-document-acl-design.md.
Schema (tenant_altlegal):
user_documents.is_restricted— booleano (defaultfalse).document_acl(document_id, scope_type ∈ {team, user, profile}, scope_ref, created_by_user_id)— una fila por equipo, perfil o persona compartida.scope_refcontiene: UUID del team,user.idcomo string, o el string del perfil ('abogado'/'paralegal'). Cascade delete desdeuser_documents. Unique en(document_id, scope_type, scope_ref). CHECK constraint enforced (migration032_document_acl_profile_scope).
OR de scopes: cualquier match grants. Un doc con ACL [team X, profile Abogado] es accesible a un paralegal en team X (match team) Y a un abogado fuera de X (match profile). El usuario que pierde membresía de team X y no tiene perfil Abogado pierde acceso inmediatamente (no se cachea grant cross-session).
Orden de resolución
El resolver src/documents/acl.py::user_can_access_document evalúa así, short-circuit en el primer True:
- Documento no existe o soft-deleted → False.
- Documento no restringido (
is_restricted = false) → True. Salta todo el resto. Es el caso común; cuesta una sola query. - Owner —
document.uploader_email == user.email→ True. El propietario siempre ve su documento; no se puede revocar desde la UI. - Admin bypass —
user.role == 'admin'→ True. - User-scope ACL match — existe una fila con
scope_type='user'yscope_ref=str(user.id)→ True. - Profile-scope ACL match — el usuario tiene
user.profiledefinido (abogado/paralegal) y existe una fila conscope_type='profile'yscope_ref=user.profile→ True. Usuarios sin perfil (NULL) no matchean esta rama; deben recibir acceso por team o user individual. - Team-scope ACL match — el usuario es miembro de algún equipo en la lista de ACL (filtrando equipos soft-deleted) → True.
- Caso contrario → False, registra
WARN document_access_denieden logs.
Hay un helper bulk paralelo, filter_accessible_document_ids(user_id, document_ids) -> set[int], optimizado para listados (una query por capa, no N por documento).
Caché 60s TTL keyed por (user_id, document_id). Invalidación al cambiar is_restricted, al modificar ACL, o al cambiar membership de team.
Dónde se aplica el ACL
Cuatro sitios consultan el resolver:
| Sitio | Comportamiento |
|---|---|
list_documents(viewer_user_id=...) en src/documents/service.py |
Post-filtra el resultado por filter_accessible_document_ids. Picker UI oculta los documentos restringidos a los que el usuario no tiene acceso. Llamado por GET /api/documents. |
attach_document(user_id=...) en src/chat/attach_service.py |
Antes de adjuntar, llama al resolver. Si denegado, raises DocumentAccessDeniedError → la route lo traduce a HTTP 403 con detalle "No tienes acceso a este documento." |
Tool anonymize_with_dlp(documento_id) |
El dispatcher en execute_tool consulta el resolver con el doc id antes de invocar el handler. Si denegado, retorna string al LLM: "Documento N no accesible para este usuario. Contacta al propietario." |
Tool extract_structured_form(fuente_documento_id) |
Mismo mecanismo que anonymize. |
get_document_text y search_documents NO se gatean.
Operan sobre tenant_altlegal.documentos (corpus jurisprudencia público del scraper SCJ), un namespace distinto. La ACL aplica solo sobre user_documents (archivos subidos por usuarios).
Compartir desde la UI UI
Dos puntos de configuración de ACL:
- Al subir — el modal de upload pregunta "¿Restringir acceso?" antes de mandar el archivo. Si el toggle se marca, el doc se sube y queda
is_restricted=truecon ACL vacío (solo owner + admins lo ven). El owner completa equipos/perfiles/personas después desde el menú ⋯. - Post-upload — En la tabla de Documentos, el menú ⋯ de cada fila tiene la entrada Compartir…. Abre un modal completo con todos los pickers.
El modal de Compartir muestra:
- Propietario — fila no editable, indicando el email de quien subió el documento.
- Toggle Restringir acceso — apagado = documento visible globalmente; encendido = solo los listados abajo más el owner y admins lo ven.
- Equipos con acceso — multi-select con autocomplete sobre el listado de teams. Cada selección aparece como chip con × para quitar.
- Perfiles con acceso — multi-select con
Abogado/Paralegal. Útil para "que todos los abogados vean este doc, sin tener que listar a cada uno". Usuarios sin perfil (NULL) no entran por esta vía. - Personas con acceso — multi-select con autocomplete sobre el listado de usuarios (excluyendo al propietario, que ya tiene acceso implícito).
- Warning automático — si el toggle está encendido pero ningún equipo / perfil / persona seleccionado, aparece "Solo tú y los admins podrán acceder".
Al guardar, el badge 🔒 Restringido aparece junto al nombre del documento en la tabla. La caché del resolver se invalida automáticamente para ese document_id.
Permission gate: solo el propietario del documento o un admin puede abrir el modal y guardar cambios. Otros usuarios reciben HTTP 403.
API endpoints código
GET /api/documents/{id}/acl # estado actual + labels (team name, user email)
POST /api/documents/{id}/acl # idempotente; wipe + reinsert
DELETE /api/documents/{id}/acl # equivalente a POST {is_restricted: false, teams:[], users:[]}
GET /api/share-targets # lista teams + users para los pickers (cualquier autenticado)
POST body:
{
"is_restricted": true,
"teams": ["<team_uuid>", "<team_uuid>"],
"users": [17, 42],
"profiles": ["abogado"]
}
Errores: 400 ante UUID de team, user_id, o perfil desconocido (válidos: abogado, paralegal); 403 para non-owner non-admin; 404 doc no encontrado o soft-deleted.
Auditoría: cada mutación emite un INFO log estructurado document_acl_changed con actor_user_id, document_id, before, after. Cada denial (resolver) emite WARN document_access_denied.
Quién puede subir documentos Política
Reglas hardcoded en los endpoints POST /documents/upload y POST /documents/drive-pick (Fase B ACL, 2026-05-10):
| Destino | Permitido a | Endpoint | Respuesta a no autorizados |
|---|---|---|---|
| Biblioteca (visible para todo el estudio por default) | Solo role='admin' |
Sin session_id en el body |
HTTP 403 con mensaje redirigiendo a chat o Drive del matter |
| Sesión de chat individual (upload-y-attach, no entra a biblioteca pública) | Cualquier user con user.profile != NULL (abogado o paralegal); admins siempre |
Con session_id en el body |
HTTP 403 con mensaje pidiendo perfilamiento al admin |
| Carpeta Drive del matter (sync por scraper, no endpoint API) | Permisos de Drive, fuera del scope de la app | — | Si el user no tiene write en la carpeta Drive del matter, Google lo bloquea. |
Frontend: los botones Subir documento e Importar desde Drive en la biblioteca tienen data-admin-only y se ocultan via applyAdminOnlyGates() a usuarios no-admin. Si admin entra a "Ver como" un viewer, los botones se ocultan también. El backend igual rechaza con 403 — la UI es solo para no mostrar botones inútiles.
Promoción futura a policy-driven: hoy la regla está hardcoded. Cuando llegue el caso real de "el team X puede subir a biblioteca pero el Y no", se eleva a resource_type='feature' con manifest yaml y items upload_library, upload_chat, ambos editables desde el policy editor. Ver entry en docs/next-phases.md § "Acceso granular a documentos".
Lo que aún no hace esta app
Tracking en docs/next-phases.md § "Phase 4 follow-ups → Acceso granular a documentos":
| Capacidad | Estado | Workaround |
|---|---|---|
| Toggle Restringir acceso al subir el documento | Sí (2026-05-08). | Modal automático antes del upload. |
Endpoint /api/documents/{id}/acl/preview con count de personas que efectivamente verán el doc (resolución teams → miembros, perfiles → usuarios con ese perfil) | No implementado | El owner inspecciona miembros del team manualmente en la admin UI. |
Profile-scope ACL (compartir con todos los abogado) | Sí (2026-05-10, migration 032). | Picker "Perfiles con acceso" en el modal de Compartir. |
| Inheritance automático desde matter / proyecto | No implementado | Compartir explícitamente con los teams del matter. |
| Default-restricted (everyone-readable se vuelve opt-in) | No | — |
| Audit log persistente de mutaciones de ACL | Sí (Phase 4.2 → tabla audit_events, evento doc_acl_changed con payload before / after). | Configuración → Actividad → Eventos. |
| Audit log de accesos exitosos (read tracking) | No | Phase 4.2 captura mutaciones, no lecturas. Trigger: requisito de compliance "quién leyó qué". |
Tag-based ACL via documento_tipo (clasificación, no per-row) | Slot reservado, collector retorna [] | Usar ACL por documento; los dos pueden coexistir cuando el feature aterrice. |
| Sticky-attach: cambios de ACL no afectan adjuntos de sesiones existentes | Comportamiento documentado (2026-05-10): el resolver corre al adjuntar, no por turno. Una sesión que ya tiene el doc adjuntado conserva acceso aunque el user pierda el ACL. Tools user-doc-specific (anonymize_with_dlp, extract_structured_form) sí re-checan. | Si necesitas forzar la salida, admin detacha manualmente desde el chat. |
| Gating de upload policy-driven (override per-team) | Hardcoded admin-only en biblioteca, profile-required en chat. Ver § Quién puede subir. | Cuando llegue requisito real de override per-team, promover a resource_type='feature'. |
| UI muestra "Compartido con N personas" en el listado | Solo el badge 🔒 booleano | Click "Compartir…" para ver la lista efectiva. |
Accesos a Drive — Modelo C
La app gestiona accesos a las carpetas de Drive de cada matter (proyecto) sincronizándolos con el ACL de la app. La cuenta de servicio app-sa@altlegal-ai.iam.gserviceaccount.com mantiene los permisos del folder del matter alineados con las políticas de tipo resource_type='matter'.
Modelo de seguridad:
| Quién | Acceso al Shared Drive root | Acceso por matter folder |
|---|---|---|
| Admins / partners | Sí (Content manager) | Heredado del root |
| Usuarios non-admin | No | Sólo si tienen ACL en el matter; la SA comparte el folder con su email automáticamente |
Per-user folder (AltLegal AI Docs — <email>) | N/A | Compartido explícito con el user al login (sobrevive la revocación del root) |
Folder Compartidos | N/A | Compartido con team General (todos los users autenticados) |
Regla central: si un usuario non-admin no tiene ACL en un matter, no puede acceder al folder del matter ni vía la app ni vía Drive UI directo. El ACL de la app es la fuente de verdad.
Layout del Shared Drive
Convención operativa (la app no impone la ruta — sólo gestiona permisos al folder id linkeado):
Shared Drive root
├── Documentos/ ← reservado para chat + Compartidos
│ ├── PROD/
│ │ ├── Compartidos/ ← shared con team General (todos)
│ │ └── <user-email>/ ← per-user session uploads
│ └── LOCAL/ ← idem para env local
└── Clientes/
└── <Cliente>/ ← folder padre del cliente (opcional)
└── <Proyecto>/ ← matter folder (linkeado a matter.drive_folder_id)
└── ... ← contenido del proyecto
Documentos/.
Ese branch se reserva para la infraestructura del chat (sessions, Compartidos). Los matters viven en Clientes/<Cliente>/<Proyecto>/ para que los grants manuales sobre la carpeta del cliente cascadeen naturalmente a sus proyectos.
Vincular un folder a un matter UI
Antes de vincular: la cuenta de servicio app-sa@altlegal-ai.iam.gserviceaccount.com debe tener acceso al folder. Si no lo tiene, el endpoint responde HTTP 400 con instrucción explícita.
- Crea (o ubica) el folder en
Clientes/<Cliente>/<Proyecto>/del Shared Drive. - En Drive UI → Compartir → pega
app-sa@altlegal-ai.iam.gserviceaccount.comcomo Editor o Content manager. Sin notificación. - En la app → vista del matter → "Vincular carpeta de Drive" → pega el ID o la URL. El validador acepta ambos formatos (URL → ID se normaliza automáticamente).
- Al guardar, el link valida acceso de la SA (preflight) y dispara reconcile inmediato. Si ya existían policies sobre ese matter, los grants se aplican al instante.
Conceder accesos por policy
El mecanismo de granularidad vive en Configuración → Equipos → Políticas, sección Proyectos (Matters). Cada matter es un recurso independiente: una policy lista UUIDs específicos de matters, no clientes ni patrones.
Acceso por team
Ejemplo: Cliente X con 4 proyectos. Team Judicial debe ver los 2 laborales; team Corporativo los 2 de M&A.
team='Judicial' → resource_type='matter' mode='allow' resource_ids=[uuid_laboral_1, uuid_laboral_2]
team='Corporativo' → resource_type='matter' mode='allow' resource_ids=[uuid_mya_1, uuid_mya_2]
Resultado en Drive:
- Miembros de Judicial ven los 2 folders laborales en "Compartido conmigo". No ven los corporativos.
- Miembros de Corporativo ven sólo los corporativos.
- Si un usuario está en ambos teams: union de allows (ve los 4).
- Si alguien sale del team Judicial: el siguiente reconcile (al editar la policy) le revoca acceso a los 2 folders laborales.
Acceso por user_override
Para accesos individuales sin meter al usuario en un team:
scope_type='user_override'conmode='allow'+resource_ids=[<uuid>]→ grant puntual. Caso típico: paralegal externa que entra a un proyecto específico, o cliente que necesita ver entregables.scope_type='user_override'conmode='deny'→ revoca acceso a un miembro del team para un matter sin sacarlo del equipo. Caso: conflicto de interés.
Compartir manualmente desde Drive UI
El reconcile sólo gestiona permisos directos en la carpeta raíz del matter (matter.drive_folder_id). Los grants heredados se ignoran (vía permissionDetails.inherited=true). Esto define qué shares manuales sobreviven:
| Dónde compartes manualmente | ¿Sobrevive al reconcile? | Notas |
|---|---|---|
| En la carpeta raíz del matter | No — el siguiente reconcile la revoca | Para accesos recurrentes, crea un user_override allow policy en la app y la SA lo gestiona |
| En un subfolder o archivo dentro del matter | Sí | Drive permite permisos a nivel de archivo. El reconcile no toca children, sólo la raíz. |
En la carpeta del cliente padre (Clientes/<Cliente>/) | Sí | Los grants en ancestros se propagan a los proyectos hijos por herencia y aparecen marcados como inherited=true en la lista de permisos, así que el reconcile los filtra y no los toca. |
| En el root del Shared Drive | Sí (idem ancestro) | Otorga acceso a todo el drive — equivale a admin de facto. Usar sólo para partners. |
Clientes/<Cliente>/ directamente con el auditor desde Drive UI. Los grants heredados sobreviven al reconcile del app. El auditor ve todos los proyectos del cliente sin que la app interfiera.
Caso Acme/ con auditor@externa.cl desde Drive UI, el próximo cambio de policy revoca el grant. Para accesos recurrentes en un solo proyecto, usa user_override allow en la app — la SA lo aplica y sobrevive todos los reconciles.
Cuándo se ejecuta el reconcile
El reconcile (reconcile_matter_acl_to_drive) corre como tarea asyncio fire-and-forget cuando:
- Se hace upsert de una policy de tipo
matter→ reconcile sobreunion(old_resource_ids, new_resource_ids). - Se hace delete de una policy de tipo
matter→ reconcile sobre todos los matters afectados. - Se vincula un folder a un matter (
PUT /matters/{id}/drive-folder) → reconcile sobre ese matter. - Manualmente via script:
PYTHONPATH=. python scripts/backfill-matter-drive-permissions.py --apply [--matter-id <uuid>]
Latencia esperada: el endpoint HTTP retorna inmediato. El reconcile toma 1-5s por matter; el usuario ve el folder en su Drive ~1-5s después del cambio de policy.
Comportamiento del reconcile:
- Carga el matter; si
drive_folder_ides NULL, retornaskipped_no_folder=Truey registra el audit event sin tocar Drive. - Resuelve los emails autorizados desde
permission_policies(expande team viateam_members; user_override via user id; profile-scope queda como warning + skip en v1). - Lista permisos directos del folder vía Drive API (filtra heredados y no-user).
- Aplica diff: grants nuevos + revokes de los que sobran. Cada grant/revoke es independiente; un fallo no detiene los demás.
- Emite audit event
matter_drive_synccon el resumen completo.
Auditoría y verificación
Cada reconcile emite un evento matter_drive_sync en tenant_altlegal.audit_events. Payload:
{
"folder_id": "1abc...",
"granted": ["fsanchez@altlegal.cl"],
"revoked": [],
"unchanged_count": 3,
"errors": [],
"skipped_no_folder": false
}
Para verificar la última sync de un matter (consola psql vía Cloud SQL Proxy):
SELECT created_at, payload
FROM tenant_altlegal.audit_events
WHERE event_type = 'matter_drive_sync'
AND target_id = '<matter_id>'
ORDER BY created_at DESC LIMIT 3;
Para ver el log en Cloud Run:
gcloud logging read 'resource.type=cloud_run_revision \
AND resource.labels.service_name=altlegal-ai \
AND textPayload:"matter_drive_sync"' \
--limit=20 --freshness=1h --project=altlegal-ai
Troubleshooting
| Síntoma | Causa probable | Acción |
|---|---|---|
| HTTP 400 al vincular folder con mensaje "La cuenta de servicio app-sa@... no puede acceder al folder" | El SA no tiene permiso sobre el folder. | Compartir el folder con app-sa@altlegal-ai.iam.gserviceaccount.com como Editor desde Drive UI; reintentar. |
reconcile registra 404 al listar/crear permisos | SA perdió acceso al folder (admin lo quitó) o el folder fue movido a la papelera. | Volver a compartir el folder con la SA; si fue eliminado, desvincular del matter (drive_folder_id = NULL) o re-linkear a uno nuevo. |
Audit event muestra skipped_no_folder: true | El matter no tiene drive_folder_id. | No es error — la policy queda registrada y aplicará cuando se vincule un folder. |
| Auditor externo desaparece después de un cambio de policy | Estaba shared directo en la raíz del matter. | Mover el share a la carpeta del cliente padre (heredado, sobrevive); o crear user_override allow policy. |
userRateLimitExceeded en backfill masivo | Drive API rate limit con muchos matters seguidos. | Re-correr el backfill; o intercalar sleep 1 entre matters editando el script. |
| User cambió de email y no ve el folder | El share quedó con el email viejo. | Forzar reconcile del matter: PYTHONPATH=. python scripts/backfill-matter-drive-permissions.py --apply --matter-id <uuid> |
Lo que aún no hace
| Capacidad | Estado | Workaround |
|---|---|---|
Expansión de policies con scope_type='profile' sobre matters | v1 logea warning + skip | Usar team o user_override; profile→users es una iteración futura. |
| Rol por policy (writer vs reader vs commenter) | Hardcoded a writer (Contributor) | ~10 líneas de código si se necesita per-policy. Trigger: requisito real de view-only por team. |
| Botón "Forzar reconcile" desde la UI del matter | No — sólo via script | Edita y guarda cualquier policy del matter; el upsert dispara reconcile. |
| Revocación automática del Shared Drive root para usuarios non-admin | Paso manual de Workspace admin | Documentado en docs/runbooks/drive-per-matter-acl.md. |
| Notificación al usuario cuando recibe acceso a un matter | No (se usa sendNotificationEmail=False) | El folder aparece silenciosamente en "Compartido conmigo" del user. Trigger: feedback de usuarios pidiendo notificación. |
Actividad — telemetría y audit log
Phase 4.2 (mayo 2026) shippea persistencia de eventos críticos del sistema. Resuelve dos preguntas que antes solo respondían los logs de stdout:
- "¿Quién hizo qué y cuándo?" — mutaciones de configuración, equipos, políticas, artefactos, documentos, ACLs, sesiones de chat.
- "¿Qué modelo respondió, cuántos tokens consumió, cuánto demoró, hubo fallback?" — una fila por cada llamada al
ResilientProvider.
Implementación: dos tablas narrow bajo tenant_altlegal, escritura asíncrona / fail-soft (detalle), viewer paginado en la pestaña Actividad de Configuración.
/api/admin/* requieren role='admin' — un viewer recibe HTTP 403.
Eventos auditables
21 tipos de eventos en producción, divididos por dominio. Cada fila en audit_events persiste: event_type, actor_user_id (FK users.id con SET NULL en delete), actor_email (snapshot — sobrevive el delete del user), target_type + target_id (referencia al objeto mutado) y payload JSONB con la diff o snapshot relevante.
| Dominio | event_type | Cuándo dispara |
|---|---|---|
| Políticas | policy_upserted | Crear o editar una política (incluye scope_type, scope_ref, resource_type, mode, resource_ids). |
policy_deleted | Eliminar política (snapshot del scope antes del delete). | |
| Equipos y perfil | team_created | POST /api/admin/teams. |
team_updated | PATCH equipo. Payload con before / after. | |
team_archived | Soft-delete (default team es protegido — no dispara). | |
team_member_added / team_member_removed | POST / DELETE en /teams/{id}/members. | |
profile_updated / user_role_changed | PATCH perfil (abogado ↔ paralegal) o cambio de role (admin ↔ viewer). | |
| Artefactos (entregables PDP) | artifact_submitted | Cada vez que create_artifact persiste un draft (tool submit_artifact o POST directo). |
artifact_approved | POST /matters/{id}/artifacts/{aid}/approve. | |
artifact_rejected | DELETE /matters/{id}/artifacts/{aid} sobre un draft. Payload con tipo, version, source_session_id. Alimenta el ratio aprobación de la sub-pestaña Calidad PDP. | |
artifact_exported | Export a Google Doc — payload incluye drive_doc_id. | |
| Proyectos | matter_created | POST /clients/{cid}/matters. |
matter_archived | Soft-delete del matter (cascade a sesiones + Drive trash). | |
| Documentos | doc_uploaded | Upload directo o pick desde Drive — payload incluye name, mime_type, size_bytes, source. |
doc_deleted | Soft-delete del documento (snapshot de name pre-delete). | |
doc_acl_changed | Upsert o clear de ACL — payload con before / after. | |
| Sesiones de chat | chat_session_created | POST /api/chat/sessions. |
chat_session_associated_to_matter | Mover sesión orphan → proyecto genérico. | |
chat_session_deleted | DELETE de la sesión (bloqueado si produjo entregables aprobados). | |
| Impersonación admin | impersonation_started | POST /api/admin/impersonate/{user_id}. Payload con email, role y profile del target. Actor es siempre el admin real. |
impersonation_ended | DELETE /api/admin/impersonate. |
Lo que NO se loggea: mensajes individuales de chat (volumen alto, valor bajo — el histórico ya vive en chat_messages); lecturas de cualquier tipo (lista de docs, queries al corpus).
El catálogo de eventos es un Literal cerrado en src/audit/types.py. Agregar uno nuevo requiere code edit + spec update — no se puede inyectar tipos arbitrarios desde la UI.
Uso LLM (tokens y latencia)
Cada llamada exitosa a ResilientProvider.generate() o generate_stream() persiste una fila en tenant_altlegal.llm_call_log con:
user_id+user_email(snapshot),session_id,caso_de_uso_id,matter_id— contexto de la llamada.model_id(ej.claude-sonnet-4-6) +provider(anthropic/google/deepseek/openai).tokens_in+tokens_out— extraídos de la respuesta del proveedor (cada provider expone_extract_usagecon su mapeo específico).latency_ms— tiempo total desde antes del primer intento hasta éxito (incluye hops de fallback).hop_count—0si el modelo primario respondió;≥1si hubo fallback.error_kind—NULLen éxito; el kind del último error (rate_limit,timeout,auth, etc.) si la cadena se agotó completa.
Edge cases:
- Provider devuelve sin objeto
usage→ tokens=0 + WARN logusage_extract_failed. La fila igual se persiste. - Streaming con fallback antes de emitir → registra el modelo que efectivamente emitió,
hop_count= hops fallidos previos. - Streaming con fallback mid-stream → no hay fallback (contrato del
ResilientProvider). Loggea como exhaustion conerror_kinddel error que rompió el stream. - Cadena exhausta (todos los modelos fallaron) → fila con
tokens_in=0,tokens_out=0,hop_count= chain length,error_kinddel último error.
Pestaña Actividad UI
Configuración → Actividad. Solo visible para admins. Dos sub-vistas:
Eventos
- Tabla paginada (20/pág) ordenada por timestamp descendente.
- Filtros: dropdown por
event_type, fecha desde / hasta. Click en "Actualizar" recarga. - Cada fila: timestamp, actor email, pill con event_type,
target_type:target_id, y un<details>expandible con el payload JSONB completo.
Uso LLM
- Selector segmented por bucket de tiempo: este mes (default) / últimos 7 días / últimos 30 días.
- Group_by dropdown: por modelo / proveedor / caso de uso / usuario.
- Tabla agregada con: clave, total de calls, tokens in, tokens out, total tokens, latencia promedio (ms), % fallback (porcentaje de calls donde el primario falló).
- Footer con suma global del período.
El % fallback es la métrica más útil para detectar problemas de proveedor: si Claude Sonnet sube de 0% a 15%, probablemente Anthropic está rate-limiteando — momento de revisar la cadena fallback en el manifiesto LLM.
Calidad PDP UI
Sub-pestaña dedicada al ciclo de vida de los entregables. Cruza eventos artifact_submitted ↔ artifact_approved / artifact_rejected por target_id (id del artefacto) y agrupa por tipo. Por cada tipo de entregable muestra:
- Aprobados / Descartados / En curso — counts en el período.
- % aprobación = approved / (approved + rejected). Coloreado: ≥90 verde, ≥70 amarillo, ≥50 naranja, <50 rojo. Indicador rápido de qué tipos requieren rework frecuente.
- TTA mediana / p90 — time-to-approve, distancia entre el submit y el approve dentro del mismo target_id. Formato auto: s/min/h/d. Detecta pasos lentos (un TTA p90 de 3 días señala que el aprobador está ahogado o el draft requiere mucha edición).
Selector segmented por bucket: últimos 7 / 30 días / este mes / total. El filtro aplica al evento terminal — un draft submitido fuera de la ventana sigue contando si su aprobación cae dentro.
Flujo de uso típico: tras una semana de operación, ordenar por aprobados + descartados descendente para ver los tipos más frecuentes; revisar % aprobación bajo (<70 sugiere mejorar el prompt master del caso de uso); revisar TTA p90 alto (>1 día sugiere reasignar aprobadores).
API endpoints código
Todos admin-only (require_admin). Útiles para automatización, reporting externo o exportar a un BI.
| Endpoint | Query params | Devuelve |
|---|---|---|
GET /api/admin/audit/events |
event_type (CSV), actor_user_id, target_type, since / until (ISO date), limit (≤500), offset |
{events: [...], total: int} |
GET /api/admin/llm-usage/aggregate |
bucket ∈ {this_month, last_7_days, last_30_days}, group_by ∈ {model, provider, caso_de_uso, user} |
[{key, total_calls, tokens_in, tokens_out, total_tokens, avg_latency_ms, hop_rate}, ...] |
GET /api/admin/llm-usage/calls |
since / until, user_id, model_id, limit (≤500), offset |
{calls: [...], total: int} — listado raw |
GET /api/admin/metrics/artifact-flow |
bucket ∈ {last_7_days, last_30_days, this_month, all_time} |
{rows: [{tipo, approved_count, rejected_count, in_progress_count, approval_rate, tta_median_seconds, tta_p90_seconds, tta_count}], totals: {…}} |
Garantía fail-soft
Tanto log_event como log_llm_call wrap su trabajo en un try / except Exception: una falla de DB, un payload malformado o un cierre del event loop nunca rompen la acción del usuario que los disparó. En caso de error:
- Se loggea WARN a stdout (
audit_write_failedollm_log_write_failed) conevent_type/model_id+repr(error). - La función retorna
None. El handler HTTP / la herramienta del agente no se entera.
Implicación: si la pestaña Actividad muestra menos eventos de los esperados, hay que revisar los logs por audit_write_failed. La ausencia de la fila NO significa que la acción no ocurrió.
El log_llm_call usa asyncio.create_task fire-and-forget, de modo que el writer abre su propia DB session — esto desacopla la escritura del request lifecycle (la respuesta del usuario sale antes de que el row se escriba).
Crecimiento y purga
Volumen esperado en uso interno:
audit_events: 50–200 filas/día. Insignificante.llm_call_log: ~20–50 calls/sesión × ~10 sesiones/abogado/día × 5 abogados → ~2.500 filas/día. Postgres se ríe; en un año son ~900K, todavía cómodo.
No hay TTL automático en v1. Si en algún momento el tamaño preocupa, purga manual recomendada (siempre con backup primero):
# Snapshot antes de borrar
docker compose exec db pg_dump -U postgres -d legal_ai \
-t tenant_altlegal.audit_events \
-t tenant_altlegal.llm_call_log \
> /tmp/telemetry-snapshot-$(date +%Y%m%d).sql
# Conservar últimos 12 meses de audit
docker compose exec db psql -U postgres -d legal_ai -c "
DELETE FROM tenant_altlegal.audit_events
WHERE created_at < now() - interval '12 months';
"
# Conservar últimos 90 días de llm_call_log
docker compose exec db psql -U postgres -d legal_ai -c "
DELETE FROM tenant_altlegal.llm_call_log
WHERE created_at < now() - interval '90 days';
"
# Snapshot a Cloud Storage antes de borrar (Cloud SQL exporta directo a GCS)
gcloud sql export sql altlegal-ai-pg \
gs://altlegal-ai-backups/telemetry-$(date +%Y%m%d).sql \
--database=legal_ai \
--table=tenant_altlegal.audit_events,tenant_altlegal.llm_call_log
# Conservar últimos 12 meses de audit
gcloud sql connect altlegal-ai-pg --user=postgres --database=legal_ai \
--quiet << 'EOF'
DELETE FROM tenant_altlegal.audit_events
WHERE created_at < now() - interval '12 months';
EOF
# Conservar últimos 90 días de llm_call_log
gcloud sql connect altlegal-ai-pg --user=postgres --database=legal_ai \
--quiet << 'EOF'
DELETE FROM tenant_altlegal.llm_call_log
WHERE created_at < now() - interval '90 days';
EOF
Phase 6 va a automatizar esto con un job programado.
Lo que aún no hace esta app
Decisiones de scope al cerrar Phase 4.2. Tracking en docs/next-phases.md § "Telemetría operacional":
| Capacidad | Estado | Workaround |
|---|---|---|
| Gráficos / dashboards visuales (tendencias, top users, distribución por modelo) | No (Phase 4.3) | La data está disponible vía /api/admin/llm-usage/aggregate; armar un dashboard externo (Grafana, Metabase) hasta que aterrice. |
| Costos en USD por modelo / proyecto / mes | No (Phase 5) | Tokens están registrados; multiplicar manualmente por la pricing table del proveedor. |
| Audit log de mensajes individuales de chat | Excluido v1 (volumen alto, valor bajo) | Histórico de chat ya en chat_messages; query directa a DB. |
| Audit log de lecturas (read tracking) | No | Phase 4.2 captura solo mutaciones. Trigger: requisito de compliance "quién leyó qué". |
| Streaming export a CSV / JSON desde el viewer | No | Vía API: curl ... | jq. |
| Replay / undo desde el audit log | No (audit es read-only) | Reverter cambios desde el flujo admin correspondiente. |
| Retención automática (TTL purge) | No (Phase 6) | Purga manual; ver § Crecimiento y purga. |
Cuotas / billing basados en llm_call_log | No (Phase 5) | Tracking ya en su lugar; falta UI de límites + alertas. |
Despliegue — visión general
La plataforma corre en dos entornos:
| Entorno | Stack | Comando primario |
|---|---|---|
| Desarrollo local | Docker Compose en la máquina del desarrollador (app + Postgres + pgvector). | docker compose up -d --build |
| Producción | GCP: Cloud Run (API + frontend estático servido por la misma imagen), Cloud SQL (Postgres), Secret Manager (claves API), Cloud Storage (assets / backups), Cloud Logging. | ./deploy.sh <subcomando> |
El frontend (HTML + JS + CSS) detecta el host al cargar esta página y muestra los comandos del entorno apropiado. Puedes flipear manualmente con el toggle del top.
deploy.sh código
Script único e idempotente, en la raíz del repo. Reemplaza un set ad-hoc de gcloud + Makefiles que tenía que recordar cada admin. Toda operación verifica el estado actual antes de aplicar cambios — correrlo dos veces seguidas no rompe nada.
Naming canónico (per gcp-conventions skill): project altlegal-ai (acepción al patrón altlegal-app-<workload> por ser inmutable), region europe-southwest1 (co-localizada con Vertex AI), Cloud SQL altlegal-ai-pg, Cloud Run service altlegal-ai, SAs app-sa y jobs-sa, secrets en kebab-case (claude-api-key, database-url, etc.).
Subcomandos disponibles:
| Subcomando | Hace | Cuándo usarlo |
|---|---|---|
init | Habilita APIs (Cloud Run, SQL, Secret Manager, Artifact Registry, IAM, Vertex AI, etc.), crea SAs app-sa + jobs-sa con roles mínimos, Artifact Registry repo, bucket de deploy artifacts. Verifica el bucket de uploads (creado por vertex-bootstrap.sh). Crea Cloud SQL Postgres 15 + DB legal_ai. | Una vez por proyecto. Idempotente: si algo ya existe, se saltea. |
secrets sync | Lee .env.deploy, crea/actualiza secrets en Secret Manager (kebab-case naming), bind a secretAccessor para ambas SAs. Skip silente para vars vacías. Bumpea versiones; Cloud Run las recoge en próximo cold start. | Bootstrap inicial + rotar claves API + agregar proveedor LLM. |
deploy | docker buildx --platform=linux/amd64 de la imagen → push a Artifact Registry → gcloud run deploy nueva revisión. Frontend (web/*) viaja en la misma imagen (FastAPI StaticFiles), no hay CDN separado. | Cambios en código Python, web/*, casos de uso (.md backupeados), manifest YAML, dependencias. |
migrate | Cloud Run Job one-shot que corre alembic upgrade head contra Cloud SQL. Reusa la imagen del Cloud Run service. SA jobs-sa con cloudsql.client. | Después de pull con nuevas migraciones. Nota: el entrypoint.sh del container también corre alembic upgrade head al boot, así que un deploy suele bastar para nuevas migrations. |
seed-corpus [--force] | Genera dump SELECTIVO del DB local (allowlist de tablas BCN + PJUD + casos_de_uso + extracted) → sube a GCS → gcloud sql import con --user=postgres. El dump incluye TRUNCATE … RESTART IDENTITY CASCADE al inicio (idempotente sobre destinos con data parcial). | Primer deploy. Re-import del corpus tras un drop. Destructivo sobre las tablas del allowlist — pide confirmación TTY salvo --force. |
seed-admin | Cloud Run Job que ejecuta scripts/seed-prod-bootstrap.py — upsert del user admin (LEGAL_AI_AUTH_SEED_ADMIN_EMAIL) y crea team "General" si no existe. | Opcional: el primer login OAuth crea el admin automáticamente, y el lifespan del FastAPI crea el team General al boot. |
oauth | Imprime instrucciones manuales para crear el OAuth Client en Google Console (la API iap oauth-brands fue deprecada 2026-03-19, ya no es scriptable). | Una vez al levantar el proyecto. Tras crear el client, llenar LEGAL_AI_GOOGLE_OAUTH_CLIENT_ID + LEGAL_AI_GOOGLE_OAUTH_CLIENT_SECRET en .env.deploy y correr secrets sync + deploy. |
all [--force] | Pipeline greenfield: init → secrets sync → deploy → migrate → seed-corpus → seed-admin. OAuth sigue siendo manual entremedio. | Levantamiento inicial de un proyecto. |
status | Imprime existencia/no de cada recurso (APIs, SAs, Cloud SQL, AR, buckets, Cloud Run, secrets, Jobs). | Diagnostico, auditoria de drift. |
url | Imprime URL pública del Cloud Run service. | Tras un deploy nuevo, copiar la URL para OAuth redirect o testing. |
logs | gcloud run services logs tail. | Debugging en runtime. |
help | Imprime ayuda con el flow recomendado paso-a-paso. |
gcloud CLI autenticado (gcloud auth login + ADC para Vertex), Docker con buildx (linux/amd64 cross-build desde Apple Silicon), gsutil, python3. El .env.deploy debe tener al menos DB_PASSWORD + LEGAL_AI_SESSION_SECRET (auto-generables: openssl rand). Los demás secrets son opcionales (vacíos = provider no disponible).
Bootstrap inicial de un entorno
Correr una vez al crear un proyecto GCP nuevo. Flow recomendado paso-a-paso:
# 1. Una vez por máquina
gcloud auth login
gcloud config set project altlegal-ai
gcloud auth application-default login # ADC para Vertex desde local
# 2. Configurar .env.deploy
cp .env.deploy.example .env.deploy
# Editar: DB_PASSWORD, LEGAL_AI_SESSION_SECRET (openssl rand)
# Opcional: LEGAL_AI_CLAUDE_API_KEY, LEGAL_AI_DEEPSEEK_API_KEY, etc.
# 3. Pipeline greenfield (cada subcomando es idempotente)
./deploy.sh init # ~10 min (Cloud SQL es lento)
./deploy.sh deploy # primer Cloud Run (sin secrets aún; URL listo)
./deploy.sh oauth # instrucciones para crear OAuth Client
# — paso manual en Google Console
# editar .env.deploy con OAuth client_id + client_secret
./deploy.sh secrets sync # sube secrets a Secret Manager
./deploy.sh deploy # re-deploy: Cloud Run picks up secrets
./deploy.sh migrate # alembic via Cloud Run Job (idempotente)
./deploy.sh seed-corpus # dump local + import a Cloud SQL
./deploy.sh seed-admin # opcional: user admin + team General
# 4. Verificar
./deploy.sh status # todo ✓
./deploy.sh url # URL pública del Cloud Run service
# 5. Dominio custom (opcional, una vez)
gcloud beta run domain-mappings create \
--service altlegal-ai \
--domain app.altlegal.cl \
--region europe-southwest1
Cambios iterativos
Workflows típicos por tipo de cambio:
| Cambio | Comando | Tiempo aprox |
|---|---|---|
Edité web/* (HTML/JS/CSS) o código Python | ./deploy.sh deploy | ~3-5 min (build + push + revision swap) |
| Cambié un caso de uso (vía UI admin) | — | Instantáneo. Persiste en DB, sobrevive redeploys. |
| Cambié el manifest YAML de LLM models | ./deploy.sh deploy | ~3-5 min (manifest va en la imagen) |
| Agregué una migración Alembic + código que la requiere | ./deploy.sh deploy (el entrypoint corre alembic upgrade head al boot) | ~3-5 min |
| Roté una clave API | ./deploy.sh secrets sync && ./deploy.sh deploy | ~3-5 min (nueva revision picks up :latest) |
| Solo cambió un secret (sin código) | ./deploy.sh secrets sync + cold start natural (o gcloud run services update --update-env-vars=RELOAD_AT=$(date +%s)) | ~30s |
| Re-importar corpus tras drop | ./deploy.sh seed-corpus --force | ~3-5 min |
| Rollback a revisión anterior | gcloud run services update-traffic altlegal-ai --to-revisions=<rev>=100 --region=europe-southwest1 | <30s |
deploy crea una revisión nueva. El tráfico migra cuando la nueva pasa healthchecks; si fallan, la anterior sigue activa. Un deploy con bug NO tira el servicio — basta el rollback manual via update-traffic. Las migraciones de DB sí son irreversibles sin alembic downgrade; evita correrlas si no estás seguro.
Dominio personalizado — visión general
La app es accesible vía https://altlegal.paralegal.technology (dominio gestionado en GoDaddy, mapeado a Cloud Run mediante domain mapping nativo). Certificado SSL provisto por Google Cloud (managed) — auto-renueva sin intervención.
Por qué Bélgica para Cloud Run
El gcloud beta run domain-mappings create NO está disponible en Madrid (europe-southwest1). Google sólo soporta domain mappings nativos en un subconjunto de regiones (entre ellas europe-west1, europe-west4, us-central1). Las alternativas eran:
- Global External Load Balancer sobre Cloud Run Madrid — funciona pero cuesta ~$18 USD/mes extra (forwarding rule + LB).
- Mover Cloud Run a Bélgica — gratis, latencia 5ms Bélgica↔Madrid sobre la DB.
Se eligió la segunda. Cloud SQL queda en Madrid (no se movió para evitar migración de datos). Cloud Run conecta vía Cloud SQL Auth Proxy cross-region, latencia despreciable.
| Componente | Región | Razón |
|---|---|---|
| Cloud Run service | europe-west1 (Bélgica) | Soporta domain mappings |
| Artifact Registry | europe-west1 | Co-localizado con Cloud Run |
Cloud SQL altlegal-ai-pg | europe-southwest1 (Madrid) | Permanece — no se migró DB |
| Vertex AI calls | europe-southwest1 (primary), europe-west4 (fallback) | Latencia Bélgica→Madrid ~5ms aceptable |
| Bucket de backups EU multi-region | EU (multi-region) | DR cross-region |
Crear / modificar domain mapping código
gcloud beta run domain-mappings create \
--service=altlegal-ai \
--domain=altlegal.paralegal.technology \
--region=europe-west1 \
--project=altlegal-ai
El comando devuelve los records DNS requeridos. Mientras DNS no esté propagado el cert managed espera en estado CertificateProvisioning.
Verificar estado del mapping:
gcloud beta run domain-mappings describe \
--domain=altlegal.paralegal.technology \
--region=europe-west1 --project=altlegal-ai \
--format='value(status.conditions[].type,status.conditions[].status)'
Salida esperada en estado operativo: Ready=True · CertificateProvisioned=True · DomainRoutable=True.
Records DNS en GoDaddy
Una vez creado el mapping, agregar el CNAME al dominio en GoDaddy:
Type: CNAME
Name: altlegal
Value: ghs.googlehosted.com.
TTL: 600
Propagación típica: 5-30 min. Verificar con:
dig altlegal.paralegal.technology +short
# Debe devolver: ghs.googlehosted.com. seguido de una IP de Google
FAILED_PRECONDITION: domain not verified. Verificación es one-time por dominio raíz (no por subdominio).
OAuth redirect URIs código
Tras activar el dominio, agregar a Google Cloud Console → APIs & Services → Credentials → OAuth client altlegal-ai:
- Authorized redirect URIs:
https://altlegal.paralegal.technology/auth/callback - Authorized JavaScript origins:
https://altlegal.paralegal.technology
Sin esto, el login falla con redirect_uri_mismatch. Mantener los URIs anteriores (URL *.run.app) como respaldo durante el cutover.
SSL cert managed
Google provee y renueva automáticamente un certificado SSL gestionado para el dominio mapeado. Estado:
gcloud beta run domain-mappings describe \
--domain=altlegal.paralegal.technology \
--region=europe-west1 --project=altlegal-ai \
--format='value(status.conditions[?type="CertificateProvisioned"].status)'
Si el cert queda en Unknown > 1 h tras configurar el CNAME: probable issue de DNS (no propagó al validador de Google). Revisar dig. Si FAILED_NOT_VISIBLE: el record CNAME no está visible públicamente todavía.
Rollback / re-mapping
Si el dominio nuevo falla y necesitas volver al URL *.run.app
El URL automático altlegal-ai-tpuelbed5a-ew.a.run.app sigue activo siempre, en paralelo al dominio custom. Apuntar usuarios a esa URL es rollback inmediato. Sin acciones.
Mover el dominio a otra región / service
# 1. Eliminar el mapping actual
gcloud beta run domain-mappings delete \
--domain=altlegal.paralegal.technology \
--region=europe-west1 --project=altlegal-ai
# 2. Crear el nuevo mapping en la región/service destino
gcloud beta run domain-mappings create \
--service=<nuevo-service> \
--domain=altlegal.paralegal.technology \
--region=<nueva-region> --project=altlegal-ai
# 3. Actualizar el CNAME en GoDaddy si la región nueva da un destino distinto
# 4. Esperar re-provisionamiento del cert (~15 min)
Borrar mapping permanentemente
gcloud beta run domain-mappings delete \
--domain=altlegal.paralegal.technology \
--region=europe-west1 --project=altlegal-ai
Quita el dominio del service; el cert managed se descarta. Borrar también el CNAME en GoDaddy.
Backups Cloud SQL — visión general
La instancia altlegal-ai-pg tiene 4 mecanismos de respaldo combinados que cubren distintos escenarios de recovery:
| Mecanismo | Frecuencia | Retención | Storage | Cubre |
|---|---|---|---|---|
| Automated backup | Diario 04:00 UTC | 7 backups | Madrid | Snapshot completo, restore in-place o a clone |
| PITR (transaction log) | Continuo | 7 días | Madrid | Restore a timestamp exacto, RPO <1 min |
| Snapshot filtrado manual | On-demand | Indefinido | Madrid (altlegal-ai-backups) | Sync local↔prod sin sessions/audit/logs |
| Cross-region weekly export | Domingo 03:00 UTC | 90 días (versions) | EU multi-region (altlegal-ai-backups-eu) | DR cross-region |
Runbook detallado:
docs/runbooks/db-backups.md · Procedimientos de restore: docs/runbooks/db-restore.md.
Listar backups disponibles
gcloud sql backups list \
--instance=altlegal-ai-pg \
--project=altlegal-ai
Devuelve una tabla con columnas ID, WINDOW_START_TIME, ERROR, STATUS, INSTANCE. El ID es el handle que necesitas para hacer restore. Identifica el backup del día anterior por WINDOW_START_TIME.
PITR — Point-in-Time Recovery
Habilitado el 2026-06-06. Cloud SQL archiva transaction logs continuamente — permite restaurar a cualquier timestamp dentro de los últimos 7 días con granularidad de segundo.
Restore a punto exacto en tiempo (NO destructivo — crea instancia nueva):
gcloud sql instances clone altlegal-ai-pg altlegal-ai-pg-restored \
--point-in-time='2026-06-06T03:45:00Z' \
--project=altlegal-ai
Tras crear el clone, validar contenido, exportar lo necesario, y eliminar el clone. Costo del clone: ~$1/día mientras vive.
Costo PITR: ~$1-2 USD/mes adicionales (transaction log storage). Habilitado con:
gcloud sql instances patch altlegal-ai-pg \
--enable-point-in-time-recovery \
--project=altlegal-ai
Cross-region weekly export DR
Cloud Scheduler dispara semanalmente un export gestionado por Cloud SQL Admin API hacia un bucket multi-region EU. Sobrevive un outage completo de Madrid.
| Componente | Valor |
|---|---|
| Bucket destino | gs://altlegal-ai-backups-eu (location: EU, multi-region) |
| Object versioning | Habilitado — preserva versiones tras overwrite |
| Lifecycle | 90 días → delete (versions live y non-current) |
| Scheduler job | altlegal-ai-pg-weekly-export en europe-west1 |
| Frecuencia | 0 3 * * 0 UTC (domingo 03:00, ~00:00 Chile) |
| Service account | backup-scheduler@altlegal-ai.iam.gserviceaccount.com |
| Costo | <$1 USD/mes (storage + egress single-region → multi-region) |
Verificar próximo fire + última ejecución:
gcloud scheduler jobs describe altlegal-ai-pg-weekly-export \
--location=europe-west1 --project=altlegal-ai \
--format='value(state,scheduleTime,lastAttemptTime,status)'
Trigger manual (fuera de schedule):
gcloud scheduler jobs run altlegal-ai-pg-weekly-export \
--location=europe-west1 --project=altlegal-ai
Listar versiones disponibles en el bucket EU:
gcloud storage ls --all-versions -l gs://altlegal-ai-backups-eu/
Snapshot filtrado manual
Script scripts/backup-prod-snapshot.sh genera un pg_dump filtrado que excluye chat_sessions, audit_events, llm_call_log, notifications. Útil para sync local↔prod o pre-flight antes de cambios riesgosos.
# Snapshot + upload a GCS Madrid:
bash scripts/backup-prod-snapshot.sh --gcs-bucket=altlegal-ai-backups
# Sólo local:
bash scripts/backup-prod-snapshot.sh --output=/tmp/snapshot.sql.gz
Atómico: TRUNCATE + INSERT envueltos en una sola transacción (fix del incidente 2026-05-26). Si el import falla a mitad, rollback automático — la DB NO queda vacía.
Restore (rollback) — dos rutas
Ruta A — restore in-place (DESTRUCTIVO)
altlegal-ai-pg completamente. Todo lo posterior al snapshot se pierde sin recuperación. Cloud Run desconecta y reconecta automático (~5 min downtime). Usar sólo si estás seguro de que la DB actual está corrupta o quieres descartar lo de hoy.
gcloud sql backups restore <BACKUP_ID> \
--restore-instance=altlegal-ai-pg \
--project=altlegal-ai
Ruta B — restore a instancia temporal (NO destructivo, recomendado)
Crea una nueva instancia con los datos del snapshot. Te conectas, copias lo que necesites, lo importas a la instancia productiva. Cuando terminas, borras la temporal.
# 1. Crear instancia temporal desde el backup
gcloud sql backups restore <BACKUP_ID> \
--restore-instance=altlegal-ai-pg-snapshot \
--project=altlegal-ai
# 2. Conectarse vía Cloud SQL Auth Proxy
gcloud sql connect altlegal-ai-pg-snapshot \
--user=postgres \
--database=legal_ai \
--project=altlegal-ai
# 3. Exportar las tablas / filas que necesitas. Ejemplo:
# pg_dump -h ... -U postgres -d legal_ai \
# -t tenant_altlegal.casos_de_uso \
# --data-only --column-inserts > restore.sql
# Luego en la productiva:
# psql -h ... -U postgres -d legal_ai < restore.sql
# 4. Cuando terminas, eliminar la temporal para no acumular costo
gcloud sql instances delete altlegal-ai-pg-snapshot \
--project=altlegal-ai \
--quiet
Configuración de backups
Verificar config actual:
gcloud sql instances describe altlegal-ai-pg \
--project=altlegal-ai \
--format='yaml(settings.backupConfiguration)'
Modificar horario o retención:
# Cambiar la hora del backup diario (UTC)
gcloud sql instances patch altlegal-ai-pg \
--backup-start-time=03:00 \
--project=altlegal-ai
# Cambiar la retención (cuántos backups guardar antes de rotar)
gcloud sql instances patch altlegal-ai-pg \
--retained-backups-count=14 \
--project=altlegal-ai
# Deshabilitar backups (NO recomendado)
gcloud sql instances patch altlegal-ai-pg \
--no-backup \
--project=altlegal-ai
Verificación periódica recomendada
| Check | Frecuencia | Cómo |
|---|---|---|
Último automated backup SUCCESSFUL | Diario (automatizable) | gcloud sql backups list --instance=altlegal-ai-pg --limit=1 |
Scheduler job ENABLED | Mensual | gcloud scheduler jobs describe altlegal-ai-pg-weekly-export --location=europe-west1 |
| Bucket EU recibe export semanal | Semanal | gcloud storage ls -l gs://altlegal-ai-backups-eu/ (mtime debe avanzar cada domingo) |
| Drill de restore (clone temporal + validar) | Trimestral | Ver docs/runbooks/db-restore.md § Escenario 1 paso 4 |
Gaps reconocidos (no implementados, evaluar trigger):
- Alertas Cloud Monitoring sobre backup failure — gratis, no implementado todavía.
- Aumentar retención automated de 7 a 30 días — ~+$1-2/mes, sólo si compliance lo pide.
- Read replica cross-region (HA caliente con failover automático) — ~+$27-30/mes, over-engineered hoy.