48 KiB
48 KiB
PENNY — Asistente Personal de Voz
Especificación Técnica v1.0
Componente del Sistema DECK
Índice
- Definición
- Arquitectura
- Planos de Información
- Estructura del Log
- Integración con GRACE
- Pipeline de Voz
- Modelos Autoalojados
- Conversación Natural por Turnos
- Implementación de Referencia
1. Definición
1.1 ¿Qué es PENNY?
PENNY es el asistente personal de voz del sistema DECK. Proporciona una interfaz conversacional hablada 100% natural para interactuar con el ecosistema.
┌─────────────────────────────────────────────────────────────────────┐
│ PENNY │
│ │
│ • ES la voz del DECK │
│ • ES la interfaz hablada con el usuario │
│ • ES quien llama a GRACE cuando necesita datos │
│ • HABLA con el usuario (GRACE no puede) │
│ • REGISTRA todo en el log (planos de información) │
│ • MANTIENE contexto durante la sesión │
│ │
│ "PENNY habla, GRACE procesa, el Log recuerda." │
│ │
└─────────────────────────────────────────────────────────────────────┘
1.2 Principios Fundamentales
| Principio | Descripción |
|---|---|
| Privacidad radical | Audio procesado 100% local en servidor autoalojado |
| Conversación natural | Turnos fluidos, interrupciones, latencia <2s |
| Log como fuente de verdad | Todo está en el log, incluyendo personalidad |
| Planos separados | Contextos cargables independientemente |
| GRACE como backend | PENNY pregunta, GRACE extrae, PENNY responde |
1.3 Rol en el Sistema
┌─────────────────────────────────────────────────────────────────────┐
│ DECK (Servidor Personal) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Usuario ◄────────────────────────────────────────────► PENNY │
│ (voz natural) │ (habla) │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ GRACE │ │ LOG │ │ VAULT │ │
│ │ (datos) │ │(planos) │ │(recados)│ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
2. Arquitectura
2.1 Diagrama de Componentes
┌─────────────────────────────────────────────────────────────────────┐
│ PENNY VOICE ENGINE (Servidor Local) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Silero │ │ Faster │ │ PENNY │ │ XTTS-v2 │ │
│ │ VAD │──▶ Whisper │──▶ CORE │──▶ /Kokoro │ │
│ │ │ │ │ │ │ │ │ │
│ │ Detecta │ │ Transcribe │ │ Orquesta │ │ Sintetiza │ │
│ │ voz │ │ audio │ │ todo │ │ respuesta │ │
│ └────────────┘ └────────────┘ └─────┬──────┘ └────────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ PLANOS │ │ GRACE │ │ THE VAULT │ │
│ │ (Log) │ │ (Contrato │ │ (Recados) │ │
│ │ │ │ Común) │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
│ SOLO texto (nunca audio)
▼
┌─────────────────────────────┐
│ Claude API │
│ (Conversación inteligente) │
└─────────────────────────────┘
2.2 Flujo de Procesamiento
┌─────────────────────────────────────────────────────────────────────┐
│ FLUJO VOZ → VOZ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. VAD DETECTA (Silero) │
│ └── Usuario empieza a hablar │
│ └── Usuario termina (silencio 700ms) │
│ │
│ 2. ASR TRANSCRIBE (Faster-Whisper) │
│ └── Audio → Texto │
│ └── ~200-400ms con GPU │
│ │
│ 3. PENNY CORE PROCESA │
│ ├── Cargar planos relevantes │
│ ├── ¿Necesita GRACE? → Llamar con Contrato Común │
│ ├── Construir prompt con contexto │
│ └── Enviar a Claude API (solo texto) │
│ │
│ 4. LLM RESPONDE (Claude) │
│ └── Streaming de tokens │
│ └── ~1-2s primera palabra │
│ │
│ 5. TTS SINTETIZA (XTTS-v2/Kokoro) │
│ └── Texto → Audio │
│ └── Streaming mientras llegan tokens │
│ │
│ 6. LOG REGISTRA │
│ └── Todo el intercambio → plano CONVERSACION │
│ │
│ LATENCIA TOTAL OBJETIVO: <2 segundos voice-to-voice │
│ │
└─────────────────────────────────────────────────────────────────────┘
3. Planos de Información
3.1 Concepto
El Log de PENNY está organizado en planos (layers) que se cargan independientemente. Cada plano tiene un propósito específico y puede actualizarse sin afectar a los demás.
┌─────────────────────────────────────────────────────────────────────┐
│ PLANOS DE INFORMACIÓN │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PLANO 0: SISTEMA │ │
│ │ Instrucciones base, comportamiento fundamental │ │
│ │ INMUTABLE durante sesión │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PLANO 1: PERSONALIDAD │ │
│ │ Tono, estilo, nombre, voz │ │
│ │ CONFIGURABLE por usuario │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PLANO 2: CONTEXTO PERSONAL │ │
│ │ Información del usuario, preferencias, historial │ │
│ │ PERSISTENTE entre sesiones │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PLANO 3: CONTEXTO AMBIENTAL │ │
│ │ Fecha, hora, ubicación, estado del sistema │ │
│ │ DINÁMICO (actualiza cada sesión) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PLANO 4: DATASET │ │
│ │ Datos específicos de la tarea actual │ │
│ │ OPCIONAL (carga bajo demanda) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PLANO 5: CONVERSACIÓN │ │
│ │ Historial de la sesión actual │ │
│ │ VOLÁTIL (se archiva al cerrar sesión) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
3.2 Definición de Planos
PLANO 0: SISTEMA
plano: SISTEMA
id: plano_0
persistencia: permanente
modificable: false
contenido:
version: "1.0"
nombre_asistente: "Penny"
capacidades:
- conversacion_natural
- gestion_recados
- consulta_datos_via_grace
- registro_habitos
limitaciones:
- no_modifica_datos_directamente
- no_ejecuta_acciones_externas
- no_accede_internet
reglas:
- respuestas_concisas_max_3_oraciones
- confirmar_antes_de_crear_recados
- nunca_inventar_datos
integraciones:
grace: true
vault: true
vision_builder: true
PLANO 1: PERSONALIDAD
plano: PERSONALIDAD
id: plano_1
persistencia: permanente
modificable: true
contenido:
nombre: "Penny"
tono: "Cercano pero profesional"
estilo: "Directo, sin rodeos, eficiente"
tratamiento: "Tuteo natural"
humor: "Ligero cuando apropiado"
muletillas_evitar:
- "¡Claro que sí!"
- "¡Por supuesto!"
- "¡Genial!"
expresiones_permitidas:
- "Vale"
- "Entendido"
- "Listo"
voz_tts:
modelo: "xtts-v2"
speaker_wav: "/voces/penny_base.wav"
idioma: "es"
velocidad: 1.0
PLANO 2: CONTEXTO PERSONAL
plano: CONTEXTO_PERSONAL
id: plano_2
persistencia: permanente
modificable: true
player_id: "uuid-usuario"
contenido:
nombre_usuario: "Carlos"
preferencias:
idioma: "es"
formato_hora: "24h"
formato_fecha: "dd/mm/yyyy"
recordar:
- "Prefiere respuestas cortas"
- "Trabaja en tecnología"
- "Tiene reuniones los lunes por la mañana"
no_recordar:
- datos_financieros_especificos
- conversaciones_marcadas_privadas
historial_reciente:
ultima_sesion: "2025-12-15T18:30:00Z"
temas_frecuentes:
- "proyectos"
- "recados"
- "calendario"
PLANO 3: CONTEXTO AMBIENTAL
plano: CONTEXTO_AMBIENTAL
id: plano_3
persistencia: sesion
modificable: auto
contenido:
fecha: "2025-12-16"
hora: "10:30"
dia_semana: "martes"
timezone: "Europe/Madrid"
estado_sistema:
grace_disponible: true
vault_disponible: true
ultimo_backup: "2025-12-16T03:00:00Z"
recados_pendientes: 3
eventos_hoy:
- "14:00 - Llamada con cliente"
- "17:00 - Revisión semanal"
PLANO 4: DATASET
plano: DATASET
id: plano_4
persistencia: sesion
modificable: false
carga: bajo_demanda
contenido:
tipo: "productos"
fuente: "/bloques/datasets/catalogo_2025.json"
registros: 1547
campos:
- nombre
- precio
- stock
- categoria
fecha_actualizacion: "2025-12-10"
PLANO 5: CONVERSACIÓN
plano: CONVERSACION
id: plano_5
persistencia: sesion
modificable: append_only
contenido:
sesion_id: "uuid-sesion"
inicio: "2025-12-16T10:25:00Z"
turnos:
- turno: 1
rol: "usuario"
timestamp: "2025-12-16T10:25:15Z"
texto: "¿Qué tengo pendiente para hoy?"
audio_hash: "sha256..."
duracion_ms: 2100
- turno: 2
rol: "penny"
timestamp: "2025-12-16T10:25:17Z"
texto: "Tienes 3 recados pendientes y 2 eventos: llamada con cliente a las 14:00 y revisión semanal a las 17:00."
tokens_usados: 45
latencia_ms: 1850
grace_llamadas: 0
3.3 Planos Adicionales Sugeridos
| Plano | Propósito | Persistencia |
|---|---|---|
| MEMORIA_CORTA | Últimas 5 sesiones resumidas | 7 días |
| EXPERTISE | Conocimiento específico de dominio | Permanente |
| RESTRICCIONES | Límites y reglas temporales | Configurable |
| MODO | Estado actual (trabajo/personal/descanso) | Sesión |
4. Estructura del Log
4.1 Tabla Principal: PENNY_LOG
CREATE TABLE penny_log (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sesion_id UUID NOT NULL,
turno_index INTEGER NOT NULL,
-- Timing
timestamp_inicio TIMESTAMPTZ NOT NULL,
timestamp_fin TIMESTAMPTZ,
-- Contenido
rol VARCHAR(10) NOT NULL CHECK (rol IN ('usuario', 'penny', 'sistema')),
texto TEXT NOT NULL,
texto_hash VARCHAR(64) NOT NULL,
-- Audio (solo para entrada de usuario)
audio_ref VARCHAR(255), -- hostinger://audio/sesion/turno.wav
audio_hash VARCHAR(64),
audio_duracion_ms INTEGER,
-- Métricas
latencia_total_ms INTEGER,
latencia_asr_ms INTEGER,
latencia_llm_ms INTEGER,
latencia_tts_ms INTEGER,
tokens_prompt INTEGER,
tokens_respuesta INTEGER,
-- Trazabilidad
trace_id UUID,
grace_llamadas JSONB DEFAULT '[]',
-- Planos cargados
planos_activos JSONB NOT NULL,
-- Calidad
interrumpido BOOLEAN DEFAULT false,
confidence_asr DECIMAL(4,3),
-- Índices
CONSTRAINT unique_sesion_turno UNIQUE (sesion_id, turno_index)
);
-- Índices
CREATE INDEX idx_penny_log_sesion ON penny_log(sesion_id);
CREATE INDEX idx_penny_log_timestamp ON penny_log(timestamp_inicio);
CREATE INDEX idx_penny_log_trace ON penny_log(trace_id);
4.2 Tabla: PENNY_PLANOS
CREATE TABLE penny_planos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
plano_tipo VARCHAR(50) NOT NULL,
plano_version VARCHAR(20) NOT NULL,
player_id UUID, -- NULL para planos globales
-- Contenido
contenido JSONB NOT NULL,
contenido_hash VARCHAR(64) NOT NULL,
-- Metadata
fecha_creacion TIMESTAMPTZ DEFAULT NOW(),
fecha_modificacion TIMESTAMPTZ DEFAULT NOW(),
modificado_por VARCHAR(100),
-- Estado
activo BOOLEAN DEFAULT true,
CONSTRAINT unique_plano_version UNIQUE (plano_tipo, plano_version, player_id)
);
4.3 Tabla: PENNY_SESIONES
CREATE TABLE penny_sesiones (
id UUID PRIMARY KEY,
-- Usuario
player_id UUID NOT NULL,
-- Timing
inicio TIMESTAMPTZ NOT NULL,
fin TIMESTAMPTZ,
duracion_total_ms INTEGER,
-- Estadísticas
total_turnos INTEGER DEFAULT 0,
total_tokens_prompt INTEGER DEFAULT 0,
total_tokens_respuesta INTEGER DEFAULT 0,
-- Planos usados
planos_cargados JSONB NOT NULL,
-- Resumen
resumen TEXT, -- Generado al cerrar sesión
temas_detectados JSONB DEFAULT '[]',
-- Estado
estado VARCHAR(20) DEFAULT 'activa' CHECK (estado IN ('activa', 'cerrada', 'archivada'))
);
4.4 Ejemplo de Registro Completo
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sesion_id": "660e8400-e29b-41d4-a716-446655440001",
"turno_index": 3,
"timestamp_inicio": "2025-12-16T10:26:00.000Z",
"timestamp_fin": "2025-12-16T10:26:01.850Z",
"rol": "penny",
"texto": "La factura es de Telefónica por 45,90 euros con fecha del 10 de diciembre.",
"texto_hash": "sha256:a1b2c3...",
"latencia_total_ms": 1850,
"latencia_asr_ms": 0,
"latencia_llm_ms": 1200,
"latencia_tts_ms": 650,
"tokens_prompt": 1250,
"tokens_respuesta": 32,
"trace_id": "770e8400-e29b-41d4-a716-446655440002",
"grace_llamadas": [
{
"modulo": "FIELD_EXTRACTOR",
"trace_id": "880e8400-e29b-41d4-a716-446655440003",
"latencia_ms": 450,
"status": "SUCCESS"
}
],
"planos_activos": {
"plano_0": "v1.0",
"plano_1": "v1.2",
"plano_2": "v3.1",
"plano_3": "auto",
"plano_4": null,
"plano_5": "current"
},
"interrumpido": false,
"confidence_asr": null
}
5. Integración con GRACE
5.1 Principio Fundamental
┌─────────────────────────────────────────────────────────────────────┐
│ PENNY ↔ GRACE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ PENNY puede HABLAR GRACE puede PROCESAR │
│ PENNY no puede PROCESAR DATOS GRACE no puede HABLAR │
│ │
│ Cuando PENNY necesita datos: │
│ 1. PENNY llama a GRACE usando Contrato Común │
│ 2. GRACE procesa y devuelve datos estructurados │
│ 3. PENNY formula respuesta hablada con los datos │
│ │
│ "PENNY es la voz, GRACE es el cerebro analítico" │
│ │
└─────────────────────────────────────────────────────────────────────┘
5.2 Módulos de GRACE que PENNY Puede Invocar
| Módulo | Cuándo PENNY lo llama |
|---|---|
ASR |
Audio del usuario → Texto (interno, no directo) |
TTS |
Texto de respuesta → Audio (interno, no directo) |
FIELD_EXTRACTOR |
"¿Qué dice esta factura?" |
SUMMARIZER |
"Resume este documento" |
CLASSIFIER |
"¿De qué tipo es este email?" |
TASK_DETECTOR |
"¿Qué tareas hay en esta reunión?" |
LANG_NORMALIZER |
Detectar idioma de entrada |
5.3 Formato de Llamada PENNY → GRACE
{
"contract_version": "1.2",
"profile": "FULL",
"envelope": {
"trace_id": "penny-trace-uuid",
"parent_trace_id": "sesion-uuid",
"step_id": "grace-call-uuid",
"step_index": 1,
"total_steps": 1,
"idempotency_key": "sha256-del-contenido",
"timestamp_init": "2025-12-16T10:26:00.000Z",
"ttl_ms": 10000
},
"routing": {
"module": "FIELD_EXTRACTOR",
"version": "1.0",
"provider_preference": ["local"],
"fallback_chain": ["FIELD_EXTRACTOR_LOCAL"],
"max_fallback_level": 1
},
"context": {
"lang": "es",
"mode": "strict",
"pii_filter": true,
"player_id": "uuid-usuario",
"caller": "PENNY",
"caller_sesion": "uuid-sesion-penny"
},
"payload": {
"type": "document",
"encoding": "url",
"content": "hostinger://uploads/factura_2025.pdf",
"content_hash": "sha256...",
"schema_expected": "extracted_invoice"
},
"storage": {
"input_location": "internal",
"output_location": "internal",
"persist_intermediate": false
},
"security": {
"encryption_profile": "E2E_BASIC",
"data_sensitivity": "MEDIUM",
"pii_detected": true
}
}
5.4 Flujo Completo de Ejemplo
Usuario: "¿Qué dice la factura que escaneé ayer?"
┌─────────────────────────────────────────────────────────────────────┐
│ PASO 1: ASR (interno) │
│ Audio → "¿Qué dice la factura que escaneé ayer?" │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PASO 2: PENNY detecta necesidad de GRACE │
│ Análisis: usuario pide contenido de documento → FIELD_EXTRACTOR │
│ Buscar: última factura escaneada del usuario │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PASO 3: PENNY → GRACE (Contrato Común) │
│ Request: FIELD_EXTRACTOR + documento │
│ Response: { proveedor: "Telefónica", total: 45.90, fecha: "..." } │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PASO 4: PENNY construye respuesta │
│ Prompt a Claude con datos de GRACE + contexto de planos │
│ Respuesta: "La factura es de Telefónica por 45,90€..." │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PASO 5: TTS (interno) │
│ Texto → Audio │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ PASO 6: LOG │
│ Registrar turno completo + llamada a GRACE + métricas │
└─────────────────────────────────────────────────────────────────────┘
6. Pipeline de Voz
6.1 Stack Autoalojado Recomendado
# Configuración 100% local para máxima privacidad
pipeline:
vad:
modelo: silero-vad-v5
ubicacion: local
config:
threshold: 0.5
min_speech_duration_ms: 250
min_silence_duration_ms: 700 # Fin de turno
speech_pad_ms: 30
asr:
modelo: faster-whisper
variante: large-v3 # Máxima calidad
ubicacion: local
config:
compute_type: float16
device: cuda
language: es
beam_size: 5
vad_filter: true
fallback:
- variante: distil-large-v2 # Más rápido
- variante: medium # Mínimo aceptable
llm:
modelo: claude-sonnet-4
ubicacion: api # Único componente externo
config:
max_tokens: 150
temperature: 0.7
streaming: true
nota: "Solo texto, nunca audio. Zero Data Retention si enterprise."
tts:
modelo: xtts-v2
ubicacion: local
config:
speaker_wav: "/voces/penny_base.wav"
language: es
gpt_cond_len: 6
streaming: true
fallback:
- modelo: kokoro-82m # Más rápido, menor calidad
- modelo: piper # Emergencia
6.2 Requisitos de Hardware
hardware_minimo:
gpu: RTX 3060 (12GB VRAM)
cpu: 8 cores
ram: 32GB
almacenamiento: 100GB SSD NVMe
distribucion_vram:
faster_whisper_large: ~3GB
xtts_v2: ~3GB
buffer: ~6GB
hardware_recomendado:
gpu: RTX 3080/3090 (10-24GB VRAM)
cpu: 12+ cores
ram: 64GB
almacenamiento: 500GB SSD NVMe
beneficios:
- Modelos más grandes
- Batch processing
- Menor latencia
6.3 Latencias Objetivo
| Componente | Objetivo | Máximo Aceptable |
|---|---|---|
| VAD | <50ms | 100ms |
| ASR | <400ms | 800ms |
| LLM (primer token) | <500ms | 1500ms |
| LLM (completo) | <1500ms | 3000ms |
| TTS (primer audio) | <200ms | 500ms |
| Total voice-to-voice | <2000ms | <4000ms |
7. Modelos Autoalojados
7.1 Comparativa ASR
| Modelo | Latencia | Calidad | VRAM | Recomendación |
|---|---|---|---|---|
| Whisper Large V3 | 400ms | ★★★★★ | 3GB | Producción |
| Whisper Distil Large V2 | 200ms | ★★★★☆ | 2GB | Baja latencia |
| Whisper Medium | 300ms | ★★★☆☆ | 2GB | Fallback |
| Whisper Small | 150ms | ★★☆☆☆ | 1GB | No recomendado |
7.2 Comparativa TTS
| Modelo | Latencia | Naturalidad | VRAM | Clonación | Recomendación |
|---|---|---|---|---|---|
| XTTS-v2 | 300ms | ★★★★★ | 3GB | ✅ 6s audio | Producción |
| Kokoro-82M | 150ms | ★★★★☆ | 1GB | ❌ | Baja latencia |
| Piper | 50ms | ★★★☆☆ | <1GB | ❌ | Emergencia |
| OpenVoice | 400ms | ★★★★☆ | 2GB | ✅ | Alternativa |
7.3 Experiencias Reales de Usuarios
┌─────────────────────────────────────────────────────────────────────┐
│ FASTER-WHISPER + XTTS-v2 en RTX 3080 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ "Conseguí ~500ms latencia voice-to-voice con un modelo 24B │
│ de Mistral via Ollama. La clave es el streaming de TTS." │
│ — KoljaB, RealtimeVoiceChat │
│ │
│ "En una 4090 con faster-distil-whisper-large-v2 bajamos │
│ a 300ms de latencia total. Es casi como hablar con alguien." │
│ — voicechat2 │
│ │
│ "XTTS-v2 clone de la voz de mi abuelo me hizo llorar. │
│ Vale cada dolor de cabeza de instalación." │
│ — GitHub contributor │
│ │
│ "El turn detection es lo más difícil. Silero VAD funciona │
│ bien pero hay que ajustar los thresholds para cada persona." │
│ — Reddit r/LocalLLaMA │
│ │
└─────────────────────────────────────────────────────────────────────┘
8. Conversación Natural por Turnos
8.1 Principios de Turn-Taking
┌─────────────────────────────────────────────────────────────────────┐
│ CONVERSACIÓN NATURAL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. DETECCIÓN DE FIN DE TURNO │
│ ├── Silencio: 700ms mínimo (configurable) │
│ ├── Prosodia: bajada de tono indica fin │
│ └── Semántica: frase completa detectada │
│ │
│ 2. BARGE-IN (Interrupción) │
│ ├── Usuario puede interrumpir a PENNY │
│ ├── VAD detecta voz mientras TTS reproduce │
│ └── Se cancela TTS y se procesa nueva entrada │
│ │
│ 3. BACKCHANNELING │
│ ├── "Mm-hmm", "Vale", "Sí" no son turnos completos │
│ └── PENNY no debe responder a acknowledgments │
│ │
│ 4. OVERLAPPING │
│ ├── Permitir ligero solapamiento natural │
│ └── No cortar bruscamente al usuario │
│ │
└─────────────────────────────────────────────────────────────────────┘
8.2 Configuración de Turn Detection
turn_detection:
silero_vad:
speech_threshold: 0.5
min_speech_ms: 250
min_silence_ms: 700 # Fin de turno
padding_ms: 30
end_of_turn:
# Tiempo de silencio para considerar fin de turno
confident_silence_ms: 500 # Si hay alta confianza semántica
uncertain_silence_ms: 1000 # Si la frase parece incompleta
max_silence_ms: 2000 # Forzar fin de turno
barge_in:
enabled: true
min_user_speech_ms: 200 # Evitar falsos positivos
cancel_tts: true
cancel_llm_streaming: true
backchanneling:
ignore_patterns:
- "^(mhm|mm|sí|ajá|vale|ok|ya)$"
max_duration_ms: 500
8.3 Manejo de Estados
┌─────────────────────────────────────────────────────────────────────┐
│ ESTADOS DE PENNY │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ IDLE ──────────────────────────────────────────────────────────▶ │
│ │ │
│ │ VAD detecta voz │
│ ▼ │
│ LISTENING ─────────────────────────────────────────────────────▶ │
│ │ │
│ │ Silencio detectado (end of turn) │
│ ▼ │
│ PROCESSING ────────────────────────────────────────────────────▶ │
│ │ │
│ │ ASR completo + LLM responde │
│ ▼ │
│ SPEAKING ──────────────────────────────────────────────────────▶ │
│ │ │
│ ├── TTS termina → IDLE │
│ │ │
│ └── Barge-in → LISTENING (cancela TTS) │
│ │
└─────────────────────────────────────────────────────────────────────┘
8.4 Feedback Visual/Sonoro
feedback:
estados:
idle:
led: "azul tenue"
sonido: null
listening:
led: "azul pulsante"
sonido: "bip_inicio" # Opcional
processing:
led: "amarillo"
sonido: null
speaking:
led: "verde"
sonido: null
error:
led: "rojo"
sonido: "error_tone"
transiciones:
inicio_escucha: true # Feedback al empezar a escuchar
fin_escucha: false # Sin feedback (fluido)
inicio_respuesta: false # Directo a hablar
9. Implementación de Referencia
9.1 Estructura de Directorios
/penny/
├── config/
│ ├── pipeline.yaml # Configuración de modelos
│ ├── planos/
│ │ ├── sistema.yaml # Plano 0
│ │ ├── personalidad.yaml # Plano 1
│ │ └── default_user.yaml # Plano 2 template
│ └── turn_detection.yaml # Configuración de turnos
│
├── core/
│ ├── penny_core.py # Orquestador principal
│ ├── planos_manager.py # Gestión de planos
│ ├── grace_client.py # Cliente para GRACE
│ └── log_writer.py # Escritura a PENNY_LOG
│
├── voice/
│ ├── vad_processor.py # Silero VAD
│ ├── asr_processor.py # Faster-Whisper
│ ├── tts_processor.py # XTTS-v2 / Kokoro
│ └── turn_manager.py # Gestión de turnos
│
├── models/
│ ├── whisper/
│ │ └── large-v3/
│ ├── xtts/
│ │ └── v2/
│ └── silero/
│ └── vad_v5.onnx
│
├── voces/
│ └── penny_base.wav # Voz base para clonación
│
└── server/
├── main.py # Servidor FastAPI
├── websocket.py # WebSocket handler
└── routes.py # Endpoints REST
9.2 Dependencias
# requirements.txt
# Framework
pipecat-ai>=0.0.50
pipecat-ai[silero,whisper,coqui]
# LLM
anthropic>=0.35.0
# Audio
pyaudio>=0.2.14
numpy>=1.24.0
scipy>=1.11.0
soundfile>=0.12.0
# Servidor
fastapi>=0.109.0
uvicorn>=0.27.0
websockets>=12.0
# Base de datos
asyncpg>=0.29.0
sqlalchemy>=2.0.0
# Utilidades
pyyaml>=6.0
python-dotenv>=1.0.0
9.3 Configuración de Inicio
# config/pipeline.yaml
penny:
version: "1.0"
server:
host: "0.0.0.0"
port: 8765
websocket_path: "/ws/voice"
models:
vad:
type: "silero"
path: "models/silero/vad_v5.onnx"
asr:
type: "faster-whisper"
model: "large-v3"
path: "models/whisper/large-v3"
device: "cuda"
compute_type: "float16"
tts:
type: "xtts-v2"
path: "models/xtts/v2"
speaker_wav: "voces/penny_base.wav"
language: "es"
llm:
provider: "anthropic"
model: "claude-sonnet-4-20250514"
max_tokens: 150
temperature: 0.7
streaming: true
grace:
endpoint: "http://localhost:8080/api/v1"
timeout_ms: 10000
log:
database_url: "postgresql://penny:xxx@localhost:5432/penny_db"
retain_audio_days: 7
retain_text_days: 365
9.4 Ejemplo de Código Core
# core/penny_core.py
import asyncio
from typing import Optional
from dataclasses import dataclass
from .planos_manager import PlanosManager
from .grace_client import GraceClient
from .log_writer import LogWriter
@dataclass
class PennyConfig:
planos_path: str
grace_endpoint: str
llm_config: dict
log_config: dict
class PennyCore:
"""Orquestador principal de PENNY."""
def __init__(self, config: PennyConfig):
self.config = config
self.planos = PlanosManager(config.planos_path)
self.grace = GraceClient(config.grace_endpoint)
self.log = LogWriter(config.log_config)
self.sesion_id: Optional[str] = None
self.turno_index: int = 0
async def iniciar_sesion(self, player_id: str) -> str:
"""Inicia una nueva sesión de conversación."""
self.sesion_id = await self.log.crear_sesion(player_id)
self.turno_index = 0
# Cargar planos
await self.planos.cargar_plano(0) # Sistema
await self.planos.cargar_plano(1) # Personalidad
await self.planos.cargar_plano(2, player_id) # Contexto personal
await self.planos.cargar_plano(3) # Contexto ambiental (auto)
return self.sesion_id
async def procesar_turno(self, texto_usuario: str, audio_ref: Optional[str] = None) -> str:
"""Procesa un turno del usuario y genera respuesta."""
self.turno_index += 1
timestamp_inicio = datetime.utcnow()
# Detectar si necesita GRACE
grace_llamadas = []
if self._necesita_grace(texto_usuario):
resultado_grace = await self._llamar_grace(texto_usuario)
grace_llamadas.append(resultado_grace)
# Construir prompt con planos
prompt = self._construir_prompt(texto_usuario, grace_llamadas)
# Llamar a Claude
respuesta = await self._llamar_llm(prompt)
# Registrar en log
await self.log.registrar_turno(
sesion_id=self.sesion_id,
turno_index=self.turno_index,
rol="usuario",
texto=texto_usuario,
audio_ref=audio_ref,
timestamp_inicio=timestamp_inicio
)
await self.log.registrar_turno(
sesion_id=self.sesion_id,
turno_index=self.turno_index + 1,
rol="penny",
texto=respuesta,
grace_llamadas=grace_llamadas,
timestamp_inicio=datetime.utcnow()
)
self.turno_index += 1
return respuesta
def _necesita_grace(self, texto: str) -> bool:
"""Determina si la consulta requiere llamar a GRACE."""
keywords = ["factura", "documento", "escaneé", "resume", "extraer"]
return any(kw in texto.lower() for kw in keywords)
async def _llamar_grace(self, texto: str) -> dict:
"""Llama a GRACE con el Contrato Común."""
modulo = self._detectar_modulo_grace(texto)
return await self.grace.llamar(
modulo=modulo,
payload={"query": texto},
caller_sesion=self.sesion_id
)
def _construir_prompt(self, texto: str, grace_datos: list) -> str:
"""Construye el prompt completo con todos los planos."""
planos_texto = self.planos.obtener_contexto_completo()
prompt = f"""
{planos_texto}
--- DATOS DE GRACE ---
{json.dumps(grace_datos, indent=2) if grace_datos else "No se consultaron datos."}
--- USUARIO ---
{texto}
--- INSTRUCCIONES ---
Responde de forma concisa (máximo 3 oraciones) siguiendo tu personalidad definida.
"""
return prompt
Resumen
PENNY es el asistente de voz del DECK con las siguientes características:
- 100% autoalojado excepto Claude API (solo texto)
- Planos de información separados y cargables independientemente
- Log completo de todas las interacciones
- Integración con GRACE via Contrato Común
- Conversación natural por turnos con barge-in y turn detection
- Latencia objetivo <2 segundos voice-to-voice
Próximos Pasos
- Configurar servidor con GPU
- Instalar modelos (Whisper, XTTS-v2, Silero)
- Crear base de datos y tablas de log
- Definir planos específicos del usuario
- Grabar voz base para clonación TTS
- Implementar pipeline con Pipecat
- Ajustar turn detection para conversación natural
Documento generado: Diciembre 2025 Versión: 1.0