Improve playground scope and answer model control
This commit is contained in:
parent
c641286372
commit
1847eaefbe
9 changed files with 202 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue