initial commit: slides + practica_resueltos + README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 11:50:55 +02:00
commit 0aff31e2d3
203 changed files with 23207 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ls:*)"
]
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/mcp-builder
+13
View File
@@ -0,0 +1,13 @@
MISTRAL_API_KEY=
GOOGLE_API_KEY=
QDRANT_URL=
QDRANT_API_KEY=
COHERE_API_KEY=
LANGSMITH_TRACING=true
LANGSMITH_ENDPOINT=
LANGSMITH_API_KEY=
LANGSMITH_PROJECT="poeta"
+47
View File
@@ -0,0 +1,47 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
.envoutput_docx/
.env
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+1
View File
@@ -0,0 +1 @@
v24.16.0
+172
View File
@@ -0,0 +1,172 @@
# Ejercicios del Curso de LangChain
Este repositorio contiene los ejercicios prácticos del curso de **LangChain con TypeScript**.
## Recursos
- **Cliente del Agente (demo)**: [https://jabiinfante.github.io/langchain.js-agent-client-dummy/](https://jabiinfante.github.io/langchain.js-agent-client-dummy/)
- Código fuente: [https://github.com/jabiinfante/langchain.js-agent-client-dummy](https://github.com/jabiinfante/langchain.js-agent-client-dummy)
## Requisitos Previos
### 1. Cuentas y API Keys necesarias
#### Mistral AI (Requerido)
1. Crear cuenta en [https://console.mistral.ai/](https://console.mistral.ai/)
2. Ir a "API Keys" y generar una nueva key
3. Guardar la key como `MISTRAL_API_KEY`
#### LangSmith (Requerido para trazabilidad)
1. Crear cuenta en [https://smith.langchain.com/](https://smith.langchain.com/)
2. Ir a "Settings" → "API Keys" y crear una nueva key
3. Guardar la key como `LANGCHAIN_API_KEY`
#### Google AI / Gemini (Opcional)
1. Ir a [https://aistudio.google.com/apikey](https://aistudio.google.com/apikey)
2. Crear una API Key
3. Guardar la key como `GOOGLE_API_KEY`
#### Cohere (Requerido para reranking en el agente)
1. Crear cuenta en [https://dashboard.cohere.com/](https://dashboard.cohere.com/)
2. Ir a "API Keys" y copiar la key
3. Guardar la key como `COHERE_API_KEY`
#### Qdrant (Opcional - solo para ejercicios 05 y 06)
1. Crear cuenta en [https://cloud.qdrant.io/](https://cloud.qdrant.io/)
2. Crear un cluster gratuito
3. Obtener la URL del cluster y la API Key
4. Guardar como `QDRANT_URL` y `QDRANT_API_KEY`
### 2. Configurar variables de entorno
Crear un archivo `.env` en la raíz del proyecto:
```env
# === Mistral AI (Requerido) ===
MISTRAL_API_KEY=tu_api_key_de_mistral
# === LangSmith (Requerido para trazabilidad) ===
LANGCHAIN_API_KEY=tu_api_key_de_langsmith
LANGCHAIN_TRACING_V2=true
LANGCHAIN_PROJECT=curso-langchain
# === Google AI / Gemini (Opcional) ===
GOOGLE_API_KEY=tu_api_key_de_google
# === Cohere (Requerido para reranking) ===
COHERE_API_KEY=tu_api_key_de_cohere
# === Qdrant (Opcional - solo para ejercicios 05 y 06) ===
QDRANT_URL=https://tu-cluster.qdrant.io
QDRANT_API_KEY=tu_api_key_de_qdrant
```
### 3. Instalar dependencias
```bash
npm install
```
## Catálogo de Ejercicios
| # | Script | Archivo | Descripción |
| --- | --------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 01 | `npm run 01:comments` | `01_comments_classifier.ts` | **Clasificador de Comentarios** - Analiza comentarios usando Structured Output con Zod. Demuestra `withStructuredOutput()` y `batch()` para procesar múltiples inputs. |
| 02 | `npm run 02:poet` | `02_cyber_poet.ts` | **Poeta Cibernético** - Combina Tools y Structured Output. El LLM genera un poema y usa una herramienta para contar palabras con precisión. |
| 03 | `npm run 03:homework` | `03_homework_maker.ts` | **Generador de Tareas** - Agente multi-tool con bucle agentic manual. Usa Wikipedia, Calculator y WordCount para generar tareas escolares adaptadas al nivel del alumno. |
| 04 | `npm run 04:agent` | `04_webserver_for_agent.ts` | **Servidor Web con Agente** - Integra un agente ReAct con Fastify y SSE para streaming en tiempo real. Incluye memoria persistente, RAG sobre guiones de Nolan, tasas de cambio y generación de fichas de películas en `.docx`. |
| 05 | `npm run 05:indexer` | `05_mdn-vector-indexer.ts` | **Indexador de Documentación** - Pipeline de indexación RAG que carga documentación de MDN, la divide en chunks y la almacena en Qdrant. |
| 06 | `npm run 06:indexer` | `06_nollan-indexer.ts` | **Indexador de Guiones de Nolan** - Pipeline de indexación RAG que carga guiones de películas de Christopher Nolan desde IMSDB y los almacena en Qdrant. |
## Estructura del Proyecto
```
src/
├── 01_comments_classifier.ts # Structured Output + batch
├── 02_cyber_poet.ts # Tools + Structured Output
├── 03_homework_maker.ts # Agentic Loop + PromptTemplate + múltiples tools
├── 04_webserver_for_agent.ts # Fastify + SSE + Agent con memoria
├── 05_mdn-vector-indexer.ts # Web scraping + chunking + Qdrant
├── 06_nollan-indexer.ts # Indexación de guiones de Nolan + Qdrant
├── agents_wrapper/
│ └── agent.ts # Wrapper del agente con streaming y contextSchema
└── helpers/
├── comments-mock.ts # Datos de prueba para ejercicio 01
├── constants.ts # Constantes compartidas (OUTPUT_DIR)
├── helper.ts # Utilidad promptUser() para input interactivo
├── middlewares.ts # Middleware trimMessages para limitar contexto
└── tools.ts # Herramientas: wordCount, wikipedia, exchangeRates, storageKnowledge, buildFilmDocument
template/
└── plantilla_ficha.docx # Plantilla Word para generar fichas de películas
output_docx/ # Directorio donde se generan los documentos .docx
```
## Conceptos por Ejercicio
### 01 - Clasificador de Comentarios
- **Zod**: Definición de esquemas para validación
- **withStructuredOutput()**: Forzar respuestas JSON estructuradas
- **batch()**: Procesar múltiples inputs en paralelo
### 02 - Poeta Cibernético
- **tool()**: Crear herramientas personalizadas
- **bindTools()**: Conectar herramientas al modelo
- **tool_calls**: El LLM solicita usar herramientas
- **ToolMessage**: Devolver resultados de herramientas
### 03 - Generador de Tareas
- **Agentic Loop**: Bucle while que procesa tool_calls hasta completar
- **PromptTemplate**: Plantillas con variables dinámicas
- **Múltiples Tools**: Wikipedia, Calculator, WordCount
- **promptUser()**: Input interactivo en consola
### 04 - Servidor Web con Agente
- **createAgent()**: Crear agente ReAct con herramientas
- **Checkpointer (SQLite)**: Memoria persistente de conversaciones
- **SSE (Server-Sent Events)**: Streaming de respuestas al cliente
- **contextSchema**: Inyección de dependencias a las tools
- **dynamicSystemPromptMiddleware**: System prompt que se regenera en cada invocación (incluye fecha actual)
- **QdrantVectorStore / MemoryVectorStore**: Fallback automático si Qdrant no está disponible
- **Herramientas disponibles en el agente**:
- `storage_knowledge` — RAG sobre guiones de películas de Christopher Nolan (Interstellar, Inception) con reranking via Cohere
- `get_exchange_rates` — Tasas de cambio actuales via Frankfurter API
- `get_historical_rates` — Tasas de cambio históricas de una fecha concreta
- `build_film_document` — Genera una ficha de película en `.docx` usando una plantilla Word
- `calculator` — Calculadora matemática
### 05 - Indexador de Documentación
- **CheerioWebBaseLoader**: Web scraping de HTML
- **RecursiveCharacterTextSplitter**: División de documentos en chunks
- **Embeddings**: Representación vectorial de texto
- **QdrantVectorStore**: Base de datos vectorial para búsqueda semántica
- **Deduplicación**: Eliminar vectores existentes antes de re-indexar
### 06 - Indexador de Guiones de Nolan
- **CheerioWebBaseLoader**: Web scraping de guiones desde IMSDB
- **RecursiveCharacterTextSplitter**: División en chunks con overlap
- **Batch indexing**: Inserción por lotes para respetar límites de la API de embeddings
- **CohereRerank**: Reranking de resultados (usado en el agente del ejercicio 04)
## Orden Recomendado
1. **01_comments_classifier** - Conceptos básicos de Structured Output
2. **02_cyber_poet** - Introducción a Tools
3. **03_homework_maker** - Agentic Loop manual con múltiples tools
4. **06_nollan-indexer** - Indexar guiones de Nolan en Qdrant (necesario para el agente)
5. **04_webserver_for_agent** - Agente completo con servidor web, streaming y generación de documentos
6. **05_mdn-vector-indexer** - Indexación de documentación MDN para RAG (opcional)
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "langchain_ejercicios",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"01:comments": "tsx --env-file=.env --watch src/01_comments_classifier.ts",
"02:poet": "tsx --env-file=.env --watch src/02_cyber_poet.ts",
"03:homework": "tsx --env-file=.env src/03_homework_maker.ts",
"04:agent": "tsx --env-file=.env --watch src/04_webserver_for_agent.ts",
"05:indexer": "tsx --env-file=.env src/05_mdn-vector-indexer.ts",
"06:indexer": "tsx --env-file=.env src/06_nollan-indexer.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@langchain/cohere": "^1.0.4",
"@langchain/community": "^1.1.4",
"@langchain/core": "^1.1.15",
"@langchain/google": "^0.1.7",
"@langchain/langgraph-checkpoint-sqlite": "^1.0.0",
"@langchain/mistralai": "^1.0.2",
"@langchain/qdrant": "^1.0.3",
"@langchain/textsplitters": "^1.0.1",
"@qdrant/js-client-rest": "^1.18.0",
"cheerio": "^1.1.2",
"docxtemplater": "^3.68.7",
"fastify": "^5.7.1",
"fastify-sse-v2": "^4.2.1",
"langchain": "^1.2.10",
"pizzip": "^3.2.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/node": "^25.0.9",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}
@@ -0,0 +1,98 @@
/**
* =============================================================================
* EJERCICIO: Clasificador de Comentarios con Structured Output
* =============================================================================
*
* Este ejercicio demuestra cómo usar LangChain para clasificar comentarios
* de usuarios utilizando "Structured Output" (salida estructurada).
*
* CONCEPTOS CLAVE:
* - Zod: Librería para definir y validar esquemas de datos en TypeScript
* - withStructuredOutput(): Método que fuerza al LLM a responder con un JSON
* que cumple exactamente con el esquema definido
* - batch(): Procesar múltiples inputs en paralelo de forma eficiente
*
* FLUJO DEL EJERCICIO:
* 1. Definir un esquema con Zod que describe la estructura de respuesta esperada
* 2. Configurar el modelo LLM con withStructuredOutput()
* 3. Enviar comentarios al modelo para que los analice y clasifique
* 4. Recibir respuestas estructuradas (JSON válido según el esquema)
* =============================================================================
*/
import { z } from "zod";
import { ChatMistralAI } from "@langchain/mistralai";
import { initChatModel } from "langchain";
import { comments } from "./helpers/comments-mock";
// =============================================================================
// PASO 1: Definir el esquema de respuesta con Zod
// =============================================================================
// Zod nos permite definir la estructura exacta que esperamos del LLM.
// Esto garantiza que la respuesta sea un JSON válido y tipado.
const CommentSchema = z.object({
// Nivel de lenguaje ofensivo (0 = ninguno, 5 = muy ofensivo)
profanity_level: z.number().min(0).max(5),
// Sentimiento general del comentario
sentiment: z.enum(["positive", "neutral", "negative"]),
// Temas identificados en el comentario (mínimo 1)
topics: z.array(z.string()).min(1)
}).describe("Esquema para analizar comentarios de usuarios");
// TypeScript infiere automáticamente el tipo desde el esquema Zod
type Comment = z.infer<typeof CommentSchema>;
// =============================================================================
// PASO 2: Ejemplo de validación con Zod (sin LLM)
// =============================================================================
// Podemos usar el esquema para validar datos manualmente.
// Si los datos no cumplen el esquema, Zod lanzará un error.
const result = CommentSchema.parse({
profanity_level: 5,
sentiment: "neutral",
topics: ["technology", "education"]
});
// =============================================================================
// PASO 3: Configurar el modelo LLM con Structured Output
// =============================================================================
// withStructuredOutput() recibe el esquema Zod y configura el modelo para
// que SIEMPRE responda con un JSON que cumpla ese esquema.
// Opción A: Usar Mistral AI directamente
const llm = new ChatMistralAI({
model: "mistral-large-latest",
streaming: true,
maxTokens: 1000,
}).withStructuredOutput(CommentSchema);
// Opción B: Usar initChatModel() para inicializar cualquier modelo de forma genérica
// Esto permite cambiar fácilmente entre proveedores (OpenAI, Google, Anthropic, etc.)
const llm2 = (await initChatModel('gemini-2.5-flash', {
modelProvider: 'google'
})).withStructuredOutput(CommentSchema);
// =============================================================================
// PASO 4: Procesar comentarios en lote (batch)
// =============================================================================
// batch() permite enviar múltiples inputs al modelo de forma eficiente.
// maxConcurrency limita cuántas peticiones se hacen en paralelo.
const result2 = await llm.batch(
comments.map(c => c.content), // Extraer solo el contenido de cada comentario
{ maxConcurrency: 2 } // Máximo 2 peticiones simultáneas
);
// =============================================================================
// PASO 5: Mostrar resultados
// =============================================================================
// Cada resultado es un objeto tipado que cumple con CommentSchema
result2.forEach(async (result, index) => {
console.log(`Respuesta a la pregunta ${comments[index].uuid} (${comments[index].content}):`);
console.dir(result, { depth: null, colors: true });
});
+181
View File
@@ -0,0 +1,181 @@
/**
* =============================================================================
* EJERCICIO: Poeta Cibernético con Tools y Structured Output
* =============================================================================
*
* Este ejercicio combina dos conceptos importantes de LangChain:
* - Tools (herramientas): Funciones que el LLM puede invocar para realizar tareas
* - Structured Output: Forzar al LLM a responder con un formato JSON específico
*
* ENUNCIADO INICIAL (problema a resolver):
* ─────────────────────────────────────────────────────────────────────────────
* import { ChatMistralAI } from "@langchain/mistralai";
*
* const llm = new ChatMistralAI({
* model: "mistral-large-latest",
* temperature: 0.2,
* });
*
* const prompt = `Eres un poeta cybernetico. Respondes en castellano.
* Tu estilo es oscuro y meláncolico.
* Necesito que además del poema me devuelvas 3 palabras clave del poema así
* como el número exacto de palabras del texto que generes.`;
*
* const response = await llm.invoke(prompt);
* console.dir(response, { depth: null, colors: true });
* ─────────────────────────────────────────────────────────────────────────────
*
* PROBLEMA: El LLM no puede contar palabras con precisión (alucinará el número).
* SOLUCIÓN: Usar una Tool que cuente las palabras de forma precisa.
*
* CONCEPTOS CLAVE:
* - tool(): Función de LangChain para crear herramientas que el LLM puede usar
* - bindTools(): Conectar herramientas a un modelo LLM
* - tool_calls: El LLM indica qué herramienta quiere usar y con qué argumentos
* - ToolMessage: Mensaje que contiene el resultado de ejecutar una herramienta
* - withStructuredOutput(): Forzar formato de respuesta final
*
* FLUJO DEL EJERCICIO:
* 1. Definir una herramienta para contar palabras
* 2. El LLM genera un poema y solicita contar sus palabras (tool_call)
* 3. Ejecutamos la herramienta y devolvemos el resultado (ToolMessage)
* 4. El LLM genera la respuesta final estructurada con el conteo correcto
* =============================================================================
*/
import {
AIMessage,
HumanMessage,
SystemMessage,
ToolMessage,
} from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { ChatMistralAI } from "@langchain/mistralai";
// =============================================================================
// PASO 1: Definir la herramienta (Tool) para contar palabras
// =============================================================================
// Las Tools son funciones que el LLM puede decidir invocar.
// El LLM NO ejecuta la función directamente, solo indica que quiere usarla.
// Nosotros ejecutamos la función y le devolvemos el resultado.
const wordCountTool = tool(
({ texto }) => {
// Esta función se ejecuta cuando procesamos el tool_call del LLM
process.stdout.write("Contando palabras...");
const words = texto.trim().split(/\s+/).filter(Boolean).length;
process.stdout.write(` [${words}]\n`);
return words.toString();
},
{
name: "contar_palabras",
description:
"Cuenta el numero de palabras en un texto, y devuelve solo el número.",
// Zod define qué parámetros acepta la herramienta
schema: z.object({ texto: z.string() }),
},
);
// =============================================================================
// PASO 2: Configurar el modelo con las herramientas
// =============================================================================
// bindTools() conecta las herramientas al modelo.
// Ahora el LLM sabe que puede usar "contar_palabras" cuando lo necesite.
const llm = new ChatMistralAI({
model: "mistral-large-latest",
temperature: 0,
});
// IMPORTANTE: Pasar el array con las herramientas disponibles
const modelWithTools = llm.bindTools([wordCountTool]);
// =============================================================================
// PASO 3: Preparar la conversación inicial
// =============================================================================
// Usamos diferentes tipos de mensajes:
// - SystemMessage: Define el comportamiento/personalidad del LLM
// - HumanMessage: El mensaje del usuario
// - AIMessage: Respuesta del LLM (se añade después)
// - ToolMessage: Resultado de ejecutar una herramienta
const messages: Array<SystemMessage | AIMessage | HumanMessage | ToolMessage> =
[
new SystemMessage(
"Eres un poeta cybernetico. Respondes en castellano. Tu estilo es oscuro y meláncolico.",
),
new HumanMessage(
`Necesito que generes un unico poema. Que uses las herramientas disponibles para contar las palabras de **ese** poema.
`,
),
];
// =============================================================================
// PASO 4: Primera invocación - El LLM genera el poema y pide contar palabras
// =============================================================================
// El LLM responderá con:
// - content: El poema generado
// - tool_calls: Array indicando que quiere usar "contar_palabras"
const firstResponse = await modelWithTools.invoke(messages);
// Añadimos la respuesta del LLM al historial de mensajes
messages.push(firstResponse as AIMessage);
// =============================================================================
// PASO 5: Procesar los tool_calls (ejecutar las herramientas solicitadas)
// =============================================================================
// Si el LLM quiere usar herramientas, las ejecutamos y añadimos el resultado.
if (firstResponse.tool_calls && firstResponse.tool_calls.length > 0) {
for (const call of firstResponse.tool_calls) {
// Ejecutar la herramienta correspondiente
let toolResult: string;
if (call.name === "contar_palabras") {
console.log("LLM solicitó contar palabras del poema...");
toolResult = await wordCountTool.invoke(call.args as { texto: string });
} else {
throw new Error(`Herramienta desconocida: ${call.name}`);
}
// Añadir el resultado como ToolMessage al historial
// IMPORTANTE: tool_call_id debe coincidir con el id del tool_call
console.log(`Devolviendo resultado de herramienta: ${toolResult}`);
messages.push(
new ToolMessage({
content:
typeof toolResult === "string"
? toolResult
: JSON.stringify(toolResult),
tool_call_id: call.id!,
}),
);
}
// ===========================================================================
// PASO 6: Generar la respuesta final estructurada
// ===========================================================================
// Ahora el LLM tiene el conteo real de palabras en el historial.
// Usamos withStructuredOutput() para obtener un JSON con formato específico.
const OutputSchema = z.object({
poema: z
.array(z.string())
.describe(
"El poema generado por el poeta cybernetico. Cada verso en una línea separada.",
),
tematica: z.array(z.string()).length(3).describe("3 palabras clave del poema"),
total_palabras: z.number().describe("El número exacto de palabras del poema"),
});
const modelWithOutput = llm.withStructuredOutput(OutputSchema);
console.log("Generando salida final con conteo de palabras...");
const final = await modelWithOutput.invoke(messages);
console.dir(messages, { depth: null, colors: true });
console.dir(final, { depth: null, colors: true });
} else {
// Si el LLM no pidió usar herramientas, mostramos su respuesta directa
console.log(firstResponse.content);
}
+250
View File
@@ -0,0 +1,250 @@
/**
* =============================================================================
* EJERCICIO: Generador de Tareas Escolares con Agente Multi-Tool
* =============================================================================
*
* Este ejercicio demuestra un caso de uso avanzado combinando múltiples conceptos:
* - Bucle de agente manual (agentic loop): El modelo decide cuándo usar herramientas
* - PromptTemplate: Plantillas de prompts con variables dinámicas
* - Múltiples herramientas: Wikipedia, Calculator, Word Counter
* - Structured Output: Respuesta final en formato JSON estructurado
* - Input interactivo: Configuración mediante prompts de usuario
*
* CASO DE USO:
* Un estudiante necesita ayuda con sus deberes. El agente:
* 1. Recibe la configuración (nivel, asignatura, idioma, extensión)
* 2. Busca información relevante en Wikipedia
* 3. Genera el texto adaptado al nivel del alumno
* 4. Verifica que cumple con la extensión requerida
* 5. Devuelve un informe estructurado
*
* CONCEPTOS CLAVE:
* - PromptTemplate.fromTemplate(): Crear prompts con variables {variable}
* - bindTools(): Conectar múltiples herramientas al modelo
* - Agentic Loop: Bucle while que procesa tool_calls hasta completar la tarea
* - withStructuredOutput(): Generar respuesta final estructurada
*
* FLUJO DEL EJERCICIO:
* 1. Recoger configuración del usuario (nivel, asignatura, idioma, etc.)
* 2. Formatear el system prompt con las variables
* 3. Ejecutar bucle de agente:
* - Invocar modelo
* - Si hay tool_calls: ejecutar herramientas y añadir resultados
* - Repetir hasta que no haya más tool_calls o se alcance el límite
* 4. Generar informe final estructurado
* =============================================================================
*/
import { Calculator } from "@langchain/community/tools/calculator";
import { WikipediaQueryRun } from "@langchain/community/tools/wikipedia_query_run";
import {
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage
} from "@langchain/core/messages";
import { PromptTemplate } from "@langchain/core/prompts";
import { ChatMistralAI } from "@langchain/mistralai";
import { z } from "zod";
import { promptUser } from "./helpers/helper";
import { wordCountTool } from "./helpers/tools";
// =============================================================================
// PASO 1: Recoger configuración del usuario
// =============================================================================
// promptUser() es un helper que muestra un prompt en consola y recoge la respuesta.
// El segundo parámetro es el valor por defecto si el usuario presiona Enter.
const config = {
level: await promptUser("¿En qué curso está el alumno?", "Primero de la ESO"),
subject: await promptUser("¿Cuál es la asignatura de la tarea?", "Historia"),
language: await promptUser("¿En qué idioma está la tarea?", "Euskera"),
number_words: await promptUser(
"¿Cuántas palabras debe tener la tarea?",
"500",
),
question: await promptUser(
"Introduce la pregunta que quieres que responda el alumno:",
"Pequeña redacción con la historia del Imperio Romano",
),
};
// =============================================================================
// PASO 2: Configurar el modelo y las herramientas
// =============================================================================
const model = new ChatMistralAI({
model: "mistral-large-latest",
temperature: 0.2, // Baja temperatura para respuestas más consistentes
});
// Wikipedia: Buscar información de referencia
const wikipediaTool = new WikipediaQueryRun({
topKResults: 3, // Máximo 3 resultados
maxDocContentLength: 1500, // Limitar contenido para no exceder contexto
});
// Calculator: Para cálculos matemáticos si la tarea lo requiere
const calculatorTool = new Calculator();
// Conectar todas las herramientas al modelo
const modelWithTools = model.bindTools([
wordCountTool, // Contar palabras (verificar extensión)
wikipediaTool, // Buscar información
calculatorTool, // Cálculos matemáticos
]);
// =============================================================================
// PASO 3: Crear el prompt template con variables
// =============================================================================
// PromptTemplate permite crear prompts reutilizables con placeholders {variable}
// que se sustituyen al llamar a format()
const systemPromptTemplate = PromptTemplate.fromTemplate(`
Eres un asistente educativo experto que ayuda a estudiantes a completar sus tareas escolares.
Tu objetivo es generar contenido educativo de alta calidad adaptado al nivel del alumno.
## CONTEXTO DEL ALUMNO
- **Nivel educativo:** {level}
- **Asignatura:** {subject}
- **Idioma de la tarea:** {language}
- **Extensión requerida:** {number_words} palabras (margen: ±10%)
## HERRAMIENTAS DISPONIBLES
1. **wikipedia-api** - Búsqueda de información
- Usar para obtener datos precisos y verificables
- Buscar en español para obtener mejores resultados
- Máximo 2 búsquedas en Wikipedia para no sobrecargar el servicio
2. **calculator** - Calculadora matemática
- USAR para cualquier cálculo numérico (fechas, porcentajes, estadísticas)
3. **contar_palabras** - Verificación de extensión
- USAR OBLIGATORIAMENTE antes de dar la respuesta final
- Si el conteo está fuera del rango permitido, ajustar el texto
## FLUJO DE TRABAJO
1. **INVESTIGAR**: Busca información en Wikipedia sobre el tema solicitado
2. **PLANIFICAR**: Organiza las ideas principales según el nivel educativo
3. **REDACTAR**: Escribe el texto en {language}, adaptando vocabulario y complejidad
4. **VERIFICAR**: Cuenta las palabras y ajusta si es necesario
5. **ENTREGAR**: Proporciona el texto final verificado
## REGLAS DE CALIDAD
- **Adaptación al nivel**: Un alumno de primaria necesita lenguaje simple; uno de bachillerato puede manejar conceptos más complejos
- **Estructura clara**: Usa párrafos bien organizados con introducción, desarrollo y conclusión
- **Precisión**: Todos los datos deben provenir de Wikipedia, no inventes información
- **Originalidad**: Redacta con tus propias palabras, no copies textualmente de Wikipedia
- **Idioma**: TODO el contenido debe estar en {language}, incluyendo términos técnicos cuando sea posible
## IMPORTANTE
- NO entregues la tarea sin verificar el conteo de palabras
- Si el texto es muy corto, amplía con más detalles o ejemplos
- Si el texto es muy largo, sintetiza manteniendo la información esencial
`);
// =============================================================================
// PASO 4: Preparar mensajes iniciales
// =============================================================================
const { question, ...params } = config;
// Formatear el prompt sustituyendo las variables
const systemPrompt = await systemPromptTemplate.format(params);
const messages: BaseMessage[] = [
new SystemMessage(systemPrompt),
new HumanMessage(question),
];
// =============================================================================
// PASO 5: Bucle de agente (Agentic Loop)
// =============================================================================
// Este bucle implementa el patrón ReAct manualmente:
// - El modelo genera una respuesta (puede incluir tool_calls)
// - Si hay tool_calls, ejecutamos las herramientas y añadimos los resultados
// - Repetimos hasta que el modelo no pida más herramientas
let iteracion = 0;
const MAX_ITERACIONES = 6; // Límite de seguridad para evitar bucles infinitos
while (iteracion < MAX_ITERACIONES) {
iteracion++;
console.log(`\n🔄 Iteración ${iteracion}...`);
// Invocar el modelo con el historial de mensajes
const response = await modelWithTools.invoke(messages);
messages.push(response);
// Si no hay tool_calls, el modelo ha terminado
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n✅ Tarea completada`);
break;
}
// Procesar cada tool_call solicitado por el modelo
for (const call of response.tool_calls) {
console.log(`\n🔧 Tool: ${call.name}`);
console.log(` 📝 Args: ${JSON.stringify(call.args)}`);
let resultado: string;
// Ejecutar la herramienta correspondiente
if (call.name === "contar_palabras") {
resultado = `${await wordCountTool.invoke(call.args as { texto: string })}`;
} else if (call.name === "wikipedia-api") {
console.log(` 🌐 Buscando en Wikipedia: "${call.args.input}"`);
await new Promise((r) => setTimeout(r, 1500));
const wikiResults = await wikipediaTool.invoke(call.args.input as string);
resultado =
typeof wikiResults === "string"
? wikiResults
: JSON.stringify(wikiResults);
} else if (call.name === "calculator") {
console.log(` 🧮 Calculando: "${call.args.input}"`);
resultado = `${await calculatorTool.invoke(call.args.input as string)}`;
} else {
resultado = `Error: herramienta "${call.name}" no reconocida`;
}
// Añadir el resultado de la herramienta como ToolMessage
messages.push(
new ToolMessage({
content: resultado,
tool_call_id: call.id!,
}),
);
}
}
// =============================================================================
// PASO 6: Generar informe final estructurado
// =============================================================================
// Una vez completada la tarea, pedimos un informe en formato JSON.
const homeworkReportSchema = z.object({
texto: z.string().describe("El texto completo de la tarea para el alumno"),
numero_palabras: z.number().describe("Número total de palabras en el texto"),
nivel: z.string().describe("Nivel educativo del alumno"),
urls_wikipedia: z
.array(z.string())
.describe("URLs de Wikipedia consultadas (si las hay)"),
});
console.log(`\n📝 Generando informehomeworkReportSchema final...`);
messages.push(
new HumanMessage(
"Genera el informe final con la tarea completada para el alumno.",
),
);
const modelWithOutput = model.withStructuredOutput(homeworkReportSchema);
const informe = await modelWithOutput.invoke(messages);
console.log("\n" + "=".repeat(60));
console.log("INFORME FINAL");
console.log("=".repeat(60));
console.dir(informe, { depth: null, colors: true });
@@ -0,0 +1,213 @@
/**
* =============================================================================
* EJERCICIO: Servidor Web con Agente LangChain y Streaming (SSE)
* =============================================================================
*
* Este ejercicio demuestra cómo integrar un agente de LangChain con un servidor
* web Fastify, usando Server-Sent Events (SSE) para streaming en tiempo real.
*
* ARQUITECTURA:
* ┌─────────────┐ POST /message ┌─────────────┐
* │ Cliente │ ─────────────────────▶│ Fastify │
* │ (Browser) │ │ Server │
* │ │◀───────────────────── │ │
* └─────────────┘ SSE /stream └──────┬──────┘
* │
* ▼
* ┌─────────────┐
* │ Agent │
* │ (LangChain) │
* └─────────────┘
*
* CONCEPTOS CLAVE:
* - SSE (Server-Sent Events): Protocolo para enviar datos del servidor al cliente
* en tiempo real, ideal para streaming de respuestas de LLMs
* - Checkpointer: Guarda el estado de la conversación (memoria persistente)
* - thread_id: Identificador único de conversación para mantener contexto
*
* FLUJO:
* 1. Cliente abre conexión SSE en GET /stream (recibe mensajes en tiempo real)
* 2. Cliente envía mensaje POST /message
* 3. Servidor pasa mensaje al Agente
* 4. Agente procesa y genera respuesta (puede usar tools)
* 5. Cada chunk de respuesta se envía al cliente vía SSE
*
* DEPENDENCIAS:
* - fastify: Framework web rápido y de bajo consumo para Node.js
* - fastify-sse-v2: Plugin para manejar Server-Sent Events
* - @fastify/cors: Plugin para habilitar CORS
* =============================================================================
*/
import cors from "@fastify/cors";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";
import { MistralAIEmbeddings } from "@langchain/mistralai";
import { QdrantVectorStore } from "@langchain/qdrant";
import Fastify from "fastify";
import { FastifySSEPlugin } from "fastify-sse-v2";
import { initChatModel } from "langchain";
import { createReadStream, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { Agent } from "./agents_wrapper/agent";
import { OUTPUT_DIR } from "./helpers/constants";
// =============================================================================
// PASO 1: Inicializar el Agente con memoria persistente
// =============================================================================
// El agente se inicializa ANTES del servidor para que esté listo cuando
// lleguen las peticiones. SqliteSaver guarda el historial de conversaciones.
const model = await initChatModel("mistral:mistral-large-latest", {
timeout: 60000, // 1 minuto de timeout para respuestas largas
});
const embeddings = new MistralAIEmbeddings({
model: "mistral-embed",
});
// VectorStore: Usamos Qdrant para almacenamiento persistente de vectores
// fallback a MemoryVectorStore si Qdrant no está disponible (desarrollo local)
const vectorStore = await QdrantVectorStore.fromExistingCollection(embeddings, {
url: process.env.QDRANT_URL,
collectionName: "nolan-scripts",
apiKey: process.env.QDRANT_API_KEY,
}).catch(() => new MemoryVectorStore(embeddings));
// Checkpointer: Guarda el estado de las conversaciones en SQLite
// Permite que las conversaciones persistan entre reinicios del servidor
const checkpointer = SqliteSaver.fromConnString(
join(tmpdir(), "agent_memory.db"),
);
const agent = new Agent(model, vectorStore, checkpointer);
// =============================================================================
// PASO 2: Configurar el servidor Fastify
// =============================================================================
const fastify = Fastify({
logger: true, // Habilitar logs para debugging
});
// CORS: Permitir peticiones desde cualquier origen (desarrollo)
// En producción, restringir a dominios específicos
fastify.register(cors, {
origin: "*",
});
// Plugin SSE: Habilita el método reply.sse() para streaming
fastify.register(FastifySSEPlugin);
// =============================================================================
// PASO 3: Ruta de health check
// =============================================================================
fastify.get("/", async (request, reply) => {
return { message: "Hola agente!", status: "running" };
});
// =============================================================================
// PASO 4: Ruta POST /message - Recibir mensajes del cliente
// =============================================================================
// El cliente envía un mensaje y el servidor lo pasa al agente.
// La respuesta se envía de forma asíncrona vía SSE (no en esta ruta).
fastify.post<{ Querystring: { uuid?: string }; Body: { message: string } }>(
"/message",
async (request, reply) => {
const { message } = request.body;
// thread_id identifica la conversación
// En producción: obtener de cookies, headers, JWT, etc.
const thread_id = request.query.uuid || "chat-id-XXX";
// Enviar mensaje al agente (procesamiento asíncrono)
// Las respuestas se enviarán vía SSE a los clientes suscritos
agent.messageReceived(message, { thread_id });
// Respuesta inmediata: confirmar que el mensaje fue recibido
return { status: "Mensaje recibido", thread_id };
},
);
// =============================================================================
// PASO 5: Ruta GET /stream - Conexión SSE para recibir respuestas
// =============================================================================
// El cliente mantiene una conexión abierta y recibe mensajes en tiempo real.
// Documentación SSE: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
fastify.get<{ Querystring: { uuid?: string } }>(
"/stream",
async (request, reply) => {
const thread_id = request.query.uuid || "chat-id-XXX";
// Enviar evento de conexión establecida
reply.sse({
data: JSON.stringify({ connected: true }),
event: "connected",
});
// Handler: Se ejecuta cada vez que el agente genera un nuevo mensaje
const handler = (message: any) => {
reply.sse({ data: JSON.stringify(message), event: "message" });
};
// Registrar el handler para esta conversación (thread_id)
agent.registerNewMessageHandler(handler, thread_id);
// Cleanup: Desregistrar cuando el cliente cierra la conexión
reply.raw.on("close", () => {
request.log.info(`SSE connection closed for thread: ${thread_id}`);
agent.unregisterNewMessageHandler(thread_id, handler);
});
},
);
fastify.get("/files", async (_request, reply) => {
const files = readdirSync(OUTPUT_DIR).map((name) => ({
name,
url: `/files/${encodeURIComponent(name)}`,
}));
return reply.send(files);
});
fastify.get<{ Params: { filename: string } }>(
"/files/:filename",
async (request, reply) => {
const filename = decodeURIComponent(request.params.filename);
const safePath = resolve(OUTPUT_DIR, filename);
if (!safePath.startsWith(OUTPUT_DIR)) {
return reply.status(403).send({ error: "Forbidden" });
}
reply.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"`,
);
reply.header(
"Content-Type",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
return reply.send(createReadStream(safePath));
},
);
// =============================================================================
// PASO 6: Arrancar el servidor
// =============================================================================
try {
await fastify.listen({ port: 3000, host: "0.0.0.0" });
console.log("Servidor corriendo en http://localhost:3000");
console.log("Endpoints disponibles:");
console.log(" GET / - Health check");
console.log(" POST /message - Enviar mensaje al agente");
console.log(" GET /stream - Conexión SSE para recibir respuestas");
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
@@ -0,0 +1,193 @@
/**
* =============================================================================
* EJERCICIO: Indexador de Documentación MDN en Vector Store (Qdrant)
* =============================================================================
*
* Este script demuestra cómo crear un pipeline de indexación para RAG:
* 1. Cargar documentos desde URLs (web scraping)
* 2. Dividir documentos en chunks más pequeños
* 3. Generar embeddings y almacenarlos en un vector store
*
* CONCEPTOS CLAVE:
* - CheerioWebBaseLoader: Carga contenido HTML de URLs y extrae texto
* - RecursiveCharacterTextSplitter: Divide documentos en chunks con overlap
* - Embeddings: Representación vectorial del texto para búsqueda semántica
* - Vector Store (Qdrant): Base de datos optimizada para búsqueda por similitud
*
* FLUJO DEL EJERCICIO:
* 1. Definir URLs de MDN a indexar
* 2. Cargar el contenido HTML de cada URL
* 3. Limpiar y dividir en chunks
* 4. **Eliminar vectores existentes** para evitar duplicados
* 5. Generar embeddings e insertar en Qdrant
* 6. Verificar con una búsqueda de prueba
*
* NOTA SOBRE DUPLICADOS:
* Este script elimina los vectores existentes que coincidan con las URLs
* a indexar antes de insertar los nuevos. Esto evita duplicados pero
* re-indexa siempre el contenido.
*
* TODO: Lo ideal sería comprobar si el contenido ha cambiado (ej: hash del
* contenido o fecha de modificación) antes de re-indexar, para evitar
* trabajo innecesario. Por simplicidad, no lo implementamos aquí.
* =============================================================================
*/
import { MistralAIEmbeddings } from "@langchain/mistralai";
import { QdrantVectorStore } from "@langchain/qdrant";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { QdrantClient } from "@qdrant/js-client-rest";
// =============================================================================
// PASO 1: Configuración
// =============================================================================
const COLLECTION_NAME = "langchainjs-testing-dia2";
// URLs de MDN a indexar (documentación sobre Web Storage)
const urls = [
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API",
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API",
// Descomentar para indexar más documentación:
// "https://developer.mozilla.org/en-US/docs/Web/API/Storage",
// "https://developer.mozilla.org/en-US/docs/Web/API/Storage_API",
// "https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria",
// "https://developer.mozilla.org/en-US/docs/Web/API/StorageManager",
// "https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API",
// "https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API/Using",
// "https://developer.mozilla.org/en-US/docs/Web/API/Shared_Storage_API",
];
// Configurar embeddings de Mistral
const embeddings = new MistralAIEmbeddings({
model: "mistral-embed",
});
// =============================================================================
// PASO 2: Cargar documentos desde las URLs
// =============================================================================
// CheerioWebBaseLoader usa Cheerio (parser HTML) para extraer contenido.
// El selector "main#content" extrae solo el contenido principal de MDN.
console.log("📥 Cargando documentos desde MDN...\n");
const loaders = urls.map(
(url) => new CheerioWebBaseLoader(url, { selector: "main#content" }),
);
const docs = [];
for (const loader of loaders) {
console.log(`${loader.webPath}`);
const loadedDocs = await loader.load();
docs.push(...loadedDocs);
}
console.log(`\n✅ ${docs.length} documentos cargados\n`);
// =============================================================================
// PASO 3: Limpiar y dividir documentos en chunks
// =============================================================================
// - Limpiamos saltos de línea excesivos para reducir ruido
// - Dividimos en chunks de ~1000 caracteres con 150 de overlap
// - El overlap ayuda a mantener contexto entre chunks adyacentes
// Limpiar pageContent: reemplazar múltiples saltos de línea por uno solo
docs.forEach((doc) => {
doc.pageContent = doc.pageContent.replace(/\n{2,}/g, "\n");
});
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000, // Tamaño máximo de cada chunk en caracteres
chunkOverlap: 150, // Solapamiento entre chunks consecutivos
});
const allSplits = await splitter.splitDocuments(docs);
console.log(`📄 Documentos divididos en ${allSplits.length} chunks\n`);
// =============================================================================
// PASO 4: Eliminar vectores existentes para evitar duplicados
// =============================================================================
// Antes de insertar, eliminamos los vectores que ya existen para las URLs
// que vamos a indexar. Usamos el metadato "source" que contiene la URL.
//
// NOTA: Lo ideal sería verificar si el contenido ha cambiado antes de
// re-indexar (usando un hash o fecha de modificación), pero por simplicidad
// siempre eliminamos y re-insertamos.
console.log("🗑️ Eliminando vectores existentes para evitar duplicados...\n");
const qdrantClient = new QdrantClient({
url: process.env.QDRANT_URL,
apiKey: process.env.QDRANT_API_KEY,
});
// Eliminar vectores por cada URL (filtrando por el metadato "source")
for (const url of urls) {
try {
await qdrantClient.delete(COLLECTION_NAME, {
filter: {
must: [
{
key: "metadata.source",
match: { value: url },
},
],
},
});
console.log(` → Eliminados vectores de: ${url}`);
} catch (error: any) {
// Si la colección no existe, no hay nada que eliminar
if (error.status === 404 || error.message?.includes("not found")) {
console.log(` → Colección no existe aún, se creará al insertar`);
break; // No hace falta seguir intentando eliminar
}
throw error;
}
}
console.log("");
// =============================================================================
// PASO 5: Generar embeddings e insertar en Qdrant
// =============================================================================
// QdrantVectorStore.fromDocuments():
// - Genera embeddings para cada chunk
// - Los inserta en la colección de Qdrant
// - Crea la colección si no existe
console.log("📤 Generando embeddings e insertando en Qdrant...\n");
const vectorStore = await QdrantVectorStore.fromDocuments(
allSplits,
embeddings,
{
url: process.env.QDRANT_URL,
collectionName: COLLECTION_NAME,
apiKey: process.env.QDRANT_API_KEY,
},
);
console.log(`${allSplits.length} vectores insertados en "${COLLECTION_NAME}"\n`);
// =============================================================================
// PASO 6: Verificar con una búsqueda de prueba
// =============================================================================
// Hacemos una búsqueda semántica para verificar que todo funciona.
console.log("🔍 Verificando con búsqueda de prueba...\n");
const query = "¿Qué capacidad máxima en megas puedo usar para localStorage en un navegador?";
console.log(`Query: "${query}"\n`);
const retrievedDocs = await vectorStore.similaritySearch(query, 2);
console.log("Documentos recuperados:");
retrievedDocs.forEach((doc, idx) => {
console.log(`\n--- Documento ${idx + 1} ---`);
console.log(`Fuente: ${doc.metadata.source}`);
console.log(`Contenido (primeros 300 chars):`);
console.log(doc.pageContent.substring(0, 300) + "...");
});
console.log("\n✅ Indexación completada");
+191
View File
@@ -0,0 +1,191 @@
/**
* =============================================================================
* EJERCICIO: Indexador de Guiones de Christopher Nolan en Vector Store (Qdrant)
* =============================================================================
*
* Este script demuestra cómo crear un pipeline de indexación para RAG:
* 1. Cargar guiones de películas desde URLs (web scraping de IMSDB)
* 2. Dividir documentos en chunks más pequeños
* 3. Generar embeddings y almacenarlos en un vector store
*
* CONCEPTOS CLAVE:
* - CheerioWebBaseLoader: Carga contenido HTML de URLs y extrae texto
* - RecursiveCharacterTextSplitter: Divide documentos en chunks con overlap
* - Embeddings: Representación vectorial del texto para búsqueda semántica
* - Vector Store (Qdrant): Base de datos optimizada para búsqueda por similitud
*
* FLUJO DEL EJERCICIO:
* 1. Definir URLs de guiones de Nolan en IMSDB
* 2. Cargar el contenido HTML de cada URL (selector: td.scrtext)
* 3. Limpiar y dividir en chunks
* 4. **Eliminar vectores existentes** para evitar duplicados
* 5. Generar embeddings e insertar en Qdrant
* 6. Verificar con una búsqueda de prueba
*
* NOTA SOBRE DUPLICADOS:
* Este script elimina los vectores existentes que coincidan con las URLs
* a indexar antes de insertar los nuevos. Esto evita duplicados pero
* re-indexa siempre el contenido.
*
* TODO: Lo ideal sería comprobar si el contenido ha cambiado (ej: hash del
* contenido o fecha de modificación) antes de re-indexar, para evitar
* trabajo innecesario. Por simplicidad, no lo implementamos aquí.
* =============================================================================
*/
import { MistralAIEmbeddings } from "@langchain/mistralai";
import { QdrantVectorStore } from "@langchain/qdrant";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { QdrantClient } from "@qdrant/js-client-rest";
// =============================================================================
// PASO 1: Configuración
// =============================================================================
const COLLECTION_NAME = "nolan-scripts";
// URLs de guiones de películas de Christopher Nolan en IMSDB
const urls = [
"https://imsdb.com/scripts/Interstellar.html",
"https://imsdb.com/scripts/Inception.html",
];
// Configurar embeddings de Mistral
const embeddings = new MistralAIEmbeddings({
model: "mistral-embed",
});
// =============================================================================
// PASO 2: Cargar documentos desde las URLs
// =============================================================================
// CheerioWebBaseLoader usa Cheerio (parser HTML) para extraer contenido.
// El selector "td.scrtext" extrae el texto del guión en IMSDB.
console.log("📥 Cargando guiones de Christopher Nolan desde IMSDB...\n");
const loaders = urls.map(
(url) => new CheerioWebBaseLoader(url, { selector: "td.scrtext" }),
);
const docs = [];
for (const loader of loaders) {
console.log(`${loader.webPath}`);
const loadedDocs = await loader.load();
docs.push(...loadedDocs);
}
console.log(`\n✅ ${docs.length} documentos cargados\n`);
// =============================================================================
// PASO 3: Limpiar y dividir documentos en chunks
// =============================================================================
// - Limpiamos saltos de línea excesivos para reducir ruido
// - Dividimos en chunks de ~1000 caracteres con 150 de overlap
// - El overlap ayuda a mantener contexto entre chunks adyacentes
// Limpiar pageContent: reemplazar múltiples saltos de línea por uno solo
docs.forEach((doc) => {
doc.pageContent = doc.pageContent.replace(/\n{2,}/g, "\n");
});
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000, // Tamaño máximo de cada chunk en caracteres
chunkOverlap: 150, // Solapamiento entre chunks consecutivos
});
const allSplits = await splitter.splitDocuments(docs);
console.log(`📄 Documentos divididos en ${allSplits.length} chunks\n`);
// =============================================================================
// PASO 4: Eliminar vectores existentes para evitar duplicados
// =============================================================================
// Antes de insertar, eliminamos los vectores que ya existen para las URLs
// que vamos a indexar. Usamos el metadato "source" que contiene la URL.
//
// NOTA: Lo ideal sería verificar si el contenido ha cambiado antes de
// re-indexar (usando un hash o fecha de modificación), pero por simplicidad
// siempre eliminamos y re-insertamos.
console.log("🗑️ Eliminando vectores existentes para evitar duplicados...\n");
const qdrantClient = new QdrantClient({
url: process.env.QDRANT_URL,
apiKey: process.env.QDRANT_API_KEY,
});
// Eliminar vectores por cada URL (filtrando por el metadato "source")
for (const url of urls) {
try {
await qdrantClient.delete(COLLECTION_NAME, {
filter: {
must: [
{
key: "metadata.source",
match: { value: url },
},
],
},
});
console.log(` → Eliminados vectores de: ${url}`);
} catch (error: any) {
// Si la colección no existe, no hay nada que eliminar
if (error.status === 404 || error.message?.includes("not found")) {
console.log(` → Colección no existe aún, se creará al insertar`);
break; // No hace falta seguir intentando eliminar
}
throw error;
}
}
console.log("");
// =============================================================================
// PASO 5: Generar embeddings e insertar en Qdrant
// =============================================================================
// QdrantVectorStore.fromDocuments():
// - Genera embeddings para cada chunk
// - Los inserta en la colección de Qdrant
// - Crea la colección si no existe
console.log("📤 Generando embeddings e insertando en Qdrant...\n");
// Instanciar el vector store (crea la colección si no existe al insertar)
const vectorStore = await QdrantVectorStore.fromExistingCollection(embeddings, {
url: process.env.QDRANT_URL,
collectionName: COLLECTION_NAME,
apiKey: process.env.QDRANT_API_KEY,
});
// Insertamos en lotes para no superar el límite de tokens de Mistral
const BATCH_SIZE = 32;
for (let i = 0; i < allSplits.length; i += BATCH_SIZE) {
const batch = allSplits.slice(i, i + BATCH_SIZE);
console.log(` → Batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allSplits.length / BATCH_SIZE)} (${batch.length} chunks)`);
await vectorStore.addDocuments(batch);
}
console.log(`${allSplits.length} vectores insertados en "${COLLECTION_NAME}"\n`);
// =============================================================================
// PASO 6: Verificar con una búsqueda de prueba
// =============================================================================
// Hacemos una búsqueda semántica para verificar que todo funciona.
console.log("🔍 Verificando con búsqueda de prueba...\n");
const query = "What does Cooper say about gravity?";
console.log(`Query: "${query}"\n`);
const retrievedDocs = await vectorStore.similaritySearch(query, 2);
console.log("Documentos recuperados:");
retrievedDocs.forEach((doc, idx) => {
console.log(`\n--- Documento ${idx + 1} ---`);
console.log(`Fuente: ${doc.metadata.source}`);
console.log(`Contenido (primeros 300 chars):`);
console.log(doc.pageContent.substring(0, 300) + "...");
});
console.log("\n✅ Indexación completada");
@@ -0,0 +1,251 @@
/**
* =============================================================================
* EJERCICIO: Wrapper de Agente LangChain con Streaming
* =============================================================================
*
* Este archivo implementa un wrapper sobre el agente de LangChain que permite:
* - Gestionar múltiples conversaciones simultáneas (por thread_id/uuid)
* - Streaming de mensajes a clientes conectados vía SSE
* - Memoria persistente de conversaciones (checkpointer)
* - Integración con herramientas (tools) personalizadas
* - Inyección de dependencias vía contextSchema (ej: vectorStore)
*
* ENUNCIADO INICIAL (versión simplificada sin LangChain):
* ─────────────────────────────────────────────────────────────────────────────
* import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages";
*
* export class Agent {
* // Handlers organizados por uuid (thread_id) para soportar múltiples conversaciones
* private messagesHandlers: Record<string, Array<(message: BaseMessage) => void>> = {};
*
* messageReceived(message: string, clientConfig: { thread_id: string }) {
* const { thread_id } = clientConfig;
* const m = new HumanMessage(message);
* this.messagesHandlers[thread_id]?.forEach((handler) => handler(m));
*
* // Simular respuesta del agente después de 6 segundos
* setTimeout(() => {
* const m = new AIMessage(`Respuesta del agente a "${message}"`);
* this.messagesHandlers[thread_id]?.forEach((handler) => handler(m));
* }, 6000);
* }
*
* registerNewMessageHandler(handler: (message: BaseMessage) => void, uuid: string) {
* this.messagesHandlers[uuid] = this.messagesHandlers[uuid] || [];
* this.messagesHandlers[uuid].push(handler);
* }
*
* unregisterNewMessageHandler(uuid: string, handler: (message: BaseMessage) => void) {
* this.messagesHandlers[uuid] = this.messagesHandlers[uuid].filter((h) => h !== handler);
* }
* }
* ─────────────────────────────────────────────────────────────────────────────
*
* CONCEPTOS CLAVE:
* - createAgent(): Crea un agente ReAct con herramientas y middleware
* - Checkpointer: Guarda el estado de la conversación (memoria persistente)
* - stream(): Permite recibir respuestas del agente de forma incremental
* - dynamicSystemPromptMiddleware: Permite generar prompts dinámicos en cada invocación
* - contextSchema: Define el contexto que se pasa a las tools (ej: vectorStore)
* - messagesHandlers: Patrón pub/sub para notificar a clientes conectados
*
* FLUJO:
* 1. Cliente se conecta y registra un handler (registerNewMessageHandler)
* 2. Cliente envía mensaje (messageReceived)
* 3. Agente procesa el mensaje y genera respuesta (puede usar tools)
* 4. Cada chunk de respuesta se envía a los handlers registrados
* 5. Cliente se desconecta (unregisterNewMessageHandler)
* =============================================================================
*/
import { Calculator } from "@langchain/community/tools/calculator";
import { BaseMessage, HumanMessage } from "@langchain/core/messages";
import { VectorStore } from "@langchain/core/vectorstores";
import { BaseCheckpointSaver, MemorySaver } from "@langchain/langgraph";
import { createAgent, dynamicSystemPromptMiddleware } from "langchain";
import { ConfigurableModel } from "langchain/chat_models/universal";
import {
AgentContext,
agentContextSchema,
buildFilmDocumentTool,
getExchangeRatesTool,
getHistoricalRatesTool,
storageKnowledgeTool,
} from "../helpers/tools";
// =============================================================================
// CLASE AGENT: Wrapper del agente LangChain
// =============================================================================
export class Agent {
// Handlers organizados por uuid (thread_id) para soportar múltiples conversaciones
// Cada conversación tiene su propio array de handlers (clientes conectados)
private messagesHandlers: Record<
string,
Array<(message: BaseMessage | any) => void>
> = {};
// Agente interno de LangChain (ReAct Agent)
// Usamos ReturnType para inferir el tipo correcto de createAgent
private internalAgent: ReturnType<typeof createAgent>;
// Vector store para las búsquedas RAG (se pasa como contexto a las tools)
private vectorStore: VectorStore;
/**
* Constructor del agente
* @param model Modelo de lenguaje LangChain (Mistral, OpenAI, etc.)
* @param vectorStore Vector store para búsquedas RAG (se inyecta en las tools)
* @param checkpointer Persistencia de conversaciones (SQLite, Memory, etc.)
*/
constructor(
model: ConfigurableModel,
vectorStore: VectorStore,
checkpointer: BaseCheckpointSaver = new MemorySaver(),
) {
this.vectorStore = vectorStore;
// createAgent() crea un agente ReAct que puede usar herramientas
this.internalAgent = createAgent({
model,
// contextSchema define la estructura del contexto que se pasa a las tools
// Las tools acceden a él vía config.context (ej: config.context.vectorStore)
contextSchema: agentContextSchema,
// Herramientas disponibles para el agente
tools: [
storageKnowledgeTool, // RAG sobre web storage (usa vectorStore del contexto)
getExchangeRatesTool, // Tasas de cambio actuales
getHistoricalRatesTool, // Tasas de cambio históricas
buildFilmDocumentTool, // Herramienta personalizada para generar documentos de películas
new Calculator(), // Calculadora matemática
],
// Middleware: funciones que procesan los mensajes antes/después del LLM
middleware: [
//trimMessages, // Limita el historial para no exceder el contexto
// System prompt dinámico: se ejecuta en cada invocación
// Esto permite incluir información que cambia (fecha, estado, etc.)
dynamicSystemPromptMiddleware(() => {
const today = new Date().toLocaleDateString("es-ES", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
return `Eres un asistente experto y preciso. Responde siempre en español de forma clara y concisa.
FECHA ACTUAL: ${today}
HERRAMIENTAS DISPONIBLES Y CUÁNDO USARLAS:
1. **storage_knowledge** - Base de conocimiento sobre almacenamiento web
USAR OBLIGATORIAMENTE cuando el usuario pregunte sobre peliculas de Christopher Nolan:
- Interstellar
- Inception
2. **get_exchange_rates** - Tasas de cambio ACTUALES
USAR cuando el usuario pregunte sobre:
- Valor actual de una divisa (ej: "¿Cuánto vale el dólar hoy?")
- Conversión de monedas al día de hoy
3. **get_historical_rates** - Tasas de cambio HISTÓRICAS
USAR cuando el usuario pregunte sobre:
- Valor de una divisa en una fecha pasada específica
- Comparación de evolución de divisas entre fechas
4. **calculator** - Calculadora matemática
USAR para cualquier cálculo numérico que requiera precisión
5. **build_film_document** - Generador de documentos de películas
USAR cuando el usuario quiera generar un documento detallado sobre una película específica
REGLAS:
- Si no estás seguro de la respuesta, USA las herramientas disponibles
- Para preguntas sobre peliculas de nolan, SIEMPRE consulta storage_knowledge primero
- Para conversiones de moneda, USA las herramientas de tasas de cambio
- Sé conciso pero completo en tus respuestas`;
}),
],
// Persistencia de la conversación
checkpointer,
});
}
// ===========================================================================
// messageReceived: Procesa un mensaje del usuario
// ===========================================================================
// Recibe un mensaje, lo pasa al agente y envía las respuestas vía streaming.
async messageReceived(message: string, clientConfig: { thread_id: string }) {
const { thread_id } = clientConfig;
const initialMessage = new HumanMessage(message);
this._sendMessageToClients(thread_id, initialMessage);
console.log(`[Agent] Mensaje recibido en thread: ${thread_id}`);
// stream() permite recibir respuestas incrementales del agente
// streamMode: "values" devuelve el estado completo en cada chunk
// context: pasa el vectorStore a las tools que lo necesiten
const response = await this.internalAgent.stream(
{ messages: [initialMessage] },
{
streamMode: "updates",
configurable: { thread_id },
// El contexto se pasa a las tools vía config.context
context: { vectorStore: this.vectorStore } satisfies AgentContext,
},
);
// Procesar cada chunk del streaming
// Con streamMode "updates", el chunk tiene forma { nodeName: { messages: [...] } }
for await (const chunk of response) {
const nodeOutput = Object.values(chunk)[0] as {
messages?: BaseMessage[];
};
const messages = nodeOutput?.messages ?? [];
for (const msg of messages) {
this._sendMessageToClients(thread_id, msg);
}
}
}
// ===========================================================================
// _sendMessageToClients: Notifica a todos los handlers de una conversación
// ===========================================================================
private _sendMessageToClients(uuid: string, message: BaseMessage | any) {
// Verificar que existan handlers para este uuid
if (!this.messagesHandlers[uuid]) return;
this.messagesHandlers[uuid].forEach((handler) => handler(message));
}
// ===========================================================================
// registerNewMessageHandler: Registra un handler para recibir mensajes
// ===========================================================================
// Se llama cuando un cliente abre una conexión SSE.
registerNewMessageHandler(
handler: (message: BaseMessage | any) => void,
uuid: string,
) {
this.messagesHandlers[uuid] = this.messagesHandlers[uuid] || [];
this.messagesHandlers[uuid].push(handler);
console.log(`[Agent] Handler registrado para thread: ${uuid}`);
}
// ===========================================================================
// unregisterNewMessageHandler: Elimina un handler cuando el cliente se desconecta
// ===========================================================================
unregisterNewMessageHandler(
uuid: string,
handler: (message: BaseMessage | any) => void,
) {
if (!this.messagesHandlers[uuid]) return;
this.messagesHandlers[uuid] = this.messagesHandlers[uuid].filter(
(h) => h !== handler,
);
console.log(`[Agent] Handler desregistrado para thread: ${uuid}`);
}
}
@@ -0,0 +1,8 @@
export const comments = [
{ uuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", content: "¡Excelente artículo! Me ha ayudado muchísimo a entender el tema. Gracias por compartir." },
{ uuid: "b2c3d4e5-f6a7-8901-bcde-f12345678901", content: "Meh, está bien pero tampoco es nada del otro mundo. He leído cosas mejores." },
{ uuid: "c3d4e5f6-a7b8-9012-cdef-123456789012", content: "No estoy de acuerdo con el punto 3, creo que habría que matizarlo un poco más." },
{ uuid: "d4e5f6a7-b8c9-0123-def0-234567890123", content: "Vaya mierda de artículo, no tienes ni puta idea de lo que hablas." },
{ uuid: "e5f6a7b8-c9d0-1234-ef01-345678901234", content: "Interesante perspectiva. ¿Podrías ampliar la información sobre el segundo apartado?" },
{ uuid: "f6a7b8c9-d0e1-2345-f012-456789012345", content: "Llevo años trabajando en esto y confirmo que todo lo que dices es correcto. Gran trabajo." }
]
@@ -0,0 +1,3 @@
import { resolve } from "node:path";
export const OUTPUT_DIR = resolve("output_docx");
+18
View File
@@ -0,0 +1,18 @@
import { createInterface } from "node:readline/promises";
export async function promptUser(
query: string,
defaultValue = ""
): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const response = await rl.question(
`${query} ${defaultValue ? `[${defaultValue}] ` : ""}`
);
rl.close();
return response || defaultValue;
}
@@ -0,0 +1,44 @@
import {
RemoveMessage,
ToolMessage
} from "@langchain/core/messages";
import { REMOVE_ALL_MESSAGES } from "@langchain/langgraph";
import { createMiddleware } from "langchain";
export const trimMessages = createMiddleware({
name: "TrimMessages",
beforeModel: (state) => {
const messages = state.messages;
if (messages.length <= 3) return; // No recortar si hay pocos
// Mantener primer mensaje + últimos 4
const firstMsg = messages[0];
let recentMsgs = messages.slice(-4);
// Asegurar que no empezamos con un mensaje 'tool' huérfano.
// Si el primer mensaje reciente es de tipo 'tool', necesitamos incluir
// el mensaje 'assistant' con tool_calls que lo precede.
while (recentMsgs.length > 0 && ToolMessage.isInstance(recentMsgs[0])) {
// Buscar el índice en el array original
const idx = messages.indexOf(recentMsgs[0]);
if (idx > 0) {
// Incluir el mensaje anterior (debería ser assistant con tool_calls)
recentMsgs = [messages[idx - 1], ...recentMsgs];
} else {
// No hay mensaje anterior, eliminar el tool huérfano
recentMsgs = recentMsgs.slice(1);
}
}
// También asegurar que no empezamos con un 'assistant' sin contexto previo
// después del primer mensaje (esto es menos problemático pero más limpio)
const newMessages = [firstMsg, ...recentMsgs];
return {
messages: [
new RemoveMessage({ id: REMOVE_ALL_MESSAGES }),
...newMessages,
],
};
},
});
+287
View File
@@ -0,0 +1,287 @@
/**
* =============================================================================
* HERRAMIENTAS (TOOLS) PARA AGENTES DE LANGCHAIN
* =============================================================================
*
* Este archivo contiene las herramientas que los agentes pueden usar para
* realizar tareas específicas. Las Tools permiten al LLM interactuar con
* el mundo exterior: APIs, bases de datos, cálculos, etc.
*
* CONCEPTOS CLAVE:
* - tool(): Función para crear herramientas personalizadas
* - description: El LLM usa esta descripción para decidir CUÁNDO usar la tool
* - schema: Define los parámetros que acepta la herramienta (validados con Zod)
* - config: Segundo parámetro de la tool que permite acceder al contexto runtime
*
* BUENAS PRÁCTICAS PARA DESCRIPTIONS:
* - Ser específico sobre cuándo usar la herramienta
* - Indicar qué tipo de información devuelve
* - Usar mayúsculas para enfatizar casos de uso obligatorios
*
* RUNTIME CONTEXT:
* Las tools pueden acceder al contexto del agente a través del parámetro `config`.
* Esto permite inyectar dependencias (como vectorStore, DB connections, etc.)
* en tiempo de ejecución, evitando estado global y haciendo las tools más testables.
* =============================================================================
*/
import { CohereRerank } from "@langchain/cohere";
import { WikipediaQueryRun } from "@langchain/community/tools/wikipedia_query_run";
import { DocumentInterface } from "@langchain/core/documents";
import { VectorStore } from "@langchain/core/vectorstores";
import { ChatMistralAI } from "@langchain/mistralai";
import { HumanMessage, SystemMessage, tool } from "langchain";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { z } from "zod";
import Docxtemplater from "docxtemplater";
import { join } from "node:path";
import PizZip from "pizzip";
import { OUTPUT_DIR } from "./constants";
// =============================================================================
// CONTEXT SCHEMA: Define la estructura del contexto del agente
// =============================================================================
// Este esquema se usa en createAgent() para tipar el contexto que se pasa
// al invocar el agente. Las tools acceden a él vía config.context
//
// Ejemplo de uso:
// const agent = createAgent({ ..., contextSchema: agentContextSchema });
// agent.invoke({ messages }, { context: { vectorStore: myVectorStore } });
export const agentContextSchema = z.object({
vectorStore: z
.custom<VectorStore>()
.describe("Vector store para búsquedas RAG"),
});
// Tipo TypeScript inferido del esquema
export type AgentContext = z.infer<typeof agentContextSchema>;
// =============================================================================
// TOOL: Contador de Palabras
// =============================================================================
// Herramienta síncrona simple para contar palabras en un texto.
// Útil porque los LLMs no pueden contar con precisión.
export const wordCountTool = tool(
({ texto }) => {
process.stdout.write("Contando palabras...");
const words = texto.trim().split(/\s+/).filter(Boolean).length;
process.stdout.write(` [${words}]\n`);
return words.toString();
},
{
name: "contar_palabras",
description:
"Cuenta el número exacto de palabras en un texto. USAR SIEMPRE que necesites saber la cantidad de palabras de un texto, ya que no puedes contarlas con precisión por ti mismo.",
schema: z.object({
texto: z.string().describe("El texto del cual contar las palabras"),
}),
},
);
// =============================================================================
// TOOL: Wikipedia
// =============================================================================
// Herramienta pre-construida de LangChain para buscar en Wikipedia.
// Útil para obtener información general y actualizada sobre cualquier tema.
export const wikipediaTool = new WikipediaQueryRun({
topKResults: 3, // Número máximo de resultados a devolver
maxDocContentLength: 4000, // Longitud máxima del contenido por documento
});
// =============================================================================
// TOOL: Tasas de Cambio Actuales
// =============================================================================
// Consulta la API de Frankfurter para obtener tasas de cambio en tiempo real.
// API gratuita y sin autenticación: https://www.frankfurter.app/
export const getExchangeRatesTool = tool(
async ({ base, symbols }) => {
const url = `https://api.frankfurter.app/latest?from=${base}&to=${symbols.join(",")}`;
const response = await fetch(url);
const data = await response.json();
return JSON.stringify(data);
},
{
name: "get_exchange_rates",
description:
"Obtiene las tasas de cambio ACTUALES desde una moneda base a otras monedas. Usar cuando el usuario pregunte por el valor actual de una divisa o quiera convertir cantidades entre monedas HOY.",
schema: z.object({
base: z
.string()
.describe("Código ISO de la moneda base (ej: EUR, USD, GBP, JPY)"),
symbols: z
.array(z.string())
.describe(
"Array de códigos ISO de las monedas destino (ej: ['USD', 'GBP'])",
),
}),
},
);
// =============================================================================
// TOOL: Tasas de Cambio Históricas
// =============================================================================
// Consulta tasas de cambio de una fecha específica en el pasado.
// Útil para comparar evolución de divisas o consultas sobre fechas concretas.
export const getHistoricalRatesTool = tool(
async ({ date, base, symbols }) => {
const url = `https://api.frankfurter.dev/v1/${date}?from=${base}&to=${symbols.join(",")}`;
console.log(`Consultando tasas de cambio históricas con URL: ${url}`);
const response = await fetch(url);
const data = await response.json();
console.log(data);
return JSON.stringify(data);
},
{
name: "get_historical_rates",
description:
"Obtiene tasas de cambio de una fecha PASADA específica. Usar cuando el usuario pregunte por el valor de una divisa en una fecha concreta, o quiera comparar la evolución de una moneda entre dos fechas.",
schema: z.object({
date: z.string().describe("Fecha en formato YYYY-MM-DD (ej: 2024-01-15)"),
base: z.string().describe("Código ISO de la moneda base (ej: EUR, USD)"),
symbols: z
.array(z.string())
.describe("Array de códigos ISO de las monedas destino"),
}),
},
);
// =============================================================================
// TOOL: Base de Conocimiento sobre Web Storage
// =============================================================================
// Herramienta RAG que consulta una base de datos vectorial con documentación
// sobre sistemas de almacenamiento en navegadores (localStorage, IndexedDB, etc.)
//
// IMPORTANTE: Esta tool accede al vectorStore desde el contexto del agente
// en lugar de usar una variable global. Esto hace la tool más testable y
// permite usar diferentes vector stores según el contexto.
//
// El vectorStore se pasa al invocar el agente:
// agent.invoke({ messages }, { context: { vectorStore: myVectorStore } });
const mistralMini = new ChatMistralAI({
model: "mistral-tiny",
}).withStructuredOutput(
z.object({
querys: z
.array(z.string())
.describe("Consulta del usuario para buscar en la base de conocimiento"),
}),
);
export const storageKnowledgeTool = tool(
async ({ query }, config) => {
process.stdout.write(`Buscando en base de conocimiento: "${query}"\n`);
const querys = await mistralMini.invoke([
new SystemMessage(
`A partir del mensaje del usuario, necesito una colección de palabras (una o dos) relevantes semanticamente relacionadas, para encontrar lo que necesita el usuario, buscando en una bbdd vectorial con guiones de películas.`,
),
new HumanMessage(query),
]);
process.stdout.write(
` → Busquedas generadas por Mistral: ${JSON.stringify(querys)}\n`,
);
// Acceder al vectorStore desde el contexto del agente
// config.context contiene los valores pasados en { context: {...} } al invocar
const vectorStore = (config as any).context?.vectorStore as VectorStore;
if (!vectorStore) {
return "Error: No se ha configurado el vector store en el contexto del agente.";
}
const docs: Array<DocumentInterface> = [];
for (const q of querys.querys) {
process.stdout.write(` → Buscando en vector store con query: "${q}"\n`);
const retrievedDocs = await vectorStore.similaritySearch(q, 3);
process.stdout.write(
`${retrievedDocs.length} documentos encontrados\n`,
);
if (retrievedDocs.length > 0) {
docs.push(...retrievedDocs);
}
}
if (docs.length === 0) {
return `No se encontró información relevante sobre "${query}".`;
}
const reranker = new CohereRerank({
apiKey: process.env.COHERE_API_KEY,
model: "rerank-v3.5",
topN: 5,
});
const rerankedDocs = await reranker.compressDocuments(docs, query);
console.log(` → Documentos reordenados por relevancia con Cohere Rerank`);
// Formatear los documentos recuperados como contexto
const context = rerankedDocs
.map((doc) => doc.pageContent)
.join("\n\n---\n\n");
return `Información encontrada sobre "${query}":\n\n${context}`;
},
{
name: "storage_knowledge",
description:
"Consulta la base de conocimiento sobre peliculas de Christopher Nolan. USAR OBLIGATORIAMENTE cuando el usuario pregunte sobre: Interstellar, Inception. Devuelve información relevante extraída de documentos relacionados con esas películas.",
schema: z.object({
query: z
.string()
.describe(
"Pregunta o tema a buscar sobre peliculas de Christopher Nolan",
),
}),
},
);
/**
*
*/
export const buildFilmDocumentTool = tool(
async (data) => {
const content = readFileSync("template/plantilla_ficha.docx", "binary");
const zip = new PizZip(content);
const doc = new Docxtemplater(zip, {
paragraphLoop: true,
linebreaks: true,
});
doc.render({
...data,
fechaGeneracion: new Date().toLocaleDateString("es-ES"),
});
mkdirSync(OUTPUT_DIR, { recursive: true });
const buf = doc.getZip().generate({ type: "nodebuffer" });
writeFileSync(join(OUTPUT_DIR, `${data.titulo}.docx`), buf);
return `Documento generado. href de descarga => "http://localhost:3000/files/${data.titulo}.docx"`;
},
{
name: "build_film_document",
description: `Construye un documento a partir de los datos de una película.
USAR CUANDO necesites crear un documento con información estructurada sobre una película, incluyendo título, director, año, género y sinopsis.
El resultado debe ser un JSON con formato específico.`,
schema: z.object({
titulo: z.string().describe("Título de la película"),
genero: z.string().describe("Género de la película"),
duracion: z.string().describe("Duración en minutos"),
director: z.string().describe("Director o directora principal"),
year: z.string().describe("Año de la propducción"),
sinopsis: z.string().describe("Sinopsis de 3-4 frases"),
reparto: z
.array(z.object({ item: z.string() }))
.describe("Lista de miembros del reparto"),
}),
},
);
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "esnext",
"target": "es2022",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src", "../samples"]
}