FichaYa AI Chatbot — Estudio Técnico Completo
Estado: ✅ Implementado — Fases 1, 2 y 3 completadas
Fecha: Abril 2026
Versión: 2.0 (actualizado 9 abril 2026)
1. Visión del Producto
1.1 ¿Qué es?
Un asistente conversacional inteligente integrado en FichaYa que permite a empleados y managers consultar datos de fichaje en lenguaje natural. No es un chatbot genérico — es un experto en control horario que conoce tus datos, la ley española y tus patrones.
1.2 Ejemplos de Conversación
Empleado:
👤 "¿Cuántas horas llevo esta semana?"
🤖 "Llevas 32h 15min en 4 días. Si mañana haces tu jornada habitual (8h), llegarás a 40h 15min."
👤 "¿He fichado fuera de la oficina algún día?"
🤖 "Sí, el miércoles 2 de abril fichaste la entrada a 1.2km del centro de trabajo (límite: 200m). Se generó una alerta."
👤 "¿Cumplo la normativa esta semana?"
🤖 "✅ Descanso entre jornadas: OK (mínimo 12h, tu mínimo fue 13h 20min)
✅ Pausa obligatoria: OK (jornadas >6h con pausa)
⚠️ Horas semanales: 40h 15min — superas por 15min el límite de 40h de tu contrato."
Manager/Owner:
👤 "¿Quién no ha fichado hoy?"
🤖 "3 empleados no han fichado entrada hoy: María López, Pedro García y Ana Ruiz. María tiene vacaciones registradas. Los otros 2 no tienen justificación."
👤 "Dame un resumen de horas del equipo esta semana"
🤖 "| Empleado | Horas | % vs contrato |
| María López | 38h 20min | 96% |
| Pedro García | 42h 10min | 105% ⚠️ |
| Ana Ruiz | 0h | 0% (vacaciones) |
Media del equipo: 38h 45min"
👤 "¿Alguien está haciendo demasiadas horas extra?"
🤖 "Pedro García acumula 8h 30min extra este mes (permitido hasta 80h/año por ley). A este ritmo llegará a 102h/año — superaría el límite legal. Recomiendo redistribuir carga."
1.3 Propuesta de Valor
| Para | Valor |
|---|---|
| Empleado | Consultar sus datos sin navegar menús. Saber si cumple la ley. Detectar anomalías. |
| Manager | Dashboard conversacional. Alertas proactivas. Cumplimiento normativo del equipo. |
| Owner | Reducción de riesgo legal. Datos para Inspección de Trabajo en segundos. |
| FichaYa (negocio) | Diferenciador enorme vs competencia. Upgrade path a planes Pro/Business. |
2. Decisión Arquitectónica: Function Calling vs Embeddings
2.1 Análisis
| Enfoque | Pros | Contras | Idóneo para |
|---|---|---|---|
| Embeddings (RAG) | Búsqueda semántica, patrones implícitos | No es preciso para datos numéricos/temporales, requiere re-indexar al fichar | Conocimiento estático (ley, FAQs, políticas) |
| Function Calling | Datos en tiempo real, 100% precisos, sin sincronización | Requiere LLM con buen soporte de tools, más tokens | Consultas sobre fichajes, cálculos de horas, alertas |
| Híbrido | Lo mejor de ambos mundos | Más complejidad | ✅ NUESTRA ELECCIÓN |
2.2 Decisión: Arquitectura Híbrida
┌─────────────────────────────────────────────────────────┐
│ USUARIO (chat UI) │
└────────────────────────┬────────────────────────────────┘
│ mensaje
▼
┌─────────────────────────────────────────────────────────┐
│ WORKER: /api/fichaya/chat │
│ │
│ 1. Auth (JWT → user_id, org_id, role) │
│ 2. Cargar contexto base (user, org, today clockings) │
│ 3. Enviar a LLM con TOOLS definidas │
│ 4. Ejecutar tool calls del LLM against D1 │
│ 5. Devolver respuesta final │
└───────┬──────────┬──────────┬───────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌──────────────┐
│DeepSeek │ │ D1 │ │ Vectorize │
│ Chat │ │(live) │ │(knowledge) │
│ (LLM) │ │ │ │ │
└─────────┘ └────────┘ └──────────────┘
2.3 Qué va en Function Calling (datos vivos)
| Tool | Descripción | Consulta D1 |
|---|---|---|
get_my_clockings |
Fichajes propios por rango de fechas | SELECT * FROM fichaya_clockings WHERE user_id = ? AND clock_date BETWEEN ? AND ? |
get_my_hours_summary |
Resumen de horas (día/semana/mes) | Agregación sobre clockings |
get_team_clockings |
Fichajes del equipo (solo admin+) | WHERE org_id = ? AND clock_date BETWEEN ... |
get_team_status |
Quién está trabajando/pausado/fuera ahora | SELECT name, last_clock_type, last_clock_at FROM fichaya_users WHERE org_id = ? |
get_geofence_alerts |
Alertas de geovalla | SELECT * FROM fichaya_alerts WHERE type = 'geofence_violation' |
get_corrections |
Correcciones pendientes/aprobadas | SELECT * FROM fichaya_corrections WHERE org_id = ? |
check_compliance |
Verificar cumplimiento normativo | Cálculos sobre clockings vs reglas (descanso 12h, pausa 15min, max 40h/sem) |
get_overtime_report |
Informe de horas extra | Clockings vs weekly_hours del contrato |
2.4 Qué va en Vectorize (conocimiento estático)
| Contenido | Tipo | Cuándo se indexa |
|---|---|---|
| Ley española de control horario (RD-Ley 8/2019, ET art. 34, 35, 37) | Texto legal chunked | Una vez (seed) |
| Normativa sectorial (hostelería, construcción, transporte, etc.) | Regulaciones específicas | Una vez (seed) |
| FAQ de FichaYa | Preguntas frecuentes de uso | Al actualizar docs |
| Políticas de empresa (si el admin las configura) | Reglas internas | Al modificar config |
| Resúmenes semanales históricos | "Semana 14: 38h, sin anomalías" | Cron semanal |
Nota: Los datos de fichaje en tiempo real NO se embedean. Se consultan vía function calling. Solo se embedean resúmenes y conocimiento estático.
3. Selección del LLM
3.1 Comparativa
| Modelo | Function Calling | Precio (1M tokens) | Latencia | Español | Contexto |
|---|---|---|---|---|---|
| DeepSeek Chat (V3) | ✅ Excelente | $0.27 in / $1.10 out | ~1.5s | Muy bueno | 64K |
| DeepSeek Reasoner (R1) | ❌ No soporta tools | $0.55 / $2.19 | ~4s | Bueno | 64K |
| Groq Llama 3.3 70B | ✅ Bueno | $0.59 / $0.79 | ~0.3s | Bueno | 128K |
| Workers AI Llama 3.1 8B | ⚠️ Básico | GRATIS (10K neurons/day) | ~2s | Aceptable | 8K |
| Gemini 2.0 Flash | ✅ Excelente | $0.10 / $0.40 | ~0.8s | Excelente | 1M |
| GPT-4o-mini | ✅ Excelente | $0.15 / $0.60 | ~1s | Excelente | 128K |
3.2 Decisión: DeepSeek Chat (primario) + Workers AI (fallback)
¿Por qué DeepSeek Chat?
- El más barato con function calling de calidad ($0.27/M input)
- Probado en producción en NutriNen (mismo stack)
DEEPSEEK_API_KEYya configurada en Cloudflare secrets- Buen español, buen razonamiento sobre datos numéricos
- Soporta
tool_choice: "auto"con múltiples tools
¿Por qué Workers AI como fallback?
- Es GRATIS (env.AI ya binding en wrangler.toml)
- Si DeepSeek cae o se agotan rate limits → fallback inmediato
- Para preguntas simples, Llama 3.1 8B es suficiente
- 0 latencia de red (mismo datacenter Cloudflare)
¿Por qué NO Gemini/GPT como primarios?
- Gemini requiere Google API key management (no configurada para FichaYa)
- GPT-4o-mini es 3x más caro que DeepSeek
- Ambos son buenos candidatos para el futuro si necesitamos más calidad
3.3 Estimación de Costes
| Escenario | Mensajes/día | Tokens/msg | Coste mensual |
|---|---|---|---|
| MVP (1 org, 5 usuarios) | ~50 | ~2K | ~$0.10 |
| Growth (50 orgs, 500 usuarios) | ~2,000 | ~2K | ~$4 |
| Scale (500 orgs, 5K usuarios) | ~20,000 | ~2K | ~$40 |
DeepSeek es tan barato que incluso en el plan Free de FichaYa podríamos dar 10 mensajes/día/usuario sin coste significativo.
4. Vectorize: Estrategia de Embeddings
4.1 Nuevo índice Vectorize
# En wrangler.toml → añadir:
[[vectorize]]
binding = "VECTORIZE_FICHAYA"
index_name = "fichaya-knowledge"
# Crear el índice (una vez):
npx wrangler vectorize create fichaya-knowledge \
--dimensions=1024 \
--metric=cosine
4.2 Modelo de embedding
Usar bge-m3 (el mismo que Codex) vía Workers AI — GRATIS:
const embeddings = await env.AI.run('@cf/baai/bge-m3', {
text: ["Artículo 34 del Estatuto de los Trabajadores..."]
});
// → vector de 1024 dimensiones
4.3 Contenido a indexar
Fase 1 — Seed (una vez):
| Documento | Chunks estimados | Ejemplo de chunk |
|---|---|---|
| RD-Ley 8/2019 (control horario) | ~15 | "Las empresas deben garantizar el registro diario de jornada, que incluirá el horario concreto de inicio y finalización..." |
| ET Art. 34 (jornada) | ~10 | "La duración máxima de la jornada ordinaria de trabajo será de 40 horas semanales de trabajo efectivo de promedio en cómputo anual." |
| ET Art. 35 (horas extra) | ~8 | "El número de horas extraordinarias no podrá ser superior a 80 al año..." |
| ET Art. 37 (descanso) | ~8 | "Entre el final de una jornada y el comienzo de la siguiente mediarán, como mínimo, 12 horas." |
| Convenios sectoriales básicos | ~20 | Reglas específicas por sector |
| FAQ FichaYa (uso de la app) | ~15 | "¿Cómo corrijo un fichaje erróneo? Pulsa en..." |
| Total | ~76 vectores |
Fase 2 — Resúmenes automáticos (cron semanal):
Cada lunes a las 03:00 UTC:
- Por cada usuario activo:
- Generar resumen de la semana anterior
- Embedear: "Usuario X, Semana 14/2026: 38h 20min trabajadas,
2 pausas/día (media 22min), sin anomalías geovalla,
fichaje medio entrada 08:52, salida 17:15"
- Insertar en Vectorize con metadata: { user_id, org_id, week, year }
Esto permite preguntas como "¿cómo han sido mis últimas semanas?" sin calcular en tiempo real 30 días de clockings.
4.4 Embeddings NO necesarios
NO embeddear:
- Fichajes individuales (son datos puntuales, mejor query directo a D1)
- Nombres de empleados (no añade valor semántico)
- Datos de geolocalización raw
- Cualquier dato que cambie más de 1 vez/semana
5. Function Calling: Diseño de Tools
5.1 Definición de herramientas para DeepSeek
const TOOLS = [
{
type: "function",
function: {
name: "get_clockings",
description: "Obtener fichajes de un usuario en un rango de fechas. Si el usuario es empleado, solo devuelve sus propios fichajes. Si es admin/owner, puede consultar los de cualquier empleado de su organización.",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "ID del usuario a consultar. Omitir para consultar los propios."
},
from: {
type: "string",
description: "Fecha inicio (YYYY-MM-DD). Default: hoy."
},
to: {
type: "string",
description: "Fecha fin (YYYY-MM-DD). Default: hoy."
}
}
}
}
},
{
type: "function",
function: {
name: "get_hours_summary",
description: "Calcular resumen de horas trabajadas, pausas e infracciones para un período. Devuelve horas_totales, pausas_totales, horas_extra, cumplimiento_normativo.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "ID del usuario. Omitir = yo" },
period: {
type: "string",
enum: ["today", "week", "month", "custom"],
description: "Período a consultar"
},
from: { type: "string", description: "Solo si period=custom. YYYY-MM-DD" },
to: { type: "string", description: "Solo si period=custom. YYYY-MM-DD" }
},
required: ["period"]
}
}
},
{
type: "function",
function: {
name: "get_team_status",
description: "Ver estado actual de todos los empleados de la organización: quién está trabajando, en pausa o fuera. Solo disponible para admin/owner/manager.",
parameters: {
type: "object",
properties: {}
}
}
},
{
type: "function",
function: {
name: "check_compliance",
description: "Verificar cumplimiento de normativa laboral española para un usuario o todo el equipo. Comprueba: descanso mínimo 12h entre jornadas, pausa 15min en jornadas >6h, máximo 40h/semana, máximo 80h extra/año.",
parameters: {
type: "object",
properties: {
user_id: { type: "string", description: "ID del usuario. Omitir = yo. 'all' = todo el equipo (solo admin)." },
period: {
type: "string",
enum: ["week", "month", "year"],
description: "Período a verificar"
}
},
required: ["period"]
}
}
},
{
type: "function",
function: {
name: "get_geofence_violations",
description: "Obtener fichajes realizados fuera de la geovalla del centro de trabajo.",
parameters: {
type: "object",
properties: {
user_id: { type: "string" },
from: { type: "string" },
to: { type: "string" }
}
}
}
},
{
type: "function",
function: {
name: "search_knowledge",
description: "Buscar en la base de conocimiento legal y de FAQ. Usar para preguntas sobre la ley de control horario, normativa laboral, o cómo usar FichaYa.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Pregunta o tema a buscar en la base de conocimiento"
}
},
required: ["query"]
}
}
}
];
5.2 Flujo de ejecución (multi-turn tool calling)
1. Usuario: "¿Cumplo la normativa esta semana?"
2. Worker recibe mensaje → construye contexto:
- system prompt + user info + tools
- Llama a DeepSeek Chat
3. DeepSeek responde con tool_call:
{ name: "check_compliance", arguments: { period: "week" } }
4. Worker ejecuta check_compliance():
- Query D1: clockings de esta semana del usuario
- Calcula: descanso entre jornadas, pausas, horas totales
- Devuelve resultado estructurado
5. Worker envía resultado al LLM como tool response
6. DeepSeek genera respuesta final en lenguaje natural:
"✅ Esta semana cumples la normativa:
- Descanso entre jornadas: OK (mínimo 13h 20min)
- Pausas: OK (todas las jornadas >6h tienen pausa)
- Horas semanales: 38h 20min (dentro de las 40h)"
5.3 Control de acceso por rol en tools
// Filtro RBAC antes de ejecutar cada tool
function canExecuteTool(toolName, userRole, targetUserId, currentUserId) {
// Empleados solo pueden consultar sus propios datos
if (userRole === 'employee') {
if (targetUserId && targetUserId !== currentUserId) {
return { allowed: false, reason: 'Solo puedes consultar tus propios datos' };
}
if (['get_team_status'].includes(toolName)) {
return { allowed: false, reason: 'Esta función requiere permisos de administrador' };
}
}
return { allowed: true };
}
6. Seguridad y Privacidad
6.1 Principios
| Principio | Implementación |
|---|---|
| Aislamiento por org | Todas las queries incluyen WHERE org_id = ? — SIEMPRE |
| RBAC estricto | Empleado solo ve sus datos. Admin ve toda su org. Nunca datos cross-org |
| Sin PII al LLM | No enviar emails ni datos de geolocalización al LLM. Solo nombres + horas |
| Logs de auditoría | Cada consulta del chatbot → fichaya_audit_log con action='ai_chat' |
| Rate limiting | Máx 30 mensajes/hora por usuario. Plan Free: 10 msgs/día |
| Sin almacenamiento de conversación en LLM | DeepSeek API no retiene datos. Historial solo en D1 |
6.2 Datos que SÍ van al LLM
- Nombre del usuario (para personalizar respuestas)
- Rol y departamento
- Datos de fichaje (hora entrada/salida, duración, tipo)
- Horas calculadas (totales, pausas, extra)
- Alertas y anomalías (sin coordenadas exactas)
6.3 Datos que NUNCA van al LLM
- Google ID
- Coordenadas GPS exactas (solo "dentro/fuera de geovalla")
- CIF de la empresa
- IP del usuario
- JWT tokens
6.4 Historial de conversación
-- Nueva tabla para mensajes del chat
CREATE TABLE IF NOT EXISTS fichaya_chat_messages (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
content TEXT NOT NULL,
tool_calls TEXT, -- JSON: [{name, arguments, result}]
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
model TEXT, -- 'deepseek-chat', 'llama-3.1-8b', etc.
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES fichaya_users(id)
);
CREATE INDEX idx_chat_user ON fichaya_chat_messages(user_id, created_at);
CREATE INDEX idx_chat_org ON fichaya_chat_messages(org_id, created_at);
7. System Prompt
Eres el asistente de FichaYa, la app española de control horario. Tu nombre es "Asistente FichaYa".
CONTEXTO DEL USUARIO:
- Nombre: {{user.name}}
- Rol: {{user.role}} ({{role_description}})
- Empresa: {{org.name}} (sector: {{org.sector}})
- Plan: {{org.plan}}
- Horas semanales de contrato: {{user.weekly_hours}}h
- Centro de trabajo: {{workcenter.name}}
REGLAS:
1. Responde SIEMPRE en español de España.
2. Sé conciso y directo. No uses más de 150 palabras por respuesta.
3. Usa datos reales de las tools — NUNCA inventes números.
4. Si no tienes datos suficientes, usa las tools para consultarlos.
5. Para preguntas legales, busca en la base de conocimiento (search_knowledge).
6. Si el usuario es empleado, nunca reveles datos de otros empleados.
7. Formato: usa markdown ligero (negritas, listas). Usa ✅/⚠️/❌ para cumplimiento.
8. Si te preguntan algo que no es sobre fichaje/horarios/normativa, responde amablemente que solo puedes ayudar con temas de control horario.
9. Las horas extra en España están limitadas a 80h/año (ET Art. 35.2).
10. El descanso mínimo entre jornadas es 12h (ET Art. 34.3).
11. Jornadas >6h requieren pausa mínima de 15min.
12. La jornada máxima ordinaria es 40h/semana de promedio anual.
HOY ES: {{today}} ({{weekday}})
HORA ACTUAL: {{current_time}}
8. Diseño de la UI
8.1 Componentes
┌────────────────────────────────┐
│ ← Asistente FichaYa ··· │ Header
├────────────────────────────────┤
│ │
│ ┌──────────────────────┐ │
│ │ ¿Cuántas horas llevo │ │ Burbuja usuario (derecha)
│ │ esta semana? │ │
│ └──────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ 🤖 Llevas 32h 15min en │ │ Burbuja asistente (izquierda)
│ │ 4 días laborables. │ │
│ │ │ │
│ │ Si mañana haces 8h, │ │
│ │ llegarás a 40h 15min. │ │
│ └─────────────────────────┘ │
│ │
│ ┌─ Sugerencias ──────────┐ │
│ │ [📊 Horas hoy] │ │ Quick actions (chips)
│ │ [⚖️ Cumplimiento] │ │
│ │ [👥 Estado equipo] │ │
│ └────────────────────────┘ │
│ │
├────────────────────────────────┤
│ [💬 Escribe tu pregunta...] ➤ │ Input + send
└────────────────────────────────┘
8.2 Quick Actions (sugerencias pre-definidas)
Para empleados:
- "¿Cuántas horas llevo hoy?"
- "¿Cumplo la normativa?"
- "¿Cuántas horas extra tengo este mes?"
- "Resumen de mi semana"
Para admins (adicionales):
- "Estado del equipo ahora"
- "¿Quién no ha fichado hoy?"
- "Resumen de horas del equipo"
- "Alertas de geovalla esta semana"
8.3 Integración en la app
Dos opciones:
Opción A — Vista dedicada (recomendada para MVP):
- Nuevo botón en bottom nav: 💬 (reemplaza BI o se añade como 6º)
- Vista completa de chat con scroll de mensajes
Opción B — Floating button:
- Botón flotante tipo "burbuja de chat" en esquina inferior
- Se expande como overlay sobre la vista actual
- Más elegante pero más complejo
Decisión → Opción A para MVP, con posibilidad de migrar a B en v2.
8.4 Animaciones
- Typing indicator: 3 dots pulsando mientras el LLM procesa
- Streaming: Mostrar respuesta token a token (DeepSeek soporta streaming)
- Tool execution: Badge "Consultando fichajes..." durante tool calls
- Fade-in: Burbujas aparecen con translateY(10px) → 0 + opacity
9. API: Endpoint /api/fichaya/chat
9.1 Request
POST /api/fichaya/chat
Authorization: Bearer <jwt>
Content-Type: application/json
{
"message": "¿Cuántas horas llevo esta semana?",
"conversation_id": "conv_abc123" // opcional, para continuar conversación
}
9.2 Response (streaming SSE)
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: tool_start
data: {"tool": "get_hours_summary", "label": "Consultando tus horas..."}
event: tool_end
data: {"tool": "get_hours_summary", "duration_ms": 120}
event: delta
data: {"content": "Llevas "}
event: delta
data: {"content": "32h 15min "}
event: delta
data: {"content": "esta semana..."}
event: done
data: {"tokens_in": 850, "tokens_out": 67, "model": "deepseek-chat", "conversation_id": "conv_abc123"}
9.3 Response (no streaming, para simplicidad del MVP)
{
"ok": true,
"data": {
"reply": "Llevas **32h 15min** en 4 días laborables...",
"conversation_id": "conv_abc123",
"tools_used": ["get_hours_summary"],
"model": "deepseek-chat",
"tokens": { "in": 850, "out": 67 }
}
}
9.4 Flujo interno del Worker
export async function onRequest(context) {
const { request, env } = context;
// 1. Auth
const auth = await verifyJWT(request, env);
if (!auth) return unauthorizedResponse();
// 2. Parse input
const { message, conversation_id } = await request.json();
// 3. Rate limit check
const msgCount = await countRecentMessages(env.DB, auth.sub, 60); // última hora
if (msgCount >= 30) return error('Límite de mensajes alcanzado', 429);
// 4. Load context
const user = await getUser(env.DB, auth.sub);
const org = await getOrg(env.DB, user.org_id);
const todayClockings = await getTodayClockings(env.DB, auth.sub);
// 5. Load conversation history (last 10 messages)
const history = conversation_id
? await getConversationHistory(env.DB, conversation_id, 10)
: [];
// 6. Build messages array
const messages = [
{ role: 'system', content: buildSystemPrompt(user, org, todayClockings) },
...history,
{ role: 'user', content: message }
];
// 7. Call DeepSeek with tools
let response;
try {
response = await callDeepSeek(env, messages, TOOLS);
} catch (e) {
// Fallback to Workers AI
response = await callWorkersAI(env, messages);
}
// 8. Handle tool calls (loop hasta que el LLM devuelva texto)
let attempts = 0;
while (response.tool_calls && attempts < 5) {
for (const call of response.tool_calls) {
const result = await executeToolCall(call, env.DB, user, org);
messages.push({ role: 'assistant', content: null, tool_calls: [call] });
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
response = await callDeepSeek(env, messages, TOOLS);
attempts++;
}
// 9. Save messages to D1
const convId = conversation_id || nanoid();
await saveMessage(env.DB, convId, user, 'user', message);
await saveMessage(env.DB, convId, user, 'assistant', response.content);
// 10. Audit log
await auditLog(env.DB, org.id, user.id, 'ai_chat', 'chat', convId);
// 11. Return
return success({
reply: response.content,
conversation_id: convId,
model: response.model || 'deepseek-chat',
tools_used: response.tools_used || [],
});
}
10. Tool Execution: Implementación
10.1 get_clockings
async function toolGetClockings(db, args, user, org) {
const targetUser = args.user_id || user.id;
// RBAC
if (targetUser !== user.id && user.role === 'employee') {
return { error: 'No tienes permisos para ver fichajes de otros usuarios' };
}
const from = args.from || new Date().toISOString().split('T')[0];
const to = args.to || from;
const clockings = await db.prepare(`
SELECT type, clock_time, clock_date, is_within_geofence
FROM fichaya_clockings
WHERE user_id = ? AND org_id = ? AND clock_date BETWEEN ? AND ?
ORDER BY clock_time ASC
`).bind(targetUser, org.id, from, to).all();
return {
user_id: targetUser,
from, to,
count: clockings.results.length,
clockings: clockings.results.map(c => ({
type: c.type,
time: c.clock_time,
date: c.clock_date,
geofence_ok: c.is_within_geofence === 1
}))
};
}
10.2 get_hours_summary
async function toolGetHoursSummary(db, args, user, org) {
const targetUser = args.user_id || user.id;
if (targetUser !== user.id && user.role === 'employee') {
return { error: 'Sin permisos' };
}
const today = new Date();
let from, to;
switch (args.period) {
case 'today':
from = to = today.toISOString().split('T')[0];
break;
case 'week':
const monday = new Date(today);
monday.setDate(today.getDate() - ((today.getDay() + 6) % 7));
from = monday.toISOString().split('T')[0];
to = today.toISOString().split('T')[0];
break;
case 'month':
from = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().split('T')[0];
to = today.toISOString().split('T')[0];
break;
case 'custom':
from = args.from;
to = args.to;
break;
}
const clockings = await db.prepare(`
SELECT type, clock_time, clock_date
FROM fichaya_clockings
WHERE user_id = ? AND org_id = ? AND clock_date BETWEEN ? AND ?
ORDER BY clock_time ASC
`).bind(targetUser, org.id, from, to).all();
// Calculate hours per day
const byDate = {};
for (const c of clockings.results) {
if (!byDate[c.clock_date]) byDate[c.clock_date] = [];
byDate[c.clock_date].push(c);
}
let totalWorkMs = 0, totalPauseMs = 0;
const dailyBreakdown = [];
for (const [date, entries] of Object.entries(byDate)) {
let dayWork = 0, dayPause = 0;
for (let i = 0; i < entries.length; i++) {
const curr = entries[i];
const next = entries[i + 1];
const start = new Date(curr.clock_time).getTime();
const end = next ? new Date(next.clock_time).getTime() : (curr.type !== 'out' ? Date.now() : start);
if (curr.type === 'in' || curr.type === 'pause_end') dayWork += end - start;
if (curr.type === 'pause_start') dayPause += end - start;
}
totalWorkMs += dayWork;
totalPauseMs += dayPause;
dailyBreakdown.push({
date,
hours_worked: +(dayWork / 3600000).toFixed(2),
hours_paused: +(dayPause / 3600000).toFixed(2),
});
}
const contractHours = user.weekly_hours || 40;
const workedHours = +(totalWorkMs / 3600000).toFixed(2);
return {
period: args.period,
from, to,
total_hours_worked: workedHours,
total_hours_paused: +(totalPauseMs / 3600000).toFixed(2),
contract_weekly_hours: contractHours,
overtime_hours: args.period === 'week' ? Math.max(0, +(workedHours - contractHours).toFixed(2)) : null,
days_worked: dailyBreakdown.length,
daily_breakdown: dailyBreakdown,
};
}
10.3 check_compliance
async function toolCheckCompliance(db, args, user, org) {
// ... (fetch clockings del período)
const checks = [];
// Check 1: Descanso mínimo 12h entre jornadas
const dates = Object.keys(byDate).sort();
for (let i = 1; i < dates.length; i++) {
const prevDay = byDate[dates[i-1]];
const currDay = byDate[dates[i]];
const lastOut = prevDay.filter(c => c.type === 'out').pop();
const firstIn = currDay.find(c => c.type === 'in');
if (lastOut && firstIn) {
const restHours = (new Date(firstIn.clock_time) - new Date(lastOut.clock_time)) / 3600000;
checks.push({
rule: 'Descanso entre jornadas (mín. 12h)',
status: restHours >= 12 ? 'ok' : 'violation',
detail: `${restHours.toFixed(1)}h entre ${dates[i-1]} y ${dates[i]}`,
legal_ref: 'ET Art. 34.3'
});
}
}
// Check 2: Pausa en jornadas >6h
for (const [date, entries] of Object.entries(byDate)) {
const workHours = calculateDayHours(entries);
if (workHours > 6) {
const hasPause = entries.some(e => e.type === 'pause_start');
checks.push({
rule: 'Pausa obligatoria en jornada >6h',
status: hasPause ? 'ok' : 'warning',
detail: `${date}: ${workHours.toFixed(1)}h, pausa: ${hasPause ? 'sí' : 'no registrada'}`,
legal_ref: 'ET Art. 34.4'
});
}
}
// Check 3: Máximo 40h/semana
if (args.period === 'week') {
const totalHours = calculateTotalHours(clockings);
checks.push({
rule: 'Jornada máxima semanal (40h)',
status: totalHours <= 40 ? 'ok' : totalHours <= 42 ? 'warning' : 'violation',
detail: `${totalHours.toFixed(1)}h esta semana`,
legal_ref: 'ET Art. 34.1'
});
}
return {
period: args.period,
total_checks: checks.length,
violations: checks.filter(c => c.status === 'violation').length,
warnings: checks.filter(c => c.status === 'warning').length,
checks
};
}
10.4 search_knowledge (RAG vía Vectorize)
async function toolSearchKnowledge(env, args) {
// 1. Embedear la query
const queryEmbedding = await env.AI.run('@cf/baai/bge-m3', {
text: [args.query]
});
// 2. Buscar en Vectorize
const results = await env.VECTORIZE_FICHAYA.query(
queryEmbedding.data[0],
{ topK: 3, returnMetadata: 'all' }
);
// 3. Devolver chunks relevantes
return {
results: results.matches.map(m => ({
content: m.metadata.text,
source: m.metadata.source,
score: m.score
}))
};
}
11. Infraestructura necesaria
11.1 Nuevos recursos Cloudflare
| Recurso | Tipo | Acción | Coste |
|---|---|---|---|
fichaya-knowledge |
Vectorize Index | wrangler vectorize create |
Gratis (Workers plan) |
VECTORIZE_FICHAYA |
Binding | Añadir a wrangler.toml | — |
/api/fichaya/chat |
Worker endpoint | Crear functions/api/fichaya/chat.js |
— |
/api/fichaya/chat/seed |
Worker endpoint | Script de seeding de vectores | — |
fichaya_chat_messages |
D1 tabla | Migración SQL | — |
11.2 Cambios en wrangler.toml
# Añadir:
[[vectorize]]
binding = "VECTORIZE_FICHAYA"
index_name = "fichaya-knowledge"
11.3 Migración D1
-- fichaya-002-chat.sql
CREATE TABLE IF NOT EXISTS fichaya_chat_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user','assistant','system','tool')),
content TEXT,
tool_calls TEXT,
tool_call_id TEXT,
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
model TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES fichaya_users(id)
);
CREATE INDEX IF NOT EXISTS idx_chat_conv ON fichaya_chat_messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_user ON fichaya_chat_messages(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_org ON fichaya_chat_messages(org_id, created_at);
-- Rate limiting helper
CREATE TABLE IF NOT EXISTS fichaya_chat_rate_limits (
user_id TEXT PRIMARY KEY,
msg_count_hour INTEGER DEFAULT 0,
msg_count_day INTEGER DEFAULT 0,
last_reset_hour TEXT,
last_reset_day TEXT,
FOREIGN KEY (user_id) REFERENCES fichaya_users(id)
);
12. Límites por Plan
| Característica | Free | Starter (€4.99) | Pro (€9.99) | Business (€19.99) |
|---|---|---|---|---|
| Mensajes/día | 10 | 50 | Ilimitado | Ilimitado |
| Historial chat | 7 días | 30 días | 90 días | 1 año |
| Tools disponibles | Básicas (horas, fichajes) | + Compliance | + Team analytics | Todas |
| Conocimiento legal | FAQ básica | + Ley completa | + Convenios sectoriales | + Custom policies |
| Streaming | ❌ | ✅ | ✅ | ✅ |
| Export respuestas | ❌ | ❌ | ✅ PDF + Excel |
13. Roadmap de Implementación
Fase 1 — MVP ✅ COMPLETADA
| Tarea | Descripción | Estado | Ficheros |
|---|---|---|---|
| 1.1 | Crear Vectorize index fichaya-knowledge |
✅ | CLI: wrangler vectorize create |
| 1.2 | Añadir binding a wrangler.toml | ✅ | wrangler.toml (línea 90-92) |
| 1.3 | Migración D1: tabla chat_messages | ✅ | fichaya_chat_messages en D1 |
| 1.4 | Worker /api/fichaya/chat con DeepSeek + tools |
✅ | functions/api/fichaya/chat.js (962 líneas) |
| 1.5 | Implementar 3 tools: get_clockings, get_hours_summary, get_team_status |
✅ | Dentro de chat.js |
| 1.6 | UI: Vista de chat en la app (botón en nav, burbujas, input) | ✅ | public/fichaya/index.html + fichaya.js + fichaya.css |
| 1.7 | Quick actions (chips de sugerencias) | ✅ | 7 chips: horas, semana, normativa, extra, ley, equipo, compliance |
| 1.8 | Workers AI fallback | ✅ | Llama 3.1 8B si DeepSeek falla |
| 1.9 | Rate limiting básico | ✅ | 30 msgs/hora via COUNT en D1 |
Fase 2 — Compliance & RAG ✅ COMPLETADA
| Tarea | Descripción | Estado |
|---|---|---|
| 2.1 | Seed Vectorize con ley española (RD-Ley 8/2019, ET) | ✅ 16 vectores: ET Arts.34-37, RD-Ley 8/2019, convenios, FAQ, jurisprudencia |
| 2.2 | Implementar check_compliance tool |
✅ 4 checks: descanso 12h, pausa 6h, 40h/sem, 80h extra/año |
| 2.3 | Implementar search_knowledge tool (RAG) |
✅ bge-m3 embeddings → Vectorize cosine search topK=4 |
| 2.4 | Cron semanal: generar y embedear resúmenes | ✅ weekly-summary.js — resúmenes semanales por usuario, D1 + Vectorize embedding |
Seed-knowledge pipeline:
functions/api/fichaya/seed-knowledge.js— usa DeepSeek Reasoner para generar contenido legal de calidad + web-fetch de BOE.es + bge-m3 embeddings. 16 topics en batches de 3 (~$0.05 total). Ejecutable comoPOST /api/fichaya/seed-knowledgecon auth admin.
Fase 3 — Streaming & Polish ✅ COMPLETADA (9 abril 2026)
| Tarea | Descripción | Estado |
|---|---|---|
| 3.1 | SSE streaming para respuestas token a token | ✅ stream: true en body → TransformStream + ctx.waitUntil() |
| 3.2 | "Consultando fichajes..." skeleton durante tool calls | ✅ Eventos tool_start/tool_end con badges animados |
| 3.3 | Persistencia de conversación (reabrir chat anterior) | ✅ localStorage + D1 (conversation_id) |
| 3.4 | Export a PDF de respuestas de compliance | ⏸️ Pendiente |
| 3.5 | Markdown mejorado: tablas GFM, headers, code blocks, links | ✅ Nuevo _renderMarkdown() + _inlineMarkdown() |
| 3.6 | Voice input: Web Speech API es-ES con auto-send |
✅ Botón micrófono con animación pulsante |
Fase 4 — Advanced ✅ COMPLETADA (9 abril 2026)
| Tarea | Descripción | Estado |
|---|---|---|
| 4.1 | Alertas proactivas ("Llevas 35h, cuidado con el límite") | ✅ chat-proactive.js — checks en tiempo real al abrir chat (horas, compliance, descanso, pausas, patrones) |
| 4.2 | Predicción de ausencias | ✅ predict-absences.js + tool predict_absences — análisis 90 días: patrones weekday, rachas, tendencias, risk score |
| 4.3 | Generación de informes para Inspección de Trabajo | ⏸️ Pendiente |
| 4.4 | ✅ Movido a Fase 3.6 | |
| 4.5 | Notificaciones push con insights diarios | ✅ push-subscribe.js + push-daily.js + sw.js v2.0 con push handler, VAPID keys, Web Push protocol |
Detalles Fase 4
4.1 Alertas proactivas
GET /api/fichaya/chat-proactive— endpoint ligero que analiza al usuario actual- Checks: fichaje pendiente, horas hoy >9h, semana cerca/sobre límite, descanso <12h, pausa >6h sin registro, alertas sin leer, patrón de ausencia
- Frontend:
_loadProactiveAlerts()se ejecuta al abrir el chat (solo si no hay historial previo) - Burbuja especial con borde amarillo y animación fade-in
- Incluye resumen: horas hoy, semana, % del contrato
4.2 Predicción de ausencias
GET /api/fichaya/predict-absences?user_id=X— API REST directa- Tool
predict_absencesintegrada en el chatbot para consultas conversacionales - Motor de predicción analiza 90 días de datos:
- Patrón por día de semana (e.g., "los viernes falta un 40%")
- Rachas recientes de ausencia
- Tendencia mensual vs media
- Llegadas tardías progresivas
- Risk score 0-95 con niveles: bajo/medio/alto
- Admin chip "🔮 Predicción" para equipo completo
4.5 Push notifications
- VAPID keys generadas y almacenadas como Cloudflare secrets
GET/POST/DELETE /api/fichaya/push-subscribe— gestión de suscripciones- D1 table:
fichaya_push_subscriptionscon endpoint, p256dh, auth POST /api/fichaya/push-daily— generador de push diarios (cron o manual)- Contenido adaptativo: resumen horas o aviso de no fichaje o alerta de extras
- Web Push protocol completo: VAPID JWT + ECDH + AES-128-GCM
- Auto-desactiva suscripciones expiradas (404/410)
- Service Worker v2.0 con
push+notificationclickhandlers - Frontend: chip "🔔 Activar notificaciones" con toggle subscribe/unsubscribe
- Notification.requestPermission + PushManager subscription flow
2.4 Cron resúmenes semanales
POST /api/fichaya/weekly-summary— ejecutable via cron (lunes) o admin manual- Por cada usuario activo: calcula horas, extras, días, horarios medios, compliance
- Guarda en
fichaya_weekly_summaries(D1) - Genera texto de resumen y lo embebe en Vectorize (
weekly-{userId}-{weekStart}) - Tool
get_weekly_summariesen el chatbot para consultar histórico - Permite preguntas como "¿cómo han sido mis últimas semanas?" con datos reales
Nuevas tablas D1 (fichaya-002-phase4.sql):
fichaya_push_subscriptions— suscripciones Web Pushfichaya_weekly_summaries— resúmenes semanales por usuariofichaya_absence_patterns— patrones de ausencia detectados
14. Métricas de Éxito
| Métrica | Target MVP | Target 3 meses |
|---|---|---|
| Mensajes/usuario/día | 3+ | 8+ |
| % usuarios que usan chat | 30% | 60% |
| Precisión de respuestas | 90% | 95% |
| Tiempo de respuesta p50 | <3s | <2s |
| Coste/mensaje | <$0.001 | <$0.001 |
| NPS del chatbot | >40 | >60 |
15. Conclusión
¿Embeddings o Function Calling?
Ambos, pero con roles claros:
| Function Calling | Embeddings (RAG) | |
|---|---|---|
| Para qué | Datos de fichaje en tiempo real | Conocimiento legal estático |
| Precisión | 100% (datos de D1) | ~90% (semántica) |
| Coste | Solo D1 queries (gratis) | Vectorize queries (gratis) |
| Mantenimiento | Cero (datos siempre frescos) | Seed inicial + cron semanal |
| Importancia | ⭐⭐⭐ Crítico | ⭐⭐ Complementario |
Function calling es el core. Los embeddings añaden valor para preguntas legales y de contexto, pero el 80% del valor viene de poder consultar D1 en tiempo real vía tools.
¿Por qué DeepSeek?
- El más barato con function calling de calidad
- Probado en nuestro stack (NutriNen)
- API key ya configurada
- Workers AI gratis como fallback → coste total ~$0/mes en MVP
Estado actual (9 abril 2026)
Fases 1, 2 y 3 completadas. El chatbot está en producción en fichaya.cadences.app con:
| Componente | Detalle |
|---|---|
| Backend | functions/api/fichaya/chat.js — 962 líneas, SSE streaming + JSON fallback |
| LLM | DeepSeek Chat (primary) + Workers AI Llama 3.1 8B (fallback) |
| Tools | 5 herramientas: get_clockings, get_hours_summary, get_team_status, check_compliance, search_knowledge |
| RAG | Vectorize fichaya-knowledge con 16 vectores (ley, convenios, FAQ) |
| Streaming | SSE con eventos: tool_start → tool_end → delta → done |
| UI | Burbujas, chips rápidos, typing indicator, tool badges, markdown con tablas |
| Voice | Web Speech API es-ES, auto-send al terminar de hablar |
| Seguridad | JWT auth, RBAC por rol, rate limit 30 msgs/h, aislamiento por org_id |
| Historial | localStorage (frontend) + D1 fichaya_chat_messages (backend) |
Siguiente paso
Implementar Fase 4 (Advanced): alertas proactivas, export PDF, informes Inspección de Trabajo.