Improve playground scope and answer model control

This commit is contained in:
Paco POR-CORREO 2026-04-06 13:09:11 +02:00
parent c641286372
commit 1847eaefbe
9 changed files with 202 additions and 14 deletions

View file

@ -47,6 +47,10 @@ Respuesta esperada aproximada:
"provider": "openrouter", "provider": "openrouter",
"model": "qwen/qwen3-embedding-8b" "model": "qwen/qwen3-embedding-8b"
}, },
"answer": {
"provider": "openrouter",
"model": "openai/gpt-4.1-mini"
},
"vectorStore": { "vectorStore": {
"ok": true, "ok": true,
"kind": "qdrant" "kind": "qdrant"
@ -137,6 +141,7 @@ Payload base:
{ {
"mode": "documental", "mode": "documental",
"intent": "specific", "intent": "specific",
"model": "openai/gpt-4.1-mini",
"query": "que tenemos pendiente por hacer en este workspace", "query": "que tenemos pendiente por hacer en este workspace",
"scope": { "scope": {
"sourceRef": "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs" "sourceRef": "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs"
@ -242,12 +247,15 @@ Es util para:
- respuestas listas para usuario - respuestas listas para usuario
- pruebas rapidas desde n8n o aplicaciones - pruebas rapidas desde n8n o aplicaciones
Tambien permite indicar el modelo de respuesta explicitamente mediante el campo opcional `model`.
Payload base: Payload base:
```json ```json
{ {
"mode": "documental", "mode": "documental",
"intent": "specific", "intent": "specific",
"model": "openai/gpt-4.1-mini",
"query": "que tenemos pendiente por hacer en este workspace", "query": "que tenemos pendiente por hacer en este workspace",
"scope": { "scope": {
"sourceRef": "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs" "sourceRef": "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs"
@ -291,6 +299,7 @@ Respuesta resumida esperada:
{ {
"mode": "codigo", "mode": "codigo",
"intent": "specific", "intent": "specific",
"model": "openai/gpt-4.1-mini",
"answer": "...", "answer": "...",
"summary": "...", "summary": "...",
"topics": ["ids.ts"], "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 ## Recomendacion practica para n8n

View file

@ -50,12 +50,14 @@ El backend lo sirve desde:
3. `retrieve` 3. `retrieve`
4. `answer` 4. `answer`
5. `answer` sin RAG para comparar impacto del contexto 5. `answer` sin RAG para comparar impacto del contexto
6. seleccion explicita del modelo de `answer`
Tambien permite: Tambien permite:
- cambiar `mode` - cambiar `mode`
- cambiar `intent` - cambiar `intent`
- ajustar `scope` - ajustar `scope`
- seleccionar el modelo de respuesta
- usar presets para docs, docs del modulo y codigo del RAG - usar presets para docs, docs del modulo y codigo del RAG
--- ---

View file

@ -13,6 +13,8 @@ const queryInput = document.getElementById("queryInput");
const queryMode = document.getElementById("queryMode"); const queryMode = document.getElementById("queryMode");
const queryIntent = document.getElementById("queryIntent"); const queryIntent = document.getElementById("queryIntent");
const queryOperation = document.getElementById("queryOperation"); const queryOperation = document.getElementById("queryOperation");
const answerModel = document.getElementById("answerModel");
const scopePresetSelect = document.getElementById("scopePresetSelect");
const scopeSourceRef = document.getElementById("scopeSourceRef"); const scopeSourceRef = document.getElementById("scopeSourceRef");
const scopeTags = document.getElementById("scopeTags"); const scopeTags = document.getElementById("scopeTags");
const compareWithoutRag = document.getElementById("compareWithoutRag"); const compareWithoutRag = document.getElementById("compareWithoutRag");
@ -30,6 +32,11 @@ function splitTags(value) {
return value.split(",").map((entry) => entry.trim()).filter(Boolean); 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") { async function request(url, payload, method = "POST") {
const response = await fetch(url, { const response = await fetch(url, {
method, method,
@ -47,6 +54,51 @@ async function request(url, payload, method = "POST") {
return data; 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 () => { healthButton.addEventListener("click", async () => {
healthResult.textContent = "Comprobando..."; healthResult.textContent = "Comprobando...";
try { try {
@ -81,6 +133,7 @@ queryButton.addEventListener("click", async () => {
mode: queryMode.value, mode: queryMode.value,
intent: queryIntent.value, intent: queryIntent.value,
query: queryInput.value, query: queryInput.value,
model: answerModel.value,
scope: { scope: {
sourceRef: scopeSourceRef.value, sourceRef: scopeSourceRef.value,
tags: splitTags(scopeTags.value) tags: splitTags(scopeTags.value)
@ -92,12 +145,13 @@ queryButton.addEventListener("click", async () => {
const data = await request(endpoint, payload); const data = await request(endpoint, payload);
mainResult.textContent = format(data); mainResult.textContent = format(data);
if (compareWithoutRag.checked && queryOperation.value === "answer") { if (compareWithoutRag.checked && queryOperation.value === "answer") {
const comparison = await request("/answer/direct", { const comparison = await request("/answer/direct", {
query: queryInput.value query: queryInput.value,
}); model: answerModel.value
compareResult.textContent = format(comparison); });
} compareResult.textContent = format(comparison);
}
} catch (error) { } catch (error) {
mainResult.textContent = String(error); mainResult.textContent = String(error);
compareResult.textContent = compareWithoutRag.checked ? String(error) : "Desactivada."; compareResult.textContent = compareWithoutRag.checked ? String(error) : "Desactivada.";
@ -110,6 +164,7 @@ presetDocs.addEventListener("click", () => {
queryOperation.value = "answer"; queryOperation.value = "answer";
queryInput.value = "que tenemos pendiente por hacer en este workspace"; queryInput.value = "que tenemos pendiente por hacer en este workspace";
scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs"; scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/docs";
answerModel.value = "openai/gpt-4.1-mini";
}); });
presetRagDocs.addEventListener("click", () => { presetRagDocs.addEventListener("click", () => {
@ -118,6 +173,7 @@ presetRagDocs.addEventListener("click", () => {
queryOperation.value = "retrieve"; queryOperation.value = "retrieve";
queryInput.value = "dame un mapa inicial del modulo RAG, su arquitectura, decisiones, estado actual y documentos clave"; 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"; scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/docs";
answerModel.value = "openai/gpt-4.1-mini";
}); });
presetCode.addEventListener("click", () => { presetCode.addEventListener("click", () => {
@ -126,4 +182,7 @@ presetCode.addEventListener("click", () => {
queryOperation.value = "answer"; queryOperation.value = "answer";
queryInput.value = "como se construye source_id en el rag"; queryInput.value = "como se construye source_id en el rag";
scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/src"; scopeSourceRef.value = "/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG/src";
answerModel.value = "openai/gpt-4.1-mini";
}); });
loadScopes();

View file

@ -67,9 +67,17 @@
<option value="answer">answer</option> <option value="answer">answer</option>
</select> </select>
</label> </label>
<label>Modelo answer
<input id="answerModel" value="openai/gpt-4.1-mini" />
</label>
</div> </div>
<div class="row"> <div class="row">
<label>Scope disponible
<select id="scopePresetSelect">
<option value="">Cargando scopes...</option>
</select>
</label>
<label>Scope por sourceRef <label>Scope por sourceRef
<input id="scopeSourceRef" value="/home/pancho/Documentos/Empresa/Desarrollo/IA/docs" /> <input id="scopeSourceRef" value="/home/pancho/Documentos/Empresa/Desarrollo/IA/docs" />
</label> </label>

View file

@ -39,12 +39,25 @@ export function createApp() {
provider: embeddingProvider.providerName, provider: embeddingProvider.providerName,
model: embeddingProvider.modelName model: embeddingProvider.modelName
}, },
answer: {
provider: env.answerProvider,
model: env.answerModel
},
vectorStore: vectorHealth, vectorStore: vectorHealth,
parsers: supportedParserExtensions(), parsers: supportedParserExtensions(),
chunking: documentalChunkingPolicy 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) => { app.post("/ingest", async (req, res) => {
try { try {
const result = await ingestService.ingest(req.body); 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 tags: Array.isArray(req.body.scope.tags) ? req.body.scope.tags.map(String) : undefined
} }
: 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); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown answer 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) => { app.post("/answer/direct", async (req, res) => {
try { try {
const query = String(req.body.query ?? ""); 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); res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown direct answer error" }); res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown direct answer error" });

View file

@ -36,14 +36,15 @@ export class AnswerService {
}); });
} }
async answer(mode: ChunkMode, intent: RetrieveIntent, query: string, scope?: RetrieveScope): Promise<AnswerResponse> { async answer(mode: ChunkMode, intent: RetrieveIntent, query: string, scope?: RetrieveScope, modelOverride?: string): Promise<AnswerResponse> {
if (!env.answerApiKey) { if (!env.answerApiKey) {
throw new Error("Missing ANSWER_API_KEY for answer provider"); throw new Error("Missing ANSWER_API_KEY for answer provider");
} }
const retrieved = await this.retrieveService.retrieve(mode, intent, query, scope); const retrieved = await this.retrieveService.retrieve(mode, intent, query, scope);
const answerModel = modelOverride?.trim() || env.answerModel;
const completion = await this.client.chat.completions.create({ const completion = await this.client.chat.completions.create({
model: env.answerModel, model: answerModel,
temperature: 0.2, temperature: 0.2,
messages: [ messages: [
{ {
@ -60,6 +61,7 @@ export class AnswerService {
return { return {
mode: retrieved.mode, mode: retrieved.mode,
intent: retrieved.intent, intent: retrieved.intent,
model: answerModel,
answer: completion.choices[0]?.message?.content?.trim() || "No se pudo generar respuesta.", answer: completion.choices[0]?.message?.content?.trim() || "No se pudo generar respuesta.",
summary: retrieved.summary, summary: retrieved.summary,
topics: retrieved.topics, topics: retrieved.topics,
@ -76,13 +78,14 @@ export class AnswerService {
}; };
} }
async answerWithoutRag(query: string): Promise<DirectAnswerResponse> { async answerWithoutRag(query: string, modelOverride?: string): Promise<DirectAnswerResponse> {
if (!env.answerApiKey) { if (!env.answerApiKey) {
throw new Error("Missing ANSWER_API_KEY for answer provider"); throw new Error("Missing ANSWER_API_KEY for answer provider");
} }
const answerModel = modelOverride?.trim() || env.answerModel;
const completion = await this.client.chat.completions.create({ const completion = await this.client.chat.completions.create({
model: env.answerModel, model: answerModel,
temperature: 0.2, temperature: 0.2,
messages: [ messages: [
{ {
@ -97,7 +100,7 @@ export class AnswerService {
}); });
return { return {
model: env.answerModel, model: answerModel,
answer: completion.choices[0]?.message?.content?.trim() || "No se pudo generar respuesta." answer: completion.choices[0]?.message?.content?.trim() || "No se pudo generar respuesta."
}; };
} }

View file

@ -1,6 +1,6 @@
import { QdrantClient } from "@qdrant/js-client-rest"; import { QdrantClient } from "@qdrant/js-client-rest";
import { env } from "../../config/env.js"; 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 { function buildQdrantClient(): QdrantClient {
const url = new URL(env.qdrantUrl); const url = new URL(env.qdrantUrl);
@ -19,6 +19,7 @@ export interface VectorStoreClient {
ensureCollection(vectorSize: number): Promise<void>; ensureCollection(vectorSize: number): Promise<void>;
upsert(chunks: IngestedChunk[]): Promise<void>; upsert(chunks: IngestedChunk[]): Promise<void>;
search(queryVector: number[], limit: number, mode?: string, scope?: RetrieveScope): Promise<RetrievedItem[]>; search(queryVector: number[], limit: number, mode?: string, scope?: RetrieveScope): Promise<RetrievedItem[]>;
listScopes(): Promise<AvailableScope[]>;
} }
function buildSearchFilter(mode?: string, scope?: RetrieveScope) { 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 endLine: point.payload?.end_line ? Number(point.payload.end_line) : undefined
})); }));
} }
async listScopes(): Promise<AvailableScope[]> {
const collections = await this.client.getCollections();
const exists = collections.collections.some((collection) => collection.name === env.qdrantCollection);
if (!exists) {
return [];
}
const scopeMap = new Map<string, AvailableScope>();
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));
}
} }

View file

@ -48,6 +48,13 @@ export interface RetrieveScope {
tags?: string[]; tags?: string[];
} }
export interface AvailableScope {
sourceId: string;
sourceRef: string;
chunkModes: ChunkMode[];
tags: string[];
}
export interface RetrieveResponse { export interface RetrieveResponse {
mode: ChunkMode; mode: ChunkMode;
intent: RetrieveIntent; intent: RetrieveIntent;
@ -62,6 +69,7 @@ export interface RetrieveResponse {
export interface AnswerResponse { export interface AnswerResponse {
mode: ChunkMode; mode: ChunkMode;
intent: RetrieveIntent; intent: RetrieveIntent;
model: string;
answer: string; answer: string;
summary: string; summary: string;
topics: string[]; topics: string[];

View file

@ -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. - 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`. - 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. - 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. - 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. - 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/`. - 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. - 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. - Creacion de `docs/TASK.md` para descomponer lineas de trabajo amplias en puntos de analisis y acuerdos.