feat(ai): agente IA interno con RAG sobre NAN + app web en /ai

- API: /api/ai/chat, /conversations, /documents con embeddings qwen3-embedding,
  coseno en SQLite, dedup por hash, historial de conversaciones
- Base de conocimiento semilla (6 docs, 36 chunks): catálogo DIAGONAL,
  propuestas del cotizador, créditos, proceso de venta, FAQ, privacidad
- App agente-ia (Vite/React/Tailwind, StudioBlank) servida en /ai/ vía nginx
- 21 tests nuevos (91/91 verdes)
This commit is contained in:
2026-08-22 03:04:47 +00:00
parent 8407fc24c3
commit 05c44478ec
32 changed files with 5448 additions and 0 deletions

12
apps/agente-ia/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FRASA Desarrollos — Asistente IA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3897
apps/agente-ia/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "agente-ia",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,33 @@
import type { Mensaje } from '../types'
function Burbuja({ mensaje }: { mensaje: Mensaje }) {
const esUsuario = mensaje.rol === 'user'
return (
<div className={`flex ${esUsuario ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] ${esUsuario ? 'text-right' : 'text-left'}`}>
<div
className={`inline-block whitespace-pre-wrap px-4 py-3 text-sm leading-relaxed ${
esUsuario ? 'bg-accent text-white' : 'bg-neutral-100 text-neutral-900'
}`}
>
{mensaje.contenido}
</div>
{!esUsuario && mensaje.fuentes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{mensaje.fuentes.map((f) => (
<span
key={f.titulo}
title={`Relevancia: ${(f.score * 100).toFixed(0)}%`}
className="border border-neutral-300 px-2 py-1 text-xs font-bold uppercase tracking-widest text-neutral-500"
>
{f.titulo}
</span>
))}
</div>
)}
</div>
</div>
)
}
export default Burbuja

View File

@@ -0,0 +1,23 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #1B4332;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
}
@layer base {
body {
@apply bg-white text-neutral-900 antialiased;
}
*:focus-visible {
outline: 2px solid #1B4332;
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { cargarSesion, guardarSesion, limpiarSesion, type StorageLike } from './sesion'
function storageFalso(): StorageLike & { datos: Map<string, string> } {
const datos = new Map<string, string>()
return {
datos,
getItem: (k) => datos.get(k) ?? null,
setItem: (k, v) => void datos.set(k, v),
removeItem: (k) => void datos.delete(k),
}
}
describe('sesion del chat (localStorage)', () => {
it('devuelve null cuando no hay sesión guardada o está vacía', () => {
const s = storageFalso()
expect(cargarSesion(s)).toBeNull()
s.setItem('frasa-ai:sessionId', '')
expect(cargarSesion(s)).toBeNull()
})
it('guarda y recupera el sessionId', () => {
const s = storageFalso()
guardarSesion('conv-123', s)
expect(cargarSesion(s)).toBe('conv-123')
})
it('limpiarSesion elimina el sessionId (nueva conversación)', () => {
const s = storageFalso()
guardarSesion('conv-123', s)
limpiarSesion(s)
expect(cargarSesion(s)).toBeNull()
})
})

View File

@@ -0,0 +1,22 @@
// Sesión del chat persistida en localStorage: al recargar, la conversación sigue viva.
const CLAVE_SESION = 'frasa-ai:sessionId'
/** Subconjunto de Storage para poder probar sin DOM */
export interface StorageLike {
getItem(clave: string): string | null
setItem(clave: string, valor: string): void
removeItem(clave: string): void
}
export function cargarSesion(storage: StorageLike = localStorage): string | null {
const id = storage.getItem(CLAVE_SESION)
return id && id.length > 0 ? id : null
}
export function guardarSesion(sessionId: string, storage: StorageLike = localStorage): void {
storage.setItem(CLAVE_SESION, sessionId)
}
export function limpiarSesion(storage: StorageLike = localStorage): void {
storage.removeItem(CLAVE_SESION)
}

View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import ChatPage from './pages/ChatPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ChatPage />
</StrictMode>,
)

View File

@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'react'
import Burbuja from '../components/Burbuja'
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import type { Mensaje } from '../types'
let contadorLocal = 0
const idLocal = () => `local-${++contadorLocal}`
export default function ChatPage() {
const [sessionId, setSessionId] = useState<string | null>(null)
const [mensajes, setMensajes] = useState<Mensaje[]>([])
const [entrada, setEntrada] = useState('')
const [escribiendo, setEscribiendo] = useState(false)
const [sinConexion, setSinConexion] = useState(false)
const [error, setError] = useState<string | null>(null)
const finalRef = useRef<HTMLDivElement>(null)
// Al cargar: si hay sesión en localStorage, recupera su historial del API
useEffect(() => {
const id = cargarSesion()
if (!id) return
obtenerHistorial(id)
.then((historial) => {
setSessionId(id)
setMensajes(historial)
})
.catch(() => limpiarSesion()) // sesión inválida o API caído: empezar limpio
}, [])
useEffect(() => {
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [mensajes, escribiendo])
async function enviar() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
setEscribiendo(true)
try {
const r = await enviarMensaje(sessionId, texto)
setSessionId(r.sessionId)
guardarSesion(r.sessionId)
setSinConexion(false)
setMensajes((prev) => [
...prev,
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
])
} catch (err) {
if (esErrorDeRed(err)) {
setSinConexion(true)
} else {
setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
}
// el mensaje del usuario se queda visible; puede reintentar
} finally {
setEscribiendo(false)
}
}
function nuevaConversacion() {
limpiarSesion()
setSessionId(null)
setMensajes([])
setError(null)
setSinConexion(false)
}
return (
<div className="flex min-h-screen flex-col bg-white text-neutral-900">
<header className="border-b border-neutral-200">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-4">
<span className="text-sm tracking-widest">
<span className="font-bold">FRASA</span>{' '}
<span className="font-light text-neutral-500">ASISTENTE IA</span>
</span>
<button
type="button"
onClick={nuevaConversacion}
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
>
Nueva conversación
</button>
</div>
</header>
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8">
{sinConexion && (
<p className="mb-6 text-xs text-amber-700">
Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de
nuevo.
</p>
)}
{error && <p className="mb-6 text-xs text-red-700">{error}</p>}
{mensajes.length === 0 && !escribiendo ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Asistente interno
</p>
<p className="max-w-md text-2xl font-light text-neutral-900">
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta
o las políticas de Frasa.
</p>
</div>
) : (
<div className="flex flex-1 flex-col gap-6">
{mensajes.map((m) => (
<Burbuja key={m.id} mensaje={m} />
))}
{escribiendo && (
<div className="flex justify-start">
<p className="bg-neutral-100 px-4 py-3 text-xs font-bold uppercase tracking-widest text-neutral-500">
Escribiendo…
</p>
</div>
)}
<div ref={finalRef} />
</div>
)}
</main>
<footer className="border-t border-neutral-200">
<form
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4"
onSubmit={(e) => {
e.preventDefault()
void enviar()
}}
>
<label htmlFor="entrada-chat" className="sr-only">
Escribe tu mensaje
</label>
<input
id="entrada-chat"
type="text"
value={entrada}
onChange={(e) => setEntrada(e.target.value)}
placeholder="Escribe tu pregunta…"
autoComplete="off"
className="flex-1 border border-neutral-300 px-4 py-3 text-sm placeholder:text-neutral-400"
/>
<button
type="submit"
disabled={escribiendo || entrada.trim().length === 0}
className="bg-accent px-6 py-3 text-xs font-bold uppercase tracking-widest text-white transition-opacity duration-150 hover:opacity-90 disabled:opacity-40"
>
Enviar
</button>
</form>
</footer>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import type { Mensaje, RespuestaChat } from '../types'
// Cliente HTTP del API Gateway de FRASA (Express + Prisma)
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
/**
* Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
* NO es error de red: el servidor respondió y su respuesta manda.
*/
export class ErrorApi extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApi'
}
}
/**
* fetch solo rechaza (TypeError) cuando falla la red; una respuesta HTTP de
* error resuelve normalmente. El aviso ámbar solo aplica al primer caso.
*/
export function esErrorDeRed(err: unknown): boolean {
return err instanceof TypeError
}
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${ruta}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) {
let mensaje = `Error ${res.status} al llamar ${ruta}`
try {
const cuerpo = (await res.json()) as { error?: string }
if (typeof cuerpo.error === 'string') mensaje = cuerpo.error
} catch {
// cuerpo no JSON: se queda el mensaje genérico
}
throw new ErrorApi(res.status, mensaje)
}
return (await res.json()) as T
}
export function enviarMensaje(sessionId: string | null, mensaje: string): Promise<RespuestaChat> {
return request<RespuestaChat>('/ai/chat', {
method: 'POST',
body: JSON.stringify({ sessionId, mensaje }),
})
}
interface ConversacionApi {
id: string
mensajes: { id: string; rol: string; contenido: string; fuentes: Mensaje['fuentes'] }[]
}
/** Recupera el historial de una conversación previa (al recargar con sessionId en localStorage) */
export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
const conv = await request<ConversacionApi>(`/ai/conversations/${sessionId}`)
return conv.mensajes.map((m) => ({
id: m.id,
rol: m.rol === 'assistant' ? 'assistant' : 'user',
contenido: m.contenido,
fuentes: m.fuentes ?? [],
}))
}

View File

@@ -0,0 +1,23 @@
export interface Fuente {
titulo: string
score: number
}
export interface Mensaje {
id: string
rol: 'user' | 'assistant'
contenido: string
fuentes: Fuente[]
}
export interface RespuestaChat {
sessionId: string
respuesta: string
fuentes: Fuente[]
}
export interface ConversacionResumen {
id: string
createdAt: string
ultimoMensaje: { rol: string; contenido: string } | null
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// La app se sirve detrás de NGINX bajo el prefijo /ai
export default defineConfig({
base: '/ai/',
plugins: [react(), tailwindcss()],
})

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
},
})