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",
"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

View file

@ -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
---

View file

@ -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();

View file

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

View file

@ -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" });

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) {
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<DirectAnswerResponse> {
async answerWithoutRag(query: string, modelOverride?: string): Promise<DirectAnswerResponse> {
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."
};
}

View file

@ -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<void>;
upsert(chunks: IngestedChunk[]): Promise<void>;
search(queryVector: number[], limit: number, mode?: string, scope?: RetrieveScope): Promise<RetrievedItem[]>;
listScopes(): Promise<AvailableScope[]>;
}
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<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[];
}
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[];

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.
- 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.