1169 lines
50 KiB
Markdown
1169 lines
50 KiB
Markdown
# Contrato de implementacion: ciclo de vida del conocimiento y OCR
|
|
|
|
**Proyecto:** Workspace de tools IA para empresas
|
|
**Modulo:** RAG
|
|
**Ultima actualizacion:** 2026-09-08
|
|
**Ultima modificacion por:** Agente RAG 2
|
|
**Estado:** Implementado y desplegado; pendiente de preparar PostgreSQL y ejecutar la migracion legacy
|
|
|
|
## Resultado esperado
|
|
|
|
Este documento define como completar, en este orden estricto:
|
|
|
|
1. el pendiente 2, ciclo de vida del conocimiento;
|
|
2. el pendiente 3, OCR integrado en la ingesta.
|
|
|
|
El punto 3 no debe implementarse ni desplegarse hasta que el punto 2 haya sido validado en produccion. El OCR necesita versionado, estados y activacion controlada para no sustituir conocimiento correcto por una extraccion defectuosa.
|
|
|
|
## Decisiones cerradas
|
|
|
|
| Area | Decision |
|
|
|---|---|
|
|
| Catalogo | PostgreSQL 17 es la fuente de verdad para fuentes, versiones, estados y version activa. |
|
|
| Vectores | Qdrant conserva chunks y embeddings versionados, pero no decide que version esta activa. |
|
|
| Visibilidad | Toda recuperacion filtra por los `source_version_id` activos resueltos en PostgreSQL. |
|
|
| Atomicidad | Los puntos nuevos se escriben primero como version invisible; una transaccion PostgreSQL cambia despues el puntero activo. |
|
|
| Rollback | Se cambia el puntero activo a una version anterior verificada; no se recalculan embeddings. |
|
|
| OCR principal | PaddleOCR `3.4.0` con PaddlePaddle CPU `3.2.2`. |
|
|
| Despliegue OCR | Servicio Python privado e independiente, en la red interna de EasyPanel y sin dominio publico. |
|
|
| Revision OCR | Si una sola pagina usa OCR, la version queda en `review_required` y no se indexa hasta aprobacion explicita. |
|
|
| OCRmyPDF | Fuera del pipeline productivo y sin fallback automatico. Solo puede conservarse como herramienta offline de diagnostico. |
|
|
| Fallos | Ante cualquier fallo se conserva la version activa anterior. Nunca se recupera sin filtro desde Qdrant. |
|
|
|
|
## Evidencia de partida
|
|
|
|
### Defectos confirmados en el pipeline actual
|
|
|
|
- `IngestService` escribe directamente en Qdrant y marca todos los chunks como `active`.
|
|
- El retrieval no filtra el campo `status`.
|
|
- Los IDs fisicos no incluyen version; una reingesta sobrescribe posiciones coincidentes.
|
|
- Si un documento nuevo tiene menos chunks, permanecen chunks sobrantes de la version anterior.
|
|
- Una ingesta parcial de carpeta puede mezclar conocimiento antiguo y nuevo.
|
|
- No existen hash de contenido, version, rollback, catalogo ni reconciliacion.
|
|
- `/sources` reconstruye fuentes recorriendo los puntos de Qdrant.
|
|
- Los uploads individuales usan una ruta temporal con timestamp para construir `document_key`.
|
|
- El parser PDF usa `pdf-parse` sobre el documento completo y no evalua calidad pagina a pagina.
|
|
- `multer.memoryStorage()` mantiene el upload completo en RAM y no impone limite especifico.
|
|
|
|
### Infraestructura confirmada
|
|
|
|
- PostgreSQL 17 ya esta desplegado en `easypanel-ia_servicios` con almacenamiento persistente.
|
|
- Qdrant contiene aproximadamente 22.605 puntos de dimension 4096 y no tiene indices de payload.
|
|
- VPS2 dispone de 6 vCPU y 11 GiB de RAM, sin swap.
|
|
- El contenedor RAG actual no tiene Python ni dependencias OCR y debe mantenerse ligero.
|
|
|
|
### Benchmark OCR real
|
|
|
|
Documento: `Errores Junio 2026.pdf`, 25 paginas y 34 entradas verificadas manualmente.
|
|
|
|
| Motor | Tiempo | Resultado relevante |
|
|
|---|---:|---|
|
|
| OCRmyPDF 17.11.0 + Tesseract | 55 s | Recupero `SQLSTATE`, `23505` y `FE666`, pero omitio varios codigos criticos. |
|
|
| PaddleOCR 3.4.0 + PaddlePaddle 3.2.2, CPU 3 cores/5 GiB | 245 s | Recupero todas las lineas criticas perdidas por Tesseract, pero confundio `0/O/o` en cuatro codigos. |
|
|
|
|
Errores PaddleOCR observados incluso con confianza entre `0.9669` y `0.9846`:
|
|
|
|
| Canonico | OCR bruto |
|
|
|---|---|
|
|
| `CBG04a` | `CBGO4a` |
|
|
| `FAT07` | `FATo7` |
|
|
| `DSAU08` | `DSAuo8` |
|
|
| `NSAV06` | `NSAvo6` |
|
|
|
|
Conclusion: PaddleOCR aporta mejor cobertura, pero la confianza numerica no garantiza integridad de identificadores. No se deben autocorregir caracteres ambiguos ni activar automaticamente contenido OCR.
|
|
|
|
# Punto 2. Ciclo de vida del conocimiento
|
|
|
|
## Alcance
|
|
|
|
El punto 2 debe entregar:
|
|
|
|
- catalogo persistente de fuentes y versiones;
|
|
- identidad estable de fuente y documento;
|
|
- deteccion de ingestas identicas;
|
|
- versiones fisicas aisladas en Qdrant;
|
|
- activacion atomica desde la perspectiva del retrieval;
|
|
- rollback sin recalcular embeddings;
|
|
- purga auditable de versiones no activas;
|
|
- migracion controlada de los puntos existentes;
|
|
- reconciliacion entre PostgreSQL y Qdrant.
|
|
|
|
No incluye OCR, busqueda hibrida, autenticacion general de la API ni rediseño del chunking.
|
|
|
|
## Modelo de autoridad
|
|
|
|
PostgreSQL responde a estas preguntas:
|
|
|
|
- que fuentes existen;
|
|
- que versiones tiene cada fuente;
|
|
- cual es la unica version activa;
|
|
- que documentos y hashes forman una version;
|
|
- si una version esta preparada, fallo, fue reemplazada o fue purgada.
|
|
|
|
Qdrant responde solo a estas preguntas:
|
|
|
|
- que chunks fisicos forman una version;
|
|
- que vector y payload tiene cada chunk;
|
|
- que chunks de las versiones autorizadas son semanticamente relevantes.
|
|
|
|
El campo `write_state` de Qdrant sera diagnostico. La visibilidad siempre depende de `rag_sources.active_version_id`.
|
|
|
|
## Estados de version
|
|
|
|
```text
|
|
pending
|
|
-> indexing
|
|
-> review_required # reservado para el punto 3
|
|
-> ready
|
|
-> active
|
|
-> superseded
|
|
-> active # rollback
|
|
-> purging -> purged
|
|
-> failed
|
|
-> indexing # reintento explicito
|
|
-> purging -> purged
|
|
|
|
review_required -> indexing # tras aprobacion OCR
|
|
review_required -> rejected
|
|
rejected -> purging -> purged
|
|
ready -> purging -> purged
|
|
```
|
|
|
|
Reglas obligatorias:
|
|
|
|
1. Puede haber cero o una version `active` por fuente.
|
|
2. Una version `active` no se puede purgar.
|
|
3. `ready` exige que todos los documentos y puntos esperados hayan sido verificados.
|
|
4. `superseded` nunca participa en retrieval normal, pero permanece disponible para rollback.
|
|
5. `failed` puede conservar puntos incompletos, siempre invisibles.
|
|
6. `purged` conserva el registro historico, pero no tiene puntos ni artefactos fisicos.
|
|
7. `review_required` y `rejected` se incorporan en el esquema desde el punto 2 para no migrar la maquina de estados durante el punto 3.
|
|
|
|
## Esquema PostgreSQL minimo
|
|
|
|
Crear `migrations/001_knowledge_lifecycle.sql` con este contrato logico. La implementacion puede ajustar nombres de constraints, no columnas ni invariantes sin actualizar antes este documento.
|
|
|
|
```sql
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
CREATE TABLE rag_sources (
|
|
source_id text PRIMARY KEY,
|
|
tenant text NOT NULL DEFAULT 'default',
|
|
source_type text NOT NULL CHECK (source_type IN ('file', 'folder')),
|
|
source_ref text NOT NULL,
|
|
active_version_id uuid NULL,
|
|
needs_reingest boolean NOT NULL DEFAULT false,
|
|
disabled_at timestamptz NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE rag_source_versions (
|
|
version_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
source_id text NOT NULL REFERENCES rag_sources(source_id) ON DELETE RESTRICT,
|
|
version_number bigint NOT NULL CHECK (version_number > 0),
|
|
previous_version_id uuid NULL,
|
|
state text NOT NULL CHECK (state IN (
|
|
'pending', 'indexing', 'review_required', 'ready', 'active',
|
|
'superseded', 'failed', 'rejected', 'purging', 'purged'
|
|
)),
|
|
original_manifest_hash char(64) NOT NULL,
|
|
source_content_hash char(64) NULL,
|
|
processing_fingerprint char(64) NOT NULL,
|
|
metadata_hash char(64) NOT NULL,
|
|
tags text[] NOT NULL DEFAULT '{}',
|
|
activate_requested boolean NOT NULL DEFAULT true,
|
|
base_active_version_id uuid NULL,
|
|
embedding_provider text NOT NULL,
|
|
embedding_model text NOT NULL,
|
|
embedding_dimensions integer NOT NULL CHECK (embedding_dimensions > 0),
|
|
qdrant_collection text NOT NULL,
|
|
expected_document_count integer NOT NULL DEFAULT 0 CHECK (expected_document_count >= 0),
|
|
expected_point_count integer NOT NULL DEFAULT 0 CHECK (expected_point_count >= 0),
|
|
verified_point_count integer NOT NULL DEFAULT 0 CHECK (verified_point_count >= 0),
|
|
error_code text NULL,
|
|
error_detail text NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
indexing_started_at timestamptz NULL,
|
|
ready_at timestamptz NULL,
|
|
activated_at timestamptz NULL,
|
|
superseded_at timestamptz NULL,
|
|
reviewed_at timestamptz NULL,
|
|
reviewed_by text NULL,
|
|
artifact_state text NOT NULL DEFAULT 'none'
|
|
CHECK (artifact_state IN ('none', 'present', 'retention_deleting', 'retention_deleted')),
|
|
retention_due_at timestamptz NULL,
|
|
purged_at timestamptz NULL,
|
|
UNIQUE (source_id, version_number),
|
|
UNIQUE (version_id, source_id),
|
|
FOREIGN KEY (previous_version_id, source_id)
|
|
REFERENCES rag_source_versions(version_id, source_id)
|
|
DEFERRABLE INITIALLY DEFERRED,
|
|
FOREIGN KEY (base_active_version_id, source_id)
|
|
REFERENCES rag_source_versions(version_id, source_id)
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
);
|
|
|
|
ALTER TABLE rag_sources
|
|
ADD CONSTRAINT rag_sources_active_version_fk
|
|
FOREIGN KEY (active_version_id, source_id)
|
|
REFERENCES rag_source_versions(version_id, source_id)
|
|
DEFERRABLE INITIALLY DEFERRED;
|
|
|
|
CREATE UNIQUE INDEX rag_one_active_version_per_source
|
|
ON rag_source_versions(source_id)
|
|
WHERE state = 'active';
|
|
|
|
CREATE UNIQUE INDEX rag_one_reusable_content_version
|
|
ON rag_source_versions(source_id, source_content_hash, processing_fingerprint, metadata_hash)
|
|
WHERE source_content_hash IS NOT NULL
|
|
AND state NOT IN ('failed', 'rejected', 'purged');
|
|
|
|
CREATE INDEX rag_source_versions_source_state_idx
|
|
ON rag_source_versions(source_id, state);
|
|
|
|
CREATE TABLE rag_version_documents (
|
|
version_id uuid NOT NULL REFERENCES rag_source_versions(version_id) ON DELETE RESTRICT,
|
|
document_id text NOT NULL,
|
|
document_key text NOT NULL,
|
|
original_hash char(64) NOT NULL,
|
|
original_hash_kind text NOT NULL CHECK (original_hash_kind IN ('bytes', 'legacy-derived')),
|
|
content_hash char(64) NULL,
|
|
mime_type text NOT NULL,
|
|
title text NOT NULL,
|
|
extraction_method text NOT NULL DEFAULT 'native',
|
|
index_state text NOT NULL CHECK (index_state IN ('pending', 'indexing', 'ready', 'failed')),
|
|
chunk_count integer NOT NULL DEFAULT 0 CHECK (chunk_count >= 0),
|
|
artifact_manifest_path text NULL,
|
|
error_detail text NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (version_id, document_id),
|
|
UNIQUE (version_id, document_key)
|
|
);
|
|
|
|
CREATE INDEX rag_version_documents_document_idx
|
|
ON rag_version_documents(document_id);
|
|
|
|
CREATE TABLE rag_ingestion_attempts (
|
|
attempt_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
source_id text NOT NULL REFERENCES rag_sources(source_id) ON DELETE RESTRICT,
|
|
version_id uuid NULL REFERENCES rag_source_versions(version_id) ON DELETE SET NULL,
|
|
state text NOT NULL CHECK (state IN ('received', 'processing', 'completed', 'failed')),
|
|
input_locator text NOT NULL,
|
|
input_hash char(64) NULL,
|
|
error_code text NULL,
|
|
error_detail text NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
completed_at timestamptz NULL
|
|
);
|
|
|
|
CREATE TABLE rag_system_state (
|
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
maintenance boolean NOT NULL DEFAULT false,
|
|
maintenance_reason text NULL,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
INSERT INTO rag_system_state(singleton) VALUES (true);
|
|
|
|
CREATE TABLE rag_legacy_migration_batches (
|
|
batch_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
state text NOT NULL CHECK (state IN ('planned', 'running', 'verified', 'rolled_back', 'failed')),
|
|
snapshot_ref text NOT NULL,
|
|
report jsonb NOT NULL DEFAULT '{}',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
completed_at timestamptz NULL
|
|
);
|
|
|
|
CREATE TABLE rag_legacy_migration_items (
|
|
batch_id uuid NOT NULL REFERENCES rag_legacy_migration_batches(batch_id) ON DELETE RESTRICT,
|
|
source_id text NOT NULL,
|
|
version_id uuid NULL REFERENCES rag_source_versions(version_id) ON DELETE SET NULL,
|
|
expected_point_count integer NOT NULL CHECK (expected_point_count >= 0),
|
|
state text NOT NULL CHECK (state IN ('planned', 'migrated', 'verified', 'rolled_back', 'failed')),
|
|
error_detail text NULL,
|
|
PRIMARY KEY (batch_id, source_id)
|
|
);
|
|
```
|
|
|
|
Las migraciones se ejecutaran desde Node usando `pg`, una tabla `rag_schema_migrations` con nombre y checksum, y un advisory lock global. El arranque usa `node dist/modules/catalog/migrations.js && node dist/server.js`: una migracion fallida o con checksum alterado impide arrancar y, por tanto, impide readiness.
|
|
|
|
La FK compuesta garantiza que el puntero pertenece a la fuente, pero no que su estado sea `active`. La migracion debe añadir constraint triggers diferidos sobre `rag_sources` y `rag_source_versions` que, al commit, validen ambas direcciones:
|
|
|
|
- todo `active_version_id` no nulo apunta a una version `active` de la misma fuente;
|
|
- toda version `active` es el `active_version_id` de su fuente.
|
|
|
|
El repositorio de catalogo es el unico codigo autorizado a mutar estos estados. El reconciliador vuelve a comprobar la misma invariante para detectar corrupcion externa.
|
|
|
|
## Identidad y hashes
|
|
|
|
Mantener la jerarquia actual:
|
|
|
|
```text
|
|
source_id = src:<tenant>:<source_kind>:<stable_name>
|
|
document_id = doc:<source_id>:<document_key>
|
|
chunk_id = chk:<document_id>:<chunk_mode>:<0001>
|
|
```
|
|
|
|
Reglas nuevas:
|
|
|
|
- `document_key` de un archivo subido se deriva de `source_ref`, nunca de `readPath` ni del timestamp temporal.
|
|
- `source_id` explicito tiene prioridad. Si se genera, se deriva de una referencia logica estable.
|
|
- `original_hash` es SHA-256 de los bytes originales cuando `original_hash_kind = bytes`; para legacy sin original usa el hash derivado de chunks y `original_hash_kind = legacy-derived`.
|
|
- `original_manifest_hash` es SHA-256 de los pares ordenados `document_key + NUL + original_hash`; siempre existe antes de parsear.
|
|
- `content_hash` es SHA-256 del contenido final normalizado. Puede ser nulo mientras una extraccion OCR espera revision.
|
|
- `source_content_hash` usa los pares ordenados `document_key + NUL + content_hash`; puede ser nulo hasta que todo el contenido final exista.
|
|
- `processing_fingerprint` cubre version del parser, politica de normalizacion, politica de chunking y proveedor/modelo/dimensiones de embeddings.
|
|
- `metadata_hash` es SHA-256 del JSON canonico de tags y metadatos recuperables; un cambio de tags crea una version aunque el contenido sea identico.
|
|
- Una coincidencia reutilizable solo aplica a versiones no fallidas, no rechazadas y no purgadas.
|
|
|
|
Serializacion canonica:
|
|
|
|
- todos los hashes reciben bytes UTF-8;
|
|
- el contenido elimina BOM, convierte CRLF/CR a LF, elimina espacios finales por linea, elimina lineas vacias exteriores y termina con un unico LF;
|
|
- los documentos se ordenan por bytes UTF-8 de `document_key` ascendente;
|
|
- el fingerprint usa JSON con claves ordenadas lexicograficamente, sin espacios y con valores de version explicitos;
|
|
- el point ID usa UUIDv5 con namespace URL `6ba7b811-9dad-11d1-80b4-00c04fd430c8` y nombre `rag-qdrant-point:<source_version_id>:<chunk_id>`.
|
|
|
|
Comportamiento de una coincidencia:
|
|
|
|
| Estado encontrado | Resultado |
|
|
|---|---|
|
|
| `active` | `noOp: true`; no escribir puntos. |
|
|
| `ready` o `superseded` | Reutilizar esa version y activarla solo con precondicion explicita. |
|
|
| `pending`, `indexing` o `review_required` | `409` con la version en curso. |
|
|
| `failed`, `rejected` o `purged` | Crear una version nueva; no devolver `noOp`. |
|
|
|
|
El ID fisico de Qdrant incorpora la version:
|
|
|
|
```text
|
|
point_id = UUIDv5(namespace_url, "rag-qdrant-point:" + source_version_id + ":" + chunk_id)
|
|
```
|
|
|
|
No usar el hash dentro de `source_id`, `document_id` o `chunk_id`; esos IDs representan identidad logica estable.
|
|
|
|
## Payload obligatorio de Qdrant
|
|
|
|
```json
|
|
{
|
|
"source_id": "src:default:folder:workspace-docs",
|
|
"source_version_id": "uuid",
|
|
"source_version_number": 2,
|
|
"source_type": "folder",
|
|
"source_ref": "docs",
|
|
"document_id": "doc:...",
|
|
"document_key": "docs/readme.md",
|
|
"document_content_hash": "sha256",
|
|
"chunk_id": "chk:...:0001",
|
|
"chunk_mode": "documental",
|
|
"chunk_index": 0,
|
|
"start_line": 1,
|
|
"end_line": 20,
|
|
"title": "readme.md",
|
|
"section_title": "...",
|
|
"mime_type": "text/markdown",
|
|
"content": "...",
|
|
"tags": ["project-docs"],
|
|
"embedding_provider": "openrouter",
|
|
"embedding_model": "qwen/qwen3-embedding-8b",
|
|
"embedding_dimensions": 4096,
|
|
"processing_fingerprint": "sha256",
|
|
"indexed_at": "ISO-8601",
|
|
"write_state": "staged"
|
|
}
|
|
```
|
|
|
|
Crear indices de payload Qdrant para:
|
|
|
|
- `source_id`;
|
|
- `source_version_id`;
|
|
- `document_id`;
|
|
- `chunk_mode`;
|
|
- `tags`.
|
|
|
|
## Secuencia de ingesta y activacion
|
|
|
|
1. Resolver la identidad logica, crear/actualizar `rag_sources` y registrar `rag_ingestion_attempts.state = received` antes de descubrir archivos.
|
|
2. Adquirir un advisory lock PostgreSQL derivado de `source_id`; descubrir originales y calcular `original_manifest_hash` sin crear aun una version.
|
|
3. Rechazar con `422` una fuente sin documentos soportados o sin contenido util; nunca reemplazar por una version vacia.
|
|
4. Calcular hashes y `processing_fingerprint`.
|
|
5. Aplicar la tabla de coincidencias anterior; `noOp` solo corresponde a una version activa util.
|
|
6. Crear una sola version `pending`, asignar `version_number`, persistir `activate_requested`, `base_active_version_id`, tags y documentos.
|
|
7. Cambiarla a `indexing` y generar embeddings.
|
|
8. Escribir puntos versionados en Qdrant con IDs deterministas.
|
|
9. Contar puntos por `source_version_id` y verificar documentos, dimensiones y cantidad esperada.
|
|
10. Cambiar la version a `ready`.
|
|
11. En una transaccion PostgreSQL, bloquear la fuente, validar la precondicion obligatoria `expectedActiveVersionId`, pasar la activa anterior a `superseded`, pasar la nueva a `active` y actualizar `active_version_id`.
|
|
12. Responder solo despues del commit.
|
|
|
|
No existe transaccion distribuida entre PostgreSQL y Qdrant. La garantia correcta es que una escritura parcial en Qdrant permanece invisible hasta el cambio atomico del puntero PostgreSQL.
|
|
|
|
## Retrieval y listado
|
|
|
|
- `GET /sources` consulta PostgreSQL y deja de recorrer Qdrant.
|
|
- `search()` y `browseScope()` resuelven primero las versiones activas aplicables.
|
|
- El filtro Qdrant siempre incluye `source_version_id` activo ademas de modo y scope.
|
|
- Si PostgreSQL no esta disponible, retrieval falla con `503`; nunca busca sin filtro como fallback.
|
|
- Los resultados y citas añaden `sourceVersionId` y `sourceVersionNumber`.
|
|
- Las fuentes deshabilitadas no participan en retrieval.
|
|
- retrieval mantiene un advisory lock compartido por las versiones resueltas hasta terminar la consulta Qdrant; purga usa lock exclusivo sobre las mismas claves.
|
|
- una fuente activa con recuento o dimensiones inconsistentes queda bloqueada con `503 SOURCE_VERSION_INCONSISTENT` hasta rollback o reparacion; nunca devuelve un corpus parcial como valido.
|
|
|
|
Semantica de scope:
|
|
|
|
- `sourceId`, `sourceRef` y cada tag recibido se combinan con AND;
|
|
- varios tags significan que todos deben estar presentes, igual que en el comportamiento actual;
|
|
- `sourceRef` usa coincidencia exacta sobre la fuente activa;
|
|
- `tenant` es `default` mientras el contrato publico no lo exponga y nunca se cruza entre fuentes;
|
|
- los tags viven en `rag_source_versions` y Qdrant como snapshot; cambiar tags altera `metadata_hash` y exige nueva version.
|
|
|
|
`GET /sources` conserva `sourceId`, `sourceRef`, `chunkModes` y `tags` para no romper el playground, y añade `activeVersionId`, `activeVersionNumber`, `state`, `needsReingest` y `updatedAt`.
|
|
|
|
## Contrato HTTP del punto 2
|
|
|
|
### Ingesta
|
|
|
|
`POST /ingest` y `POST /ingest/upload` aceptan ademas:
|
|
|
|
```json
|
|
{
|
|
"activate": true,
|
|
"expectedActiveVersionId": "uuid-o-null"
|
|
}
|
|
```
|
|
|
|
Si `activate` es `true`, `expectedActiveVersionId` es obligatorio: UUID para reemplazo y `null` para primera activacion. Omitirlo devuelve `400`; una diferencia devuelve `409`. Si `activate` es `false`, se omite porque no cambia el puntero.
|
|
|
|
Respuesta sincronica `201 Created`:
|
|
|
|
```json
|
|
{
|
|
"accepted": true,
|
|
"sourceId": "...",
|
|
"versionId": "uuid",
|
|
"versionNumber": 2,
|
|
"state": "active",
|
|
"previousVersionId": "uuid",
|
|
"documentsProcessed": 10,
|
|
"chunksStored": 80,
|
|
"activated": true,
|
|
"noOp": false,
|
|
"collectionName": "rag_chunks"
|
|
}
|
|
```
|
|
|
|
`activate: false` deja la version en `ready`. El cambio de `202` a `201` debe anunciarse en `API_RAG.md` y verificarse en consumidores conocidos.
|
|
|
|
Errores:
|
|
|
|
- `409`: cambio concurrente de version activa o conflicto de idempotencia;
|
|
- `422`: fuente vacia, no soportada o inconsistente;
|
|
- `503`: PostgreSQL, embeddings o Qdrant no disponibles;
|
|
- `500`: fallo interno registrado en la version.
|
|
|
|
### Catalogo y operaciones
|
|
|
|
```text
|
|
GET /sources
|
|
GET /sources/:sourceId
|
|
GET /sources/:sourceId/versions
|
|
GET /sources/:sourceId/versions/:versionId
|
|
POST /sources/:sourceId/versions/:versionId/activate
|
|
POST /sources/:sourceId/rollback
|
|
DELETE /sources/:sourceId/versions/:versionId
|
|
```
|
|
|
|
Rollback:
|
|
|
|
```json
|
|
{
|
|
"targetVersionId": "uuid",
|
|
"expectedActiveVersionId": "uuid"
|
|
}
|
|
```
|
|
|
|
Las operaciones de activacion, rollback y purga exigen `Authorization: Bearer <LIFECYCLE_ADMIN_TOKEN>`. El secreto se configura solo en EasyPanel, se compara en tiempo constante y nunca se registra. Ausente o invalido devuelve `401`; el OpenAPI define `bearerAuth`. Esta proteccion acotada no sustituye la autenticacion general del pendiente 5.
|
|
|
|
`POST /cleanup` queda deprecado. Durante una version de transicion puede traducirse a purga de versiones no activas, pero nunca puede borrar implicitamente la version activa.
|
|
|
|
## Reintentos, reconciliacion y purga
|
|
|
|
| Fallo | Comportamiento obligatorio |
|
|
|---|---|
|
|
| Descubrimiento o parsing | Marcar el intento y la version `failed` cuando ya exista; mantener activa anterior. |
|
|
| Upsert parcial | Exigir Qdrant `wait=true`, validar estado de operacion y reintentar con los mismos IDs fisicos deterministas. |
|
|
| Qdrant completo y PostgreSQL sin actualizar | El reconciliador cuenta puntos y recupera a `ready` o marca `failed`. |
|
|
| Activacion PostgreSQL fallida | El puntero anterior permanece activo. |
|
|
| Respuesta HTTP perdida tras commit | Reintento devuelve la version ya activa sin duplicarla. |
|
|
| Proceso muerto en `indexing` | Reintenta puntos solo si los originales siguen disponibles; un upload temporal sin original durable se marca `failed`. |
|
|
| Purga parcial | Usar lock exclusivo, `wait=true`, mantener `purging`, repetir delete y verificar lectura consistente con cero puntos. |
|
|
| Puntos activos ausentes | Marcar `needs_reingest`, bloquear esa fuente con `503` y exigir rollback o reparacion explicita. |
|
|
|
|
Ejecutar reconciliacion al arrancar y periodicamente. Conservar al menos una version `superseded` verificada hasta que la nueva haya superado la validacion funcional en produccion.
|
|
|
|
## Migracion del corpus actual
|
|
|
|
La migracion debe ser explicita y reversible:
|
|
|
|
1. Añadir codigo y esquema con `KNOWLEDGE_LIFECYCLE_ENFORCED=false`.
|
|
2. Bloquear en EasyPanel/Traefik las rutas de escritura o detener la instancia RAG antigua; no confiar en que codigo legacy consulte PostgreSQL. Desplegar el codigo nuevo con `INGEST_WRITES_ENABLED=false`, tomar advisory lock global exclusivo y poner `rag_system_state.maintenance=true`. Toda creacion/activacion nueva comprueba esa fila bajo lock y devuelve `503 maintenance`; esperar a que no haya ingestas activas.
|
|
3. Ejecutar un `dry-run` que agrupe puntos por `source_id` y reporte colisiones, referencias incompatibles, modelos, dimensiones y recuentos.
|
|
4. Detener la migracion ante un `source_id` asociado a varias referencias incompatibles o proveedores/dimensiones incompatibles.
|
|
5. Crear un snapshot Qdrant y una fila `rag_legacy_migration_batches` con esa referencia antes de modificar payloads.
|
|
6. Crear un `rag_legacy_migration_items` por fuente y una version 1 asociada al `batch_id`.
|
|
7. Poblar cada documento legacy agrupando por `document_id`; derivar `document_key`, titulo, MIME, chunk count y hash de contenido ordenado. Si falta identidad documental fuerte, bloquear esa fuente.
|
|
8. Poblar proveedor, modelo, dimensiones y coleccion desde los payloads, exigiendo un unico valor por version.
|
|
9. Marcar `original_manifest_hash` y `source_content_hash` como derivados legacy; no presentarlos como hashes de archivos originales.
|
|
10. Añadir a cada punto `source_version_id`, `source_version_number: 1` y `processing_fingerprint` igual a SHA-256 de `legacy:<batch_id>`.
|
|
11. Verificar documentos, recuentos PostgreSQL/Qdrant y payloads; marcar item y lote `verified`.
|
|
12. Activar version 1 en PostgreSQL.
|
|
13. Activar `KNOWLEDGE_LIFECYCLE_ENFORCED=true`, redeployar y validar retrieval.
|
|
14. Reactivar `INGEST_WRITES_ENABLED=true` solo despues de la validacion.
|
|
15. Eliminar el camino legacy tras la validacion final, no mantener compatibilidad indefinida.
|
|
|
|
Rollback de migracion:
|
|
|
|
1. Bloquear en Traefik lectura y escritura del RAG y mantener `rag_system_state.maintenance=true`.
|
|
2. En una transaccion diferida, bloquear las fuentes del lote, cambiar sus versiones `active` a `purging` y poner `rag_sources.active_version_id = null`; confirmar que no queda ningun puntero activo del lote.
|
|
3. Detener las instancias con enforcement antes de restaurar Qdrant, para impedir retrieval contra un estado intermedio.
|
|
4. Restaurar el snapshot Qdrant y verificar sus recuentos legacy.
|
|
5. En transaccion, poner a nulo `rag_legacy_migration_items.version_id`, eliminar documentos y versiones del lote, y marcar items/lote `rolled_back`.
|
|
6. Desplegar con enforcement desactivado, verificar retrieval legacy y solo entonces reabrir trafico e ingesta.
|
|
|
|
No se admite migracion concurrente con nuevas escrituras, retrieval durante restauracion ni un modo dual-write implicito.
|
|
|
|
## Archivos del punto 2
|
|
|
|
Crear:
|
|
|
|
- `migrations/001_knowledge_lifecycle.sql`;
|
|
- `src/modules/catalog/client.ts`;
|
|
- `src/modules/catalog/repository.ts`;
|
|
- `src/modules/catalog/migrations.ts`;
|
|
- `src/modules/catalog/reconciler.ts`;
|
|
- `src/scripts/migrate-legacy-lifecycle.ts`.
|
|
|
|
Modificar:
|
|
|
|
- `package.json` y lockfile para `pg`, tipos y scripts de migracion;
|
|
- `src/config/env.ts` para PostgreSQL, secreto administrativo y feature flag temporal;
|
|
- `src/shared/types/rag.ts` para versiones, estados y respuestas;
|
|
- `src/shared/utils/ids.ts` para point IDs versionados y hashes;
|
|
- `src/modules/ingest/service.ts` para staging, verificacion y activacion;
|
|
- `src/modules/vectorstore/client.ts` para filtros e indices de payload;
|
|
- `src/modules/retrieve/service.ts` para resolver versiones activas;
|
|
- `src/modules/answer/service.ts` para propagar version en citas;
|
|
- `src/app.ts`, `src/api/openapi.ts` y playground para el nuevo contrato;
|
|
- `Dockerfile` para arrancar con `node dist/modules/catalog/migrations.js && node dist/server.js`;
|
|
- `docs/API_RAG.md`, `docs/INGESTA.md` y documentación de despliegue.
|
|
|
|
## Orden de tareas del punto 2
|
|
|
|
1. Añadir esquema, migrador, configuracion y health de PostgreSQL.
|
|
2. Implementar repositorio de catalogo y tests de invariantes.
|
|
3. Versionar IDs y payloads Qdrant; crear indices de payload.
|
|
4. Refactorizar ingesta para staging, no-op, verificacion y activacion.
|
|
5. Hacer que retrieval y `/sources` dependan del catalogo.
|
|
6. Añadir API de versiones, rollback y purga administrativa.
|
|
7. Implementar reconciliador y migrador legacy con `dry-run`.
|
|
8. Actualizar playground, OpenAPI y documentacion.
|
|
9. Ejecutar `npm run check`, `npm run build` y validaciones estaticas.
|
|
10. Commit y push; desplegar con enforcement desactivado.
|
|
11. Ejecutar dry-run, snapshot, migracion y validacion de recuentos en produccion.
|
|
12. Activar enforcement, redeployar y ejecutar criterios funcionales.
|
|
|
|
## Criterios de aceptacion del punto 2
|
|
|
|
- Una ingesta inicial crea version 1 y solo ella aparece en retrieval.
|
|
- Una ingesta identica devuelve `noOp: true` y no duplica version ni puntos.
|
|
- Una version con un documento acortado no expone chunks antiguos.
|
|
- Una version de carpeta sin un archivo anterior deja de recuperar ese archivo.
|
|
- Un fallo a mitad de ingesta no altera los resultados activos.
|
|
- Dos activaciones concurrentes no pueden quedar activas simultaneamente.
|
|
- Rollback cambia resultados sin recalcular embeddings.
|
|
- No se puede purgar la version activa.
|
|
- Una purga interrumpida se puede reanudar.
|
|
- Un cambio de modelo o chunking cambia `processing_fingerprint`.
|
|
- PostgreSQL caido provoca `503`, no retrieval sin filtro.
|
|
- El corpus legacy conserva sus scopes y resultados despues de la migracion.
|
|
- OpenAPI coincide con rutas, codigos HTTP y esquemas reales.
|
|
- `/health` informa PostgreSQL, Qdrant y estado del reconciliador.
|
|
|
|
## Puerta de salida del punto 2
|
|
|
|
El punto 2 solo se marca completado cuando:
|
|
|
|
- los criterios anteriores se prueban en produccion;
|
|
- no existen fuentes legacy sin catalogar, salvo excepciones documentadas;
|
|
- se conserva al menos una version apta para rollback de una fuente de prueba;
|
|
- el usuario confirma que retrieval y playground siguen operativos;
|
|
- se actualizan backlog e historiales.
|
|
|
|
# Punto 3. OCR integrado en la ingesta
|
|
|
|
## Prerrequisito
|
|
|
|
No iniciar este punto hasta superar la puerta de salida del punto 2. Cada candidato OCR se representara como una `rag_source_version` no visible.
|
|
|
|
## Alcance
|
|
|
|
El punto 3 debe entregar:
|
|
|
|
- extraccion PDF pagina a pagina;
|
|
- deteccion de paginas con texto nativo insuficiente;
|
|
- OCR privado y asincrono solo sobre paginas necesarias;
|
|
- artefactos y metricas auditables;
|
|
- deteccion de identificadores de riesgo;
|
|
- revision humana obligatoria cuando interviene OCR;
|
|
- aprobacion, rechazo y posterior activacion versionada;
|
|
- limites de recursos, tamaño, tiempo y retencion.
|
|
|
|
No incluye correccion automatica con LLM, exposicion publica del servicio OCR ni busqueda hibrida por codigos.
|
|
|
|
## Arquitectura
|
|
|
|
```text
|
|
Cliente
|
|
-> RAG API
|
|
-> guarda original y manifiesto
|
|
-> extrae texto nativo por pagina
|
|
-> selecciona paginas OCR
|
|
-> envia trabajo al servicio OCR privado
|
|
-> construye candidato revisable
|
|
-> espera aprobacion
|
|
-> genera chunks y embeddings
|
|
-> activa usando el contrato del punto 2
|
|
|
|
RAG API -> red interna EasyPanel -> OCR service Python/PaddleOCR
|
|
```
|
|
|
|
Responsabilidades:
|
|
|
|
- RAG posee fuentes, estados, artefactos, revision, embeddings y activacion.
|
|
- OCR recibe un PDF y una lista cerrada de paginas, devuelve texto bruto, cajas y confianza.
|
|
- OCR no escribe en PostgreSQL del RAG ni en Qdrant y no decide activacion.
|
|
|
|
## Persistencia OCR
|
|
|
|
Crear `migrations/002_ocr_review.sql`:
|
|
|
|
```sql
|
|
CREATE TABLE rag_ocr_jobs (
|
|
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
version_id uuid NOT NULL,
|
|
document_id text NOT NULL,
|
|
remote_job_id text NULL,
|
|
remote_idempotency_key text NOT NULL,
|
|
state text NOT NULL CHECK (state IN ('queued', 'running', 'succeeded', 'failed')),
|
|
requested_pages integer[] NOT NULL,
|
|
completed_pages integer NOT NULL DEFAULT 0,
|
|
config_version text NOT NULL,
|
|
attempt_count integer NOT NULL DEFAULT 0,
|
|
heartbeat_at timestamptz NULL,
|
|
lease_expires_at timestamptz NULL,
|
|
next_attempt_at timestamptz NULL,
|
|
error_code text NULL,
|
|
error_detail text NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
started_at timestamptz NULL,
|
|
completed_at timestamptz NULL,
|
|
UNIQUE (version_id, document_id),
|
|
FOREIGN KEY (version_id, document_id)
|
|
REFERENCES rag_version_documents(version_id, document_id) ON DELETE RESTRICT
|
|
);
|
|
|
|
CREATE TABLE rag_document_pages (
|
|
version_id uuid NOT NULL,
|
|
document_id text NOT NULL,
|
|
page_number integer NOT NULL CHECK (page_number > 0),
|
|
extraction_method text NOT NULL CHECK (extraction_method IN ('native', 'ocr', 'blank')),
|
|
native_text_hash char(64) NULL,
|
|
ocr_text_hash char(64) NULL,
|
|
candidate_text_hash char(64) NULL,
|
|
reviewed_text_hash char(64) NULL,
|
|
metrics jsonb NOT NULL DEFAULT '{}',
|
|
risk_tokens jsonb NOT NULL DEFAULT '[]',
|
|
blocked_reason text NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (version_id, document_id, page_number),
|
|
FOREIGN KEY (version_id, document_id)
|
|
REFERENCES rag_version_documents(version_id, document_id) ON DELETE RESTRICT
|
|
);
|
|
|
|
CREATE TABLE rag_review_corrections (
|
|
correction_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
version_id uuid NOT NULL,
|
|
document_id text NOT NULL,
|
|
page_number integer NOT NULL,
|
|
line_id text NOT NULL,
|
|
expected_line_hash char(64) NOT NULL,
|
|
replacement_text text NOT NULL,
|
|
reviewed_by text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
FOREIGN KEY (version_id, document_id, page_number)
|
|
REFERENCES rag_document_pages(version_id, document_id, page_number) ON DELETE RESTRICT,
|
|
UNIQUE (version_id, document_id, page_number, line_id)
|
|
);
|
|
```
|
|
|
|
Los textos completos viven en los artefactos comprimidos; PostgreSQL conserva hashes, metricas, riesgos, estados y correcciones.
|
|
|
|
La identidad local del job es `version_id + document_id`; varios jobs locales pueden reutilizar el mismo `remote_idempotency_key` y resultado remoto. El dispatcher toma jobs con `FOR UPDATE SKIP LOCKED`, renueva `heartbeat_at` y `lease_expires_at`, y al arrancar recupera leases vencidos. Un reconciliador periodico reenvia con la misma clave remota y recupera `remote_job_id` sin duplicar OCR.
|
|
|
|
## Contrato asincrono hacia consumidores
|
|
|
|
La ingesta nativa que no requiere OCR conserva la respuesta sincronica `201` del punto 2. Cuando un PDF necesita OCR:
|
|
|
|
1. se guarda el original durable;
|
|
2. se crea la version con `source_content_hash = null`, `activate_requested` y `base_active_version_id`, y despues el job;
|
|
3. se responde sin esperar los minutos de OCR.
|
|
|
|
Respuesta `202 Accepted` de `/ingest` o `/ingest/upload`:
|
|
|
|
```json
|
|
{
|
|
"accepted": true,
|
|
"sourceId": "...",
|
|
"versionId": "uuid",
|
|
"versionNumber": 3,
|
|
"state": "indexing",
|
|
"phase": "ocr_queued",
|
|
"statusUrl": "/ingestions/uuid",
|
|
"reviewUrl": null,
|
|
"activated": false
|
|
}
|
|
```
|
|
|
|
`GET /ingestions/:versionId` devuelve `state`, `phase`, progreso por documentos/paginas, error reintentable, `statusUrl` y `reviewUrl`. Fases: `native_extracting`, `ocr_queued`, `ocr_running`, `review_required`, `indexing`, `ready`, `active`, `failed` y `rejected`.
|
|
|
|
Estados terminales para el trabajo solicitado: `active`, `ready`, `failed` o `rejected`. `review_required` espera accion humana y no caduca a activa. Un reintento con la misma identidad y original devuelve el job no terminal existente; si fallo, crea una version nueva salvo reintento administrativo explicito con artefactos aun validos.
|
|
|
|
## Deteccion por pagina
|
|
|
|
Despues de extraccion nativa calcular:
|
|
|
|
- `N`: caracteres no blancos normalizados;
|
|
- `A`: caracteres alfanumericos;
|
|
- `W`: palabras o tokens;
|
|
- `R`: proporcion de caracteres de reemplazo/control;
|
|
- `inkCoverage`: proporcion no blanca calculada por el servicio OCR sobre un render de baja resolucion.
|
|
|
|
La extraccion nativa debe usar un callback por pagina probado con fixture. Si `pdf-parse` no conserva paginas de forma fiable, se sustituye por una libreria Node con licencia permisiva antes de continuar; no se simulan paginas cortando el texto agregado.
|
|
|
|
Una pagina tiene texto nativo suficiente solo si cumple todo:
|
|
|
|
```text
|
|
N >= 120
|
|
A >= 80
|
|
W >= 20
|
|
R <= 0.01
|
|
```
|
|
|
|
Todo texto nativo insuficiente se envia al servicio OCR. El servicio clasifica la pagina como vacia solo si:
|
|
|
|
```text
|
|
inkCoverage < 0.015 AND nonWhitespaceCharactersOCR < 10
|
|
```
|
|
|
|
Reglas:
|
|
|
|
- `N == 0` siempre solicita inspeccion OCR; el servicio decide si esta vacia.
|
|
- Una pagina con `inkCoverage >= 0.015` no puede clasificarse como vacia aunque el OCR no encuentre texto: falla el quality gate.
|
|
- Nunca decidir por promedio del documento.
|
|
- Umbrales configurables y registrados bajo `detectionPolicyVersion: "pdf-detection-v1"`.
|
|
|
|
Composicion del candidato:
|
|
|
|
- pagina nativa suficiente: usar solo texto nativo;
|
|
- pagina OCR: ordenar lineas por bbox superior, bbox izquierda e indice original; el candidato es esas lineas unidas por LF;
|
|
- `text` de pagina es una representacion derivada de los registros de linea, no una segunda fuente editable;
|
|
- cada correccion reemplaza el texto completo de un `lineId`, por lo que el texto revisado se reconstruye con el mismo orden;
|
|
- conservar texto nativo y salida OCR bruta para comparacion;
|
|
- pagina vacia verificada: no aportar texto;
|
|
- ordenar por `page_number` ascendente y separar paginas no vacias con `\n\n--- Page <N> ---\n\n`;
|
|
- no concatenar texto nativo insuficiente con OCR porque duplica y mezcla variantes.
|
|
|
|
## Contrato interno del servicio OCR
|
|
|
|
### Crear trabajo
|
|
|
|
`POST /v1/jobs` usa `multipart/form-data`, bearer interno e `Idempotency-Key`. Limite total: 50 MiB.
|
|
|
|
Partes obligatorias:
|
|
|
|
- `file`: `application/pdf`, bytes del PDF cuyo SHA-256 coincide con `documentSha256`;
|
|
- `request`: `application/json`, objeto siguiente.
|
|
|
|
Los numeros de pagina son enteros unicos, ordenados y basados en 1. El servidor rechaza configuraciones no incluidas en su allowlist; el cliente no puede elegir modelos o DPI arbitrarios.
|
|
|
|
```json
|
|
{
|
|
"documentSha256": "sha256",
|
|
"pages": [4, 7, 8, 14, 15],
|
|
"languages": ["es", "en"],
|
|
"dpi": 200,
|
|
"engine": "paddleocr",
|
|
"engineVersion": "3.4.0",
|
|
"runtimeVersion": "3.2.2",
|
|
"configVersion": "ocr-v1",
|
|
"returnLayout": true
|
|
}
|
|
```
|
|
|
|
`Idempotency-Key`:
|
|
|
|
```text
|
|
<documentSha256>:<configVersion>:<sha256-de-pages>
|
|
```
|
|
|
|
Respuesta `202`:
|
|
|
|
```json
|
|
{
|
|
"jobId": "ocr_uuid",
|
|
"status": "queued",
|
|
"documentSha256": "sha256",
|
|
"requestedPages": [4, 7, 8, 14, 15],
|
|
"configVersion": "ocr-v1",
|
|
"createdAt": "ISO-8601"
|
|
}
|
|
```
|
|
|
|
### Consultar trabajo
|
|
|
|
`GET /v1/jobs/:jobId`:
|
|
|
|
```json
|
|
{
|
|
"jobId": "ocr_uuid",
|
|
"status": "queued|running|succeeded|failed",
|
|
"completedPages": 2,
|
|
"totalPages": 5,
|
|
"error": null
|
|
}
|
|
```
|
|
|
|
### Obtener resultado
|
|
|
|
`GET /v1/jobs/:jobId/result` devuelve por pagina:
|
|
|
|
```json
|
|
{
|
|
"schemaVersion": "1",
|
|
"jobId": "ocr_uuid",
|
|
"documentSha256": "sha256",
|
|
"engine": {
|
|
"name": "paddleocr",
|
|
"version": "3.4.0",
|
|
"runtime": "paddlepaddle-3.2.2",
|
|
"device": "cpu",
|
|
"configVersion": "ocr-v1",
|
|
"dpi": 200
|
|
},
|
|
"pages": [{
|
|
"page": 7,
|
|
"width": 1654,
|
|
"height": 2339,
|
|
"processingMs": 9750,
|
|
"text": "texto OCR bruto",
|
|
"metrics": {
|
|
"lineCount": 42,
|
|
"nonWhitespaceCharacters": 1380,
|
|
"medianConfidence": 0.94,
|
|
"p10Confidence": 0.71,
|
|
"lowConfidenceLineRatio": 0.07
|
|
},
|
|
"lines": [{
|
|
"lineId": "p7-l12-120-340-245-372",
|
|
"text": "FATo7",
|
|
"confidence": 0.98,
|
|
"bbox": [120, 340, 245, 372]
|
|
}]
|
|
}]
|
|
}
|
|
```
|
|
|
|
`lineId` es determinista dentro de `documentSha256 + configVersion`: pagina, indice de lectura y bbox normalizado. El resultado conserva texto bruto. No normaliza ni autocorrige identificadores.
|
|
|
|
Otros endpoints:
|
|
|
|
```text
|
|
DELETE /v1/jobs/:jobId
|
|
GET /health/live
|
|
GET /health/ready
|
|
```
|
|
|
|
Errores: `400` solicitud invalida, `401/403` autenticacion, `409` conflicto de idempotencia, `413` limite excedido, `422` PDF corrupto/cifrado/no soportado, `429` cola llena y `500/503` fallo de motor.
|
|
|
|
## Quality gate OCR
|
|
|
|
Una pagina OCR produce candidato mecanicamente revisable si cumple:
|
|
|
|
```text
|
|
nonWhitespaceCharacters >= 40
|
|
medianConfidence >= 0.80
|
|
p10Confidence >= 0.50
|
|
lowConfidenceLineRatio <= 0.20
|
|
```
|
|
|
|
El documento solo llega a `review_required` cuando:
|
|
|
|
1. cada pagina no vacia tiene texto nativo u OCR util;
|
|
2. ninguna pagina solicitada fallo;
|
|
3. numero y orden de paginas coinciden con el original;
|
|
4. hashes de original, extracciones y manifiesto coinciden;
|
|
5. ninguna pagina se omitio silenciosamente.
|
|
|
|
Si no cumple, queda `failed` con `error_code: OCR_QUALITY_BLOCKED`. No puede aprobarse; requiere una nueva version mediante reintento con configuracion corregida o transcripcion manual fuera de este alcance. Si cumple y alguna pagina uso OCR, queda `review_required`.
|
|
|
|
## Identificadores de riesgo
|
|
|
|
Marcar tokens similares a codigos:
|
|
|
|
```regex
|
|
\b[A-Za-z]{2,}[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*\b
|
|
```
|
|
|
|
Elevar prioridad cuando:
|
|
|
|
- mezclan mayusculas y minusculas de forma inesperada;
|
|
- difieren entre texto nativo y OCR;
|
|
- aparecen una sola vez;
|
|
- estan junto a `codigo`, `error`, `regla`, `SQLSTATE`, `estado` o `identificador`.
|
|
|
|
No autocorregir `O/0`, `I/1/l`, `S/5` ni casing. La revision muestra imagen, texto nativo, texto OCR, bbox, confianza y diferencias.
|
|
|
|
## Revision y activacion
|
|
|
|
Endpoints RAG administrativos:
|
|
|
|
```text
|
|
GET /ingestions/:versionId
|
|
GET /ingestions/:versionId/review
|
|
POST /ingestions/:versionId/approve
|
|
POST /ingestions/:versionId/reject
|
|
```
|
|
|
|
Todos exigen `Authorization: Bearer <LIFECYCLE_ADMIN_TOKEN>`, incluidos lectura de revision, imagenes, aprobar y rechazar.
|
|
|
|
Aprobacion:
|
|
|
|
```json
|
|
{
|
|
"candidateSha256": "sha256",
|
|
"expectedActiveVersionId": "uuid-o-null",
|
|
"reviewedBy": "operator-id",
|
|
"corrections": [{
|
|
"documentId": "doc:...",
|
|
"page": 8,
|
|
"lineId": "p8-l05-120-340-245-372",
|
|
"expectedLineSha256": "sha256-de-FATo7",
|
|
"replacementText": "FAT07"
|
|
}]
|
|
}
|
|
```
|
|
|
|
`candidateSha256` se calcula sobre JSON canonico UTF-8 con paginas y lineas ordenadas, claves lexicograficas y sin espacios. Cada correccion apunta a una linea unica y exige el hash del texto previo; una linea repetida, bbox cambiado o candidato obsoleto devuelve `409` sin aplicar correcciones parciales.
|
|
|
|
Secuencia:
|
|
|
|
1. Verificar que `candidateSha256` y todos los `expectedLineSha256` siguen vigentes, y que `expectedActiveVersionId` coincide con `base_active_version_id` persistido.
|
|
2. Aplicar todas las correcciones en una unica operacion y guardar texto revisado inmutable.
|
|
3. Registrar revisor, fecha y hashes.
|
|
4. Calcular `content_hash`, `source_content_hash` y comprobar bajo lock si ya existe una version reutilizable con el mismo contenido, fingerprint y metadata.
|
|
5. Si existe, marcar el candidato `rejected` con `DUPLICATE_REUSABLE_VERSION` y activar la version existente solo si cumple la precondicion.
|
|
6. Si no existe, persistir hashes y cambiar `review_required -> indexing`.
|
|
7. Solo entonces generar chunks y embeddings.
|
|
8. Verificar puntos y comparar entonces `expectedActiveVersionId` con la version activa actual; si coincide, activar con el protocolo del punto 2.
|
|
|
|
Si otra version se activo durante la revision, guardar el candidato aprobado como `ready`, devolver `409 ACTIVE_VERSION_CHANGED` y no activar. El operador puede activarlo despues con una nueva precondicion explicita; nunca se sobrescribe silenciosamente una version mas reciente.
|
|
|
|
Rechazar cambia a `rejected`, conserva auditoria temporal y no genera embeddings.
|
|
|
|
## Artefactos y retencion
|
|
|
|
El RAG usa un volumen persistente privado montado en `/data/ingestions`. Estructura por version:
|
|
|
|
```text
|
|
/data/ingestions/<versionId>/
|
|
manifest.json
|
|
metrics.json
|
|
documents/
|
|
<documentArtifactId>/
|
|
original.pdf
|
|
native-pages.json.gz
|
|
ocr-raw.json.gz
|
|
candidate-pages.json.gz
|
|
reviewed-pages.json.gz
|
|
review-images/
|
|
page-0001.webp
|
|
```
|
|
|
|
`documentArtifactId` es UUIDv5 del `document_id`, no una ruta ni un nombre aportado por el usuario. El manifiesto relaciona ese ID con `document_id` y evita colisiones en fuentes con varios PDFs.
|
|
|
|
`manifest.json` incluye IDs, hashes, decisiones por pagina, versiones de motor y politicas, tiempos, flags, revisor y estado.
|
|
|
|
Retencion inicial:
|
|
|
|
- pendiente de revision: 30 dias;
|
|
- fallido, bloqueado o rechazado: 7 dias;
|
|
- imagenes renderizadas de revision: 7 dias despues de aprobar o rechazar;
|
|
- original, texto revisado, OCR bruto y manifiesto activos: mientras la version este activa y 30 dias despues de ser reemplazada;
|
|
- copias del servicio OCR: borrado al transferir correctamente o TTL maximo de 24 horas.
|
|
|
|
Los archivos usan permisos `0600`, no tienen URL publica y no se comparten por filesystem entre los dos servicios.
|
|
|
|
Un limpiador diario adquiere el mismo lock/CAS de version usado por revision y purga. Primero cambia `artifact_state` a `retention_deleting`, despues elimina idempotentemente y finalmente marca `retention_deleted`. Caducar `review_required` cambia la version a `rejected` con `error_code: REVIEW_EXPIRED`; caducar `failed` o `rejected` ejecuta `purging -> purged`. Una version `active` nunca se elimina por TTL. Las imagenes se sirven solo por endpoint administrativo autenticado y validando que la ruta resuelta permanece bajo el directorio de la version.
|
|
|
|
## Limites operativos iniciales
|
|
|
|
| Limite | Valor |
|
|
|---|---:|
|
|
| Upload PDF | 50 MiB |
|
|
| Paginas por documento | 100 |
|
|
| Paginas OCR por trabajo | 100 |
|
|
| Render | 200 DPI |
|
|
| Pagina renderizada | 25 megapixeles |
|
|
| Trabajos Paddle concurrentes | 1 |
|
|
| Cola OCR | 3 |
|
|
| Timeout por pagina | 60 s |
|
|
| Timeout total | 15 min |
|
|
| OCR service | maximo 3 CPU y 5 GiB RAM |
|
|
|
|
Antes de habilitar OCR, sustituir `multer.memoryStorage()` por upload a disco con limite y limpieza garantizada. Rechazar PDFs cifrados, corruptos, sobredimensionados o con riesgo de bomba de descompresion antes del render completo.
|
|
|
|
## Reintentos y degradacion
|
|
|
|
- Reintentar envio dos veces ante conexion, `502` o `503`, con backoff y misma clave idempotente.
|
|
- Polling con backoff de 2 a 15 segundos.
|
|
- Reintentar una pagina una vez solo por crash o fallo transitorio del runtime.
|
|
- No reintentar `400`, `401`, `403`, `413`, `422` ni errores deterministas de render.
|
|
- `429` conserva el candidato pendiente y devuelve estado reintentable.
|
|
- Paddle no disponible implica fallo cerrado; no activar contenido parcial ni cambiar silenciosamente de motor.
|
|
- Todo fallo conserva intacta la version activa anterior.
|
|
|
|
## Despliegue OCR
|
|
|
|
Crear dentro del repo:
|
|
|
|
```text
|
|
ocr-service/
|
|
Dockerfile
|
|
requirements.txt
|
|
app/
|
|
main.py
|
|
engine.py
|
|
models.py
|
|
jobs.py
|
|
```
|
|
|
|
Condiciones:
|
|
|
|
- imagen Python CPU separada;
|
|
- PaddleOCR `3.4.0` y PaddlePaddle `3.2.2` fijados exactamente;
|
|
- resto de dependencias bloqueadas con versiones y hashes despues de validar compatibilidad;
|
|
- modelos descargados durante build, nunca al arrancar ni al procesar;
|
|
- un solo worker y una sola replica;
|
|
- readiness falsa hasta cargar modelos;
|
|
- solo red `easypanel-ia_servicios`;
|
|
- sin dominio Traefik;
|
|
- bearer interno incluso dentro de la red Docker;
|
|
- token en `OCR_INTERNAL_TOKEN`, enviado como `Authorization: Bearer` y comparado en tiempo constante;
|
|
- URL RAG `OCR_SERVICE_URL=http://<servicio-interno>:8000`;
|
|
- deploy y rollback independientes del RAG.
|
|
|
|
El servicio OCR usa un volumen efimero acotado en `/data/jobs` con SQLite para cola, idempotencia y recuperacion tras reinicio. No es la copia durable: el RAG conserva original y resultados. Los trabajos del volumen OCR se eliminan tras transferencia confirmada o por TTL de 24 horas.
|
|
|
|
OCRmyPDF/Tesseract no se instala en estas imagenes productivas. Puede mantenerse en scripts de benchmark no desplegados.
|
|
|
|
## Archivos del punto 3
|
|
|
|
Crear:
|
|
|
|
- `migrations/002_ocr_review.sql`;
|
|
- `ocr-service/` con el servicio y sus pruebas;
|
|
- `src/modules/ocr/client.ts`;
|
|
- `src/modules/ocr/detection.ts`;
|
|
- `src/modules/ocr/artifacts.ts`;
|
|
- `src/modules/ocr/review.ts`.
|
|
|
|
Modificar:
|
|
|
|
- `src/modules/parsers/parser-registry.ts` para resultado por pagina;
|
|
- `src/modules/ingest/service.ts` para estados OCR y aprobacion;
|
|
- `src/shared/types/rag.ts` para paginas, metricas, jobs y revision;
|
|
- `src/config/env.ts` para URL, token, volumen, limites y timeouts;
|
|
- `src/app.ts` para upload acotado y endpoints de revision;
|
|
- `src/api/openapi.ts`, playground y documentación API;
|
|
- `Dockerfile` y configuracion EasyPanel para el volumen RAG.
|
|
|
|
## Orden de tareas del punto 3
|
|
|
|
1. Añadir migracion OCR y contratos TypeScript sin cambiar aun el flujo nativo.
|
|
2. Implementar extraccion PDF por pagina, composicion canonica, metricas y tests de deteccion.
|
|
3. Crear servicio OCR privado con modelos baked, auth, limites, idempotencia y health.
|
|
4. Implementar cliente RAG, reintentos, polling y validacion estricta de respuesta.
|
|
5. Añadir volumen y gestor de artefactos con hashes, permisos y retencion.
|
|
6. Integrar respuesta asincrona, estados `review_required`, `rejected` y fallo cerrado.
|
|
7. Añadir deteccion de identificadores y vista de revision en playground.
|
|
8. Implementar aprobar/rechazar y enlazar aprobacion con indexacion versionada.
|
|
9. Actualizar OpenAPI, API, ingesta, procesado y despliegue.
|
|
10. Ejecutar comprobaciones estaticas Node y tests del servicio OCR.
|
|
11. Commit y push; desplegar primero OCR privado y validar readiness.
|
|
12. Desplegar RAG con OCR deshabilitado y comprobar regresiones nativas.
|
|
13. Activar OCR, probar el fixture real, revisar/corregir y validar retrieval exacto.
|
|
|
|
## Criterios de aceptacion del punto 3
|
|
|
|
- Un PDF textual no llama al servicio OCR y sigue el flujo normal.
|
|
- Un PDF escaneado selecciona todas las paginas no vacias necesarias.
|
|
- Un PDF mixto selecciona solo paginas con texto nativo insuficiente.
|
|
- Ninguna version que haya usado OCR se activa automaticamente.
|
|
- Los cuatro errores conocidos de `O/0/o` aparecen marcados para revision.
|
|
- La aprobacion con correcciones produce chunks con `CBG04a`, `FAT07`, `DSAU08` y `NSAV06` exactos.
|
|
- Si una pagina falla, no hay embeddings ni activacion parcial.
|
|
- Si OCR no esta disponible, la fuente activa anterior no cambia.
|
|
- Un reenvio con la misma clave idempotente no duplica trabajo.
|
|
- Los artefactos tienen hashes verificables y respetan retencion.
|
|
- El servicio OCR no es accesible desde Internet.
|
|
- CPU, RAM, cola y tiempos respetan los limites definidos.
|
|
- `/health` informa disponibilidad y cola OCR sin exponer secretos.
|
|
- El PDF real de FacturaTech completa sus 34 entradas tras revision.
|
|
- Retrieval en produccion devuelve los codigos canonicos corregidos.
|
|
|
|
## Puerta de salida del punto 3
|
|
|
|
El punto 3 solo se marca completado cuando:
|
|
|
|
- el flujo nativo sigue funcionando sin regresion;
|
|
- el fixture mixto real completa deteccion, OCR, revision y activacion;
|
|
- los codigos exactos se recuperan en produccion;
|
|
- se verifica que no hay rutas publicas hacia OCR ni artefactos;
|
|
- se valida limpieza de temporales y TTL;
|
|
- se actualizan backlog, API, despliegue e historiales.
|
|
|
|
# Verificacion y entrega comun
|
|
|
|
Cada punto se entrega como bloque independiente:
|
|
|
|
1. implementar en local;
|
|
2. ejecutar `npm run check`, `npm run build` y las pruebas estaticas aplicables;
|
|
3. documentar;
|
|
4. revisar que no entran secretos;
|
|
5. commit y push al repo `paco/rag-service`;
|
|
6. deploy en EasyPanel;
|
|
7. pruebas funcionales exclusivamente en produccion;
|
|
8. rollback por Git y redeploy si falla;
|
|
9. cerrar el punto en `PENDIENTES_RAG.md` solo con evidencia.
|
|
|
|
## Exclusiones explicitas
|
|
|
|
- No corregir texto OCR con un LLM en esta fase.
|
|
- No usar confianza OCR como garantia de exactitud de codigos.
|
|
- No combinar automáticamente resultados Paddle y Tesseract.
|
|
- No guardar originales o resultados OCR en Qdrant.
|
|
- No usar Qdrant como catalogo de estados.
|
|
- No borrar versiones activas mediante `/cleanup`.
|
|
- No conservar un camino legacy indefinido despues de migrar.
|
|
- No iniciar busqueda hibrida; corresponde al pendiente 4.
|
|
- No presentar el servicio OCR en un dominio publico.
|