Implement cleanup endpoint and playground tab for scope deletion

This commit is contained in:
Paco POR-CORREO 2026-04-06 22:29:03 +02:00
parent 6cdf82e80e
commit 93a5aee6cb
9 changed files with 211 additions and 10 deletions

View file

@ -409,7 +409,32 @@ Si se usa `sourceId`, el archivo subido no se mezcla con otros scopes salvo que
---
### 8. `GET /logs/recent`
### 8. `POST /cleanup`
Sirve para eliminar vectores (chunks) ya ingeridos en el RAG segun un scope determinado, permitiendo limpiar contexto viejo antes de una reingesta.
Payload base (requiere `sourceId` o `sourceRef` por seguridad):
```json
{
"scope": {
"sourceId": "src:default:folder:src_a9df8105"
}
}
```
Respuesta esperada:
```json
{
"ok": true,
"deleted": 124
}
```
---
### 9. `GET /logs/recent`
Devuelve los logs recientes de evaluacion guardados por el sistema.
@ -428,7 +453,7 @@ Tambien admite actualizacion posterior del estado de revision mediante `PATCH /l
---
### 9. `POST /logs/manual`
### 10. `POST /logs/manual`
Permite registrar manualmente una consulta o respuesta que quieras revisar despues.
@ -456,7 +481,7 @@ Payload base:
---
### 10. `PATCH /logs/:id`
### 11. `PATCH /logs/:id`
Permite actualizar el seguimiento de un log ya creado.

View file

@ -43,3 +43,7 @@ Dar continuidad al RAG en `RAG/` a partir del estado actual documentado.
- Implementacion de ayuda visual en la zona de `Bootstrap` del playground.
- Añadidos tooltip y `aria-label` en `Cargar bootstrap`, `Reemplazar contexto`, `Vaciar contexto`, `Preset docs`, `Preset RAG docs` y `Preset codigo`.
- Actualizacion de `RAG/docs/PLAYGROUND.md` y `RAG/docs/TEXTOS_AYUDA_PLAYGROUND.md` para reflejar la mejora.
- Implementacion de la pestaña Limpieza en el playground y soporte en el backend (`POST /cleanup`) para borrado seguro de contextos ya ingeridos.
- Limpieza ejecutada exitosamente sobre el `scope` del código fuente antiguo (`RAG/src`).
- Reingesta del directorio `RAG/src` con el código actualizado.
- Documento de seguimiento `RAG/docs/TASK_LIMPIEZA.md` y documentacion API `RAG/docs/API_RAG.md` actualizados.

View file

@ -69,14 +69,19 @@ Tambien permite:
## Mecanica actual del playground
El playground ya no funciona como una sola caja de consulta tecnica. Ahora se organiza en tres pestañas:
El playground ya no funciona como una sola caja de consulta tecnica. Ahora se organiza en cuatro pestañas:
1. `Ingesta`
- lanzar ingesta documental o de codigo
- subir archivos directamente desde el navegador
- definir un `sourceId` propio para aislar una carga concreta
2. `Bootstrap`
2. `Limpieza`
- eliminar todo el contexto asociado a un scope especifico
- util para poder reingestar una fuente actualizada sin duplicar chunks
- dialogos nativos de confirmacion para evitar borrados accidentales
3. `Bootstrap`
- elegir scope
- elegir modo
- cargar un mapa inicial del dominio
@ -84,7 +89,7 @@ El playground ya no funciona como una sola caja de consulta tecnica. Ahora se or
- reemplazar o vaciar el contexto de sesion
- mostrar ayuda visible y tooltip en los botones principales para reducir confusiones de uso
3. `Chat`
4. `Chat`
- conversar con el modelo
- ver visualmente si hay contexto cargado o no
- reutilizar el ultimo bootstrap como contexto base

31
RAG/docs/TASK_LIMPIEZA.md Normal file
View file

@ -0,0 +1,31 @@
# Task: Limpieza y reingesta controlada del RAG
**Proyecto:** Workspace de tools IA para empresas
**Modulo:** RAG
**Ultima actualizacion:** 2026-04-06
**Estado:** En implementacion
---
## Proposito
Permitir borrar contenido ya ingerido del RAG de forma controlada antes de reingestar una fuente actualizada, evitando que convivan chunks viejos y nuevos del mismo `scope`.
## Alcance de la implementacion
### 1. Backend (API y Qdrant)
- **VectorStoreClient:** Añadir metodo `delete(scope: RetrieveScope): Promise<number>` (devuelve cantidad de puntos borrados).
- **Endpoint:** Exponer `POST /cleanup` que reciba el `scope` (ej. `sourceId` o `sourceRef`) y orqueste el borrado.
### 2. Frontend (Playground)
- **UI:** Nueva pestaña "Limpieza".
- **Comportamiento:**
- Selector de scopes disponibles (se reutiliza el endpoint `/sources`).
- Resumen visual de lo que se va a borrar (`sourceId`, `sourceRef`).
- Boton de borrado.
- Dialogo de confirmacion nativo (`window.confirm`).
- Log del resultado de la peticion.
### 3. Validacion
- Borrar el codigo de `RAG/src` previamente ingerido.
- Reingestar `RAG/src`.
- Confirmar que se mantiene la integridad sin duplicar fragmentos.

View file

@ -1,5 +1,6 @@
const healthButton = document.getElementById("healthButton");
const ingestButton = document.getElementById("ingestButton");
const cleanupButton = document.getElementById("cleanupButton");
const bootstrapButton = document.getElementById("bootstrapButton");
const replaceBootstrapButton = document.getElementById("replaceBootstrapButton");
const clearBootstrapButton = document.getElementById("clearBootstrapButton");
@ -12,6 +13,7 @@ const presetCode = document.getElementById("presetCode");
const healthResult = document.getElementById("healthResult");
const ingestResult = document.getElementById("ingestResult");
const cleanupResult = document.getElementById("cleanupResult");
const bootstrapResult = document.getElementById("bootstrapResult");
const mainResult = document.getElementById("mainResult");
const compareResult = document.getElementById("compareResult");
@ -33,6 +35,11 @@ const ingestMode = document.getElementById("ingestMode");
const ingestTags = document.getElementById("ingestTags");
const ingestModeHint = document.getElementById("ingestModeHint");
const cleanupScopeSelect = document.getElementById("cleanupScopeSelect");
const cleanupSourceId = document.getElementById("cleanupSourceId");
const cleanupSourceRef = document.getElementById("cleanupSourceRef");
const cleanupTags = document.getElementById("cleanupTags");
const bootstrapQuery = document.getElementById("bootstrapQuery");
const bootstrapMode = document.getElementById("bootstrapMode");
const answerModel = document.getElementById("answerModel");
@ -100,6 +107,12 @@ function applySelectedScope(scope) {
}
}
function applySelectedCleanupScope(scope) {
cleanupSourceId.value = scope?.sourceId || "";
cleanupSourceRef.value = scope?.sourceRef || "";
cleanupTags.value = (scope?.tags || []).join(", ");
}
function updateScopeEditState() {
const locked = scopeEditMode.value !== "manual";
scopeSourceRef.readOnly = locked;
@ -185,24 +198,31 @@ async function loadScopes() {
const scopes = await fetch("/sources").then((response) => response.json());
availableScopes = scopes;
scopePresetSelect.innerHTML = "";
cleanupScopeSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Selecciona un scope disponible";
scopePresetSelect.appendChild(placeholder);
scopePresetSelect.appendChild(placeholder.cloneNode(true));
cleanupScopeSelect.appendChild(placeholder.cloneNode(true));
for (const scope of scopes) {
const option = document.createElement("option");
option.value = JSON.stringify(scope);
option.textContent = buildScopeLabel(scope);
scopePresetSelect.appendChild(option);
scopePresetSelect.appendChild(option.cloneNode(true));
cleanupScopeSelect.appendChild(option.cloneNode(true));
}
if (scopes.length === 0) {
placeholder.textContent = "No hay scopes detectados";
const emptyMsg = "No hay scopes detectados";
scopePresetSelect.options[0].textContent = emptyMsg;
cleanupScopeSelect.options[0].textContent = emptyMsg;
} else if (!scopePresetSelect.value) {
scopePresetSelect.value = JSON.stringify(scopes[0]);
cleanupScopeSelect.value = JSON.stringify(scopes[0]);
applySelectedScope(scopes[0]);
applySelectedCleanupScope(scopes[0]);
}
} catch (error) {
scopePresetSelect.innerHTML = `<option value="">Error cargando scopes: ${error}</option>`;
@ -256,6 +276,15 @@ scopePresetSelect.addEventListener("change", () => {
applySelectedScope(scope);
});
cleanupScopeSelect.addEventListener("change", () => {
if (!cleanupScopeSelect.value) {
applySelectedCleanupScope(null);
return;
}
const scope = JSON.parse(cleanupScopeSelect.value);
applySelectedCleanupScope(scope);
});
ingestUploadFile.addEventListener("change", updateIngestUiState);
ingestScopeMode.addEventListener("change", updateIngestUiState);
scopeEditMode.addEventListener("change", updateScopeEditState);
@ -311,6 +340,29 @@ ingestButton.addEventListener("click", async () => {
}
});
cleanupButton.addEventListener("click", async () => {
if (!cleanupScopeSelect.value) {
cleanupResult.textContent = "Error: Debes seleccionar un scope primero.";
return;
}
const scope = JSON.parse(cleanupScopeSelect.value);
const identifier = scope.sourceId || scope.sourceRef || "scope desconocido";
if (!window.confirm(`¿Seguro que quieres eliminar TODO el contexto de:\n\n${identifier}\n\nEsto borrara todos los vectores de Qdrant asociados y no se puede deshacer.`)) {
return;
}
cleanupResult.textContent = "Eliminando contexto...";
try {
const data = await request("/cleanup", { scope });
cleanupResult.textContent = format(data);
await loadScopes();
} catch (error) {
cleanupResult.textContent = String(error);
}
});
async function executeBootstrap() {
bootstrapResult.textContent = "Cargando bootstrap...";
try {

View file

@ -28,6 +28,7 @@
<section class="tabs">
<button class="tab-button active" data-tab="ingest">Ingesta</button>
<button class="tab-button" data-tab="cleanup">Limpieza</button>
<button class="tab-button" data-tab="bootstrap">Bootstrap</button>
<button class="tab-button" data-tab="chat">Chat</button>
</section>
@ -75,6 +76,33 @@
</article>
</section>
<section id="tab-cleanup" class="tab-panel">
<article class="panel">
<h2>Limpieza controlada</h2>
<p class="helper">Permite eliminar contexto ya ingerido del RAG antes de reingestar una fuente, para evitar duplicados y versiones mezcladas.</p>
<div class="grid single-grid">
<label>Scope disponible a limpiar
<select id="cleanupScopeSelect">
<option value="">Cargando scopes...</option>
</select>
</label>
<label>Source ID (obligatorio si no hay Source Ref)
<input id="cleanupSourceId" readonly />
</label>
<label>Source Ref (obligatorio si no hay Source ID)
<input id="cleanupSourceRef" readonly />
</label>
<label>Tags asociados (informativo)
<input id="cleanupTags" readonly />
</label>
</div>
<div class="actions">
<button id="cleanupButton" style="background: var(--danger); color: white;" title="Borrar de Qdrant los vectores del scope seleccionado" aria-label="Eliminar scope de Qdrant">Eliminar contexto seleccionado</button>
</div>
<pre id="cleanupResult">Selecciona un scope y pulsa el boton para eliminarlo.</pre>
</article>
</section>
<section id="tab-bootstrap" class="tab-panel">
<article class="panel">
<h2>Bootstrap de sesion</h2>

View file

@ -124,6 +124,31 @@ export function createApp() {
}
});
app.post("/cleanup", async (req, res) => {
try {
if (!req.body.scope) {
res.status(400).json({ ok: false, error: "Missing scope for cleanup" });
return;
}
const scope: RetrieveScope = {
sourceId: req.body.scope.sourceId ? String(req.body.scope.sourceId) : undefined,
sourceRef: req.body.scope.sourceRef ? String(req.body.scope.sourceRef) : undefined,
tags: Array.isArray(req.body.scope.tags) ? req.body.scope.tags.map(String) : undefined
};
if (!scope.sourceId && !scope.sourceRef) {
res.status(400).json({ ok: false, error: "Cleanup requires at least sourceId or sourceRef to prevent accidental mass deletion" });
return;
}
const result = await ingestService.cleanup(scope);
res.status(200).json({ ok: true, ...result });
} catch (error) {
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : "Unknown cleanup error" });
}
});
app.post("/ingest/upload", upload.single("file"), async (req: UploadRequest, res) => {
let tempPath: string | undefined;

View file

@ -3,7 +3,7 @@ import { parseDocument, isSupportedDocument } from "../parsers/parser-registry.j
import { chunkDocument, codeChunkingPolicy, documentalChunkingPolicy } from "../process/chunking.js";
import type { EmbeddingProvider } from "../embeddings/provider.js";
import type { VectorStoreClient } from "../vectorstore/client.js";
import type { IngestResult, IngestSourceInput, IngestedChunk } from "../../shared/types/rag.js";
import type { IngestResult, IngestSourceInput, IngestedChunk, RetrieveScope } from "../../shared/types/rag.js";
import { buildChunkId, buildDocumentId, buildQdrantPointId, buildSourceId, normalizeDocumentKey } from "../../shared/utils/ids.js";
import { listFilesRecursively } from "../../shared/utils/files.js";
import { env } from "../../config/env.js";
@ -14,6 +14,11 @@ export class IngestService {
private readonly vectorStore: VectorStoreClient
) {}
async cleanup(scope: RetrieveScope): Promise<{ deleted: number }> {
const count = await this.vectorStore.deleteScope(scope);
return { deleted: count };
}
async ingest(source: IngestSourceInput): Promise<IngestResult> {
const sourceId = source.sourceId ?? buildSourceId(source.sourceType, source.sourceRef);
const discoveredFiles = await this.resolveInputFiles(source);

View file

@ -21,6 +21,7 @@ export interface VectorStoreClient {
search(queryVector: number[], limit: number, mode?: string, scope?: RetrieveScope): Promise<RetrievedItem[]>;
listScopes(): Promise<AvailableScope[]>;
browseScope(limit: number, mode?: string, scope?: RetrieveScope): Promise<RetrievedItem[]>;
deleteScope(scope: RetrieveScope): Promise<number>;
}
function buildSearchFilter(mode?: string, scope?: RetrieveScope) {
@ -218,4 +219,29 @@ export class QdrantVectorStoreClient implements VectorStoreClient {
endLine: point.payload?.end_line ? Number(point.payload.end_line) : undefined
}));
}
async deleteScope(scope: RetrieveScope): Promise<number> {
const collections = await this.client.getCollections();
const exists = collections.collections.some((collection) => collection.name === env.qdrantCollection);
if (!exists || !scope || (!scope.sourceId && !scope.sourceRef)) {
return 0; // Se requiere al menos un identificador fuerte para no borrar a lo loco
}
const filter = buildSearchFilter(undefined, scope);
if (!filter) {
return 0;
}
// Contamos antes de borrar para devolver la cantidad
const countResult = await this.client.count(env.qdrantCollection, {
filter
});
await this.client.delete(env.qdrantCollection, {
filter
});
return countResult.count;
}
}