From bfb2d4840ed8f005239d459854e50e8d06855dad Mon Sep 17 00:00:00 2001 From: Paco POR-CORREO Date: Thu, 24 Sep 2026 21:30:59 +0200 Subject: [PATCH] feat(ocr): add durable scheduler and hybrid review --- docs/CONTEXTO_PROYECTO.md | 6 +- docs/CONTRATO_CICLO_VIDA_Y_OCR.md | 81 ++++++-- docs/HISTORIAL_SESIONES.md | 144 ++++++++++++- docs/OPERATIVA.md | 30 ++- docs/PENDIENTES_RAG.md | 127 ++++++++++-- docs/SISTEMA_RAG_BASE.md | 299 +++++++++++++++++++++++---- public/playground/app.js | 10 +- src/api/openapi.ts | 2 +- src/app.ts | 9 +- src/config/env.ts | 4 +- src/modules/catalog/repository.ts | 13 +- src/modules/ocr/client.ts | 10 - src/modules/ocr/dispatcher.ts | 68 +++++- src/modules/ocr/review.ts | 8 +- tests/catalog/repository-ocr.test.ts | 16 ++ tests/ocr/client.test.ts | 21 -- tests/ocr/contracts-deploy.test.ts | 7 +- tests/ocr/dispatcher.test.ts | 204 +++++++++++++++++- tests/ocr/review.test.ts | 35 ++-- 19 files changed, 944 insertions(+), 150 deletions(-) diff --git a/docs/CONTEXTO_PROYECTO.md b/docs/CONTEXTO_PROYECTO.md index feca674..2355590 100644 --- a/docs/CONTEXTO_PROYECTO.md +++ b/docs/CONTEXTO_PROYECTO.md @@ -19,7 +19,7 @@ Servicio RAG para ingesta, versionado y recuperacion de conocimiento. Incluye un - [x] En desarrollo - [x] En produccion -**Ultima sesion:** 2026-09-22 +**Ultima sesion:** 2026-09-24 **Ultimo agente:** Agente RAG 3 ### Ubicacion del proyecto @@ -36,4 +36,6 @@ Servicio RAG para ingesta, versionado y recuperacion de conocimiento. Incluye un ### Notas rapidas - `rag-service` es el unico proyecto Engram canonico; la sesion debe iniciarse desde la raiz de este worktree. -- R4 de OCR `0.2.1` esta completada localmente: `004` se valido en PostgreSQL Docker aislado y detenido. R5 es la unica fase que puede desplegar o tocar produccion, con autorizacion explicita. +- RAG y OCR `0.2.1` estan desplegados y verificados conjuntamente. El OCR queda aceptado para esta fase como servicio best-effort autonomo; la mejora de precision y la consola humana son evoluciones futuras no bloqueantes. +- El scheduler durable, la validacion local end-to-end hibrida y la bateria local previa a publicacion estan completados. La unidad queda autorizada para commit/push; el despliegue y la validacion productiva posterior conservan una autorizacion separada. +- FacturaTech v8 sigue no activa; aprobacion/indexacion y activacion requieren autorizaciones separadas. diff --git a/docs/CONTRATO_CICLO_VIDA_Y_OCR.md b/docs/CONTRATO_CICLO_VIDA_Y_OCR.md index ce1ade6..38cb680 100644 --- a/docs/CONTRATO_CICLO_VIDA_Y_OCR.md +++ b/docs/CONTRATO_CICLO_VIDA_Y_OCR.md @@ -1358,7 +1358,7 @@ Quedan fuera: La evidencia durable de v7 esta completa: original, paginas nativas, resultado OCR y 25 PNG unicos; todos los hashes y firmas PNG son validos y las imagenes ocupan `12.556.918` bytes. Las 34 entradas esperadas estan presentes. Al considerar que una entrada puede continuar en la pagina siguiente, la coincidencia minima por tokens es `88,9 %` para mensaje y `100 %` para causa y solucion. -El OCR produjo tres ambiguedades que deben llegar a revision humana, no provocar correccion automatica: +El OCR produjo tres ambiguedades que deben llegar a revision independiente, no provocar correccion automatica: | Esperado | OCR v7 | Pagina | |---|---|---| @@ -1370,9 +1370,9 @@ El OCR produjo tres ambiguedades que deben llegar a revision humana, no provocar ### Limite de la evidencia historica -Las cifras anteriores describen la inspeccion productiva ya realizada, pero no sustituyen evidencia reproducible dentro del repositorio. El documento manual de contraste vive fuera de este repositorio en `Empresa/Clientes/FacturaTech/Fidi/Info-para-Agente-Comercial/Errores Junio 2026 - OCR verificado.md`; por tanto, cualquier afirmacion que dependa de su contenido se etiqueta como verificacion manual externa y no obtiene PASS por si sola. +Las cifras anteriores describen la inspeccion productiva ya realizada, pero no sustituyen evidencia reproducible dentro del repositorio. El documento manual de contraste vive fuera de este repositorio en `Empresa/Clientes/FacturaTech/Fidi/Info-para-Agente-Comercial/Errores Junio 2026 - OCR verificado.md`; por tanto, cualquier afirmacion que dependa de su contenido se etiqueta como verificacion externa del agente y no obtiene PASS por si sola. -R4 debe aportar fixtures o generadores autocontenidos para probar identidad, recuperacion, calidad e integridad sin depender del documento del cliente. R5 debe registrar en el historial los IDs, estados, recuentos, hashes, versiones, revisiones y digests necesarios para auditar la ejecucion; la comprobacion semantica de las 34 entradas y los codigos se mantiene separada como evidencia manual externa, sin copiar contenido del cliente al repositorio. +R4 debe aportar fixtures o generadores autocontenidos para probar identidad, recuperacion, calidad e integridad sin depender del documento del cliente. R5 debe registrar en el historial los IDs, estados, recuentos, hashes, versiones, revisiones y digests necesarios para auditar la ejecucion; la comprobacion semantica de las 34 entradas y los codigos se mantiene separada como evidencia externa producida por un modelo visual independiente, sin copiar contenido del cliente al repositorio. ## Causas raiz @@ -1522,16 +1522,58 @@ La migracion `004_ocr_quality_diagnostics.sql` anade a `rag_document_pages` `qua La migracion no reinterpreta filas antiguas ni altera estados existentes. `error_detail` conserva compatibilidad como texto para errores historicos; solo `OCR_QUALITY_BLOCKED` nuevo persiste un JSON canonico acotado que el repositorio valida antes de exponer como `error.details`. El diagnostico por pagina y `quality_report_sha256` se confirman en PostgreSQL antes de marcar la version como `failed` o `review_required`. +### Aceptacion operativa del OCR best-effort + +Para la fase autonoma actual, el servicio OCR se considera aceptable cuando completa el contrato tecnico, conserva evidencia durable e informa calidad y estado revisable. No se exige exactitud semantica total: el modelo puede confundir caracteres con confianza alta aunque la imagen sea legible. Esta limitacion no debe ocultarse ni corregirse mediante sustituciones silenciosas. + +La ingesta puede ser orquestada de extremo a extremo por un agente sin interfaz grafica. El agente consume los mismos artefactos y rutas de revision que usara una futura consola humana. Una futura revision visual por IA podra proponer correcciones usando imagen, bounding box, texto y contexto, pero queda fuera de esta fase. + +Aceptar el servicio no equivale a aceptar cualquier candidata ni elimina los estados del ciclo de vida. Las correcciones de contenido, la aprobacion/indexacion y la activacion conservan sus precondiciones y autorizaciones separadas. + ### Revision de FacturaTech Llegar a `review_required` no equivale a PASS. Antes de aprobar v8 o posterior: -1. verificar las 25 imagenes y sus hashes; -2. confirmar las 34 entradas contra el documento manual; -3. corregir explicitamente `FATo7 -> FAT07`, `NSAvo6 -> NSAV06` y `DSAUo4 -> DSAU04` si reaparecen; -4. comprobar exactamente `CBG04a`, `FAT07`, `DSAU08` y `NSAV06` en el texto revisado; -5. presentar diferencias, hashes y version al usuario; -6. esperar una autorizacion separada para aprobar/indexar y otra confirmacion si se desea activar. +Para esta aceptacion, la revision humana externa se sustituye por una revision visual independiente realizada por el agente. La referencia de calidad es una capacidad visual profesional distinta de PaddleOCR. Esta excepcion no elimina el estado `review_required`, no autoriza correcciones automaticas y no cambia las autorizaciones posteriores. + +#### Entradas y limites + +- Candidata productiva: v8 `f4fd1502-1d4d-454f-bbfb-4c3b6820ee97`, o una posterior identificada expresamente si v8 deja de ser la candidata vigente. +- Evidencia visual: las 25 imagenes durables servidas por las rutas de revision de RAG. +- Texto a evaluar: la candidata producida por OCR, obtenida desde la ruta administrativa de revision. +- Referencia externa: `Empresa/Clientes/FacturaTech/Fidi/Info-para-Agente-Comercial/Errores Junio 2026 - OCR verificado.md`. +- Capacidad independiente: modelo con vision que no consuma como entrada el texto producido por PaddleOCR durante su primera lectura de cada imagen. +- Alcance de esta fase: solo lectura, comparacion y preparacion de correcciones. Quedan prohibidos aprobar, rechazar, indexar o activar. +- Privacidad: las imagenes temporales viven bajo `/tmp/opencode/`, no se copian al repositorio y se eliminan al cerrar la revision. + +#### Procedimiento obligatorio para el agente + +1. Consultar de nuevo la candidata y detenerse si no esta `review_required`, si `activateRequested` no es `false`, si cambio `candidateSha256` o si la version activa ya no coincide con `baseActiveVersionId`. +2. Descargar en solo lectura las 25 imagenes de revision a `/tmp/opencode/facturatech-v8-review/`, con nombres estables `page-01.png` a `page-25.png`. Confirmar que hay 25 ficheros no vacios y registrar sus SHA-256 sin incluir contenido del cliente en documentos versionados. +3. Ejecutar una primera pasada visual independiente pagina por pagina. Si el agente principal no tiene vision, debe invocar el subagente `vision` y pasarle las rutas absolutas de los PNG materializados; nunca debe pedirle que lea una imagen "adjunta" o una imagen del contexto anterior. Procesar como maximo cinco paginas por lote y exigir una salida separada para cada pagina. +4. En cada lote, proporcionar al modelo visual solo las imagenes y esta instruccion: `Transcribe literalmente cada entrada visible. Conserva mayusculas, minusculas, numeros y sufijos. No corrijas por contexto. Marca cada caracter dudoso en uncertainTokens. Devuelve page, entries, codes, uncertainTokens y visualNotes por pagina.` No mostrarle aun el texto OCR productivo, los cuatro codigos esperados ni el documento manual. +5. Guardar temporalmente la salida estructurada de todos los lotes. Comprobar que existen resultados para las paginas 1-25, sin duplicados. Si el modelo no puede leer una pagina o devuelve una salida incompleta, repetir una vez con un recorte o mayor resolucion; si sigue sin resolverse, bloquear el PASS. +6. Solo despues de terminar la primera pasada, obtener el texto candidato de RAG y leer el documento manual externo. Construir una comparacion de tres vias: imagen transcrita por el modelo, candidata OCR y referencia manual. +7. Enumerar exactamente las 34 entradas. Para cada una registrar pagina, identificador esperado, valor visual, valor OCR, valor de referencia y resultado `match`, `correction_required` o `unresolved`. +8. Verificar por igualdad literal `CBG04a`, `FAT07`, `DSAU08` y `NSAV06`. Comprobar tambien si reaparecen `FATo7`, `NSAvo6` o `DSAUo4` y preparar, sin aplicar, las correcciones exactas. +9. Considerar resuelta una diferencia solo cuando la lectura visual y la referencia manual coinciden. Si discrepan entre si, si falta una entrada o si queda cualquier token incierto, clasificarla como `unresolved` y no declarar PASS. +10. Preparar un informe sin texto sensible innecesario que incluya version, estado, hashes, recuento `34/34`, resultado de los cuatro codigos criticos, diferencias por pagina, correcciones propuestas y cualquier incertidumbre. No incluir imagenes ni transcripciones completas en el repositorio. +11. Consultar de nuevo la candidata antes de presentar el resultado y confirmar que estado, `candidateSha256`, `currentActiveVersionId` y `activateRequested=false` no cambiaron durante la revision. +12. Presentar el informe al usuario. El resultado solo puede ser `READY_FOR_APPROVAL` si hay 34 entradas, cero casos `unresolved`, las 25 imagenes fueron verificadas y las correcciones propuestas producen exactamente los cuatro codigos criticos. En cualquier otro caso el resultado es `REVIEW_BLOCKED`. +13. Esperar una autorizacion separada antes de aprobar y comenzar la indexacion. La solicitud debe mostrar las correcciones concretas que se aplicarian. La activacion requiere otra autorizacion posterior. + +#### Salida minima de la revision + +| Campo | Requisito | +|---|---| +| `reviewActor` | Modelo y agente que realizaron la revision visual independiente. | +| `versionId` y `candidateSha256` | Deben coincidir con la candidata consultada antes y despues de la revision. | +| `imageCount` | `25`; cada imagen con SHA-256 registrado en evidencia no versionada o resumida. | +| `entryCount` | `34`; ninguna entrada ausente o duplicada. | +| `criticalCodes` | Resultado literal para `CBG04a`, `FAT07`, `DSAU08` y `NSAV06`. | +| `differences` | Pagina, valor OCR, valor independiente y correccion propuesta, sin aplicar. | +| `unresolvedCount` | Debe ser `0` para `READY_FOR_APPROVAL`. | +| `sideEffects` | Debe confirmar que no hubo aprobacion, indexacion ni activacion. | ## Unidades de implementacion @@ -1583,16 +1625,16 @@ R4 se ejecuta exclusivamente en local. La validacion de `004_ocr_quality_diagnos ### R5. Entrega conjunta `0.2.1` y nueva aceptacion -- [ ] Antes de aplicar migraciones en produccion, identificar explicitamente base y entorno, revisar `rag_schema_migrations` y confirmar que solo estan pendientes las migraciones esperadas. La validacion local de R4 no autoriza esta operacion. +- [x] Antes de aplicar migraciones en produccion, identificar explicitamente base y entorno, revisar `rag_schema_migrations` y confirmar que solo estan pendientes las migraciones esperadas. Preflight de solo lectura en VPS2 (2026-09-23): RAG usa `db_rag` con el rol `usr_rag`; `001-003` estan aplicadas con checksums coincidentes y solo queda `004_ocr_quality_diagnostics.sql`. La validacion local de R4 no autoriza esta operacion. - [x] Crear una unica fuente versionada en la raiz, `VERSION`, con `0.2.1`; RAG, OCR, pruebas y proceso de build deben consumirla y fallar si aparece otra version declarada. -- [ ] Publicar RAG/OCR desde el mismo commit. `BUILD_REVISION` se carga manualmente con ese commit mientras no exista automatizacion aprobada; los digests de imagen siguen siendo la identidad exacta autoritativa. -- [ ] Ejecutar una unica operacion conjunta de despliegue con `OCR_INGEST_ENABLED=true`. EasyPanel puede reconstruir cada servicio por separado; no se ejecuta la aceptacion intencional de FacturaTech y no se considera desplegada la entrega hasta que ambos terminen. No introducir una desactivacion preventiva ni un despliegue por etapas sin un fallo real que lo justifique. -- [ ] Cuando ambos despliegues terminen, comprobar que health de RAG y OCR devuelve `version=0.2.1`, la misma revision esperada y que las etiquetas OCI de ambas imagenes coinciden; una diferencia bloquea la aceptacion. -- [ ] Verificar OCR live/ready, cola vacia, almacenamiento, RAG, PostgreSQL, Qdrant y reconciliador como una unica bateria posterior al despliegue conjunto. -- [ ] Cerrar o conservar auditadamente v7 sin aprobarla, indexarla ni activarla. -- [ ] Crear una candidata nueva con `activate=false` y supervisarla hasta `review_required` o fallo. -- [ ] Ejecutar la revision de FacturaTech definida en este anexo. -- [ ] Registrar evidencia autocontenida de IDs, estados, recuentos, hashes, versiones, revisiones y digests; marcar como manual externa la comparacion con el documento del cliente. +- [x] Publicar RAG/OCR desde el mismo commit: entrega `0654ce5`, con registro documental `ec0d233`. `BUILD_REVISION` se carga manualmente con `0654ce5` mientras no exista automatizacion aprobada; los digests de imagen siguen siendo la identidad exacta autoritativa. +- [x] Ejecutar una unica operacion conjunta de despliegue con `OCR_INGEST_ENABLED=true`. EasyPanel puede reconstruir cada servicio por separado; no se ejecuta la aceptacion intencional de FacturaTech y no se considera desplegada la entrega hasta que ambos terminen. El 2026-09-23 ambos servicios quedaron en ejecucion con `BUILD_REVISION=0654ce5`; no se introdujo una desactivacion preventiva ni un despliegue por etapas. +- [x] Cuando ambos despliegues terminen, comprobar que health de RAG y OCR devuelve `version=0.2.1`, la misma revision esperada y que las etiquetas OCI de ambas imagenes coinciden; una diferencia bloquea la aceptacion. Ambos exponen `0.2.1` y `0654ce5`; digests: RAG `sha256:9a22730406c09c6b773891d8953f451e6263bf9fb0033188f4479f0b93ec8bbf`, OCR `sha256:6725991d3893a4f16ae6c75f28e9dd59b051bdbc344b28d79f1292224ddceda1`. +- [x] Verificar OCR live/ready, cola vacia, almacenamiento, RAG, PostgreSQL, Qdrant y reconciliador como una unica bateria posterior al despliegue conjunto. OCR esta preparado con cola vacia, worker y sweeper operativos; RAG, PostgreSQL, Qdrant y reconciliador estan sanos; `004` quedo aplicada. +- [x] Cerrar o conservar auditadamente v7 sin aprobarla, indexarla ni activarla. v7 `c9756e59-f91d-4b57-8f31-e7b5b2b09558` sigue `failed` por `OCR_QUALITY_BLOCKED`, con artefactos presentes y la version activa anterior intacta. +- [x] Crear una candidata nueva con `activate=false` y supervisarla hasta `review_required` o fallo. v8 `f4fd1502-1d4d-454f-bbfb-4c3b6820ee97` alcanzo `review_required` sin activacion: 25/25 paginas OCR, 25 imagenes verificadas, 22 paginas aceptadas, tres advertencias `LOW_P10_CONFIDENCE` (2, 20 y 21) y cero bloqueos. +- [x] Ejecutar y reauditar la revision visual independiente de FacturaTech definida en este anexo. La primera revision quedo bloqueada por cuatro dudas; una auditoria posterior con un modelo visual mas potente resolvio esas dudas contra imagen, candidata y referencia, y dejo v8 `READY_FOR_APPROVAL`: 34/34 entradas, cero duplicados, cero ausencias y cero casos sin resolver. No se aprobo, indexo ni activo. +- [x] Registrar evidencia autocontenida de IDs, estados, recuentos, hashes, versiones, revisiones y digests; mantener separada la comparacion con el documento del cliente. v8: candidata SHA-256 `7705407ecbc6f6ec991202e46f0f37ba5a3c7defffb1addad6ed99ba67ea1827`, informe de calidad SHA-256 `76344dbbffc47bf6603013098a8f328141b76905cd9539ed696cae1d38e4c08e`, fingerprint `b26d6e8bb445d69928f4da7845ec2e1b484f376e0bc07284c29cd4fcc830b131` y metadata SHA-256 `545436706e3fda7dc56742e0cdefc8510b49b4a1c005e1708139cc42a0f536c1`. La comparacion visual contabilizo las 34 entradas y las correcciones pendientes exactas son `FATo7` -> `FAT07`, `NSAvo6` -> `NSAV06` y `DSAUo4` -> `DSAU04`. - [ ] Solicitar autorizacion explicita antes de aprobar, indexar o activar. ## Criterios de salida @@ -1609,12 +1651,13 @@ La correccion solo obtiene PASS cuando se cumplen todos estos puntos: 8. Las 25 imagenes y todos los artefactos conservan identidad, hash, permisos y retencion correctos. 9. RAG y OCR obtienen `0.2.1` de `VERSION`, exponen metadatos coincidentes y se despliegan y prueban como una unica unidad. 10. La nueva candidata contiene las 34 entradas y alcanza `review_required` sin indexacion ni activacion. -11. Los cuatro codigos criticos son exactos despues de revision humana externa. +11. Los cuatro codigos criticos son exactos despues de la revision visual independiente del agente, con las 34 entradas contabilizadas, cero casos sin resolver y una comparacion de tres vias entre imagen, candidata OCR y referencia manual. 12. La version activa anterior permanece disponible hasta una activacion autorizada y conserva rollback verificable. ## Rollback - RAG y OCR `0.2.1` forman una unidad de compatibilidad; no se revierte solo uno de los dos. - Ante fallo, mantener detenidas las nuevas ingestas OCR, volver ambos servicios al commit previamente validado de la entrega `0.2.0` dentro de la misma operacion y verificar conjuntamente salud, versiones y digests antes de reabrir trafico. +- La migracion `004_ocr_quality_diagnostics.sql` es aditiva y permanece aplicada al volver a `0.2.0`; no se eliminan columnas ni se rebaja el historial. Antes de reabrir trafico se debe confirmar que ambos servicios `0.2.0` arrancan y operan con ese esquema extendido. - No borrar versiones fallidas ni artefactos antes de capturar la evidencia necesaria. - La version activa actual no se modifica durante implementacion, validacion, despliegue ni revision. diff --git a/docs/HISTORIAL_SESIONES.md b/docs/HISTORIAL_SESIONES.md index 9bb3752..9656698 100644 --- a/docs/HISTORIAL_SESIONES.md +++ b/docs/HISTORIAL_SESIONES.md @@ -2,7 +2,7 @@ **Proyecto:** Workspace de tools IA para empresas **Modulo:** RAG -**Ultima actualizacion:** 2026-09-23 +**Ultima actualizacion:** 2026-09-24 **Ultima modificacion por:** Agente RAG 3 **Estado:** Activo @@ -10,6 +10,148 @@ ## Registro de sesion +### 2026-09-24 - Agente RAG 3 - Gate local final y correcciones de revision +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se completo el gate local previo a publicacion y se reviso integralmente la unidad del scheduler durable. La revision independiente detecto y corrigio dos regresiones: el playground enviaba `documentId=undefined` al aprobar correcciones por un renombre incompleto, y OpenAPI 3.1 expresaba `imageUrl` nullable con sintaxis obsoleta. Tambien se alinearon el gate visual de v8, el estado local frente al desplegado, el token exacto `DSAUo4` y el rollback con la migracion aditiva `004` ya aplicada. +**Validation:** Node `121/121`, OCR `26/26`, `npm run check`, `npm run build`, PostgreSQL local sano con cuatro migraciones, builds Docker RAG/OCR `0.2.1` con revision `local-gate`, OCR real `healthy` con modelo cargado, worker/sweeper y almacenamiento operativos, cola vacia y `git diff --check`. La imagen RAG reporto dos vulnerabilidades npm moderadas en `qs` via Express, sin vulnerabilidades altas o criticas; quedan como riesgo conocido no bloqueante. Los revisores especializados no arrancaron por `opencode_review_transport_binding_invalid`; dos revisores generales read-only completaron la revision. No se contacto Qdrant ni produccion. +**Next:** Crear commit y push de la unidad autorizada, excluyendo el cambio previo ajeno de `docs/DESPLIEGUE_EASYPANEL.md`. El usuario realizara el despliegue; despues corresponde la validacion productiva conjunta sin activar contenido. +**Files:** `src/modules/ocr/dispatcher.ts`, `src/modules/catalog/repository.ts`, `src/modules/ocr/review.ts`, `src/modules/ocr/client.ts`, `src/app.ts`, `src/config/env.ts`, `src/api/openapi.ts`, `public/playground/app.js`, pruebas OCR/catalogo y documentacion canonica. + +--- + +### 2026-09-24 - Agente RAG 3 - Rectificacion del alcance local de Qdrant +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se corrigio el siguiente gate documentado: el entorno local dispone de PostgreSQL, pero no de una instancia Qdrant. La referencia anterior mezclaba la bateria local previa a publicacion con el health completo de Qdrant, que pertenece a produccion. +**Validation:** La configuracion del repositorio no incluye un compose de Qdrant local y `DESPLIEGUE_EASYPANEL.md` documenta que las pruebas locales historicas usaban Qdrant remoto. La bateria local queda limitada a PostgreSQL, RAG/OCR, scheduler, almacenamiento, regresiones automatizadas del reconciliador y builds; Qdrant y el health RAG completo se verifican solo despues de un despliegue autorizado. +**Next:** Completar la bateria y los builds locales sin contactar Qdrant ni produccion, revisar el diff y solicitar autorizacion antes de commit/push. El despliegue y su validacion productiva conservan una autorizacion posterior independiente. +**Files:** `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-24 - Agente RAG 3 - End-to-end local hibrido y correcciones de revision +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se ejecuto una ingesta local fresca con PostgreSQL y OCR aislados y un PDF sintetico de dos paginas sin datos de cliente. La primera pagina se conservo nativa y solo la segunda se envio a OCR. La prueba descubrio dos defectos del camino hibrido: `completedPages` contaba dos veces una pagina solicitada a OCR cuando terminaba como blank, y `DurableOcrReviewReader` exigia imagen OCR tambien para paginas nativas. Se corrigio el conteo, `imageUrl` paso a ser nullable para paginas nativas, la validacion durable se limito a paginas procesadas por OCR y el playground dejo de solicitar imagenes nulas; tambien se corrigio el sombreado accidental del objeto global `document` en esa vista. +**Validation:** La ejecucion fresca `94f1af75-b6b0-4fb1-8a93-f0f5c0c5f0a0` paso de `ocr_queued` a `review_required` en 7 segundos, con pagina 1 nativa, pagina 2 OCR, `2/2`, `requested_pages={2}`, tres consultas discretas, quality report y candidata durables, revision autenticada, imagen PNG protegida, `activateRequested=false`, `next_attempt_at=NULL` al terminar y cola OCR vacia. Suite Node `121/121`; suite OCR real en imagen `26/26`; typecheck, build RAG y `git diff --check` correctos. No se contacto produccion, Qdrant no participo porque la candidata no se aprobo ni indexo. +**Next:** Ejecutar la bateria local previa a publicacion con PostgreSQL, RAG/OCR, scheduler, almacenamiento, regresiones automatizadas del reconciliador y builds finales, sin contactar Qdrant ni produccion; despues, revisar el diff y solicitar autorizacion antes de commit/push. Qdrant y el health RAG completo se verifican tras un despliegue autorizado posterior. +**Files:** `src/modules/catalog/repository.ts`, `src/modules/ocr/review.ts`, `src/api/openapi.ts`, `public/playground/app.js`, `tests/ocr/dispatcher.test.ts`, `tests/ocr/review.test.ts`, `docs/PENDIENTES_RAG.md`, `docs/SISTEMA_RAG_BASE.md`, `docs/CONTEXTO_PROYECTO.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-24 - Agente RAG 3 - Scheduler durable OCR local +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se implemento el scheduler durable dentro del dispatcher RAG. El repositorio expone el menor `next_attempt_at`; el dispatcher mantiene un unico temporizador, reconstruye el plan al arrancar, reclama solo trabajos vencidos mediante los locks existentes y reprograma estados pendientes con el backoff persistido de 2 a 15 segundos. Se conservaron `activeDrain`, leases, `FOR UPDATE SKIP LOCKED` y el reconciliador de cinco minutos. Se eliminaron `pollUntilTerminal()` y las variables RAG no consumidas `OCR_PAGE_TIMEOUT_MS`/`OCR_TOTAL_TIMEOUT_MS`. +**Validation:** Pasaron las pruebas focalizadas de vencimiento corto, estados terminales sin temporizador, reinicio sin reenvio OCR, dos dispatchers concurrentes y busqueda del proximo vencimiento. Tambien pasaron `npm run check`, `npm run build`, la suite Node completa con 119 pruebas y `git diff --check`. No se contacto produccion ni se desplego. +**Next:** Completar el bloque E con la ingesta local end-to-end de un PDF hibrido y las validaciones conjuntas restantes. Commit/push y despliegue requieren autorizaciones explicitas posteriores. +**Files:** `src/modules/ocr/dispatcher.ts`, `src/modules/catalog/repository.ts`, `src/modules/ocr/client.ts`, `src/config/env.ts`, `src/app.ts`, `tests/ocr/dispatcher.test.ts`, `tests/catalog/repository-ocr.test.ts`, `tests/ocr/client.test.ts`, `tests/ocr/contracts-deploy.test.ts`, `docs/PENDIENTES_RAG.md`, `docs/SISTEMA_RAG_BASE.md`, `docs/OPERATIVA.md`. + +--- + +### 2026-09-24 - Agente RAG 3 - Aceptacion OCR best-effort y siguiente bloque autonomo +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se completo el diagnostico focalizado en VPS2 PRODUCCION, exclusivamente en lectura. Los PNG verificados muestran `FAT07`, `NSAV06` y `DSAU04`; `ocr-result.json` ya contiene `FATo7`, `NSAvo6` y `DSAUo4` con confianzas altas, y la candidata conserva esos valores. Se descartan renderizado, reconstruccion y RAG como origen. El usuario acepto el OCR como servicio best-effort para la fase autonoma actual. +**Decision:** La ingesta sera orquestada por un agente y debe terminar sin depender de GUI ni correccion humana por documento. La mejora de precision, una consola de revision humana y un posible revisor visual por IA quedan como evolucion futura no bloqueante. Esto no declara exactitud total ni elimina correcciones y autorizaciones de candidatas concretas. +**Validation:** Se actualizaron contrato, arquitectura, operativa y backlog. El diagnostico queda cerrado; no fue necesario reprocesar. El siguiente bloque obligatorio es el scheduler durable y despues la validacion end-to-end. No se modifico codigo ni produccion. +**Next:** Tras compactar, iniciar el bloque D de `docs/PENDIENTES_RAG.md`; mantener el reconciliador de cinco minutos como fallback y validar despues el circuito autonomo completo. +**Files:** `docs/PENDIENTES_RAG.md`, `docs/SISTEMA_RAG_BASE.md`, `docs/OPERATIVA.md`, `docs/CONTRATO_CICLO_VIDA_Y_OCR.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-24 - Agente RAG 3 - TODO post-compactacion para diagnostico OCR +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se convirtio la fase inicial del plan en un TODO ejecutable de diez pasos dedicado exclusivamente a comprender las confusiones de precision de las paginas 8, 15 y 16 antes de modificar codigo. El recorrido compara PDF, render, entrada y salida cruda de PaddleOCR, normalizacion OCR, respuesta API, artefactos RAG, reconstruccion y candidata. +**Validation:** El TODO empieza reutilizando evidencia durable en solo lectura. Solo si resulta insuficiente y existe autorizacion explicita se repetira el procesamiento real, limitado a `[8, 15, 16]`, sin ajustes y sin crear una candidata completa. Las advertencias de confianza de las paginas 2, 20 y 21 quedan fuera de este diagnostico porque ya tienen otra causa y tratamiento. +**Next:** Tras compactar, ejecutar el TODO desde el paso 1. Detenerse al completar el informe de diagnostico y presentarlo al usuario; el bloque B y cualquier ajuste de codigo requieren una autorizacion posterior. +**Files:** `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-24 - Agente RAG 3 - Plan OCR completo hasta operacion productiva +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se completo el plan que antes terminaba prematuramente en el diagnostico de las confusiones `o/0`. Ahora cubre diagnostico, implementacion, regresiones focalizadas, scheduler durable, validacion local conjunta, publicacion, despliegue, validacion productiva sin activar contenido, cierre separado de v8 y criterio verificable de servicio completamente operativo. +**Validation:** El reprocesado del PDF real para precision queda limitado a `[8, 15, 16]`. La prueba productiva end-to-end usa una ingesta controlada no activa; aprobar/indexar y activar v8 conservan autorizaciones separadas. El cierre tecnico exige precision, latencia corta, reinicios, leases, reconciliacion, health, almacenamiento, PostgreSQL, Qdrant, observabilidad y rollback sin bloqueantes. +**Next:** Solicitar autorizacion para ejecutar primero el diagnostico local. No implementar, publicar, desplegar ni procesar contenido real sin la autorizacion correspondiente a cada fase. +**Files:** `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Deuda de precision OCR y pruebas focalizadas +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** `ses_f358ccfe9ffeEzEVNuKo93aHyH` +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se corrigio el plan post-compactacion para tratar `FATo7`, `NSAvo6` y `DSAUo4`/`DSAuo4` no solo como correcciones pendientes de FacturaTech v8, sino como deuda tecnica de precision OCR. La investigacion debe preceder a la solucion, aislar si el fallo nace en renderizado, PaddleOCR, reconstruccion o transformacion posterior y evitar reglas especificas o sustituciones silenciosas. Se decidio tambien eliminar `pollUntilTerminal()` durante la implementacion del scheduler durable. +**Validation:** El contrato identifica los valores correctos `FAT07`, `NSAV06` y `DSAU04` y ubica las confusiones en las paginas 8, 15 y 16. Si una prueba autorizada necesita reprocesar el PDF real, solicitara exclusivamente `[8, 15, 16]`; no repetira las 25 paginas ni creara una candidata productiva completa para este diagnostico. No se modifico codigo, no se reproceso contenido y no se altero produccion. +**Next:** Ejecutar primero el diagnostico local y preparar regresiones no sensibles; despues implementar el scheduler durable basado en `next_attempt_at`. Aprobar/indexar y activar v8 mantienen autorizaciones separadas. +**Files:** `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Arquitectura interna consolidada y plan post-compactacion +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** no disponible tras compactacion +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Se amplio `SISTEMA_RAG_BASE.md` como explicacion canonica y progresiva del funcionamiento interno: RAG autonomo sin OCR, extension OCR opcional por pagina, composicion, revision, indexacion, activacion, almacenamiento, fallos y recuperacion. Se documento el comportamiento real del seguimiento OCR: despacho inmediato, `next_attempt_at` de 2-15 segundos sin scheduler dedicado, reconciliador global cada cinco minutos y ausencia de callback OCR -> RAG. El backlog conserva un plan ejecutable para implementar el scheduler durable, probarlo, publicarlo, cerrar v8 y demostrar despues un PDF hibrido. +**Validation:** La revision de codigo confirma que v8 si completo la comunicacion RAG -> OCR -> RAG: resultado, 25 imagenes, diagnostico y candidata quedaron durables. La auditoria visual con el modelo mas potente sustituyo el veredicto inicial `REVIEW_BLOCKED` por `READY_FOR_APPROVAL`: 34/34 entradas, cero casos sin resolver y tres correcciones `o/0` conocidas. `OPERATIVA.md` y `PENDIENTES_RAG.md` quedaron alineados. No se modifico codigo, no se desplego y no se aprobo, indexo ni activo v8. +**Next:** Despues de compactar, leer `SISTEMA_RAG_BASE.md`, el plan post-compactacion de `PENDIENTES_RAG.md` y `Revision de FacturaTech` en el contrato. Empezar por el scheduler durable local; conservar el reconciliador de cinco minutos como fallback. Toda publicacion, aprobacion/indexacion y activacion mantienen sus autorizaciones separadas. +**Files:** `docs/SISTEMA_RAG_BASE.md`, `docs/PENDIENTES_RAG.md`, `docs/OPERATIVA.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Revision visual independiente de FacturaTech v8 bloqueada +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-terra + subagente `vision` · **Session:** no disponible tras compactacion +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Con autorizacion explicita del usuario, se ejecuto en VPS2 PRODUCCION la revision visual independiente de v8, exclusivamente en lectura. Se descargaron temporalmente sus 25 PNG de revision, se verificaron por SHA-256 y se transcribieron en cinco lotes ciegos antes de consultar la candidata OCR o la referencia manual. Los ficheros temporales se eliminaron al cerrar la revision. +**Validation:** La candidata permanecio `review_required`, `activateRequested=false`, con SHA-256 `7705407ecbc6f6ec991202e46f0f37ba5a3c7defffb1addad6ed99ba67ea1827` y activa `3fc78163-9cfb-4979-985c-1520a63327b0` sin cambios antes y despues. La comparacion de tres vias confirma 25/25 paginas y 34/34 entradas, sin duplicados ni ausencias. `CBG04a` y `DSAU08` coinciden; `FATo7` debe ser `FAT07`, `NSAvo6` debe ser `NSAV06` y `DSAUo4` debe ser `DSAU04`. Ademas hay diferencias textuales y una clausula de la pagina 22 queda truncada en la imagen y silenciosamente incompleta en la candidata. Resultado: `REVIEW_BLOCKED`, con cuatro casos sin resolver. No se aprobo, rechazo, indexo, activo ni corrigio contenido. +**Note:** Este fue el veredicto inicial del modelo menos potente. La auditoria posterior registrada arriba comprobo visualmente que las correcciones `o/0` estaban resueltas por imagen y referencia, que el recorte de la pagina 22 pertenecia a la fuente y que los demas puntos no eran materiales. El estado documental vigente es `READY_FOR_APPROVAL`. +**Next:** Entrada historica superada por la auditoria posterior; seguir el plan post-compactacion de `PENDIENTES_RAG.md`. +**Files:** `docs/OPERATIVA.md`, `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Sustitucion del revisor humano externo en R5 +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-sol · **Session:** no disponible tras compactacion +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Por decision del usuario, el criterio especifico de aceptacion de FacturaTech R5 deja de exigir revision humana externa y adopta como referencia una revision visual independiente realizada por el agente con un modelo profesional distinto de PaddleOCR. El contrato incorpora un procedimiento cerrado para que un modelo menos potente ejecute la tarea sin confundir actores, fuentes ni autorizaciones. +**Validation:** El procedimiento obliga a leer primero las 25 imagenes sin exponer al modelo al texto OCR, producir salida estructurada por pagina, comparar despues imagen/candidata/referencia manual, contabilizar 34 entradas, comprobar literalmente cuatro codigos criticos y bloquear el resultado ante cualquier incertidumbre. La revision es de solo lectura; aprobar/indexar y activar conservan autorizaciones separadas. +**Files:** `docs/CONTRATO_CICLO_VIDA_Y_OCR.md`, `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Candidata FacturaTech v8 preparada para revision humana +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-terra · **Session:** no disponible tras compactacion +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Con autorizacion explicita del usuario, se envio el PDF original de FacturaTech a RAG en VPS2 PRODUCCION usando la fuente existente `facturatech:agente-whatsapp-central:errores-junio-2026:v1`, las etiquetas existentes y `activate=false`. No se ejecutaron aprobacion, indexacion ni activacion. +**Validation:** La version v8 `f4fd1502-1d4d-454f-bbfb-4c3b6820ee97` alcanzo `review_required`; `activateRequested=false` y la activa continua siendo `3fc78163-9cfb-4979-985c-1520a63327b0`. OCR proceso 25/25 paginas, todas con imagen durable verificada: 22 `accepted`, 3 `warning` por `LOW_P10_CONFIDENCE` (paginas 2, 20 y 21), sin bloqueos. La consulta de revision valido la cadena durable: candidata SHA-256 `7705407ecbc6f6ec991202e46f0f37ba5a3c7defffb1addad6ed99ba67ea1827`, informe `76344dbbffc47bf6603013098a8f328141b76905cd9539ed696cae1d38e4c08e`, fingerprint `b26d6e8bb445d69928f4da7845ec2e1b484f376e0bc07284c29cd4fcc830b131` y metadata `545436706e3fda7dc56742e0cdefc8510b49b4a1c005e1708139cc42a0f536c1`. Riesgos detectados para revision humana: `FATo7`, `NSAvo6` y `DSAUo4`; tambien revisar exactamente `CBG04a` y `DSAU08`. +**Note:** La primera consulta administrativa dentro de RAG no ejecuto porque la imagen no contiene `curl`; se repitio con `fetch` de Node y fue satisfactoria. No fue un error del servicio ni altero datos. +**Next:** Realizar la revision humana externa de las 34 entradas y los codigos criticos. Solo pedir autorizacion para aprobar/indexar tras presentar esa evidencia; activar requiere otra autorizacion. +**Files:** `docs/CONTRATO_CICLO_VIDA_Y_OCR.md`, `docs/PENDIENTES_RAG.md`, `docs/OPERATIVA.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Validacion productiva del despliegue conjunto R5 +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-terra · **Session:** no disponible tras compactacion +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** El usuario implemento en EasyPanel VPS2 la entrega conjunta RAG/OCR `0.2.1`, con `BUILD_REVISION=0654ce5` y `OCR_INGEST_ENABLED=true`. Se verifico el entorno efectivo sin modificar datos: RAG y OCR tienen los valores manuales esperados, health/version/revision coinciden y la migracion `004` se aplico al arrancar RAG. +**Validation:** VPS2 PRODUCCION: RAG `GET /health` responde `0.2.1`/`0654ce5` con PostgreSQL, Qdrant y reconciliador sanos. OCR interno responde `live=ok` y `ready=true`, modelo cargado, cola `0/3`, worker/sweeper operativos y almacenamiento disponible. Etiquetas/digests: RAG `sha256:9a22730406c09c6b773891d8953f451e6263bf9fb0033188f4479f0b93ec8bbf`; OCR `sha256:6725991d3893a4f16ae6c75f28e9dd59b051bdbc344b28d79f1292224ddceda1`. `rag_schema_migrations` contiene `001-004` con checksums coincidentes. v7 `c9756e59-f91d-4b57-8f31-e7b5b2b09558` sigue `failed` por `OCR_QUALITY_BLOCKED`, con artefactos presentes; no se aprobo, indexo ni activo y la activa `3fc78163-9cfb-4979-985c-1520a63327b0` permanece intacta. +**Rollback:** Ante un fallo posterior, revertir RAG y OCR juntos a la unidad `0.2.0`; no revertir uno solo. No se inicio una candidata nueva ni se altero el corpus activo. +**Files:** `docs/OPERATIVA.md`, `docs/CONTRATO_CICLO_VIDA_Y_OCR.md`, `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + +### 2026-09-23 - Agente RAG 3 - Acceso operativo VPS2 para R5 +**Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-terra · **Session:** no disponible tras compactacion +**Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. +**Work:** Corregida la contradiccion entre la guia de despliegue y el Dockerfile: RAG ejecuta migraciones al arrancar, por lo que el preflight productivo debe revisar el historial antes de desplegar. Consultada la fuente canonica de acceso VPS2 y registrada en `OPERATIVA.md` la conexion directa sin duplicar su contrasena. +**Validation:** Conexion SSH de solo lectura a `VPS2 PRODUCCION` correcta: host `vps-4863ac0d`, usuario `debian`. El health publico de RAG confirma que la entrega actual sigue en `0.2.0` revision `640d3a4a1a2234b899885c130b4d9b6d92e2c95f`, con PostgreSQL, Qdrant y reconciliador sanos. El preflight consulto desde el contenedor RAG su destino efectivo: `db_rag` con rol `usr_rag`; `rag_schema_migrations` contiene `001-003` con checksums coincidentes y solo queda `004_ocr_quality_diagnostics.sql`. No se desplego, no se aplicaron migraciones y no se modificaron servicios ni datos productivos. +**Rollback:** Revertir solo los ajustes documentales si la referencia de acceso o el comportamiento del arranque cambia; no existe cambio productivo que revertir. +**Files:** `docs/OPERATIVA.md`, `docs/DESPLIEGUE_EASYPANEL.md`, `docs/CONTRATO_CICLO_VIDA_Y_OCR.md`, `docs/PENDIENTES_RAG.md`, `docs/HISTORIAL_SESIONES.md`. + +--- + ### 2026-09-23 - Agente RAG 3 - Publicacion conjunta RAG-OCR R5 **Agent:** Agente RAG 3 · **Model:** openai/gpt-5.6-terra · **Session:** no disponible tras compactacion **Role:** Desarrollo, mantenimiento y continuidad del servicio RAG y su integracion con el servicio OCR reutilizable. diff --git a/docs/OPERATIVA.md b/docs/OPERATIVA.md index ce104f9..225e676 100644 --- a/docs/OPERATIVA.md +++ b/docs/OPERATIVA.md @@ -1,15 +1,19 @@ # Operativa del servicio RAG **Modulo:** RAG -**Ultima actualizacion:** 2026-09-22 -**Version:** 1.4 +**Ultima actualizacion:** 2026-09-24 +**Version:** 1.6 --- Este documento registra los hechos operativos del servicio RAG: la configuracion vigente en EasyPanel y la salida por etapas del OCR. No contiene secretos: las credenciales reales viven unicamente en EasyPanel. -## Estado verificado (2026-09-17) +## Estado verificado (2026-09-24) +- Preflight R5 de solo lectura (2026-09-23): el contenedor RAG usa la base `db_rag` con el rol `usr_rag`. `rag_schema_migrations` contiene `001_knowledge_lifecycle.sql`, `002_ocr_review.sql` y `003_ocr_recovery_audit.sql`, todos con checksums coincidentes con el commit publicado; la unica migracion pendiente es `004_ocr_quality_diagnostics.sql`. +- Despliegue R5 verificado (2026-09-23): RAG y OCR exponen `version=0.2.1` y `revision=0654ce5`; sus etiquetas OCI coinciden. Digests: RAG `sha256:9a22730406c09c6b773891d8953f451e6263bf9fb0033188f4479f0b93ec8bbf`, OCR `sha256:6725991d3893a4f16ae6c75f28e9dd59b051bdbc344b28d79f1292224ddceda1`. `004_ocr_quality_diagnostics.sql` esta aplicada; OCR esta `live/ready`, con cola vacia, worker/sweeper operativos y almacenamiento disponible; RAG confirma PostgreSQL, Qdrant y reconciliador sanos. +- Candidata R5 (2026-09-23): FacturaTech v8 `f4fd1502-1d4d-454f-bbfb-4c3b6820ee97` esta `review_required`, con `activateRequested=false`; no se aprobo, indexo ni activo. Tiene 25/25 paginas OCR completas, 25 imagenes durables verificadas, tres advertencias no bloqueantes en paginas 2, 20 y 21, y ningun bloqueo. La revision visual independiente y su auditoria posterior concluyen `READY_FOR_APPROVAL`: 34/34 entradas, sin duplicados, ausencias ni casos sin resolver. `CBG04a` y `DSAU08` ya son exactos; deben proponerse `FATo7` -> `FAT07`, `NSAvo6` -> `NSAV06` y `DSAUo4` -> `DSAU04`. El recorte de la pagina 22 pertenece a la imagen fuente y la candidata conserva el contenido visible. La version activa `3fc78163-9cfb-4979-985c-1520a63327b0` sigue intacta. +- Diagnostico de precision (2026-09-24): los PNG productivos verificados de las paginas 8, 15 y 16 muestran los ceros correctos, mientras `ocr-result.json` ya contiene las tres confusiones `o/0` con confianza alta y la candidata las conserva sin cambios. Se descartan renderizado, reconstruccion y RAG como origen. El OCR queda aceptado para esta fase como servicio best-effort autonomo; la mejora de precision, la consola humana y un posible revisor visual por IA quedan diferidos. Esta aceptacion no aprueba ni activa v8. - `KNOWLEDGE_LIFECYCLE_ENFORCED=true`: el ciclo de vida del conocimiento esta activo; no queda pendiente ninguna activacion. - `OCR_INGEST_ENABLED=true`: la ingesta OCR esta habilitada en el RAG desplegado. - `OCR_SERVICE_URL`: configurada hacia el servicio privado `ocr-service` de la red interna. @@ -18,11 +22,11 @@ Este documento registra los hechos operativos del servicio RAG: la configuracion - Volumen transitorio del OCR en `/data/jobs`: cola SQLite y trabajos en curso. - La respuesta publica sin token de una ruta OCR protegida es `401`, lo que confirma que el proceso desplegado tiene OCR habilitado. Con OCR deshabilitado, la misma ruta responderia `404` antes de autenticar. - El estado mostrado en EasyPanel debe contrastarse con su entorno persistido y con el entorno del contenedor en ejecucion; anteriormente la UI mostro valores distintos por variables duplicadas y desfasadas. -- `GET /health` del RAG confirma PostgreSQL, Qdrant y reconciliador sanos. OCR responde internamente `live=ok` y `ready=true`, con cola vacia y version `0.1.0`. +- `GET /health` del RAG confirma PostgreSQL, Qdrant y reconciliador sanos. OCR responde internamente `live=ok` y `ready=true`, con cola vacia, worker/sweeper operativos y version/revision `0.2.1`/`0654ce5`. - La migracion `003_ocr_recovery_audit.sql` esta aplicada y su tabla de auditoria existe. - Las rutas autenticadas de revision y recuperacion devuelven errores estructurados y seguros para una candidata inexistente: `404`, `OCR_CANDIDATE_NOT_FOUND` y accion `verify_version_id`. - La candidata OCR heredada de FacturaTech v4 fue cerrada como `failed` el 2026-09-20 mediante recuperacion administrativa auditada tras confirmar `OCR_ARTIFACT_UNAVAILABLE`. No fue indexada ni activada; la fuente conserva su version activa y no tiene candidatas OCR bloqueantes. -- Las imagenes en ejecucion tienen digest, pero ambas etiquetas OCI `org.opencontainers.image.revision` valen `unknown`: EasyPanel no esta pasando `BUILD_REVISION` durante el build. La identidad de revision verificable sigue pendiente. +- Las imagenes en ejecucion tienen digests y ambas etiquetas OCI `org.opencontainers.image.revision=0654ce5`: EasyPanel recibe `BUILD_REVISION` durante el build. La identidad de revision se verifica mediante las etiquetas, el health y los digests registrados para R5. ## Version de despliegue @@ -61,6 +65,16 @@ Como hacerlo: - No existe ninguna accion automatizada documentada sobre EasyPanel: ni scripts, ni comandos de panel, ni webhooks. El deploy siempre lo dispara una persona desde el panel. - Verificacion minima tras cada deploy: `GET /health` del RAG. +## Acceso remoto a VPS2 + +- Entorno: `VPS2 PRODUCCION`. +- Host SSH: `162.19.152.15`, puerto `22`, usuario `debian`. +- Metodo actual: conexion SSH directa con password mediante `SSH_ASKPASS` temporal; nunca reenviar la contrasena por entrada estandar ni mostrarla en la salida. +- Fuente canonica del procedimiento y de la credencial vigente: `Empresa/Servidores/VPS2/Vps2_despliegue_apps/instrucciones_montado_y_despliegue_apps_vps2_easypanel.md`. +- Verificacion de acceso: 2026-09-23, conexion directa correcta como `debian` al host `vps-4863ac0d`. +- Antes de cualquier accion remota, indicar expresamente `VPS2 PRODUCCION`; las comprobaciones que no contacten ese host se etiquetan `LOCAL`. +- La contrasena no se copia en este repositorio, historial ni chat. Si cambia, actualizarla solo en la fuente canonica. + ## Configuracion actual (valores no secretos) Valores vigentes confirmados en produccion (2026-09-17): @@ -131,6 +145,7 @@ El OCR es un servicio privado e independiente. El RAG solo lo llama si `OCR_INGE - Si esa transferencia no llega, un sweeper ejecutado al arrancar y cada 15 minutos expira los trabajos a las 24 horas, hace checkpoint WAL y vacuum incremental. - Antes de admitir el PDF y antes de publicar cada PNG, OCR limita su uso al menor entre 10 % del filesystem y 2 GiB, reservando libre el mayor entre 10 % y 2 GiB. La presion falla cerrada como `OCR_STORAGE_PRESSURE`. - La limpieza transitoria nunca toca los artefactos duraderos del RAG ni el corpus activo. +- La siguiente entrega de RAG incorpora un scheduler durable basado en `next_attempt_at`, con backoff de 2 a 15 segundos y un unico temporizador. Hasta que esa unidad se publique y despliegue, produccion conserva el seguimiento de la revision `0654ce5`; el reconciliador de `300000` ms permanece activo como fallback global en ambas versiones. ### Verificacion posterior al despliegue (cuando cambian ambos servicios) @@ -148,8 +163,9 @@ El OCR es un servicio privado e independiente. El RAG solo lo llama si `OCR_INGE - Ante un fallo cuyos sintomas indiquen que conviene aislar OCR: poner `OCR_INGEST_ENABLED=false`, desplegar el RAG y conservar la version activa actual del corpus. No aplicar este paso automaticamente a fallos no relacionados. - El sistema es fail-closed: un fallo del OCR deja intacta la version activa anterior; no hay activacion parcial. -- Las paginas con OCR no se activan solas; requieren revision humana obligatoria (estado `review_required`). +- Las paginas con OCR no se activan solas; pasan por el contrato de revision en estado `review_required`. En la fase autonoma actual esa revision puede ejecutarla el agente; una futura consola humana reutilizara el mismo contrato. - Desactivar el OCR no borra el corpus activo ni exige reingesta. +- Si se revierte a `0.2.0`, la migracion aditiva `004_ocr_quality_diagnostics.sql` permanece aplicada. No se eliminan sus columnas ni su fila de historial; antes de reabrir trafico se verifica que RAG y OCR `0.2.0` arrancan y pasan health con ese esquema extendido. ### Limites opcionales del RAG (valores por defecto del codigo) @@ -157,8 +173,6 @@ El OCR es un servicio privado e independiente. El RAG solo lo llama si `OCR_INGE |---|---| | `OCR_MAX_UPLOAD_BYTES` | `52428800` (50 MiB) | | `OCR_MAX_PAGES` | `100` | -| `OCR_PAGE_TIMEOUT_MS` | `60000` | -| `OCR_TOTAL_TIMEOUT_MS` | `900000` (15 min) | | `LIFECYCLE_RECONCILE_INTERVAL_MS` | `300000` | | `LIFECYCLE_INDEXING_STALE_TIMEOUT_MS` | `1800000` | | `QDRANT_LOGS_COLLECTION` | `rag_eval_logs` | diff --git a/docs/PENDIENTES_RAG.md b/docs/PENDIENTES_RAG.md index a00a317..7d3de2e 100644 --- a/docs/PENDIENTES_RAG.md +++ b/docs/PENDIENTES_RAG.md @@ -1,6 +1,6 @@ # Pendientes priorizados del RAG -**Ultima actualizacion:** 2026-09-23 +**Ultima actualizacion:** 2026-09-24 **Responsable de la priorizacion:** Usuario **Estado:** Activo @@ -35,7 +35,7 @@ Esta secuencia tiene prioridad sobre la aceptacion productiva pendiente de Factu ### Fase 3. Hardening OCR y aceptacion de FacturaTech -**Estado:** `0.2.0` desplegada y D1-D4 verificados en produccion. La aceptacion v6-v7 encontro dos defectos correctivos; queda bloqueada hasta entregar `0.2.1` segun el anexo del contrato. +**Estado:** `0.2.1` desplegada y verificada conjuntamente en produccion. v8 completo la aceptacion no activa y esta `READY_FOR_APPROVAL`; la aprobacion/indexacion y la activacion permanecen separadas y requieren sus autorizaciones. La candidata v5 `5f2317c6-7a8a-4e08-a614-f8189602ebb8` completo 25/25 paginas OCR, pero fallo despues cuando RAG solicito 25 imagenes de revision en paralelo. El rerender concurrente provoco un `SIGSEGV` de PDFium/FreeType, salida `139` y reinicio del contenedor; no hubo OOM. La candidata quedo fallida y la version activa no cambio. @@ -60,41 +60,138 @@ La candidata v5 `5f2317c6-7a8a-4e08-a614-f8189602ebb8` completo 25/25 paginas OC #### Fase 3C. Correccion post-aceptacion `0.2.1` -**Estado:** R1-R4 completados y verificados localmente. R5 centraliza `0.2.1` en `VERSION`; RAG, OCR, health, OpenAPI, etiquetas OCI y builds rechazan valores declarados divergentes. La validacion local completa ya paso: imagen OCR construida con PaddleOCR/PaddlePaddle y modelos baked, health real correcto, suite Python en imagen y suite Node correctas. El fixture autocontenido de 25 paginas valida advertencias p10, 25 PNG y candidata revisable; las caidas en cada frontera de persistencia se recuperan exactamente una vez y los diagnosticos administrativos no exponen errores internos. La migracion `004` se valido con historial `001-003` y una fila historica en PostgreSQL Docker local aislado; el contenedor reutilizable quedo detenido. R5 permanece bloqueada hasta autorizacion explicita de despliegue conjunto. Contrato completo en `CONTRATO_CICLO_VIDA_Y_OCR.md`, anexo "Hallazgos productivos v6-v7". +**Estado:** R1-R5 completados. `0.2.1` se centralizo en `VERSION`, supero la validacion local y se desplego conjuntamente en produccion con revision y digests verificables. La migracion `004`, health, cola, almacenamiento, PostgreSQL, Qdrant y reconciliador quedaron sanos. v8 completo la aceptacion no activa y conserva las correcciones conocidas pendientes de aplicar durante su revision. Contrato completo en `CONTRATO_CICLO_VIDA_Y_OCR.md`, anexo "Hallazgos productivos v6-v7". 1. Completado localmente: R1, identidad `ocr-v2` completa, acknowledgement de los cuatro estados, recuperacion posterior a `succeeded` sin reenviar OCR y rechazo fail-closed de respuestas incompatibles. 2. Completado localmente: R2, `p10Confidence` bajo aislado como advertencia, bloqueos reales preservados y diagnostico determinista por pagina. 3. Completado localmente: R3, migracion `004` preparada sin aplicar, cadena de hashes durable, `quality-report.json` privado e inmutable y diagnostico seguro. 4. Completado localmente: R4, suites, revision independiente, fixture autocontenido de 25 paginas, simulaciones de recuperacion tras cada frontera de persistencia y hardening de errores administrativos. `004` se aplico mediante el runner en PostgreSQL Docker local aislado despues de controlar `001-003`; se verificaron la fila historica, columnas, restriccion, historial `001-004` e idempotencia. El contenedor reutilizable queda detenido; nunca se uso EasyPanel ni produccion. -5. Pendiente: R5, consumir `0.2.1` desde un unico `VERSION`, validar igualdad de health y etiquetas OCI, desplegar y probar RAG/OCR como una unica operacion y repetir aceptacion no activa con revision humana. +5. Completado: R5 consumio `0.2.1` desde un unico `VERSION`, valido health y etiquetas OCI, desplego y probo RAG/OCR como una unica operacion y completo la aceptacion no activa con revision visual independiente del agente. #### Checklist ejecutable de R5 -Retomar esta lista en orden despues de la compactacion. No iniciar acciones productivas antes de completar la preparacion local y obtener la autorizacion correspondiente. +R5 esta completada hasta `READY_FOR_APPROVAL`. La aprobacion/indexacion y la activacion de v8 siguen pendientes y conservan autorizaciones separadas. - [x] Crear `VERSION` en la raiz con `0.2.1` y convertirlo en la unica fuente de version para RAG, OCR, health, pruebas y builds; cualquier version duplicada o divergente debe fallar. - [x] Ejecutar la validacion local completa de RAG, OCR e imagenes y actualizar la documentacion afectada. -- [ ] Revisar el worktree, crear un unico commit de entrega y hacer push a `main`; RAG y OCR deben publicarse desde ese mismo commit. -- [ ] Antes del despliegue, identificar explicitamente la base y el entorno productivos, revisar `rag_schema_migrations` y confirmar que solo estan pendientes las migraciones esperadas. -- [ ] Configurar en ambos servicios `BUILD_REVISION=` y mantener `OCR_INGEST_ENABLED=true`. -- [ ] Desplegar RAG y OCR desde el mismo commit como una unica operacion en EasyPanel; no considerar completada la entrega hasta que ambos terminen. -- [ ] Verificar conjuntamente `version=0.2.1`, revision, etiquetas OCI, digests, migracion `004`, health, cola OCR, almacenamiento, PostgreSQL, Qdrant y reconciliador. -- [ ] Confirmar el estado auditado de v7 sin aprobarla, indexarla ni activarla. -- [ ] Crear una candidata nueva de FacturaTech con `activate=false` y supervisarla hasta `review_required` o fallo. -- [ ] Ejecutar la revision humana de las 34 entradas y los cuatro codigos criticos; registrar IDs, estados, recuentos, hashes, versiones, revisiones y digests. +- [x] Revisar el worktree, crear un unico commit de entrega y hacer push a `main`; RAG y OCR se publicaron desde `0654ce5` y el registro documental se completo en `ec0d233`. +- [x] Antes del despliegue, identificar explicitamente la base y el entorno productivos, revisar `rag_schema_migrations` y confirmar que solo estan pendientes las migraciones esperadas. Preflight de solo lectura en VPS2 (2026-09-23): RAG usa `db_rag` con el rol `usr_rag`; `001-003` estan aplicadas con checksums coincidentes y solo queda `004_ocr_quality_diagnostics.sql`. +- [x] Configurar en ambos servicios `BUILD_REVISION=` y mantener `OCR_INGEST_ENABLED=true`. Ambos servicios se construyeron con `BUILD_REVISION=0654ce5` y RAG conserva OCR habilitado. +- [x] Desplegar RAG y OCR desde el mismo commit como una unica operacion en EasyPanel; ambos servicios quedaron en ejecucion el 2026-09-23 con `BUILD_REVISION=0654ce5` y OCR habilitado. +- [x] Verificar conjuntamente `version=0.2.1`, revision, etiquetas OCI, digests, migracion `004`, health, cola OCR, almacenamiento, PostgreSQL, Qdrant y reconciliador. RAG: `sha256:9a22730406c09c6b773891d8953f451e6263bf9fb0033188f4479f0b93ec8bbf`; OCR: `sha256:6725991d3893a4f16ae6c75f28e9dd59b051bdbc344b28d79f1292224ddceda1`. Ambos exponen `0.2.1` y `0654ce5`; `004` esta aplicada, OCR esta `live/ready`, cola vacia y almacenamiento disponible. +- [x] Confirmar el estado auditado de v7 sin aprobarla, indexarla ni activarla. v7 `c9756e59-f91d-4b57-8f31-e7b5b2b09558` sigue `failed` por `OCR_QUALITY_BLOCKED`, conserva artefactos y la version activa anterior `3fc78163-9cfb-4979-985c-1520a63327b0` permanece intacta. +- [x] Crear una candidata nueva de FacturaTech con `activate=false` y supervisarla hasta `review_required` o fallo. v8 `f4fd1502-1d4d-454f-bbfb-4c3b6820ee97` alcanzo `review_required` sin activacion: 25/25 paginas OCR, 25 imagenes verificadas, 22 paginas aceptadas, tres advertencias `LOW_P10_CONFIDENCE` (2, 20 y 21) y cero bloqueos. +- [x] Ejecutar y reauditar la revision visual independiente definida en el contrato: v8 se revalido antes y despues, se descargaron temporalmente 25 imagenes, un modelo visual las transcribio sin recibir primero el OCR productivo, y se compararon despues imagen/candidata/referencia. La auditoria con un modelo mas potente corrigio el veredicto inicial: resultado `READY_FOR_APPROVAL`, 34/34 entradas, cero duplicados, cero ausencias y cero casos sin resolver. `CBG04a` y `DSAU08` ya son exactos; deben proponerse `FATo7` -> `FAT07`, `NSAvo6` -> `NSAV06` y `DSAUo4` -> `DSAU04`. El recorte de la pagina 22 pertenece a la imagen fuente y la candidata conserva el contenido visible. No hubo cambios productivos. - [ ] Solicitar autorizacion separada antes de aprobar o indexar y una confirmacion adicional antes de activar. +#### Plan ejecutable + +Antes de ejecutar este plan, leer `SISTEMA_RAG_BASE.md`, esta seccion y el bloque `Revision de FacturaTech` de `CONTRATO_CICLO_VIDA_Y_OCR.md`. No desplegar, aprobar, indexar ni activar sin la autorizacion correspondiente. + +##### A. Diagnostico de las confusiones `o/0` - completado + +Este diagnostico se completo sin modificar codigo, configuracion, modelos, preprocesado, reconstruccion ni postproceso. + +1. [x] Revalidar las referencias exactas: `FAT07` en pagina 8, `NSAV06` en pagina 15 y `DSAU04` en pagina 16; mantener separadas estas confusiones de precision de las advertencias `LOW_P10_CONFIDENCE` de las paginas 2, 20 y 21. +2. [x] Inventariar la evidencia durable ya existente para las paginas 8, 15 y 16: imagenes, resultado OCR, lineas, bounding boxes, confianzas, manifiestos, hashes, candidata y configuracion efectiva. +3. [x] Mapear el recorrido exacto del dato entre PDF, render a 200 DPI, entrada del reconocedor, adaptador de PaddleOCR, respuesta API, artefacto durable de RAG, reconstruccion de lineas y candidata. +4. [x] Construir una linea base por pagina e identificar la primera frontera observable de cada diferencia. +5. [x] Confirmar que los artefactos productivos existentes bastan para localizar la causa. +6. [x] Descartar la repeticion focalizada: no fue necesaria porque la evidencia durable resulto suficiente. +7. [x] Comparar imagen, respuesta OCR, artefactos RAG y candidata y clasificar la causa. +8. [x] Registrar confianzas, limites de reproducibilidad y efectos sobre el quality gate. +9. [x] Redactar el informe de diagnostico. +10. [x] Presentar el informe y obtener la aceptacion del usuario. + +**Resultado:** los PNG verificados muestran `FAT07`, `NSAV06` y `DSAU04`; `ocr-result.json` ya contiene `FATo7`, `NSAvo6` y `DSAUo4`, con confianzas de linea `0.9775`, `0.9903` y `0.9808`. `candidate-pages.json` conserva exactamente esos valores. La primera frontera observable es imagen correcta -> reconocimiento PaddleOCR incorrecto. RAG, la reconstruccion y la composicion no sustituyen caracteres. + +**Decision de aceptacion:** para la fase actual se acepta el OCR como servicio best-effort autonomo. Estos errores semanticos puntuales de alta confianza son una limitacion conocida, no un bloqueo tecnico. La ingesta sera orquestada por un agente y no dependera de una GUI ni de una persona corrigiendo cada documento. Esto no convierte el texto OCR en exacto ni elimina las correcciones y autorizaciones requeridas para una candidata concreta. + +##### B. Mejora futura de precision OCR - no bloqueante + +Este bloque queda diferido. No forma parte del cierre operativo actual y solo se reabrira con una decision explicita. + +- [ ] Comparar sobre la linea base alternativas acotadas y generales, por ejemplo resolucion o preprocesado de imagen, configuracion del reconocedor o tratamiento de caracteres ambiguos respaldado por confianza y evidencia visual. +- [ ] Elegir la intervencion minima que actue en la etapa donde se demostro la causa. Documentar por que se descartan las demas alternativas y su impacto esperado en tiempo, memoria y compatibilidad. +- [ ] Implementar la mejora sin reglas especificas para FacturaTech ni sustituciones silenciosas como cambiar toda `o` por `0`. +- [ ] Conservar identidad, hashes, orden de lineas, bounding boxes, metricas, idempotencia y contrato `ocr-v2`; si la configuracion efectiva cambia semanticamente, versionarla y no reutilizar trabajos incompatibles. +- [ ] Mantener separadas la mejora general del OCR y las correcciones exactas que requiere la candidata v8. La implementacion no modifica por si sola la candidata ya creada. + +##### C. Validacion futura de mejoras de precision - no bloqueante + +Este bloque se ejecutara solo si se implementa el bloque B. + +- [ ] Crear fixtures no sensibles que reproduzcan caracteres ambiguos en codigos y texto normal, con casos donde `o`, `O`, `0`, `u` y `U` sean correctos y deban conservarse. +- [ ] Ejecutar pruebas unitarias y de integracion sobre renderizado, reconocimiento, reconstruccion y resultado API segun la etapa modificada. +- [ ] Repetir, con autorizacion y fuera del repositorio, el OCR real exclusivamente para las paginas `[8, 15, 16]`; exigir `FAT07`, `NSAV06` y `DSAU04` exactos y confirmar que no se degradan el resto de sus lineas, posiciones ni metricas relevantes. +- [ ] Comparar antes y despues con evidencia estructurada. La mejora no pasa si solo corrige los tres tokens mediante una regla particular, introduce falsos cambios `o/0` o `u/U`, pierde contenido o rompe identidades y hashes. +- [ ] Medir tiempo y memoria de las tres paginas y ejecutar la suite Python completa. Si la alternativa aumenta materialmente el coste, justificarlo antes de integrarla. + +##### D. Seguimiento rapido RAG -> OCR - siguiente bloque + +- [x] Disenar un scheduler durable en RAG que use `next_attempt_at` como reloj de los trabajos OCR pendientes y mantenga un unico temporizador para el vencimiento mas cercano. +- [x] Al despertar, reclamar trabajos vencidos mediante PostgreSQL y `FOR UPDATE SKIP LOCKED`, consultar el estado remoto y reprogramar solo `queued` o `running` con backoff de 2 a 15 segundos. +- [x] Finalizar y dejar de seguir inmediatamente los trabajos `succeeded` o `failed`; conservar la transferencia durable, validacion de identidad y composicion existentes. +- [x] Reconstruir el scheduler al arrancar RAG y mantener `dispatchAvailable()` idempotente ante llamadas simultaneas. +- [x] Mantener el reconciliador de cinco minutos como red de seguridad global; no convertirlo en el mecanismo normal de latencia ni detenerlo cuando termine un trabajo. +- [x] Eliminar `pollUntilTerminal()` y su prueba especifica: el scheduler no usa un bucle en memoria sin limite, persistencia ni renovacion de lease. El dispatcher conserva las consultas discretas y persiste el backoff mediante `next_attempt_at`. +- [x] Retirar `OCR_PAGE_TIMEOUT_MS` y `OCR_TOTAL_TIMEOUT_MS`: eran configuracion no consumida y no representaban garantias reales del runtime RAG. + +##### E. Validacion local conjunta + +- [x] Probar que un trabajo que termina antes de cinco minutos se recupera en el siguiente intervalo corto sin esperar al reconciliador. +- [x] Probar secuencias `queued -> running -> succeeded` y `queued -> failed`, y que no quedan temporizadores activos para trabajos terminales. +- [x] Probar reinicio de RAG entre consultas: `next_attempt_at`, `remoteJobId` e idempotency key permiten continuar sin repetir PaddleOCR. +- [x] Probar dos dispatchers concurrentes y confirmar que `SKIP LOCKED`, leases y `activeDrain` evitan doble procesamiento. +- [x] Probar caida durante resultado, imagenes, informe y candidata; el reconciliador de cinco minutos sigue recuperando exactamente una vez. +- [x] Ejecutar una ingesta local end-to-end con un PDF controlado que combine paginas nativas y paginas OCR. La ejecucion fresca `94f1af75-b6b0-4fb1-8a93-f0f5c0c5f0a0` alcanzo `review_required` en 7 segundos: pagina 1 nativa, pagina 2 OCR, `2/2`, quality report y candidata durables, revision autenticada e imagen protegida correctas, `activateRequested=false` y cola OCR vacia. La prueba descubrio y corrigio el doble conteo de paginas OCR clasificadas como blank y la exigencia incorrecta de imagen OCR para paginas nativas. +- [x] Ejecutar suites Node/Python, regresiones focalizadas, chequeos de tipos, build de RAG y OCR, health de contenedores, validacion de imagenes y `git diff --check` antes de publicar. + +Validacion completada: suite Node `121/121`, suite OCR `26/26`, regresiones del scheduler y reconciliador, `npm run check`, build RAG, PostgreSQL local con cuatro migraciones, builds finales de ambas imagenes `0.2.1`, health OCR con modelo, worker, sweeper y almacenamiento preparados, imagen de revision correcta y `git diff --check` superado. La revision final corrigio el `documentId` de las correcciones del playground, la nulabilidad OpenAPI 3.1 y las incoherencias de contrato y rollback. No existe Qdrant local: Qdrant y el health RAG completo se verifican exclusivamente en el gate productivo posterior al despliegue autorizado. + +##### F. Publicacion y despliegue conjunto + +- [x] Preparar una unidad versionada para el scheduler durable y la operacion autonoma, revisar el diff y registrar codigo, pruebas, contrato, operativa y rollback. +- [x] Solicitar y recibir autorizacion para commit/push. El despliegue conserva una autorizacion explicita posterior y separada. +- [ ] Publicar y desplegar RAG/OCR juntos segun la regla operativa vigente, con OCR habilitado y version, revision, etiquetas y digests coincidentes. +- [ ] Verificar migraciones esperadas, health, scheduler rapido, reconciliador de cinco minutos, cola OCR, almacenamiento, PostgreSQL y Qdrant. Ante fallo, revertir RAG y OCR como una unidad y conservar intacta la version activa. + +##### G. Validacion productiva sin activar contenido + +- [ ] Ejecutar una ingesta productiva controlada y no activa para validar el circuito RAG -> OCR -> RAG, incluyendo que el scheduler recupera el resultado en el intervalo corto sin esperar cinco minutos. +- [ ] Verificar seleccion de paginas, composicion, informe de calidad, candidata revisable, ausencia de activacion y continuidad del reconciliador como fallback. +- [ ] Confirmar que cualquier error semantico residual conserva imagen, texto, lineas, bounding boxes, confianzas, hashes y trazabilidad suficientes para revision posterior; no exigir exactitud textual total al OCR best-effort. +- [ ] Registrar IDs, estados, tiempos, hashes, versiones, revisiones y digests sin incorporar contenido del cliente al repositorio. + +##### H. Cierre de FacturaTech v8 + +- [ ] Revalidar antes de aprobar que v8 sigue `review_required`, que no cambio su `candidateSha256`, que `activateRequested=false` y que la activa coincide con `baseActiveVersionId`. +- [ ] Preparar el payload exacto de correcciones por linea, incluyendo hashes vigentes, sin aplicarlo todavia. +- [ ] Solicitar autorizacion explicita para aprobar e indexar v8 con las correcciones verificadas. +- [ ] Tras la autorizacion, aprobar, generar el artefacto revisado, crear chunks y embeddings, escribir Qdrant y verificar estado `ready` con recuentos correctos. +- [ ] Solicitar una segunda autorizacion para activar v8. +- [ ] Tras la autorizacion, activar, verificar retrieval real y conservar rollback hacia `3fc78163-9cfb-4979-985c-1520a63327b0`. + +##### I. Criterio de servicio completamente operativo + +- [ ] Confirmar en produccion que el PDF controlado hibrido no envia las paginas nativas, envia solo las visuales y compone un documento unico sin duplicar texto dentro de una pagina. +- [ ] Confirmar que las limitaciones de precision conocidas quedan trazables, el scheduler corto funciona tras reinicio, los leases evitan dobles procesamientos y el reconciliador recupera fallos residuales. +- [ ] Confirmar health, observabilidad, almacenamiento, cola, PostgreSQL, Qdrant, revision no activa y rollback documentado sin incidencias abiertas de severidad bloqueante. +- [ ] Declarar cerrado el trabajo tecnico solo cuando toda la evidencia anterior este registrada. Este cierre significa que el servicio RAG-OCR esta funcionando completamente; no implica por si mismo aprobar, indexar ni activar FacturaTech v8. + +Las advertencias `LOW_P10_CONFIDENCE` de las paginas 2, 20 y 21 no son un defecto de comunicacion ni un bloqueo pendiente de codigo. Son senales de revision. Las confusiones puntuales `o/0` de las paginas 8, 15 y 16 son una limitacion aceptada del reconocimiento best-effort y deben corregirse explicitamente en la revision de v8, nunca mediante una autocorreccion silenciosa. Una futura consola podra incorporar revision humana y un revisor visual por IA podra proponer correcciones con imagen y contexto; ambos quedan fuera de esta fase. + Si aparece un fallo real cuyos sintomas justifiquen aislar servicios, valorar entonces `OCR_INGEST_ENABLED=false` y el despliegue por etapas. No aplicar ese modo preventivamente. **Regla de despliegue:** R1-R4 no despliegan ni prueban produccion. Solo R5 puede desplegar; RAG y OCR se despliegan juntos con `OCR_INGEST_ENABLED=true` y la entrega no se considera completada hasta que ambos coincidan en version/revision y superen juntos la bateria de salud. No se desactiva OCR ni se despliega por etapas de forma preventiva: ese modo queda reservado para un fallo real cuyos sintomas justifiquen aislar un servicio. `BUILD_REVISION` sigue manual para esta entrega y se contrasta con el commit; los digests son la identidad exacta. No se introduce CI, registry ni automatizacion de EasyPanel sin aprobacion separada. **Regla PostgreSQL de R4:** no usar la base configurada en EasyPanel ni cargar los ficheros locales que contienen sus secretos. Usar un contenedor local aislado y reutilizable, pasar una URL local solo al proceso de prueba y comprobar la secuencia de migraciones. El contenedor conserva su volumen para futuras pruebas, no tiene reinicio automatico y debe quedar detenido al terminar para que PostgreSQL no consuma CPU ni RAM activa. No apagar ni limpiar Docker globalmente. El runner aplica todas las migraciones pendientes. La aplicacion de `004` en produccion pertenece a R5 y necesita autorizacion explicita independiente. -**Evidencia:** las pruebas automatizadas deben ser autocontenidas. La comparacion con el documento manual de FacturaTech es evidencia externa y se registra como tal, separada del PASS automatizado. R5 debe dejar IDs, estados, recuentos, hashes, versiones, revisiones y digests suficientes para auditar la aceptacion sin depender de memoria conversacional. +**Evidencia:** las pruebas automatizadas deben ser autocontenidas. La comparacion con el documento manual de FacturaTech es evidencia externa producida por el agente mediante un modelo visual independiente y se registra separada del PASS automatizado. R5 debe dejar IDs, estados, recuentos, hashes, versiones, revisiones y digests suficientes para auditar la aceptacion sin depender de memoria conversacional. El SDD `ocr-ingest-integration` se archiva con 29/30 tareas completas y 7.4 incompleta. La continuidad del hardening y de la aceptacion se controla mediante el contrato ODD canónico, sin declarar retrospectivamente superada la aceptacion fallida. -**Salida:** `0.2.1` supera los criterios del anexo, FacturaTech alcanza `review_required` con evidencia completa y solo se aprueba o activa con autorizacion explicita. +**Salida anterior:** `0.2.1` supero los criterios del anexo y FacturaTech alcanzo `review_required` con evidencia completa. El nuevo bloque A-I solo se cierra cuando cumple el criterio de servicio completamente operativo; FacturaTech v8 solo se aprueba o activa con sus autorizaciones explicitas separadas. ## 1. Documentacion y descubrimiento de la API diff --git a/docs/SISTEMA_RAG_BASE.md b/docs/SISTEMA_RAG_BASE.md index 691ab5b..27b9c8c 100644 --- a/docs/SISTEMA_RAG_BASE.md +++ b/docs/SISTEMA_RAG_BASE.md @@ -2,9 +2,9 @@ **Proyecto:** Workspace de tools IA para empresas **Modulo:** RAG -**Ultima actualizacion:** 2026-09-17 -**Ultima modificacion por:** Agente RAG 2 -**Estado:** RAG y OCR desplegados; pendientes correcciones de revision OCR y versionado verificable de servicios +**Ultima actualizacion:** 2026-09-23 +**Ultima modificacion por:** Agente RAG 3 +**Estado:** RAG y OCR `0.2.1` desplegados; candidata FacturaTech v8 revisada y pendiente de aprobacion, indexacion y activacion --- @@ -87,59 +87,278 @@ Si el sistema esta bien planteado, deberiamos poder usarlo como una capa de cont --- -## Estado actual de la v1 +## Resumen operativo -La v1 ya ha alcanzado un estado operativo real. +El RAG funciona de forma autonoma. OCR es una extension opcional que solo interviene cuando RAG determina que una o mas paginas de un PDF no tienen texto nativo suficiente. -Estado confirmado: -- desplegada en `VPS2` -- dominio activo: `https://rag.por-correo.com` -- conexion operativa con `Qdrant` -- soporte funcional para `documental` y `codigo` -- API HTTP operativa con ingesta, upload, cleanup, retrieval, answer, chat, catalogo de fuentes/modelos y logs de evaluacion -- playground web operativo en `/playground` -- contrato OpenAPI y ayuda de descubrimiento operativos en produccion +```text +Fuente + -> ingesta y versionado + -> extraccion de texto + -> [OCR opcional por pagina] + -> composicion del documento + -> revision si intervino OCR + -> chunks y embeddings + -> Qdrant + -> activacion + -> retrieve / answer +``` -Pruebas funcionales ya superadas: -- consulta documental sobre pendientes del workspace -- consulta conceptual sobre caracteristicas del RAG -- consulta tecnica en modo codigo sobre la construccion de `source_id` +Si todo el contenido puede leerse de forma nativa, el servicio OCR no participa y la ingesta sigue el flujo RAG normal. -### Ciclo de vida del conocimiento +## Componentes y responsabilidades -Cada fuente dispone de versiones controladas en PostgreSQL, mientras Qdrant conserva sus chunks y embeddings. Solo la version activa participa en las consultas. +| Componente | Responsabilidad | +|---|---| +| API RAG | Recibe ingestas y consultas; expone estado, revision, activacion y recuperacion. | +| Servicio de ingesta | Lee fuentes, identifica documentos, calcula hashes y prepara versiones. | +| Procesado | Normaliza contenido, conserva estructura y genera chunks. | +| Proveedor de embeddings | Convierte chunks en vectores. | +| PostgreSQL | Conserva fuentes, versiones, estados, trabajos OCR, hashes, diagnosticos y decisiones de revision. | +| Qdrant | Conserva chunks y embeddings versionados. Solo la version activa participa en retrieval. | +| Reconciliador | Recupera trabajo interrumpido y verifica invariantes entre PostgreSQL, Qdrant y artefactos. | +| Servicio OCR | Procesa paginas rasterizadas o con texto nativo insuficiente. No indexa ni activa contenido. | +| Almacenamiento RAG | Conserva originales y evidencias durables bajo `/data/ingestions`. | +| Almacenamiento OCR | Conserva temporalmente trabajos bajo `/data/jobs` hasta que RAG confirma la transferencia. | -Esto permite actualizar una fuente sin mezclar contenido antiguo y nuevo, detectar ingestas identicas, conservar versiones anteriores y hacer rollback sin recalcular embeddings. Los parametros de consulta actuales no cambian. +## Funcionamiento interno del RAG sin OCR -Esta mejora esta implementada y desplegada. Falta preparar PostgreSQL, registrar el corpus existente como version inicial y validarla antes de activar enforcement en produccion. +### 1. Recepcion e identidad -Los pendientes vigentes, su prioridad y su estado se mantienen en [`PENDIENTES_RAG.md`](./PENDIENTES_RAG.md). +RAG recibe una fuente mediante ingesta directa o upload. La fuente tiene una identidad logica estable y cada intento genera o reutiliza una version controlada. -### Flujo RAG y OCR +Antes de indexar, RAG: -RAG y OCR son servicios separados con responsabilidades distintas: +1. valida tipo, tamano y parametros; +2. lee los documentos; +3. calcula hashes del original y de la configuracion de procesamiento; +4. registra fuente, intento y version en PostgreSQL; +5. evita duplicar una version equivalente cuando la identidad ya existe. -- RAG recibe documentos compatibles, extrae texto nativo, conserva sus versiones y convierte el texto aprobado en conocimiento consultable. -- OCR recibe un PDF y una lista cerrada de paginas, procesa solo esas paginas y devuelve texto, lineas, posiciones y metricas de calidad. +### 2. Extraccion nativa -Para un PDF, RAG revisa primero todas sus paginas. Usa OCR cuando el texto nativo de una pagina es insuficiente o cuando su contenido rasterizado supera el umbral configurado. Reune todas las paginas seleccionadas y las envia en un solo trabajo OCR; no procesa una pagina, reinicia el documento y continua en ciclos. +Cada tipo de documento usa su lector correspondiente. En un PDF, la extraccion conserva la separacion por paginas; no divide artificialmente un texto agregado para simularlas. -El resultado OCR queda como evidencia revisable. RAG conserva la responsabilidad de revision humana, indexacion, versionado y activacion. OCR no escribe en PostgreSQL ni en Qdrant, ni decide que contenido se activa. +Si todas las paginas contienen texto nativo suficiente, el flujo sigue sin contactar OCR. -Actualmente OCR procesa PDFs. La admision directa de imagenes JPG y PNG es una mejora futura registrada en [`PENDIENTES_RAG.md`](./PENDIENTES_RAG.md). El detalle de contratos, umbrales y estados esta en [`CONTRATO_CICLO_VIDA_Y_OCR.md`](./CONTRATO_CICLO_VIDA_Y_OCR.md). +### 3. Normalizacion y composicion ---- +El texto extraido se normaliza para obtener hashes reproducibles y una representacion estable. Se conserva el orden de documentos y paginas, junto con metadatos como titulo, tipo MIME, origen, etiquetas y version. -## Alcance de este documento +### 4. Chunking -Este documento define el que y el para que del sistema RAG base. +El modo `documental` divide por estructura natural, parrafos y limites de tamano. El modo `codigo` prioriza bloques top-level y conserva nombres y rangos de lineas cuando estan disponibles. -En documentos posteriores de esta carpeta se podran detallar, por separado: +Cada chunk queda ligado a: -- arquitectura propuesta -- flujo de ingesta -- estrategia de chunking -- embeddings y almacenamiento -- interfaz de consulta -- pruebas con `docs/` -- criterios de evaluacion de resultados +- fuente; +- version; +- documento; +- posicion dentro del documento; +- politica de procesamiento; +- modelo y dimensiones de embeddings. + +### 5. Embeddings e indexacion + +RAG solicita embeddings para todos los chunks y escribe puntos versionados en Qdrant. Antes de dar la version por preparada, compara el numero esperado de chunks con el numero almacenado. + +PostgreSQL es la autoridad del ciclo de vida; Qdrant es el indice vectorial. Una escritura parcial no convierte una version en activa. + +### 6. Versiones y activacion + +Una fuente puede tener varias versiones, pero solo una activa. Los estados principales son: + +| Estado | Significado | +|---|---| +| `pending` | Version creada, aun no preparada. | +| `indexing` | Procesamiento o escritura en curso. | +| `ready` | Indexada y verificada, pero no activa. | +| `active` | Version usada por retrieval. | +| `failed` | El intento termino sin sustituir la activa. | +| `rejected` | La candidata fue rechazada durante revision. | +| `purging` / `purged` | Eliminacion controlada de una version no activa. | + +La activacion exige una precondicion sobre la version activa esperada. Esto evita sobrescribir silenciosamente una version que cambio durante el procesamiento. + +### 7. Recuperacion y respuesta + +`retrieve` consulta exclusivamente versiones activas y devuelve chunks trazables. `answer` usa `retrieve`, construye contexto con citas y pide al modelo de respuesta que conteste apoyandose en ese contexto. + +OCR no participa en consultas. Su trabajo termina antes de indexar el conocimiento. + +## Cuando entra OCR + +OCR se decide por pagina, no por documento completo. RAG mide el texto nativo y la cobertura rasterizada. Una pagina necesita OCR cuando no cumple simultaneamente los umbrales nativos: + +```text +caracteres no blancos >= 120 +caracteres alfanumericos >= 80 +palabras o tokens >= 20 +proporcion de caracteres invalidos <= 0.01 +``` + +Aunque cumpla esos umbrales, tambien se selecciona cuando la cobertura rasterizada es `>= 0.05`, porque puede contener informacion visual que la capa de texto no representa. + +RAG agrupa las paginas seleccionadas de cada PDF en un trabajo OCR. Las paginas nativas suficientes no se envian. + +## Flujo interno RAG -> OCR -> RAG + +### 1. Preparacion durable + +Antes de contactar OCR, RAG guarda el original y su manifiesto bajo `/data/ingestions`, crea la version y persiste el trabajo local con: + +- documento y paginas solicitadas; +- hash del PDF; +- configuracion `ocr-v2`; +- clave idempotente; +- version y documento propietarios del trabajo. + +La API responde `202 Accepted` con `versionId`, `statusUrl` y fase `ocr_queued`. El cliente no mantiene abierta la peticion durante el procesamiento. + +### 2. Envio al servicio OCR + +RAG envia al endpoint privado `POST /v1/jobs`: + +- PDF original; +- lista cerrada y ordenada de paginas; +- hashes e identidad de configuracion; +- clave de idempotencia. + +OCR responde con un `jobId` y estado `queued`, `running`, `succeeded` o `failed`. Repetir la misma solicitud reutiliza el mismo trabajo compatible. + +### 3. Procesamiento OCR + +OCR tiene cola durable SQLite, un worker y concurrencia uno. Renderiza cada pagina una sola vez a PNG; ese mismo render alimenta PaddleOCR y se conserva temporalmente como imagen de revision. + +OCR produce por pagina: + +- texto; +- lineas; +- posiciones; +- confianza; +- metricas de cobertura y calidad. + +OCR no llama actualmente a RAG cuando termina. RAG consulta el estado mediante `GET /v1/jobs/:jobId`. + +### 4. Recuperacion del resultado + +Cuando el trabajo llega a `succeeded`, RAG: + +1. descarga y valida el resultado estructurado; +2. descarga secuencialmente las imagenes requeridas; +3. valida identidad, pagina, configuracion y SHA-256; +4. publica resultado e imagenes en almacenamiento durable; +5. relee los artefactos publicados antes de marcarlos como transferidos; +6. elimina el trabajo remoto solo despues de completar la evidencia local. + +Si RAG cae, el resultado remoto se reutiliza con la misma identidad. No se repite PaddleOCR mientras el trabajo y sus artefactos sigan disponibles. + +### 5. Composicion de paginas nativas y OCR + +RAG elige un metodo por pagina: + +| Tipo de pagina | Texto usado en la candidata | +|---|---| +| Nativa suficiente | Texto extraido directamente del PDF. | +| Requiere OCR | Lineas devueltas por OCR, ordenadas por posicion. | +| Vacia verificada | Cadena vacia con diagnostico de pagina vacia. | + +No mezcla texto nativo y OCR dentro de la misma pagina, porque produciria duplicados. Si una pagina necesita OCR, el texto OCR sustituye al texto nativo insuficiente de esa pagina. Despues RAG ordena todas las paginas y compone un unico documento. + +### 6. Calidad y candidata revisable + +RAG clasifica todas las paginas antes de decidir el estado: + +- `accepted`: calidad suficiente; +- `warning`: revisable, pero no bloqueante; +- `blocked`: no permite crear candidata. + +Una candidata valida pasa a `review_required`. En ese estado todavia no tiene chunks activos ni participa en respuestas. + +La revision puede proponer correcciones exactas por linea. Aprobarla genera un artefacto revisado inmutable y transiciona a `indexing`; entonces se crean chunks, embeddings y puntos de Qdrant. Con `activateRequested=false`, termina en `ready`. La activacion es una operacion posterior y separada. + +## Seguimiento, temporizadores y recuperacion + +### Comportamiento de la siguiente entrega + +Este comportamiento esta implementado y validado en el worktree local, pero todavia no esta publicado ni desplegado. Produccion conserva la revision `0654ce5` hasta completar los gates separados de commit/push y despliegue. + +Al aceptar una ingesta OCR, RAG intenta despacharla inmediatamente. Si OCR sigue en `queued` o `running`, el dispatcher guarda `next_attempt_at` con un backoff entre 2 y 15 segundos. + +El dispatcher consulta entonces el menor `next_attempt_at` pendiente y mantiene un unico temporizador hasta ese vencimiento. Al despertar reclama mediante PostgreSQL solo los trabajos vencidos; `FOR UPDATE SKIP LOCKED`, los leases y `activeDrain` evitan doble procesamiento entre instancias o llamadas simultaneas. Los estados terminales no conservan temporizador. + +Al arrancar RAG, el scheduler ejecuta un drain y reconstruye el siguiente despertar desde PostgreSQL. `remoteJobId` e idempotency key permanecen durables, por lo que un reinicio entre consultas continua el trabajo remoto existente sin repetir PaddleOCR. + +El reconciliador general conserva su intervalo independiente: + +```text +LIFECYCLE_RECONCILE_INTERVAL_MS=300000 +``` + +Ese ciclo ya no es el mecanismo normal de latencia. Permanece como red de seguridad para leases vencidos, caidas, candidatas incompletas, retencion e invariantes PostgreSQL/Qdrant. + +Se eliminaron `pollUntilTerminal()`, `OCR_PAGE_TIMEOUT_MS` y `OCR_TOTAL_TIMEOUT_MS`: no se mantiene un bucle de polling en memoria ni configuracion que aparente imponer timeouts que RAG no aplica. + +### Algoritmo durable + +El scheduler: + +1. consulte el proximo `next_attempt_at`; +2. programe un temporizador hasta ese instante; +3. reclame trabajos vencidos con lock de PostgreSQL; +4. vuelva a consultar OCR; +5. reprograme solo los trabajos pendientes; +6. deje de seguir un trabajo cuando termine; +7. reconstruya su plan al reiniciar. + +El reconciliador de cinco minutos no se elimina ni se detiene cuando termina un trabajo. Aunque no haya trabajos OCR, sigue ejecutandose y normalmente no encuentra nada que recuperar. + +No se recomienda introducir primero un callback OCR -> RAG. Un callback exige otro endpoint interno, autenticacion, reintentos e idempotencia inversa. El scheduler pull durable mejora la latencia conservando el aislamiento actual. + +## Modelo de revision por fases + +En la fase actual, el OCR es un servicio best-effort y la ingesta se orquesta mediante un agente. El flujo debe poder terminar sin una GUI ni una persona esperando para corregir cada documento. Los quality gates detectan fallos estructurales y señales estadisticas, pero no garantizan exactitud semantica: un reconocedor puede devolver un caracter incorrecto con confianza alta. + +La aceptacion operativa exige conservar imagen, texto, lineas, bounding boxes, confianzas, hashes y estado revisable. No exige que todo texto OCR sea literalmente perfecto. El agente puede inspeccionar esa evidencia y usar el contrato de revision existente; aprobar, indexar y activar contenido siguen siendo operaciones separadas sujetas a sus autorizaciones. + +Una consola futura podra presentar el mismo contrato a una persona antes de guardar contenido en RAG. Tambien podra incorporarse un modelo visual como segundo revisor para casos ambiguos, usando el recorte de imagen, el texto y su contexto. Ninguno de esos componentes forma parte del cierre de la fase actual y nunca debe introducir sustituciones silenciosas. + +En documentos hibridos, `imageUrl` es `null` para paginas nativas y solo referencia una imagen protegida para paginas enviadas a OCR. La revision valida imagenes exclusivamente en esas paginas; exigir una imagen OCR para contenido nativo haria fallar una candidata integra. + +## Fallos y garantias + +| Situacion | Conducta esperada | +|---|---| +| Reenvio del mismo PDF y configuracion | Reutiliza identidad y trabajo compatible. | +| Reinicio de RAG | Recupera trabajos y finalizacion desde PostgreSQL y artefactos. | +| Reinicio de OCR | Recupera una vez el trabajo mediante lease; una segunda interrupcion falla cerrado. | +| Respuesta con identidad distinta | `OCR_RESPONSE_INTEGRITY_FAILED`; no publica contenido. | +| Calidad bloqueante | Version `failed`; activa anterior intacta. | +| Advertencia de calidad | Candidata `review_required`; exige revision. | +| Fallo durante indexacion | Version no activa; reconciliador valida y recupera o falla. | +| Cambio concurrente de activa | No activa silenciosamente; exige nueva precondicion. | + +## Estado productivo verificado + +- RAG y OCR `0.2.1` estan desplegados desde la misma revision. +- PostgreSQL, Qdrant, OCR y reconciliador estan sanos. +- FacturaTech v8 completo OCR en 25/25 paginas. +- RAG recupero resultado, 25 imagenes, diagnostico y candidata durable. +- La candidata conserva 34/34 entradas y esta pendiente de aprobar correcciones, indexar y activar. +- La validacion local hibrida posterior completo una pagina nativa y una OCR hasta `review_required` en 7 segundos, sin activacion y sin esperar al reconciliador de cinco minutos. +- Las paginas 2, 20 y 21 tienen `LOW_P10_CONFIDENCE` no bloqueante. +- Las correcciones visuales conocidas incluyen `FATo7` -> `FAT07`, `NSAvo6` -> `NSAV06` y `DSAUo4` -> `DSAU04`. +- FacturaTech valida el camino OCR completo, pero no demuestra en produccion un PDF hibrido: sus 25 paginas necesitaron OCR. La composicion mixta esta cubierta localmente y queda pendiente una demostracion productiva controlada. + +## Fuentes de detalle + +| Necesidad | Documento | +|---|---| +| Flujo de entrada e identidades | [`INGESTA.md`](./INGESTA.md) | +| Normalizacion y chunking | [`PROCESADO.md`](./PROCESADO.md) | +| Retrieval y respuestas | [`SALIDA.md`](./SALIDA.md) | +| Estados, umbrales y contrato OCR | [`CONTRATO_CICLO_VIDA_Y_OCR.md`](./CONTRATO_CICLO_VIDA_Y_OCR.md) | +| Operacion de produccion | [`OPERATIVA.md`](./OPERATIVA.md) | +| Orden de trabajo pendiente | [`PENDIENTES_RAG.md`](./PENDIENTES_RAG.md) | + +Este documento explica el funcionamiento general vigente. El contrato conserva la autoridad sobre invariantes y criterios de aceptacion; la operativa conserva la autoridad sobre el entorno desplegado. diff --git a/public/playground/app.js b/public/playground/app.js index 7316de6..5506eac 100644 --- a/public/playground/app.js +++ b/public/playground/app.js @@ -214,11 +214,11 @@ async function renderReview(candidate) { const identity = document.createElement("p"); identity.textContent = `Candidate SHA-256: ${candidate.candidateSha256} · Base active version: ${candidate.baseActiveVersionId || "none"}`; reviewCandidate.append(identity); - for (const document of candidate.documents) for (const page of document.pages) { + for (const candidateDocument of candidate.documents) for (const page of candidateDocument.pages) { const card = document.createElement("article"); card.className = "review-page"; const title = document.createElement("h3"); - title.textContent = `${document.documentId} · Page ${page.page}`; + title.textContent = `${candidateDocument.documentId} · Page ${page.page}`; const image = document.createElement("img"); image.className = "review-image"; image.alt = `Source page ${page.page}`; @@ -230,11 +230,15 @@ async function renderReview(candidate) { detail.textContent = `${line.lineId} · confidence ${line.confidence} · bbox ${line.bbox.join(", ")}`; const input = document.createElement("textarea"); input.value = line.text; - Object.assign(input.dataset, { original: line.text, documentId: document.documentId, page: String(page.page), lineId: line.lineId, expectedLineSha256: line.lineSha256 }); + Object.assign(input.dataset, { original: line.text, documentId: candidateDocument.documentId, page: String(page.page), lineId: line.lineId, expectedLineSha256: line.lineSha256 }); label.append(detail, input); card.append(label); } reviewCandidate.append(card); + if (!page.imageUrl) { + image.remove(); + continue; + } void fetch(page.imageUrl, { headers: { Authorization: `Bearer ${reviewToken.value}` } }).then(async (response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); const url = URL.createObjectURL(await response.blob()); diff --git a/src/api/openapi.ts b/src/api/openapi.ts index bb5a54b..7662035 100644 --- a/src/api/openapi.ts +++ b/src/api/openapi.ts @@ -635,7 +635,7 @@ export const openApiDocument = { OcrReviewDocument: { type: "object", required: ["documentId", "pages"], properties: { documentId: { type: "string" }, pages: { type: "array", items: ref("OcrReviewPage") } } }, OcrReviewPage: { type: "object", required: ["page", "imageUrl", "nativeText", "ocr", "candidateText", "differences", "risks"], - properties: { page: { type: "integer" }, imageUrl: { type: "string" }, nativeText: { type: "string" }, ocr: { type: "object", required: ["text", "lines"], properties: { text: { type: "string" }, lines: { type: "array", items: ref("OcrLine") } } }, candidateText: { type: "string" }, differences: { type: "array", items: { type: "string" } }, risks: { type: "array", items: { type: "string" } } } + properties: { page: { type: "integer" }, imageUrl: { type: ["string", "null"] }, nativeText: { type: "string" }, ocr: { type: "object", required: ["text", "lines"], properties: { text: { type: "string" }, lines: { type: "array", items: ref("OcrLine") } } }, candidateText: { type: "string" }, differences: { type: "array", items: { type: "string" } }, risks: { type: "array", items: { type: "string" } } } }, OcrCorrection: { type: "object", required: ["documentId", "page", "lineId", "expectedLineSha256", "replacementText"], diff --git a/src/app.ts b/src/app.ts index c13cf62..5cb9e18 100644 --- a/src/app.ts +++ b/src/app.ts @@ -109,7 +109,10 @@ export function createApp(options: AppOptions = {}) { const indexingService = options.indexingService ?? (catalogPool ? new OcrReadyIndexingService(new PostgresOcrIndexingStore(catalogPool, ocr.artifactRoot, embeddingProvider, vectorStore)) : undefined); - if (options.startReconciler !== false) reconciler.start(); + if (options.startReconciler !== false) { + ocrDispatcher?.start(); + reconciler.start(); + } function sendError(res: express.Response, error: unknown, fallback: string) { const upstreamUnavailable = error instanceof Error && /(ECONNREFUSED|ETIMEDOUT|timeout|connection|database|postgres|qdrant)/i.test(error.message); @@ -133,7 +136,9 @@ export function createApp(options: AppOptions = {}) { } function dispatchAcceptedOcr(result: Awaited>): void { - if ("phase" in result) void ocrDispatcher?.dispatchAvailable(); + if ("phase" in result) void ocrDispatcher?.dispatchAvailable().catch((error) => { + console.error("OCR accepted-job dispatch failed", error); + }); } function requireLifecycleAdmin(req: express.Request, res: express.Response): boolean { diff --git a/src/config/env.ts b/src/config/env.ts index cceac7a..d5ef8ae 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -61,7 +61,5 @@ export const env = { ocrInternalToken: process.env.OCR_INTERNAL_TOKEN ?? "", ocrArtifactRoot: process.env.OCR_ARTIFACT_ROOT ?? "/data/ingestions", ocrMaxUploadBytes: Number(process.env.OCR_MAX_UPLOAD_BYTES ?? 50 * 1024 * 1024), - ocrMaxPages: Number(process.env.OCR_MAX_PAGES ?? 100), - ocrPageTimeoutMs: Number(process.env.OCR_PAGE_TIMEOUT_MS ?? 60_000), - ocrTotalTimeoutMs: Number(process.env.OCR_TOTAL_TIMEOUT_MS ?? 15 * 60_000) + ocrMaxPages: Number(process.env.OCR_MAX_PAGES ?? 100) } as const; diff --git a/src/modules/catalog/repository.ts b/src/modules/catalog/repository.ts index 1a84f11..576a807 100644 --- a/src/modules/catalog/repository.ts +++ b/src/modules/catalog/repository.ts @@ -374,6 +374,17 @@ export class CatalogRepository { }); } + async getNextOcrAttemptAt(): Promise { + const result = await this.pool.query<{ next_attempt_at: Date | null }>( + `SELECT next_attempt_at FROM rag_ocr_jobs + WHERE state = 'queued' + ORDER BY next_attempt_at ASC NULLS FIRST + LIMIT 1` + ); + if (!result.rows[0]) return undefined; + return result.rows[0].next_attempt_at ?? new Date(); + } + async claimOcrJob(jobId: string, leaseMs: number): Promise { const result = await this.pool.query( `UPDATE rag_ocr_jobs @@ -562,7 +573,7 @@ export class CatalogRepository { return { documentId: document.document_id, state, - completedPages: documentPages.filter((page) => page.extraction_method !== "ocr" && !page.blocked_reason).length + completedPages: documentPages.filter((page) => page.extraction_method === "native" && !page.blocked_reason).length + Number(document.completed_pages ?? 0), totalPages: documentPages.length, pages: documentPages.map((page) => ({ diff --git a/src/modules/ocr/client.ts b/src/modules/ocr/client.ts index 2766433..d097c97 100644 --- a/src/modules/ocr/client.ts +++ b/src/modules/ocr/client.ts @@ -106,16 +106,6 @@ export class OcrClient { return value as unknown as OcrJobStatus; } - async pollUntilTerminal(jobId: string, identity: OcrIdentity): Promise { - let delay = 2_000; - while (true) { - const status = await this.getStatus(jobId, identity); - if (status.status === "succeeded" || status.status === "failed") return status; - await this.sleep(delay); - delay = Math.min(delay * 2, 15_000); - } - } - async getResult(jobId: string, identity: OcrIdentity): Promise { assertExpected({ documentSha256: identity.documentSha256, pages: identity.requestedPages }); const value = await this.requestJson(`/v1/jobs/${encodeURIComponent(jobId)}/result`, () => ({ headers: this.headers() })); diff --git a/src/modules/ocr/dispatcher.ts b/src/modules/ocr/dispatcher.ts index a241ab7..cb99f2f 100644 --- a/src/modules/ocr/dispatcher.ts +++ b/src/modules/ocr/dispatcher.ts @@ -3,6 +3,7 @@ import { buildOcrIdentity, OcrClientError, type OcrClient, type OcrResult } from export interface OcrDispatchStore { claimNextOcrJob(leaseMs: number): Promise; + getNextOcrAttemptAt(): Promise; claimOcrJob(jobId: string, leaseMs: number): Promise; recoverExpiredOcrLeases(): Promise; setOcrRemoteJob(jobId: string, remoteJobId: string, leaseMs: number): Promise; @@ -20,8 +21,23 @@ export type OcrDispatchResult = "idle" | "pending" | "succeeded" | "failed"; type PersistOcrResult = (job: OcrJobRow, result: OcrResult) => Promise; type FinalizeCandidate = (versionId: string) => Promise; +export interface OcrSchedulerClock { + now(): number; + setTimeout(callback: () => void, delayMs: number): NodeJS.Timeout; + clearTimeout(timer: NodeJS.Timeout): void; +} + +const systemClock: OcrSchedulerClock = { + now: Date.now, + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer) +}; + export class OcrDispatcher { private activeDrain: Promise | undefined; + private schedulerStarted = false; + private schedulerTimer: NodeJS.Timeout | undefined; + private scheduleGeneration = 0; constructor( private readonly store: OcrDispatchStore, @@ -29,7 +45,8 @@ export class OcrDispatcher { private readonly loadInput: (job: OcrJobRow) => Promise, private readonly persistResult: PersistOcrResult, private readonly finalizeCandidate: FinalizeCandidate, - private readonly leaseMs = 30_000 + private readonly leaseMs = 30_000, + private readonly schedulerClock = systemClock ) {} async runOnce(): Promise { @@ -143,11 +160,29 @@ export class OcrDispatcher { return recovered; } + start(): void { + if (this.schedulerStarted) return; + this.schedulerStarted = true; + void this.dispatchAvailable().catch((error) => { + console.error("OCR scheduler startup dispatch failed", error); + }); + } + + stop(): void { + this.schedulerStarted = false; + this.cancelScheduledWake(); + } + dispatchAvailable(): Promise { + this.cancelScheduledWake(); if (this.activeDrain) return this.activeDrain; const drain = this.drainAvailable(); this.activeDrain = drain; - const clear = () => { if (this.activeDrain === drain) this.activeDrain = undefined; }; + const clear = () => { + if (this.activeDrain !== drain) return; + this.activeDrain = undefined; + if (this.schedulerStarted) void this.scheduleNextAttempt(this.scheduleGeneration); + }; void drain.then(clear, clear); return drain; } @@ -158,6 +193,35 @@ export class OcrDispatcher { return processed; } + private cancelScheduledWake(): void { + this.scheduleGeneration += 1; + if (!this.schedulerTimer) return; + this.schedulerClock.clearTimeout(this.schedulerTimer); + this.schedulerTimer = undefined; + } + + private async scheduleNextAttempt(generation: number): Promise { + try { + const nextAttemptAt = await this.store.getNextOcrAttemptAt(); + if (!this.schedulerStarted || generation !== this.scheduleGeneration || !nextAttemptAt) return; + this.schedulerTimer = this.schedulerClock.setTimeout(() => { + this.schedulerTimer = undefined; + void this.dispatchAvailable().catch((error) => { + console.error("OCR scheduled dispatch failed", error); + }); + }, Math.max(0, nextAttemptAt.getTime() - this.schedulerClock.now())); + this.schedulerTimer.unref(); + } catch (error) { + if (!this.schedulerStarted || generation !== this.scheduleGeneration) return; + console.error("OCR scheduler could not read the next attempt", error); + this.schedulerTimer = this.schedulerClock.setTimeout(() => { + this.schedulerTimer = undefined; + void this.dispatchAvailable().catch(() => undefined); + }, 15_000); + this.schedulerTimer.unref(); + } + } + private async fail(job: OcrJobRow, code: string, detail: string): Promise { await this.store.failOcrJob(job.jobId, code, detail); await this.store.markFailed(job.versionId, code, detail); diff --git a/src/modules/ocr/review.ts b/src/modules/ocr/review.ts index dec2419..f106e20 100644 --- a/src/modules/ocr/review.ts +++ b/src/modules/ocr/review.ts @@ -25,7 +25,7 @@ export interface OcrReviewCandidate { metadataHash: string; documents: Array<{ documentId: string; pages: Array<{ page: number; - imageUrl: string; + imageUrl: string | null; nativeText: string; ocr: { text: string; lines: OcrReviewLine[] }; candidateText: string; @@ -223,12 +223,14 @@ export class DurableOcrReviewReader { || persisted.primaryBlockingReason !== item.page.primaryBlockingReason || persisted.qualityReportSha256 !== item.page.qualityReportSha256 || persisted.qualityReportSha256 !== candidate.qualityReportSha256) throw new Error("OCR review candidate lifecycle validation failed"); - await readReviewImageArtifact({ rootDirectory: this.rootDirectory, versionId, documentId: item.documentId, page: item.page.page }); + if (item.page.method !== "native") { + await readReviewImageArtifact({ rootDirectory: this.rootDirectory, versionId, documentId: item.documentId, page: item.page.page }); + } } const { pages: _lifecyclePages, ...identity } = context; const documents = candidate.documents.map((document) => ({ documentId: document.documentId, pages: prioritizeReviewPages(document.pages.map((page) => ({ - page: page.page, imageUrl: `/ingestions/${versionId}/documents/${encodeURIComponent(document.documentId)}/pages/${page.page}/image`, + page: page.page, imageUrl: page.method === "native" ? null : `/ingestions/${versionId}/documents/${encodeURIComponent(document.documentId)}/pages/${page.page}/image`, nativeText: page.nativeText, ocr: { text: page.rawOcrText, lines: page.lines }, candidateText: page.candidateText, differences: page.nativeText === page.rawOcrText ? [] : ["Native and OCR text differ"], risks: page.risks, qualityOutcome: page.qualityOutcome, warnings: page.warnings, blockingReasons: page.blockingReasons, diff --git a/tests/catalog/repository-ocr.test.ts b/tests/catalog/repository-ocr.test.ts index 54529bf..3d9b6d3 100644 --- a/tests/catalog/repository-ocr.test.ts +++ b/tests/catalog/repository-ocr.test.ts @@ -120,6 +120,22 @@ test("lease claiming returns undefined when no queued job is eligible", async () assert.equal(await repository.claimNextOcrJob(30_000), undefined); }); +test("scheduler lookup returns the earliest queued attempt and treats a missing timestamp as due", async () => { + const queuedAt = new Date("2026-09-24T12:00:00Z"); + let response: Date | null | undefined = queuedAt; + const repository = new CatalogRepository(poolFor(async (sql) => { + assert.match(sql, /state = 'queued'/u); + assert.match(sql, /ORDER BY next_attempt_at ASC NULLS FIRST/u); + return response === undefined ? { rowCount: 0, rows: [] } : { rowCount: 1, rows: [{ next_attempt_at: response }] }; + }) as never); + + assert.equal((await repository.getNextOcrAttemptAt())?.toISOString(), queuedAt.toISOString()); + response = null; + assert.ok((await repository.getNextOcrAttemptAt()) instanceof Date); + response = undefined; + assert.equal(await repository.getNextOcrAttemptAt(), undefined); +}); + test("expired leases return to queued without clearing remote recovery identity", async () => { const repository = new CatalogRepository(poolFor(async (sql) => { assert.match(sql, /state = 'running'/); diff --git a/tests/ocr/client.test.ts b/tests/ocr/client.test.ts index 11209cd..fefe49d 100644 --- a/tests/ocr/client.test.ts +++ b/tests/ocr/client.test.ts @@ -146,27 +146,6 @@ test("strict validation rejects mismatched acknowledgements and result contracts } }); -test("polling uses bounded exponential backoff until a terminal status", async () => { - const delays: number[] = []; - const states = ["queued", "running", "succeeded"] as const; - const client = new OcrClient({ - baseUrl: "http://ocr.internal:8000", - token: "token", - fetch: (async () => jsonResponse(200, { - jobId, - status: states.shift(), - ...identity, - completedPages: states.length === 0 ? 2 : 0, - totalPages: 2, - error: null - })) as typeof fetch, - sleep: async (milliseconds) => { delays.push(milliseconds); } - }); - - assert.equal((await client.pollUntilTerminal(jobId, identity)).status, "succeeded"); - assert.deepEqual(delays, [2_000, 4_000]); -}); - test("authenticated delete accepts an empty idempotent response", async () => { let request: { url: string; method: string; authorization: string | null } | undefined; const client = new OcrClient({ diff --git a/tests/ocr/contracts-deploy.test.ts b/tests/ocr/contracts-deploy.test.ts index 132aa41..9e6f989 100644 --- a/tests/ocr/contracts-deploy.test.ts +++ b/tests/ocr/contracts-deploy.test.ts @@ -57,6 +57,7 @@ test("OpenAPI OCR schemas preserve progress, review evidence, corrections, and d assert.deepEqual(schemas.OcrAccepted!.required, ["accepted", "sourceId", "versionId", "versionNumber", "state", "phase", "statusUrl", "reviewUrl", "activated"]); assert.deepEqual(schemas.IngestionStatus!.required, ["sourceId", "versionId", "state", "phase", "activated", "documents", "error", "statusUrl", "reviewUrl"]); assert.deepEqual(schemas.OcrReview!.required, ["versionId", "sourceId", "state", "candidateSha256", "baseActiveVersionId", "currentActiveVersionId", "activateRequested", "processingFingerprint", "metadataHash", "documents"]); + assert.deepEqual((schemas.OcrReviewPage!.properties as Record).imageUrl, { type: ["string", "null"] }); const approvalProperties = schemas.OcrApprovalRequest!.properties as Record; assert.deepEqual(approvalProperties.corrections!.items, { $ref: "#/components/schemas/OcrCorrection" }); assert.deepEqual(schemas.OcrDecisionResponse!.properties, { @@ -75,10 +76,8 @@ test("OCR deployment defaults and container wiring match the private durable con assert.deepEqual({ root: env.ocrArtifactRoot, uploadBytes: (env as unknown as Record).ocrMaxUploadBytes, - pages: (env as unknown as Record).ocrMaxPages, - pageTimeoutMs: (env as unknown as Record).ocrPageTimeoutMs, - totalTimeoutMs: (env as unknown as Record).ocrTotalTimeoutMs - }, { root: "/data/ingestions", uploadBytes: 50 * 1024 * 1024, pages: 100, pageTimeoutMs: 60_000, totalTimeoutMs: 15 * 60_000 }); + pages: (env as unknown as Record).ocrMaxPages + }, { root: "/data/ingestions", uploadBytes: 50 * 1024 * 1024, pages: 100 }); const dockerfile = await readFile(new URL("../../Dockerfile", import.meta.url), "utf8"); const ocrDockerfile = await readFile(new URL("../../ocr-service/Dockerfile", import.meta.url), "utf8"); diff --git a/tests/ocr/dispatcher.test.ts b/tests/ocr/dispatcher.test.ts index 6cd0010..61ca047 100644 --- a/tests/ocr/dispatcher.test.ts +++ b/tests/ocr/dispatcher.test.ts @@ -8,7 +8,7 @@ import { env } from "../../src/config/env.js"; import { CatalogError } from "../../src/modules/catalog/errors.js"; import { CatalogRepository } from "../../src/modules/catalog/repository.js"; import { KnowledgeLifecycleReconciler } from "../../src/modules/catalog/reconciler.js"; -import { OcrDispatcher } from "../../src/modules/ocr/dispatcher.js"; +import { OcrDispatcher, type OcrSchedulerClock } from "../../src/modules/ocr/dispatcher.js"; import { readReviewImageArtifact } from "../../src/modules/ocr/artifacts.js"; import { IngestService } from "../../src/modules/ingest/service.js"; import type { EmbeddingProvider } from "../../src/modules/embeddings/provider.js"; @@ -59,6 +59,10 @@ function vectorStore(): VectorStoreClient { } as VectorStoreClient; } +async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + test("OCR-disabled textual PDFs retain the native synchronous path", async (context) => { const previousLifecycle = env.knowledgeLifecycleEnforced; setEnvFlag("knowledgeLifecycleEnforced", true); @@ -263,6 +267,7 @@ test("dispatcher recovers only expired work, reuses its remote job, and complete const repository = { async recoverExpiredOcrLeases() { calls.push("recover"); return [job]; }, async claimNextOcrJob() { throw new Error("recovery must not claim unrelated queued work"); }, + async getNextOcrAttemptAt() { return undefined; }, async claimOcrJob(jobId: string) { assert.equal(jobId, job.jobId); if (!available) return undefined; available = false; calls.push("claim-exact"); return { ...job, state: "running" as const }; }, async setOcrRemoteJob() { calls.push("submit-persist"); }, async requeueOcrJob() { calls.push("requeue"); }, @@ -342,6 +347,181 @@ test("dispatcher propagates the remote terminal failure code", async () => { assert.deepEqual(calls, ["job:OCR_ENGINE_FAILED", "version:OCR_ENGINE_FAILED"]); }); +test("durable scheduler follows persisted backoff and stops after terminal success", async () => { + let now = 1_000; + let timer: { callback: () => void; delayMs: number; cleared: boolean; unref(): void } | undefined; + const clock: OcrSchedulerClock = { + now: () => now, + setTimeout(callback, delayMs) { + timer = { callback: () => { timer = undefined; callback(); }, delayMs, cleared: false, unref() {} }; + return timer as unknown as NodeJS.Timeout; + }, + clearTimeout(handle) { + (handle as unknown as { cleared: boolean }).cleared = true; + } + }; + const job = { jobId: "job-scheduled", versionId: "version-1", documentId: "document-1", remoteJobId: "remote-1", + remoteIdempotencyKey: "key", state: "queued" as "queued" | "running" | "succeeded", requestedPages: [1], completedPages: 0, + configVersion: "ocr-v2", attemptCount: 0, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt: null as Date | null, + errorCode: null, errorDetail: null }; + const statuses = ["queued", "running", "succeeded"] as const; + let statusCalls = 0; + let completed = 0; + const store = { + async claimNextOcrJob() { + if (job.state !== "queued" || (job.nextAttemptAt && job.nextAttemptAt.getTime() > now)) return undefined; + job.state = "running"; + job.attemptCount += 1; + return { ...job, state: "running" as const }; + }, + async getNextOcrAttemptAt() { return job.state === "queued" ? job.nextAttemptAt ?? new Date(now) : undefined; }, + async requeueOcrJob(_jobId: string, _code: string, _detail: string, delayMs: number) { + job.state = "queued"; + job.nextAttemptAt = new Date(now + delayMs); + }, + async completeOcrJob() { job.state = "succeeded"; completed += 1; return true; }, + async failOcrJob() {}, async markReviewRequired() {}, async markFailed() {} + }; + const dispatcher = new OcrDispatcher(store as never, { + async getStatus() { const status = statuses[statusCalls++]!; return { status, error: null }; }, + async getResult() { return { pages: [] }; }, async delete() {} + } as never, async () => ({ bytes: Buffer.from("pdf"), documentSha256: sha256Hex("pdf") }), + async (_job, result) => result, async () => undefined, 30_000, clock); + + dispatcher.start(); + await flushPromises(); + assert.equal(timer?.delayMs, 2_000); + now += 2_000; + timer!.callback(); + await flushPromises(); + assert.equal(timer?.delayMs, 4_000); + now += 4_000; + timer!.callback(); + await flushPromises(); + + assert.equal(statusCalls, 3); + assert.equal(completed, 1); + assert.equal(job.state, "succeeded"); + assert.equal(timer, undefined); + dispatcher.stop(); +}); + +test("durable scheduler leaves no timer after a terminal remote failure", async () => { + let state: "queued" | "running" | "failed" = "queued"; + let timersCreated = 0; + const clock: OcrSchedulerClock = { + now: () => 1_000, + setTimeout() { timersCreated += 1; return { unref() {} } as unknown as NodeJS.Timeout; }, + clearTimeout() {} + }; + const store = { + async claimNextOcrJob() { + if (state !== "queued") return undefined; + state = "running"; + return { jobId: "job-failed", versionId: "version-1", documentId: "document-1", remoteJobId: "remote-1", + remoteIdempotencyKey: "key", state: "running" as const, requestedPages: [1], completedPages: 0, configVersion: "ocr-v2", + attemptCount: 1, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt: null, errorCode: null, errorDetail: null }; + }, + async getNextOcrAttemptAt() { return undefined; }, + async failOcrJob() { state = "failed"; }, async markFailed() {} + }; + const dispatcher = new OcrDispatcher(store as never, { + async getStatus() { return { status: "failed", error: { code: "OCR_ENGINE_FAILED", message: "engine failed" } }; } + } as never, async () => ({ bytes: Buffer.from("pdf"), documentSha256: sha256Hex("pdf") }), + async (_job, result) => result, async () => undefined, 30_000, clock); + + dispatcher.start(); + await flushPromises(); + + assert.equal(state, "failed"); + assert.equal(timersCreated, 0); + dispatcher.stop(); +}); + +test("scheduler rebuilds a future persisted attempt after restart without resubmitting OCR", async () => { + let now = 10_000; + let scheduled: { callback: () => void; delayMs: number; unref(): void } | undefined; + const clock: OcrSchedulerClock = { + now: () => now, + setTimeout(callback, delayMs) { + scheduled = { callback: () => { scheduled = undefined; callback(); }, delayMs, unref() {} }; + return scheduled as unknown as NodeJS.Timeout; + }, + clearTimeout() {} + }; + let state: "queued" | "running" | "succeeded" = "queued"; + const nextAttemptAt = new Date(now + 8_000); + let submitCalls = 0; + const store = { + async claimNextOcrJob() { + if (state !== "queued" || now < nextAttemptAt.getTime()) return undefined; + state = "running"; + return { jobId: "job-restart", versionId: "version-1", documentId: "document-1", remoteJobId: "remote-existing", + remoteIdempotencyKey: "key", state: "running" as const, requestedPages: [1], completedPages: 0, configVersion: "ocr-v2", + attemptCount: 2, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt, errorCode: null, errorDetail: null }; + }, + async getNextOcrAttemptAt() { return state === "queued" ? nextAttemptAt : undefined; }, + async completeOcrJob() { state = "succeeded"; return true; }, + async failOcrJob() {}, async markReviewRequired() {}, async markFailed() {} + }; + const dispatcher = new OcrDispatcher(store as never, { + async submit() { submitCalls += 1; throw new Error("must reuse persisted remote job"); }, + async getStatus() { return { status: "succeeded" }; }, async getResult() { return { pages: [] }; }, async delete() {} + } as never, async () => ({ bytes: Buffer.from("pdf"), documentSha256: sha256Hex("pdf") }), + async (_job, result) => result, async () => undefined, 30_000, clock); + + dispatcher.start(); + await flushPromises(); + assert.equal(scheduled?.delayMs, 8_000); + now += 8_000; + scheduled!.callback(); + await flushPromises(); + + assert.equal(state, "succeeded"); + assert.equal(submitCalls, 0); + dispatcher.stop(); +}); + +test("concurrent dispatchers and duplicate drains process a leased job only once", async () => { + let state: "queued" | "running" | "succeeded" = "queued"; + let claimCalls = 0; + let statusCalls = 0; + let completeCalls = 0; + const job = { jobId: "job-concurrent", versionId: "version-1", documentId: "document-1", remoteJobId: "remote-1", + remoteIdempotencyKey: "key", state: "running" as const, requestedPages: [1], completedPages: 0, configVersion: "ocr-v2", + attemptCount: 1, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt: null, errorCode: null, errorDetail: null }; + const store = { + async claimNextOcrJob() { + claimCalls += 1; + await Promise.resolve(); + if (state !== "queued") return undefined; + state = "running"; + return job; + }, + async getNextOcrAttemptAt() { return undefined; }, + async completeOcrJob() { state = "succeeded"; completeCalls += 1; return true; }, + async failOcrJob() {}, async markReviewRequired() {}, async markFailed() {} + }; + const client = { + async getStatus() { statusCalls += 1; return { status: "succeeded" }; }, + async getResult() { return { pages: [] }; }, async delete() {} + }; + const createDispatcher = () => new OcrDispatcher(store as never, client as never, + async () => ({ bytes: Buffer.from("pdf"), documentSha256: sha256Hex("pdf") }), + async (_job, result) => result, async () => undefined); + const first = createDispatcher(); + const second = createDispatcher(); + + const firstDrain = first.dispatchAvailable(); + assert.equal(first.dispatchAvailable(), firstDrain); + await Promise.all([firstDrain, second.dispatchAvailable()]); + + assert.equal(state, "succeeded"); + assert.equal(statusCalls, 1); + assert.equal(completeCalls, 1); + assert.ok(claimCalls >= 2); +}); + test("dispatcher retains remote OCR state when durable candidate finalization fails", async () => { const calls: string[] = []; const job = { jobId: "job-1", versionId: "version-1", documentId: "document-1", remoteJobId: "remote-1", remoteIdempotencyKey: "key", state: "running" as const, requestedPages: [1], completedPages: 0, configVersion: "ocr-v2", attemptCount: 1, heartbeatAt: null, leaseExpiresAt: null, nextAttemptAt: null, errorCode: null, errorDetail: null }; @@ -548,6 +728,7 @@ test("runtime HTTP routing returns native 201, OCR 202/status, and catalog-down async cleanup() { return { deleted: 0 }; } }; const catalog = { + async claimNextOcrJob() { return undefined; }, async getIngestionStatus(id: string) { assert.equal(id, versionId); return { sourceId: "src:scan", versionId, state: "indexing", phase: "ocr_running", activated: false, documents: [{ documentId: "doc:scan", state: "ocr_running", completedPages: 0, totalPages: 1, pages: [{ page: 1, method: "ocr", state: "ocr_running" }] }], error: null, statusUrl: `/ingestions/${versionId}`, reviewUrl: null }; @@ -620,3 +801,24 @@ test("status reports all native and OCR documents and fails the version when one ]); assert.equal(status.error.code, "OCR_QUALITY_BLOCKED"); }); + +test("status counts an OCR-requested blank page only once", async () => { + let query = 0; + const pool = { async query() { + query += 1; + if (query === 1) return { rowCount: 1, rows: [{ source_id: "src:mixed", state: "review_required", error_code: null, error_detail: null }] }; + if (query === 2) return { rowCount: 1, rows: [{ + document_id: "doc:mixed", index_state: "indexing", job_state: "succeeded", completed_pages: 1, requested_pages: [2] + }] }; + return { rowCount: 2, rows: [ + { document_id: "doc:mixed", page_number: 1, extraction_method: "native", blocked_reason: null }, + { document_id: "doc:mixed", page_number: 2, extraction_method: "blank", blocked_reason: null } + ] }; + } }; + + const status = await new CatalogRepository(pool as never).getIngestionStatus("version-mixed") as { + documents: Array<{ completedPages: number; totalPages: number }> + }; + + assert.deepEqual(status.documents.map(({ completedPages, totalPages }) => [completedPages, totalPages]), [[2, 2]]); +}); diff --git a/tests/ocr/review.test.ts b/tests/ocr/review.test.ts index dbd6c38..cbec81d 100644 --- a/tests/ocr/review.test.ts +++ b/tests/ocr/review.test.ts @@ -225,6 +225,8 @@ test("playground serves the authenticated OCR review controls and audit fields", ]); for (const marker of ["data-tab=\"review\"", "reviewVersionId", "reviewToken", "loadReviewButton", "approveReviewButton", "rejectReviewButton", "reviewCandidate"]) assert.match(html, new RegExp(marker)); for (const marker of ["Authorization", "/review", "/approve", "/reject", "candidateSha256", "expectedLineSha256", "imageUrl", "nativeText", "confidence", "bbox", "differences", "risks"]) assert.match(script, new RegExp(marker)); + assert.match(script, /documentId: candidateDocument\.documentId/u); + assert.doesNotMatch(script, /documentId: document\.documentId/u); assert.match(styles, /\.review-page/); }); @@ -238,30 +240,35 @@ test("production review reader survives restart and fails closed on unauthorized Object.assign(env, { lifecycleAdminToken: "review-token", ocrIngestEnabled: true, ocrArtifactRoot: rootDirectory }); context.after(async () => { Object.assign(env, { lifecycleAdminToken: previous.token, ocrIngestEnabled: previous.enabled, ocrArtifactRoot: previous.root }); await rm(rootDirectory, { recursive: true, force: true }); }); await stageOcrArtifacts({ rootDirectory, versionId, createdAt: "2026-09-16T12:00:00.000Z", documents: [{ - documentId, documentKey: "review.pdf", bytes: original, requestedPages: [1], - pages: [{ page: 1, text: "weak native", rasterCoverage: 1, textSha256: sha256Hex("weak native") }] + documentId, documentKey: "review.pdf", bytes: original, requestedPages: [2], + pages: [ + { page: 1, text: "native text with enough content", rasterCoverage: 0, textSha256: sha256Hex("native text with enough content") }, + { page: 2, text: "weak native", rasterCoverage: 1, textSha256: sha256Hex("weak native") } + ] }] }); - const identity = buildOcrIdentity({ documentSha256: sha256Hex(original), pages: [1], idempotencyKey: "review-key" }); + const identity = buildOcrIdentity({ documentSha256: sha256Hex(original), pages: [2], idempotencyKey: "review-key" }); const result = { schemaVersion: "1" as const, jobId: "ocr-review", ...identity, engine: { name: "paddleocr" as const, version: "3.4.0" as const, runtime: "paddlepaddle-3.2.2" as const, device: "cpu" as const, configVersion: "ocr-v2" as const, dpi: 200 as const }, - pages: [{ page: 1, width: 100, height: 100, processingMs: 1, text, + pages: [{ page: 2, width: 100, height: 100, processingMs: 1, text, metrics: { lineCount: 1, nonWhitespaceCharacters: 50, inkCoverage: 0.5, medianConfidence: 0.95, p10Confidence: 0.95, lowConfidenceLineRatio: 0 }, lines: [{ lineId: "p1-l1", text, confidence: 0.95, bbox: [1, 2, 30, 10] as [number, number, number, number] }] }] }; await persistOcrResultArtifact({ rootDirectory, versionId, documentId, result }); const png = Buffer.from("89504e470d0a1a0a0102", "hex"); - const images = await persistReviewImageArtifacts({ rootDirectory, versionId, documentId, images: [{ page: 1, bytes: png, sha256: sha256Hex(png) }] }); - const { candidate: durable } = await persistComposedCandidateArtifact({ rootDirectory, versionId, jobs: [{ documentId, remoteJobId: "ocr-review", requestedPages: [1], state: "succeeded" }] }); - const page = durable.documents[0]!.pages[0]!; + const images = await persistReviewImageArtifacts({ rootDirectory, versionId, documentId, images: [{ page: 2, bytes: png, sha256: sha256Hex(png) }] }); + const { candidate: durable } = await persistComposedCandidateArtifact({ rootDirectory, versionId, jobs: [{ documentId, remoteJobId: "ocr-review", requestedPages: [2], state: "succeeded" }] }); + const pages = durable.documents[0]!.pages; let reads = 0; const contextValue = { versionId, sourceId: "source-1", state: "review_required" as const, baseActiveVersionId: null, currentActiveVersionId: null, - activateRequested: false, processingFingerprint: "fingerprint", metadataHash: "metadata", pages: [{ documentId, page: 1, + activateRequested: false, processingFingerprint: "fingerprint", metadataHash: "metadata", pages: pages.map((page) => ({ documentId, page: page.page, nativeTextSha256: page.nativeTextSha256, ocrTextSha256: page.ocrTextSha256, candidateTextSha256: page.candidateTextSha256, metrics: page.metrics, risks: page.risks, qualityOutcome: page.qualityOutcome, warnings: page.warnings, blockingReasons: page.blockingReasons, - primaryBlockingReason: page.primaryBlockingReason, qualityReportSha256: page.qualityReportSha256 }] }; + primaryBlockingReason: page.primaryBlockingReason, qualityReportSha256: page.qualityReportSha256 })) }; const catalog = { async loadOcrReviewContext() { reads += 1; return contextValue; } }; const restarted = new DurableOcrReviewReader(catalog, rootDirectory); - assert.equal((await restarted.view(versionId)).documents[0]!.pages[0]!.ocr.lines[0]!.lineSha256, sha256Hex(text)); - assert.deepEqual((await restarted.image(versionId, documentId, 1)).bytes, png); + const review = await restarted.view(versionId); + assert.equal(review.documents[0]!.pages.find(({ page }) => page === 1)!.imageUrl, null); + assert.equal(review.documents[0]!.pages.find(({ page }) => page === 2)!.ocr.lines[0]!.lineSha256, sha256Hex(text)); + assert.deepEqual((await restarted.image(versionId, documentId, 2)).bytes, png); const server = createApp({ catalog: catalog as never, startReconciler: false }).listen(0); context.after(() => server.close()); @@ -271,7 +278,7 @@ test("production review reader survives restart and fails closed on unauthorized assert.equal((await fetch(`${base}/review`)).status, 401); assert.equal(reads, 2); assert.equal((await fetch(`${base}/review`, { headers: { authorization: "Bearer review-token" } })).status, 200); - const image = await fetch(`${base}/documents/${encodeURIComponent(documentId)}/pages/1/image`, { headers: { authorization: "Bearer review-token" } }); + const image = await fetch(`${base}/documents/${encodeURIComponent(documentId)}/pages/2/image`, { headers: { authorization: "Bearer review-token" } }); assert.equal(image.status, 200); assert.deepEqual(Buffer.from(await image.arrayBuffer()), png); assert.equal((await fetch(`${base}/approve`, { method: "POST", headers: { authorization: "Bearer review-token", "content-type": "application/json" }, body: "{}" })).status, 503); @@ -279,10 +286,10 @@ test("production review reader survives restart and fails closed on unauthorized contextValue.pages[0]!.candidateTextSha256 = "0".repeat(64); assert.equal((await fetch(`${base}/review`, { headers: { authorization: "Bearer review-token" } })).status, 422); - contextValue.pages[0]!.candidateTextSha256 = page.candidateTextSha256; + contextValue.pages[0]!.candidateTextSha256 = pages[0]!.candidateTextSha256; await assert.rejects(restarted.image(versionId, "../escape", 1), /not found/i); await writeFile(images.images[0]!.artifactPath, "corrupt", { mode: 0o600 }); - await assert.rejects(restarted.image(versionId, documentId, 1), /integrity validation failed/); + await assert.rejects(restarted.image(versionId, documentId, 2), /integrity validation failed/); }); function decisionPool(current: OcrReviewCandidate, state = "review_required", failReviewedPage = false) {