rag-service/docs/CONTRATO_CICLO_VIDA_Y_OCR.md

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:** Punto 2 implementado, migrado y validado en produccion; punto 3 OCR pendiente
## 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.