From 1847eaefbed491dacfd887f467e1448ecd576632 Mon Sep 17 00:00:00 2001 From: Paco POR-CORREO Date: Mon, 6 Apr 2026 13:09:11 +0200 Subject: [PATCH] Improve playground scope and answer model control --- RAG/docs/API_RAG.md | 35 +++++++++++++ RAG/docs/PLAYGROUND.md | 2 + RAG/public/playground/app.js | 71 ++++++++++++++++++++++++--- RAG/public/playground/index.html | 8 +++ RAG/src/app.ts | 19 ++++++- RAG/src/modules/answer/service.ts | 13 +++-- RAG/src/modules/vectorstore/client.ts | 58 +++++++++++++++++++++- RAG/src/shared/types/rag.ts | 8 +++ docs/HISTORIAL_SESIONES.md | 2 + 9 files changed, 202 insertions(+), 14 deletions(-) diff --git a/RAG/docs/API_RAG.md b/RAG/docs/API_RAG.md index bc52c10..3a8af5a 100644 --- a/RAG/docs/API_RAG.md +++ b/RAG/docs/API_RAG.md @@ -47,6 +47,10 @@ Respuesta esperada aproximada: "provider": "openrouter", "model": "qwen/qwen3-embedding-8b" }, + "answer": { + "provider": "openrouter", + "model": "openai/gpt-4.1-mini" + }, "vectorStore": { "ok": true, "kind": "qdrant" @@ -137,6 +141,7 @@ Payload base: { "mode": "documental", "intent": "specific", + "model": "openai/gpt-4.1-mini", "query": "que tenemos pendiente por hacer en este workspace", "scope": { "sourceRef": "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs" @@ -242,12 +247,15 @@ Es util para: - respuestas listas para usuario - pruebas rapidas desde n8n o aplicaciones +Tambien permite indicar el modelo de respuesta explicitamente mediante el campo opcional `model`. + Payload base: ```json { "mode": "documental", "intent": "specific", + "model": "openai/gpt-4.1-mini", "query": "que tenemos pendiente por hacer en este workspace", "scope": { "sourceRef": "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs" @@ -291,6 +299,7 @@ Respuesta resumida esperada: { "mode": "codigo", "intent": "specific", + "model": "openai/gpt-4.1-mini", "answer": "...", "summary": "...", "topics": ["ids.ts"], @@ -311,6 +320,32 @@ Respuesta resumida esperada: } ``` +### 5. `POST /answer/direct` + +Sirve para pedir una respuesta directa al modelo sin usar contexto del RAG. + +Es util para comparar: +- respuesta sin RAG +- respuesta con RAG + +Payload base: + +```json +{ + "query": "que es este servicio", + "model": "openai/gpt-4.1-mini" +} +``` + +Respuesta esperada: + +```json +{ + "model": "openai/gpt-4.1-mini", + "answer": "..." +} +``` + --- ## Recomendacion practica para n8n diff --git a/RAG/docs/PLAYGROUND.md b/RAG/docs/PLAYGROUND.md index a050807..e3a8e72 100644 --- a/RAG/docs/PLAYGROUND.md +++ b/RAG/docs/PLAYGROUND.md @@ -50,12 +50,14 @@ El backend lo sirve desde: 3. `retrieve` 4. `answer` 5. `answer` sin RAG para comparar impacto del contexto +6. seleccion explicita del modelo de `answer` Tambien permite: - cambiar `mode` - cambiar `intent` - ajustar `scope` +- seleccionar el modelo de respuesta - usar presets para docs, docs del modulo y codigo del RAG --- diff --git a/RAG/public/playground/app.js b/RAG/public/playground/app.js index f15694b..e79cfa8 100644 --- a/RAG/public/playground/app.js +++ b/RAG/public/playground/app.js @@ -13,6 +13,8 @@ const queryInput = document.getElementById("queryInput"); const queryMode = document.getElementById("queryMode"); const queryIntent = document.getElementById("queryIntent"); const queryOperation = document.getElementById("queryOperation"); +const answerModel = document.getElementById("answerModel"); +const scopePresetSelect = document.getElementById("scopePresetSelect"); const scopeSourceRef = document.getElementById("scopeSourceRef"); const scopeTags = document.getElementById("scopeTags"); const compareWithoutRag = document.getElementById("compareWithoutRag"); @@ -30,6 +32,11 @@ function splitTags(value) { return value.split(",").map((entry) => entry.trim()).filter(Boolean); } +function buildScopeLabel(scope) { + const modes = scope.chunkModes.join(", ") || "sin modo"; + return `${scope.sourceRef} [${modes}]`; +} + async function request(url, payload, method = "POST") { const response = await fetch(url, { method, @@ -47,6 +54,51 @@ async function request(url, payload, method = "POST") { return data; } +async function loadScopes() { + try { + const scopes = await fetch("/sources").then((response) => response.json()); + scopePresetSelect.innerHTML = ""; + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Selecciona un scope disponible"; + scopePresetSelect.appendChild(placeholder); + + for (const scope of scopes) { + const option = document.createElement("option"); + option.value = JSON.stringify(scope); + option.textContent = buildScopeLabel(scope); + scopePresetSelect.appendChild(option); + } + + if (scopes.length === 0) { + placeholder.textContent = "No hay scopes detectados"; + } + } catch (error) { + scopePresetSelect.innerHTML = ""; + const option = document.createElement("option"); + option.value = ""; + option.textContent = `Error cargando scopes: ${error}`; + scopePresetSelect.appendChild(option); + } +} + +scopePresetSelect.addEventListener("change", () => { + if (!scopePresetSelect.value) { + return; + } + + const scope = JSON.parse(scopePresetSelect.value); + scopeSourceRef.value = scope.sourceRef || ""; + scopeTags.value = (scope.tags || []).join(", "); + + if (scope.chunkModes.includes("codigo")) { + queryMode.value = "codigo"; + } else if (scope.chunkModes.includes("documental")) { + queryMode.value = "documental"; + } +}); + healthButton.addEventListener("click", async () => { healthResult.textContent = "Comprobando..."; try { @@ -81,6 +133,7 @@ queryButton.addEventListener("click", async () => { mode: queryMode.value, intent: queryIntent.value, query: queryInput.value, + model: answerModel.value, scope: { sourceRef: scopeSourceRef.value, tags: splitTags(scopeTags.value) @@ -92,12 +145,13 @@ queryButton.addEventListener("click", async () => { const data = await request(endpoint, payload); mainResult.textContent = format(data); - if (compareWithoutRag.checked && queryOperation.value === "answer") { - const comparison = await request("/answer/direct", { - query: queryInput.value - }); - compareResult.textContent = format(comparison); - } + if (compareWithoutRag.checked && queryOperation.value === "answer") { + const comparison = await request("/answer/direct", { + query: queryInput.value, + model: answerModel.value + }); + compareResult.textContent = format(comparison); + } } catch (error) { mainResult.textContent = String(error); compareResult.textContent = compareWithoutRag.checked ? String(error) : "Desactivada."; @@ -110,6 +164,7 @@ presetDocs.addEventListener("click", () => { queryOperation.value = "answer"; queryInput.value = "que tenemos pendiente por hacer en este workspace"; scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs"; + answerModel.value = "openai/gpt-4.1-mini"; }); presetRagDocs.addEventListener("click", () => { @@ -118,6 +173,7 @@ presetRagDocs.addEventListener("click", () => { queryOperation.value = "retrieve"; queryInput.value = "dame un mapa inicial del modulo RAG, su arquitectura, decisiones, estado actual y documentos clave"; scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/docs"; + answerModel.value = "openai/gpt-4.1-mini"; }); presetCode.addEventListener("click", () => { @@ -126,4 +182,7 @@ presetCode.addEventListener("click", () => { queryOperation.value = "answer"; queryInput.value = "como se construye source_id en el rag"; scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/src"; + answerModel.value = "openai/gpt-4.1-mini"; }); + +loadScopes(); diff --git a/RAG/public/playground/index.html b/RAG/public/playground/index.html index 79f9985..d45fe08 100644 --- a/RAG/public/playground/index.html +++ b/RAG/public/playground/index.html @@ -67,9 +67,17 @@ +
+ diff --git a/RAG/src/app.ts b/RAG/src/app.ts index e5695e4..b919fdc 100644 --- a/RAG/src/app.ts +++ b/RAG/src/app.ts @@ -39,12 +39,25 @@ export function createApp() { provider: embeddingProvider.providerName, model: embeddingProvider.modelName }, + answer: { + provider: env.answerProvider, + model: env.answerModel + }, vectorStore: vectorHealth, parsers: supportedParserExtensions(), chunking: documentalChunkingPolicy }); }); + app.get("/sources", async (_req, res) => { + try { + const scopes = await vectorStore.listScopes(); + res.json(scopes); + } catch (error) { + res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown sources error" }); + } + }); + app.post("/ingest", async (req, res) => { try { const result = await ingestService.ingest(req.body); @@ -85,7 +98,8 @@ export function createApp() { tags: Array.isArray(req.body.scope.tags) ? req.body.scope.tags.map(String) : undefined } : undefined; - const result = await answerService.answer(mode, intent, query, scope); + const model = req.body.model ? String(req.body.model) : undefined; + const result = await answerService.answer(mode, intent, query, scope, model); res.json(result); } catch (error) { res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown answer error" }); @@ -95,7 +109,8 @@ export function createApp() { app.post("/answer/direct", async (req, res) => { try { const query = String(req.body.query ?? ""); - const result = await answerService.answerWithoutRag(query); + const model = req.body.model ? String(req.body.model) : undefined; + const result = await answerService.answerWithoutRag(query, model); res.json(result); } catch (error) { res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown direct answer error" }); diff --git a/RAG/src/modules/answer/service.ts b/RAG/src/modules/answer/service.ts index be661e4..29da2db 100644 --- a/RAG/src/modules/answer/service.ts +++ b/RAG/src/modules/answer/service.ts @@ -36,14 +36,15 @@ export class AnswerService { }); } - async answer(mode: ChunkMode, intent: RetrieveIntent, query: string, scope?: RetrieveScope): Promise { + async answer(mode: ChunkMode, intent: RetrieveIntent, query: string, scope?: RetrieveScope, modelOverride?: string): Promise { if (!env.answerApiKey) { throw new Error("Missing ANSWER_API_KEY for answer provider"); } const retrieved = await this.retrieveService.retrieve(mode, intent, query, scope); + const answerModel = modelOverride?.trim() || env.answerModel; const completion = await this.client.chat.completions.create({ - model: env.answerModel, + model: answerModel, temperature: 0.2, messages: [ { @@ -60,6 +61,7 @@ export class AnswerService { return { mode: retrieved.mode, intent: retrieved.intent, + model: answerModel, answer: completion.choices[0]?.message?.content?.trim() || "No se pudo generar respuesta.", summary: retrieved.summary, topics: retrieved.topics, @@ -76,13 +78,14 @@ export class AnswerService { }; } - async answerWithoutRag(query: string): Promise { + async answerWithoutRag(query: string, modelOverride?: string): Promise { if (!env.answerApiKey) { throw new Error("Missing ANSWER_API_KEY for answer provider"); } + const answerModel = modelOverride?.trim() || env.answerModel; const completion = await this.client.chat.completions.create({ - model: env.answerModel, + model: answerModel, temperature: 0.2, messages: [ { @@ -97,7 +100,7 @@ export class AnswerService { }); return { - model: env.answerModel, + model: answerModel, answer: completion.choices[0]?.message?.content?.trim() || "No se pudo generar respuesta." }; } diff --git a/RAG/src/modules/vectorstore/client.ts b/RAG/src/modules/vectorstore/client.ts index b67f717..7bb962a 100644 --- a/RAG/src/modules/vectorstore/client.ts +++ b/RAG/src/modules/vectorstore/client.ts @@ -1,6 +1,6 @@ import { QdrantClient } from "@qdrant/js-client-rest"; import { env } from "../../config/env.js"; -import type { IngestedChunk, RetrieveScope, RetrievedItem } from "../../shared/types/rag.js"; +import type { AvailableScope, IngestedChunk, RetrieveScope, RetrievedItem } from "../../shared/types/rag.js"; function buildQdrantClient(): QdrantClient { const url = new URL(env.qdrantUrl); @@ -19,6 +19,7 @@ export interface VectorStoreClient { ensureCollection(vectorSize: number): Promise; upsert(chunks: IngestedChunk[]): Promise; search(queryVector: number[], limit: number, mode?: string, scope?: RetrieveScope): Promise; + listScopes(): Promise; } function buildSearchFilter(mode?: string, scope?: RetrieveScope) { @@ -133,4 +134,59 @@ export class QdrantVectorStoreClient implements VectorStoreClient { endLine: point.payload?.end_line ? Number(point.payload.end_line) : undefined })); } + + async listScopes(): Promise { + const collections = await this.client.getCollections(); + const exists = collections.collections.some((collection) => collection.name === env.qdrantCollection); + + if (!exists) { + return []; + } + + const scopeMap = new Map(); + let nextOffset = undefined; + + do { + const response = await this.client.scroll(env.qdrantCollection, { + limit: 128, + offset: nextOffset, + with_payload: ["source_id", "source_ref", "chunk_mode", "tags"] + }); + + for (const point of response.points) { + const sourceId = String(point.payload?.source_id ?? ""); + const sourceRef = String(point.payload?.source_ref ?? ""); + const chunkMode = String(point.payload?.chunk_mode ?? ""); + const tags = Array.isArray(point.payload?.tags) ? point.payload.tags.map(String) : []; + + if (!sourceId || !sourceRef) { + continue; + } + + const key = `${sourceId}::${sourceRef}`; + const existing = scopeMap.get(key) ?? { + sourceId, + sourceRef, + chunkModes: [], + tags: [] + }; + + if ((chunkMode === "documental" || chunkMode === "codigo" || chunkMode === "auto") && !existing.chunkModes.includes(chunkMode)) { + existing.chunkModes.push(chunkMode); + } + + for (const tag of tags) { + if (!existing.tags.includes(tag)) { + existing.tags.push(tag); + } + } + + scopeMap.set(key, existing); + } + + nextOffset = response.next_page_offset; + } while (nextOffset); + + return [...scopeMap.values()].sort((left, right) => left.sourceRef.localeCompare(right.sourceRef)); + } } diff --git a/RAG/src/shared/types/rag.ts b/RAG/src/shared/types/rag.ts index 8b4e376..0434f4e 100644 --- a/RAG/src/shared/types/rag.ts +++ b/RAG/src/shared/types/rag.ts @@ -48,6 +48,13 @@ export interface RetrieveScope { tags?: string[]; } +export interface AvailableScope { + sourceId: string; + sourceRef: string; + chunkModes: ChunkMode[]; + tags: string[]; +} + export interface RetrieveResponse { mode: ChunkMode; intent: RetrieveIntent; @@ -62,6 +69,7 @@ export interface RetrieveResponse { export interface AnswerResponse { mode: ChunkMode; intent: RetrieveIntent; + model: string; answer: string; summary: string; topics: string[]; diff --git a/docs/HISTORIAL_SESIONES.md b/docs/HISTORIAL_SESIONES.md index 965ccc7..a73d447 100644 --- a/docs/HISTORIAL_SESIONES.md +++ b/docs/HISTORIAL_SESIONES.md @@ -60,8 +60,10 @@ Este archivo registra agentes y sesiones de trabajo de este workspace. - Pruebas satisfactorias en produccion de `health`, `retrieve` y `answer`, tanto en modo documental como en modo codigo. - Deteccion y anotacion como pendiente del cambio futuro del modelo actual de `answer`. - Creacion de `RAG/docs/API_RAG.md` como referencia operativa de la API para conectar rapidamente el servicio desde n8n, agentes y otras aplicaciones. +- Alineacion de las variables de entorno para EasyPanel, dejando `.env.example` ajustado al despliegue esperado y `RAG/.env.easypanel.local` como respaldo operativo local ignorado por Git. - Implementacion de un playground web interno servido por el propio backend del RAG para probar `health`, `ingest`, `retrieve`, `answer` y comparacion sin RAG. - Creacion de `RAG/docs/PLAYGROUND.md` para documentar la tecnologia elegida, su ubicacion y su papel dentro del modulo. +- Ajuste de la API y del playground para hacer visible y seleccionable el modelo de `answer`, evitando dejarlo oculto como una decision fija del backend. - Reorganizacion de RAG como modulo raiz independiente con documentacion propia en `RAG/docs/`. - Ajuste del indice documental global para reflejar la separacion entre documentacion global y documentacion por tool. - Creacion de `docs/TASK.md` para descomponer lineas de trabajo amplias en puntos de analisis y acuerdos.