From dc3fcc2280f8ad24b8ee38dabf022e112965581c Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 17 Jul 2026 08:32:00 -0500 Subject: [PATCH] feat: Update docker-compose configuration to use environment variables for sensitive data, enhance testing documentation with new LaTeX files for capacity planning, and refine README for clarity on compilation and testing processes. --- bots/bot_descarga.zip | Bin 0 -> 113682 bytes docker-compose.yml | 30 +- docs/plan_pruebas/README.md | 48 +- docs/plan_pruebas/capacidad_100k/README.md | 16 + docs/plan_pruebas/capacidad_100k/main.aux | 78 +++ docs/plan_pruebas/capacidad_100k/main.out | 28 + docs/plan_pruebas/capacidad_100k/main.tex | 20 + docs/plan_pruebas/capacidad_100k/main.toc | 29 + docs/plan_pruebas/capacidad_100k/preamble.tex | 106 ++++ .../capacidad_100k/sections/01_objetivo.tex | 45 ++ .../sections/02_arquitectura_pipeline.tex | 44 ++ .../sections/03_modelo_etapas.tex | 237 ++++++++ .../capacidad_100k/sections/04_evidencias.tex | 40 ++ .../capacidad_100k/sections/05_coste_llm.tex | 32 + .../capacidad_100k/sections/06_veredicto.tex | 91 +++ .../capacidad_100k/sections/07_requisitos.tex | 36 ++ docs/plan_pruebas/diagrams/arquitectura.tex | 44 +- docs/plan_pruebas/diagrams/patrones.tex | 40 ++ .../diagrams/pipeline_negocio.tex | 30 + docs/plan_pruebas/informe/README.md | 6 +- docs/plan_pruebas/informe/main.aux | 285 +++++---- docs/plan_pruebas/informe/main.out | 164 ++--- docs/plan_pruebas/informe/main.tex | 23 +- docs/plan_pruebas/informe/main.toc | 172 +++--- docs/plan_pruebas/informe/preamble.tex | 24 +- .../informe/sections/01_resumen_ejecutivo.tex | 52 +- .../sections/02_arquitectura_contexto.tex | 43 ++ .../informe/sections/02_objeto_alcance.tex | 7 +- .../informe/sections/04_como_leer.tex | 51 ++ .../sections/05_resultados_unitarias.tex | 79 ++- .../sections/06_resultados_humo_funcional.tex | 61 +- .../informe/sections/07_resultados_carga.tex | 198 +++++-- .../informe/sections/08_capacidad_100k.tex | 104 +--- .../sections/09_analisis_hallazgos.tex | 33 +- .../informe/sections/10_conclusiones.tex | 36 +- .../informe/sections/11_anexos.tex | 18 +- .../informe/sections/12_count_latency.tex | 173 ++++-- .../informe/sections/13_llm_latency.tex | 90 +++ .../informe/sections/14_arq_ext01.tex | 83 +++ .../informe/sections/15_st_count_01.tex | 85 +++ .../informes/count_latency_20260713.csv | 28 +- .../informes/llm_latency_20260717.csv | 24 + .../informes/llm_latency_PENDING.csv | 1 + .../informes/st_count_01_20260717.csv | 10 + .../informes/st_ext_01_arq_20260717.csv | 31 + docs/plan_pruebas/main.aux | 204 ++++--- docs/plan_pruebas/main.out | 150 +++-- docs/plan_pruebas/main.tex | 39 +- docs/plan_pruebas/main.toc | 108 ++-- docs/plan_pruebas/preamble.tex | 15 +- docs/plan_pruebas/scripts/README.md | 20 + .../scripts/e2e_07_worker_offline.py | 193 ++++++ .../plan_pruebas/scripts/estimate_capacity.md | 94 ++- docs/plan_pruebas/scripts/k6_count_batches.js | 4 +- docs/plan_pruebas/scripts/llm_mock_server.ps1 | 10 + docs/plan_pruebas/scripts/llm_mock_server.py | 166 ++++++ docs/plan_pruebas/scripts/llm_mock_server.sh | 7 + .../scripts/measure_llm_latency.ps1 | 11 + .../scripts/measure_llm_latency.py | 10 + .../scripts/measure_llm_latency.sh | 5 + .../scripts/st_count_01_bench.ps1 | 20 + .../plan_pruebas/scripts/st_count_01_bench.py | 279 +++++++++ .../plan_pruebas/scripts/st_count_01_bench.sh | 7 + .../scripts/st_ext_01_arq_bench.ps1 | 27 + .../scripts/st_ext_01_arq_bench.py | 561 ++++++++++++++++++ .../scripts/st_ext_01_arq_bench.sh | 8 + .../scripts/st_ext_02_llm_bench.ps1 | 27 + .../scripts/st_ext_02_llm_bench.py | 418 +++++++++++++ .../scripts/st_ext_02_llm_bench.sh | 8 + .../plan_pruebas/sections/02_arquitectura.tex | 59 +- docs/plan_pruebas/sections/03_estrategia.tex | 21 +- docs/plan_pruebas/sections/04_unitarias.tex | 31 +- docs/plan_pruebas/sections/07_estres.tex | 35 +- .../sections/12_informe_2026_07_13.tex | 108 +--- services/bot_descarga/api/main.py | 37 ++ .../infrastructure/storage_minio.py | 32 +- .../tests/test_list_download_files.py | 38 ++ .../0005_count_job_files_nit_object_key.py | 45 ++ .../renderer/components/CountResultsTable.tsx | 367 +++++++++--- .../renderer/components/CountingWizard.tsx | 266 +++++++-- .../desktop_app/src/renderer/lib/apiClient.ts | 15 + .../modules/counting/CountingWizardModule.tsx | 28 +- .../extraction/JobExtractionWizard.tsx | 122 +++- .../src/renderer/views/JobResultsView.tsx | 33 +- .../src/renderer/views/JobView.tsx | 2 + services/desktop_app/src/shared/types.ts | 10 + services/extraction/api/main.py | 16 +- .../extraction/infrastructure/database.py | 2 + services/extraction/worker/tasks.py | 8 +- 89 files changed, 5331 insertions(+), 1210 deletions(-) create mode 100644 bots/bot_descarga.zip create mode 100644 docs/plan_pruebas/capacidad_100k/README.md create mode 100644 docs/plan_pruebas/capacidad_100k/main.aux create mode 100644 docs/plan_pruebas/capacidad_100k/main.out create mode 100644 docs/plan_pruebas/capacidad_100k/main.tex create mode 100644 docs/plan_pruebas/capacidad_100k/main.toc create mode 100644 docs/plan_pruebas/capacidad_100k/preamble.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/01_objetivo.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/02_arquitectura_pipeline.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/03_modelo_etapas.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/04_evidencias.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/05_coste_llm.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/06_veredicto.tex create mode 100644 docs/plan_pruebas/capacidad_100k/sections/07_requisitos.tex create mode 100644 docs/plan_pruebas/diagrams/patrones.tex create mode 100644 docs/plan_pruebas/diagrams/pipeline_negocio.tex create mode 100644 docs/plan_pruebas/informe/sections/02_arquitectura_contexto.tex create mode 100644 docs/plan_pruebas/informe/sections/04_como_leer.tex create mode 100644 docs/plan_pruebas/informe/sections/13_llm_latency.tex create mode 100644 docs/plan_pruebas/informe/sections/14_arq_ext01.tex create mode 100644 docs/plan_pruebas/informe/sections/15_st_count_01.tex create mode 100644 docs/plan_pruebas/informes/llm_latency_20260717.csv create mode 100644 docs/plan_pruebas/informes/llm_latency_PENDING.csv create mode 100644 docs/plan_pruebas/informes/st_count_01_20260717.csv create mode 100644 docs/plan_pruebas/informes/st_ext_01_arq_20260717.csv create mode 100644 docs/plan_pruebas/scripts/e2e_07_worker_offline.py create mode 100644 docs/plan_pruebas/scripts/llm_mock_server.ps1 create mode 100644 docs/plan_pruebas/scripts/llm_mock_server.py create mode 100644 docs/plan_pruebas/scripts/llm_mock_server.sh create mode 100644 docs/plan_pruebas/scripts/measure_llm_latency.ps1 create mode 100644 docs/plan_pruebas/scripts/measure_llm_latency.py create mode 100644 docs/plan_pruebas/scripts/measure_llm_latency.sh create mode 100644 docs/plan_pruebas/scripts/st_count_01_bench.ps1 create mode 100644 docs/plan_pruebas/scripts/st_count_01_bench.py create mode 100644 docs/plan_pruebas/scripts/st_count_01_bench.sh create mode 100644 docs/plan_pruebas/scripts/st_ext_01_arq_bench.ps1 create mode 100644 docs/plan_pruebas/scripts/st_ext_01_arq_bench.py create mode 100644 docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh create mode 100644 docs/plan_pruebas/scripts/st_ext_02_llm_bench.ps1 create mode 100644 docs/plan_pruebas/scripts/st_ext_02_llm_bench.py create mode 100644 docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh create mode 100644 services/bot_descarga/tests/test_list_download_files.py create mode 100644 services/db/alembic/versions/0005_count_job_files_nit_object_key.py diff --git a/bots/bot_descarga.zip b/bots/bot_descarga.zip new file mode 100644 index 0000000000000000000000000000000000000000..1d825be7324b1e20c0d9d815b1e53160391b08b9 GIT binary patch literal 113682 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 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml index 31f331f..a7280eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,12 +52,13 @@ services: environment: DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor REDIS_URL: redis://redis:6379/0 - EXTRACTOR_BASE_URL: https://api.openai.com/v1 - EXTRACTOR_API_KEY: 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 + ASYNC_QUEUE: ${ASYNC_QUEUE:-false} + EXTRACTOR_BASE_URL: ${EXTRACTOR_BASE_URL:-https://api.openai.com/v1} + EXTRACTOR_API_KEY: ${EXTRACTOR_API_KEY} EXTRACTOR_MODEL: ${EXTRACTOR_MODEL:-gpt-5.4-mini} - NORMALIZER_BASE_URL: https://api.openai.com/v1 - NORMALIZER_API_KEY: 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 - NORMALIZER_MODEL: gpt-5.4-mini + NORMALIZER_BASE_URL: ${NORMALIZER_BASE_URL:-${EXTRACTOR_BASE_URL:-https://api.openai.com/v1}} + NORMALIZER_API_KEY: ${NORMALIZER_API_KEY:-${EXTRACTOR_API_KEY}} + NORMALIZER_MODEL: ${NORMALIZER_MODEL:-gpt-5.4-mini} EXTRACTOR_TIMEOUT_S: ${EXTRACTOR_TIMEOUT_S:-120} PDF_DPI: ${PDF_DPI:-150} PDF_THREAD_COUNT: ${PDF_THREAD_COUNT:-4} @@ -91,19 +92,26 @@ services: environment: DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor REDIS_URL: redis://redis:6379/0 - EXTRACTOR_BASE_URL: https://api.openai.com/v1 - EXTRACTOR_API_KEY: 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 + ASYNC_QUEUE: ${ASYNC_QUEUE:-false} + EXTRACTOR_BASE_URL: ${EXTRACTOR_BASE_URL:-https://api.openai.com/v1} + EXTRACTOR_API_KEY: ${EXTRACTOR_API_KEY} EXTRACTOR_MODEL: ${EXTRACTOR_MODEL:-gpt-5.4-mini} - NORMALIZER_BASE_URL: https://api.openai.com/v1 - NORMALIZER_API_KEY: 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 - NORMALIZER_MODEL: gpt-5.4-mini + NORMALIZER_BASE_URL: ${NORMALIZER_BASE_URL:-${EXTRACTOR_BASE_URL:-https://api.openai.com/v1}} + NORMALIZER_API_KEY: ${NORMALIZER_API_KEY:-${EXTRACTOR_API_KEY}} + NORMALIZER_MODEL: ${NORMALIZER_MODEL:-gpt-5.4-mini} EXTRACTOR_TIMEOUT_S: ${EXTRACTOR_TIMEOUT_S:-120} PDF_DPI: ${PDF_DPI:-150} PDF_THREAD_COUNT: ${PDF_THREAD_COUNT:-4} - WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-25} + CONCURRENCY_LIMIT: ${CONCURRENCY_LIMIT:-50} ALLOWED_DOCUMENT_TYPES: ${ALLOWED_DOCUMENT_TYPES:-CC,TI,CE,RC,PA,PPT,PEP,EPT,NUIP} + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4} volumes: - ips_queue:/tmp/ips_queue + # Código montado para que fixes del worker (p.ej. FOR UPDATE) apliquen sin rebuild. + - ./services/extraction/worker:/app/worker + - ./services/extraction/domain:/app/domain + - ./services/extraction/core:/app/core + - ./services/extraction/infrastructure:/app/infrastructure cleaning: build: diff --git a/docs/plan_pruebas/README.md b/docs/plan_pruebas/README.md index cc5bb40..7a8c823 100644 --- a/docs/plan_pruebas/README.md +++ b/docs/plan_pruebas/README.md @@ -1,26 +1,36 @@ -# Plan / Informe de pruebas IPSExtractor +# Plan / Informe / Capacidad — IPSExtractor -## Entregable principal +Tres documentos LaTeX **para compartir**: -**Informe de Pruebas (resultados):** [`InformePruebas_IPSExtractor.pdf`](InformePruebas_IPSExtractor.pdf) -Fuentes: [`informe/`](informe/) +| PDF | Fuentes | Contenido | +|-----|---------|-----------| +| [`PlanPruebas_IPSExtractor.pdf`](PlanPruebas_IPSExtractor.pdf) | [`main.tex`](main.tex) | Estrategia, arquitectura, catálogo UT/IT/E2E/ST | +| [`InformePruebas_IPSExtractor.pdf`](InformePruebas_IPSExtractor.pdf) | [`informe/`](informe/) | Resultados, métricas, interpretación | +| [`Capacidad100k_IPSExtractor.pdf`](Capacidad100k_IPSExtractor.pdf) | [`capacidad_100k/`](capacidad_100k/) | Modelo 100k/mes, coste LLM, veredicto | -Incluye métricas, gráficas, scorecard y análisis de viabilidad de **100.000 facturas/mes**. - -## Plan histórico (referencia) - -El documento de plan de pruebas sigue en esta carpeta (`main.tex` → `PlanPruebas_IPSExtractor.pdf`) como referencia de metodología/IDs. El foco de QA es el **informe**. - -## Campañas - -- [`informes/2026-07-13-shared8g.md`](informes/2026-07-13-shared8g.md) -- Scripts k6: [`scripts/`](scripts/) - -## Compilar informe +## Compilar los tres ```powershell -cd docs\plan_pruebas\informe -pdflatex -interaction=nonstopmode main.tex -pdflatex -interaction=nonstopmode main.tex +# Plan +cd docs\plan_pruebas +pdflatex -interaction=nonstopmode -file-line-error main.tex +pdflatex -interaction=nonstopmode -file-line-error main.tex +Copy-Item main.pdf PlanPruebas_IPSExtractor.pdf -Force + +# Informe +cd informe +pdflatex -interaction=nonstopmode -file-line-error main.tex +pdflatex -interaction=nonstopmode -file-line-error main.tex Copy-Item main.pdf ..\InformePruebas_IPSExtractor.pdf -Force + +# Capacidad 100k +cd ..\capacidad_100k +pdflatex -interaction=nonstopmode -file-line-error main.tex +pdflatex -interaction=nonstopmode -file-line-error main.tex +Copy-Item main.pdf ..\Capacidad100k_IPSExtractor.pdf -Force ``` + +## Campañas y scripts + +- [`informes/2026-07-13-shared8g.md`](informes/2026-07-13-shared8g.md) +- Scripts: [`scripts/`](scripts/) diff --git a/docs/plan_pruebas/capacidad_100k/README.md b/docs/plan_pruebas/capacidad_100k/README.md new file mode 100644 index 0000000..edd6b41 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/README.md @@ -0,0 +1,16 @@ +# Capacidad 100 000 facturas/mes + +Documento LaTeX compartible: modelo por etapa, evidencias (bot/LLM/OCR), coste LLM y veredicto. + +## Compilar + +```powershell +cd docs\plan_pruebas\capacidad_100k +pdflatex -interaction=nonstopmode -file-line-error main.tex +pdflatex -interaction=nonstopmode -file-line-error main.tex +Copy-Item main.pdf ..\Capacidad100k_IPSExtractor.pdf -Force +``` + +Salida: [`../Capacidad100k_IPSExtractor.pdf`](../Capacidad100k_IPSExtractor.pdf) + +Complementa el [Informe de Pruebas](../informe/) y el [Plan](../). diff --git a/docs/plan_pruebas/capacidad_100k/main.aux b/docs/plan_pruebas/capacidad_100k/main.aux new file mode 100644 index 0000000..8b8d9fb --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/main.aux @@ -0,0 +1,78 @@ +\relax +\providecommand \babel@aux [2]{\global \let \babel@toc \@gobbletwo } +\@nameuse{bbl@beforestart} +\catcode `"\active +\catcode `<\active +\catcode `>\active +\@nameuse{es@quoting} +\providecommand\hyper@newdestlabel[2]{} +\providecommand\HyField@AuxAddToFields[1]{} +\providecommand\HyField@AuxAddToCoFields[2]{} +\babel@aux{spanish}{} +\@writefile{toc}{\contentsline {chapter}{\numberline {1}Objetivo: 100\,000 facturas al mes}{2}{chapter.1}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:objetivo}{{1}{2}{Objetivo: 100\,000 facturas al mes}{chapter.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {1.1}Qu\'e pregunta responde este documento}{2}{section.1.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.2}Traducci\'on a ritmo (facturas/hora)}{2}{section.1.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {1.1}{\ignorespaces Ritmo m\'inimo para cumplir 100k/mes.}}{2}{table.1.1}\protected@file@percent } +\newlabel{tab:objetivos-h}{{1.1}{2}{Ritmo m\'inimo para cumplir 100k/mes}{table.1.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {1.3}C\'omo usar este documento}{2}{section.1.3}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {2}Arquitectura del pipeline}{4}{chapter.2}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:arq-cap}{{2}{4}{Arquitectura del pipeline}{chapter.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.1}Patrones de dise\~no relevantes}{4}{section.2.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {2.2}Flujo de negocio (punta a punta)}{4}{section.2.2}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.1}{\ignorespaces Pipeline punta a punta. La descarga la hace el \textbf {agent Selenium} (PC); el servicio \texttt {bot-descarga} solo orquesta jobs/claim/heartbeats.}}{5}{figure.2.1}\protected@file@percent } +\newlabel{fig:pipeline-negocio}{{2.1}{5}{Pipeline punta a punta. La descarga la hace el \textbf {agent Selenium} (PC); el servicio \texttt {bot-descarga} solo orquesta jobs/claim/heartbeats}{figure.2.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.3}Vista de componentes}{5}{section.2.3}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.2}{\ignorespaces Arquitectura bajo prueba. El \textbf {gateway no habla con Postgres}: solo hace proxy HTTP. Usan la BD compartida: \texttt {extraction} (+ workers), \texttt {cleaning} y \texttt {bot-descarga}. \texttt {count-api} hace OCR y \textbf {devuelve JSON} (no escribe en PG); el Desktop/extraction persiste esos resultados con \texttt {POST /count-jobs} en Postgres.}}{5}{figure.2.2}\protected@file@percent } +\newlabel{fig:arquitectura}{{2.2}{5}{Arquitectura bajo prueba. El \textbf {gateway no habla con Postgres}: solo hace proxy HTTP. Usan la BD compartida: \texttt {extraction} (+ workers), \texttt {cleaning} y \texttt {bot-descarga}. \texttt {count-api} hace OCR y \textbf {devuelve JSON} (no escribe en PG); el Desktop/extraction persiste esos resultados con \texttt {POST /count-jobs} en Postgres}{figure.2.2}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {3}Modelo de capacidad por etapa}{6}{chapter.3}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:modelo}{{3}{6}{Modelo de capacidad por etapa}{chapter.3}{}} +\@writefile{toc}{\contentsline {section}{\numberline {3.1}C\'omo leer los n\'umeros}{6}{section.3.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.2}Tabla de capacidad (datos julio 2026)}{6}{section.3.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {3.1}{\ignorespaces Ritmo por etapa. Pipeline $\approx \mathop {\mathgroup \symoperators m\acute {\dotlessi }n}$ de etapas (cuello OCR). $^{\mathrm {P}}$Conservador $\sim $100 $<$ 139; techo observado OCR $\sim $325 puede cubrir 139 si el host no degrada m\'as.}}{6}{table.3.1}\protected@file@percent } +\newlabel{tab:capacidad}{{3.1}{6}{Ritmo por etapa. Pipeline $\approx \min $ de etapas (cuello OCR). $^{\mathrm {P}}$Conservador $\sim $100 $<$ 139; techo observado OCR $\sim $325 puede cubrir 139 si el host no degrada m\'as}{table.3.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {3.3}C\'omo se obtiene el ritmo del pipeline}{7}{section.3.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.4}Gr\'afico 1 --- etapas vs objetivos}{8}{section.3.4}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {3.1}{\ignorespaces Azul = host compartido (medido/estimado). Verde = host dedicado (estimado). L\'ineas = m\'inimos para 100k/mes.}}{8}{figure.3.1}\protected@file@percent } +\newlabel{fig:capacidad}{{3.1}{8}{Azul = host compartido (medido/estimado). Verde = host dedicado (estimado). L\'ineas = m\'inimos para 100k/mes}{figure.3.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {3.5}Gr\'afico 2 --- \textquestiondown llegamos a 100k/mes?}{8}{section.3.5}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {3.2}{\ignorespaces Solo capacidad (barras) vs metas (l\'ineas). Modelo por cuello OCR; no es E2E de 100k.}}{9}{figure.3.2}\protected@file@percent } +\newlabel{fig:pipeline-metas}{{3.2}{9}{Solo capacidad (barras) vs metas (l\'ineas). Modelo por cuello OCR; no es E2E de 100k}{figure.3.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {3.6}Qu\'e concluir aqu\'i}{9}{section.3.6}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {4}Evidencias que alimentan el modelo}{10}{chapter.4}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:evidencias}{{4}{10}{Evidencias que alimentan el modelo}{chapter.4}{}} +\@writefile{toc}{\contentsline {section}{\numberline {4.1}Resumen}{10}{section.4.1}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {4.1}{\ignorespaces Medido vs estimado (campa\~na julio 2026).}}{10}{table.4.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.2}Detalle por prueba}{10}{section.4.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.3}A\'un no medido a escala}{11}{section.4.3}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {5}Coste LLM (100k que pasan conteo)}{12}{chapter.5}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:coste}{{5}{12}{Coste LLM (100k que pasan conteo)}{chapter.5}{}} +\@writefile{lot}{\contentsline {table}{\numberline {5.1}{\ignorespaces Presupuesto LLM: $100\,000 \times 0{,}0257 = 2\,570$ USD. No incluye agent Selenium, orquestaci\'on \texttt {bot-descarga}, OCR, infra ni reintentos.}}{12}{table.5.1}\protected@file@percent } +\newlabel{tab:coste-llm-100k}{{5.1}{12}{Presupuesto LLM: $100\,000 \times 0{,}0257 = 2\,570$ USD. No incluye agent Selenium, orquestaci\'on \texttt {bot-descarga}, OCR, infra ni reintentos}{table.5.1}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {6}Veredicto}{13}{chapter.6}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:veredicto}{{6}{13}{Veredicto}{chapter.6}{}} +\@writefile{toc}{\contentsline {section}{\numberline {6.1}La respuesta corta}{13}{section.6.1}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {6.1}{\ignorespaces Barras = capacidad. L\'ineas = metas. Rojo no llega a 139; verde llega a 139 pero no a 570.}}{13}{figure.6.1}\protected@file@percent } +\newlabel{fig:veredicto-brecha}{{6.1}{13}{Barras = capacidad. L\'ineas = metas. Rojo no llega a 139; verde llega a 139 pero no a 570}{figure.6.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {6.2}Si hoy no est\'a garantizado: qu\'e hace falta}{14}{section.6.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {6.3}Por meta operativa}{14}{section.6.3}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {7}Requisitos sugeridos y pr\'oximos pasos}{15}{chapter.7}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:requisitos}{{7}{15}{Requisitos sugeridos y pr\'oximos pasos}{chapter.7}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.1}Para lograr 100k/mes (lista simple)}{15}{section.7.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {7.2}Pr\'oximas mediciones}{15}{section.7.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {7.3}Documentos relacionados}{15}{section.7.3}\protected@file@percent } +\gdef \@abspage@last{16} diff --git a/docs/plan_pruebas/capacidad_100k/main.out b/docs/plan_pruebas/capacidad_100k/main.out new file mode 100644 index 0000000..84643b3 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/main.out @@ -0,0 +1,28 @@ +\BOOKMARK [0][-]{chapter.1}{\376\377\000O\000b\000j\000e\000t\000i\000v\000o\000:\000\040\0001\0000\0000\0000\0000\0000\000\040\000f\000a\000c\000t\000u\000r\000a\000s\000\040\000a\000l\000\040\000m\000e\000s}{}% 1 +\BOOKMARK [1][-]{section.1.1}{\376\377\000Q\000u\000\351\000\040\000p\000r\000e\000g\000u\000n\000t\000a\000\040\000r\000e\000s\000p\000o\000n\000d\000e\000\040\000e\000s\000t\000e\000\040\000d\000o\000c\000u\000m\000e\000n\000t\000o}{chapter.1}% 2 +\BOOKMARK [1][-]{section.1.2}{\376\377\000T\000r\000a\000d\000u\000c\000c\000i\000\363\000n\000\040\000a\000\040\000r\000i\000t\000m\000o\000\040\000\050\000f\000a\000c\000t\000u\000r\000a\000s\000/\000h\000o\000r\000a\000\051}{chapter.1}% 3 +\BOOKMARK [1][-]{section.1.3}{\376\377\000C\000\363\000m\000o\000\040\000u\000s\000a\000r\000\040\000e\000s\000t\000e\000\040\000d\000o\000c\000u\000m\000e\000n\000t\000o}{chapter.1}% 4 +\BOOKMARK [0][-]{chapter.2}{\376\377\000A\000r\000q\000u\000i\000t\000e\000c\000t\000u\000r\000a\000\040\000d\000e\000l\000\040\000p\000i\000p\000e\000l\000i\000n\000e}{}% 5 +\BOOKMARK [1][-]{section.2.1}{\376\377\000P\000a\000t\000r\000o\000n\000e\000s\000\040\000d\000e\000\040\000d\000i\000s\000e\000\361\000o\000\040\000r\000e\000l\000e\000v\000a\000n\000t\000e\000s}{chapter.2}% 6 +\BOOKMARK [1][-]{section.2.2}{\376\377\000F\000l\000u\000j\000o\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o\000\040\000\050\000p\000u\000n\000t\000a\000\040\000a\000\040\000p\000u\000n\000t\000a\000\051}{chapter.2}% 7 +\BOOKMARK [1][-]{section.2.3}{\376\377\000V\000i\000s\000t\000a\000\040\000d\000e\000\040\000c\000o\000m\000p\000o\000n\000e\000n\000t\000e\000s}{chapter.2}% 8 +\BOOKMARK [0][-]{chapter.3}{\376\377\000M\000o\000d\000e\000l\000o\000\040\000d\000e\000\040\000c\000a\000p\000a\000c\000i\000d\000a\000d\000\040\000p\000o\000r\000\040\000e\000t\000a\000p\000a}{}% 9 +\BOOKMARK [1][-]{section.3.1}{\376\377\000C\000\363\000m\000o\000\040\000l\000e\000e\000r\000\040\000l\000o\000s\000\040\000n\000\372\000m\000e\000r\000o\000s}{chapter.3}% 10 +\BOOKMARK [1][-]{section.3.2}{\376\377\000T\000a\000b\000l\000a\000\040\000d\000e\000\040\000c\000a\000p\000a\000c\000i\000d\000a\000d\000\040\000\050\000d\000a\000t\000o\000s\000\040\000j\000u\000l\000i\000o\000\040\0002\0000\0002\0006\000\051}{chapter.3}% 11 +\BOOKMARK [1][-]{section.3.3}{\376\377\000C\000\363\000m\000o\000\040\000s\000e\000\040\000o\000b\000t\000i\000e\000n\000e\000\040\000e\000l\000\040\000r\000i\000t\000m\000o\000\040\000d\000e\000l\000\040\000p\000i\000p\000e\000l\000i\000n\000e}{chapter.3}% 12 +\BOOKMARK [1][-]{section.3.4}{\376\377\000G\000r\000\341\000f\000i\000c\000o\000\040\0001\000\040\040\024\000\040\000e\000t\000a\000p\000a\000s\000\040\000v\000s\000\040\000o\000b\000j\000e\000t\000i\000v\000o\000s}{chapter.3}% 13 +\BOOKMARK [1][-]{section.3.5}{\376\377\000G\000r\000\341\000f\000i\000c\000o\000\040\0002\000\040\040\024\000\040\000\277\000l\000l\000e\000g\000a\000m\000o\000s\000\040\000a\000\040\0001\0000\0000\000k\000/\000m\000e\000s\000?}{chapter.3}% 14 +\BOOKMARK [1][-]{section.3.6}{\376\377\000Q\000u\000\351\000\040\000c\000o\000n\000c\000l\000u\000i\000r\000\040\000a\000q\000u\000\355}{chapter.3}% 15 +\BOOKMARK [0][-]{chapter.4}{\376\377\000E\000v\000i\000d\000e\000n\000c\000i\000a\000s\000\040\000q\000u\000e\000\040\000a\000l\000i\000m\000e\000n\000t\000a\000n\000\040\000e\000l\000\040\000m\000o\000d\000e\000l\000o}{}% 16 +\BOOKMARK [1][-]{section.4.1}{\376\377\000R\000e\000s\000u\000m\000e\000n}{chapter.4}% 17 +\BOOKMARK [1][-]{section.4.2}{\376\377\000D\000e\000t\000a\000l\000l\000e\000\040\000p\000o\000r\000\040\000p\000r\000u\000e\000b\000a}{chapter.4}% 18 +\BOOKMARK [1][-]{section.4.3}{\376\377\000A\000\372\000n\000\040\000n\000o\000\040\000m\000e\000d\000i\000d\000o\000\040\000a\000\040\000e\000s\000c\000a\000l\000a}{chapter.4}% 19 +\BOOKMARK [0][-]{chapter.5}{\376\377\000C\000o\000s\000t\000e\000\040\000L\000L\000M\000\040\000\050\0001\0000\0000\000k\000\040\000q\000u\000e\000\040\000p\000a\000s\000a\000n\000\040\000c\000o\000n\000t\000e\000o\000\051}{}% 20 +\BOOKMARK [0][-]{chapter.6}{\376\377\000V\000e\000r\000e\000d\000i\000c\000t\000o}{}% 21 +\BOOKMARK [1][-]{section.6.1}{\376\377\000L\000a\000\040\000r\000e\000s\000p\000u\000e\000s\000t\000a\000\040\000c\000o\000r\000t\000a}{chapter.6}% 22 +\BOOKMARK [1][-]{section.6.2}{\376\377\000S\000i\000\040\000h\000o\000y\000\040\000n\000o\000\040\000e\000s\000t\000\341\000\040\000g\000a\000r\000a\000n\000t\000i\000z\000a\000d\000o\000:\000\040\000q\000u\000\351\000\040\000h\000a\000c\000e\000\040\000f\000a\000l\000t\000a}{chapter.6}% 23 +\BOOKMARK [1][-]{section.6.3}{\376\377\000P\000o\000r\000\040\000m\000e\000t\000a\000\040\000o\000p\000e\000r\000a\000t\000i\000v\000a}{chapter.6}% 24 +\BOOKMARK [0][-]{chapter.7}{\376\377\000R\000e\000q\000u\000i\000s\000i\000t\000o\000s\000\040\000s\000u\000g\000e\000r\000i\000d\000o\000s\000\040\000y\000\040\000p\000r\000\363\000x\000i\000m\000o\000s\000\040\000p\000a\000s\000o\000s}{}% 25 +\BOOKMARK [1][-]{section.7.1}{\376\377\000P\000a\000r\000a\000\040\000l\000o\000g\000r\000a\000r\000\040\0001\0000\0000\000k\000/\000m\000e\000s\000\040\000\050\000l\000i\000s\000t\000a\000\040\000s\000i\000m\000p\000l\000e\000\051}{chapter.7}% 26 +\BOOKMARK [1][-]{section.7.2}{\376\377\000P\000r\000\363\000x\000i\000m\000a\000s\000\040\000m\000e\000d\000i\000c\000i\000o\000n\000e\000s}{chapter.7}% 27 +\BOOKMARK [1][-]{section.7.3}{\376\377\000D\000o\000c\000u\000m\000e\000n\000t\000o\000s\000\040\000r\000e\000l\000a\000c\000i\000o\000n\000a\000d\000o\000s}{chapter.7}% 28 diff --git a/docs/plan_pruebas/capacidad_100k/main.tex b/docs/plan_pruebas/capacidad_100k/main.tex new file mode 100644 index 0000000..3aad642 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/main.tex @@ -0,0 +1,20 @@ +\documentclass[11pt,a4paper,oneside]{report} + +\input{preamble} + +\begin{document} + +\makecover + +\tableofcontents +\clearpage + +\input{sections/01_objetivo} +\input{sections/02_arquitectura_pipeline} +\input{sections/03_modelo_etapas} +\input{sections/04_evidencias} +\input{sections/05_coste_llm} +\input{sections/06_veredicto} +\input{sections/07_requisitos} + +\end{document} diff --git a/docs/plan_pruebas/capacidad_100k/main.toc b/docs/plan_pruebas/capacidad_100k/main.toc new file mode 100644 index 0000000..36a4447 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/main.toc @@ -0,0 +1,29 @@ +\babel@toc {spanish}{}\relax +\contentsline {chapter}{\numberline {1}Objetivo: 100\,000 facturas al mes}{2}{chapter.1}% +\contentsline {section}{\numberline {1.1}Qu\'e pregunta responde este documento}{2}{section.1.1}% +\contentsline {section}{\numberline {1.2}Traducci\'on a ritmo (facturas/hora)}{2}{section.1.2}% +\contentsline {section}{\numberline {1.3}C\'omo usar este documento}{2}{section.1.3}% +\contentsline {chapter}{\numberline {2}Arquitectura del pipeline}{4}{chapter.2}% +\contentsline {section}{\numberline {2.1}Patrones de dise\~no relevantes}{4}{section.2.1}% +\contentsline {section}{\numberline {2.2}Flujo de negocio (punta a punta)}{4}{section.2.2}% +\contentsline {section}{\numberline {2.3}Vista de componentes}{5}{section.2.3}% +\contentsline {chapter}{\numberline {3}Modelo de capacidad por etapa}{6}{chapter.3}% +\contentsline {section}{\numberline {3.1}C\'omo leer los n\'umeros}{6}{section.3.1}% +\contentsline {section}{\numberline {3.2}Tabla de capacidad (datos julio 2026)}{6}{section.3.2}% +\contentsline {section}{\numberline {3.3}C\'omo se obtiene el ritmo del pipeline}{7}{section.3.3}% +\contentsline {section}{\numberline {3.4}Gr\'afico 1 --- etapas vs objetivos}{8}{section.3.4}% +\contentsline {section}{\numberline {3.5}Gr\'afico 2 --- \textquestiondown llegamos a 100k/mes?}{8}{section.3.5}% +\contentsline {section}{\numberline {3.6}Qu\'e concluir aqu\'i}{9}{section.3.6}% +\contentsline {chapter}{\numberline {4}Evidencias que alimentan el modelo}{10}{chapter.4}% +\contentsline {section}{\numberline {4.1}Resumen}{10}{section.4.1}% +\contentsline {section}{\numberline {4.2}Detalle por prueba}{10}{section.4.2}% +\contentsline {section}{\numberline {4.3}A\'un no medido a escala}{11}{section.4.3}% +\contentsline {chapter}{\numberline {5}Coste LLM (100k que pasan conteo)}{12}{chapter.5}% +\contentsline {chapter}{\numberline {6}Veredicto}{13}{chapter.6}% +\contentsline {section}{\numberline {6.1}La respuesta corta}{13}{section.6.1}% +\contentsline {section}{\numberline {6.2}Si hoy no est\'a garantizado: qu\'e hace falta}{14}{section.6.2}% +\contentsline {section}{\numberline {6.3}Por meta operativa}{14}{section.6.3}% +\contentsline {chapter}{\numberline {7}Requisitos sugeridos y pr\'oximos pasos}{15}{chapter.7}% +\contentsline {section}{\numberline {7.1}Para lograr 100k/mes (lista simple)}{15}{section.7.1}% +\contentsline {section}{\numberline {7.2}Pr\'oximas mediciones}{15}{section.7.2}% +\contentsline {section}{\numberline {7.3}Documentos relacionados}{15}{section.7.3}% diff --git a/docs/plan_pruebas/capacidad_100k/preamble.tex b/docs/plan_pruebas/capacidad_100k/preamble.tex new file mode 100644 index 0000000..74f3983 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/preamble.tex @@ -0,0 +1,106 @@ +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage[spanish,es-tabla]{babel} +\usepackage{lmodern} +\usepackage{microtype} +\usepackage[ + a4paper, + margin=2.4cm, + headheight=14pt +]{geometry} +\usepackage{setspace} +\onehalfspacing + +\usepackage{fancyhdr} +\pagestyle{fancy} +\fancyhf{} +\fancyhead[L]{\small IPSExtractor --- Capacidad 100k/mes} +\fancyhead[R]{\small\thepage} +\fancyfoot[C]{\footnotesize Documento para compartir} +\renewcommand{\headrulewidth}{0.4pt} + +\usepackage{titlesec} +\titleformat{\chapter}[display] + {\normalfont\huge\bfseries}{\chaptertitlename\ \thechapter}{14pt}{\Huge} +\titleformat{\section}{\normalfont\Large\bfseries}{\thesection}{1em}{} + +\usepackage{booktabs} +\usepackage{longtable} +\usepackage{tabularx} +\usepackage{multirow} +\usepackage{array} +\usepackage{enumitem} +\setlist[itemize]{nosep,leftmargin=1.4em} +\setlist[enumerate]{nosep,leftmargin=1.4em} + +\usepackage{siunitx} +\usepackage{xcolor} +\definecolor{ipsblue}{HTML}{1B4F72} +\definecolor{ipsgrey}{HTML}{566573} +\definecolor{codebg}{HTML}{F4F6F7} +\definecolor{passgreen}{HTML}{1E8449} +\definecolor{failred}{HTML}{C0392B} +\definecolor{warnorange}{HTML}{B9770E} + +\usepackage{listings} +\lstset{ + basicstyle=\ttfamily\footnotesize, + backgroundcolor=\color{codebg}, + frame=single, + framerule=0.3pt, + breaklines=true, + columns=fullflexible, + showstringspaces=false +} + +\usepackage{pgfplots} +\pgfplotsset{compat=1.18} + +\usepackage{tikz} +\usetikzlibrary{arrows.meta,positioning,fit,calc,shapes.geometric} + +\usepackage{graphicx} +\usepackage{float} +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=ipsblue, + urlcolor=ipsblue, + pdftitle={Capacidad 100000 facturas/mes IPSExtractor}, + pdfauthor={IPSExtractor QA} +} + +\newcommand{\docdate}{17 de julio de 2026} +\newcommand{\pass}{\textcolor{passgreen}{\textbf{PASS}}} +\newcommand{\fail}{\textcolor{failred}{\textbf{FAIL}}} +\newcommand{\parcial}{\textcolor{warnorange}{\textbf{PARCIAL}}} + +\newcommand{\callout}[2]{% + \begin{center} + \fbox{\parbox{0.92\textwidth}{\centering #1\\[0.3em]\normalsize #2}} + \end{center} +} + +\newcommand{\makecover}{% + \begin{titlepage} + \centering + \vspace*{1.2cm} + {\color{ipsblue}\rule{\textwidth}{2.5pt}}\\[0.35cm] + {\Huge\bfseries IPSExtractor}\\[0.25cm] + {\Large\color{ipsgrey} Extracci\'on y an\'alisis de facturas m\'edicas}\\[0.35cm] + {\color{ipsblue}\rule{\textwidth}{2.5pt}}\\[1.5cm] + {\LARGE\bfseries Capacidad: 100\,000 facturas/mes}\\[0.25cm] + {\large Modelo por etapa $\cdot$ Evidencias $\cdot$ Coste LLM}\\[1.8cm] + \begin{tabular}{@{}ll@{}} + \textbf{Fecha} & \docdate \\ + \textbf{Evidencias} & Campa\~na 13--17 jul 2026 (k6, LLM, ARQ, OCR) \\ + \textbf{Host de referencia} & ubuntu --- ASUS X441UAK, 8\,GiB (shared8g) \\ + \textbf{Audiencia} & Documento para compartir \\ + \end{tabular} + \vfill + {\small Complementa el \emph{Informe de Pruebas} y el \emph{Plan de pruebas}.}\\[0.15cm] + {\small\textbf{Veredicto breve:} host actual \textcolor{warnorange}{no garantizado} (cuello OCR); + dedicado 24$\times$7 \textcolor{passgreen}{S\'I condicional}.}\\[0.25cm] + {\color{ipsblue}\rule{0.45\textwidth}{0.6pt}} + \end{titlepage} +} diff --git a/docs/plan_pruebas/capacidad_100k/sections/01_objetivo.tex b/docs/plan_pruebas/capacidad_100k/sections/01_objetivo.tex new file mode 100644 index 0000000..a6f327b --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/01_objetivo.tex @@ -0,0 +1,45 @@ +\chapter{Objetivo: 100\,000 facturas al mes} +\label{ch:objetivo} + +\section{Qu\'e pregunta responde este documento} +\textquestiondown Puede IPSExtractor procesar del orden de \textbf{100\,000 facturas m\'edicas al mes}? + +\callout{\textbf{Anticipo del veredicto (detalle en cap.~\ref{ch:veredicto})}}{% +\textbf{En el host compartido actual: no garantizado} (pipeline $\approx$ OCR $\sim$100--325/h; piso $\sim$100 $<$ 139).\\ +\textbf{Con host dedicado y OCR dimensionado: s\'i plausible para 24$\times$7;} jornada 8\,h pide m\'as escalado. +} + +La respuesta depende de dos cosas: +\begin{enumerate} + \item \textbf{Horario:} 24$\times$7 vs solo jornada laboral. + \item \textbf{Infra:} host compartido (hoy) vs host dedicado + OCR, agent Selenium y LLM dimensionados (orquestador \texttt{bot-descarga} en Docker). +\end{enumerate} + +\section{Traducci\'on a ritmo (facturas/hora)} +\begin{table}[ht] +\centering +\begin{tabular}{@{}lrr@{}} +\toprule +\textbf{Escenario operativo} & \textbf{Facturas/d\'ia} & \textbf{Facturas/hora} \\ +\midrule +100\,000 / mes, 24$\times$7 & $\approx$3\,333 & $\approx$\textbf{139} \\ +100\,000 / mes, 8\,h $\times$ 22\,d & $\approx$4\,545 & $\approx$\textbf{570} \\ +\bottomrule +\end{tabular} +\caption{Ritmo m\'inimo para cumplir 100k/mes.} +\label{tab:objetivos-h} +\end{table} + +\callout{\large Lectura r\'apida}{% +24$\times$7 $\Rightarrow$ $\approx$\textbf{139 fact/h} punta a punta.\\ +Jornada 8\,h $\Rightarrow$ $\approx$\textbf{570 fact/h}.\\ +Gana la etapa m\'as lenta; el resto sobra si esa falla. +} + +\section{C\'omo usar este documento} +\begin{enumerate} + \item Mire el veredicto (cap.~\ref{ch:veredicto}): s\'i / no / qu\'e falta. + \item Compare etapas vs 139 y 570 (tabla~\ref{tab:capacidad}). + \item Revise evidencias medidas (cap.~\ref{ch:evidencias}) y coste LLM (cap.~\ref{ch:coste}). + \item Use el checklist de requisitos (cap.~\ref{ch:requisitos}) como plan de acci\'on. +\end{enumerate} diff --git a/docs/plan_pruebas/capacidad_100k/sections/02_arquitectura_pipeline.tex b/docs/plan_pruebas/capacidad_100k/sections/02_arquitectura_pipeline.tex new file mode 100644 index 0000000..dacd06d --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/02_arquitectura_pipeline.tex @@ -0,0 +1,44 @@ +\chapter{Arquitectura del pipeline} +\label{ch:arq-cap} + +\callout{\textbf{Nombres (importante)}}{% +\textbf{bot-descarga} = servicio de orquestaci\'on (API Docker): jobs, claim, heartbeats.\\ +\textbf{Agent Selenium} = programa en el PC que descarga PDFs (Imaging $\rightarrow$ MinIO).\\ +Las pruebas \textbf{ST-BOT-*} miden el orquestador. La capacidad $\sim$422 fact/h es del agent. +} + +\section{Patrones de dise\~no relevantes} +IPSExtractor no es un monolito: el volumen mensualmente viable depende de c\'omo +escalan \textbf{por separado} cada bounded context. + +\begin{description}[style=nextline,leftmargin=0.5cm] + \item[API Gateway] + \'Unica puerta HTTP en el host (\texttt{:8189}). Orquesta, no procesa OCR ni LLM. + Escala de control; no es el cuello de 100k. + \item[Microservicios por dominio] + \texttt{count-api} (OCR), \texttt{extraction}+workers (LLM), \texttt{bot-descarga} (orquestador) + + agent Selenium (PC), \texttt{cleaning} (CSV). Cada uno tiene su propio l\'imite de capacidad. + \item[Cola as\'incrona (ARQ/Redis)] + La extracci\'on puede encolar PDFs y drenar con $N$ workers. Throughput LLM + $\approx N \times (3600/\mathrm{p50})$ si no hay cuota del proveedor. + \item[Claim/lease del orquestador (\texttt{bot-descarga})] + Varios agents Selenium en PCs distintos reclaman jobs v\'ia la API; la capacidad de descarga + crece con $N$ agents, no con un solo proceso Selenium. +\end{description} + +\section{Flujo de negocio (punta a punta)} +\input{../diagrams/pipeline_negocio} + +Orden t\'ipico: +\begin{enumerate} + \item \textbf{Descarga:} agent Selenium $\rightarrow$ PDFs en MinIO. + \item \textbf{Conteo:} OCR/ONNX valida p\'aginas/palabras (filtro). + \item \textbf{Extracci\'on:} LLM rellena campos $\rightarrow$ filas en PostgreSQL. + \item \textbf{Limpieza:} reglas/CSV sobre el lote extra\'ido. +\end{enumerate} + +El pipeline completo solo es tan r\'apido como la etapa m\'as lenta bajo la misma +contenci\'on de CPU/RAM. + +\section{Vista de componentes} +\input{../diagrams/arquitectura} diff --git a/docs/plan_pruebas/capacidad_100k/sections/03_modelo_etapas.tex b/docs/plan_pruebas/capacidad_100k/sections/03_modelo_etapas.tex new file mode 100644 index 0000000..d812d7a --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/03_modelo_etapas.tex @@ -0,0 +1,237 @@ +\chapter{Modelo de capacidad por etapa} +\label{ch:modelo} + +\callout{\textbf{Idea en una frase}}{% +Cada etapa tiene un ritmo (facturas/hora).\\ +El \textbf{pipeline completo} solo va tan r\'apido como la etapa m\'as lenta +(hoy: OCR en host compartido).\\ +El agent Selenium y el LLM pueden ir bien por separado y el sistema entero igual no llega a 100k/mes. +} + +\section{C\'omo leer los n\'umeros} +\begin{itemize} + \item \textbf{Host actual:} servidor compartido de las pruebas (8\,GiB + MLflow y otros). + \item \textbf{Host dedicado:} estimaci\'on si IPSExtractor va solo (sin competir por CPU/RAM). + \item \textbf{139/h:} m\'inimo para 100k/mes en 24$\times$7. + \item \textbf{570/h:} m\'inimo para 100k/mes en jornada 8\,h $\times$ 22\,d. +\end{itemize} + +\noindent Sem\'aforo: +\textcolor{passgreen}{\textbf{S\'I}} = llega; +\textcolor{warnorange}{\textbf{JUSTO}} = cerca / hace falta margen; +\textcolor{failred}{\textbf{NO}} = no llega. + +\section{Tabla de capacidad (datos julio 2026)} +\begin{itemize} + \item LLM ST-EXT-02: p50 7{,}1\,s $\Rightarrow$ $\approx$506 fact/h (1 worker). + \item OCR ST-COUNT-01: p50 35{,}8\,s @3 concurrentes $\Rightarrow$ $\approx$100--325 fact/h + (en serie era $\sim$2\,s/PDF). + \item Agent Selenium: 633 PDFs / 90\,min $\Rightarrow$ $\approx$422 fact/h (1 agent PC). + \item Gateway ST-GW-01: p95 41\,ms, 0\,\% fallos --- no limita. +\end{itemize} + +\begin{table}[ht] +\centering +\small +\begin{tabular}{@{}lrrcc@{}} +\toprule +\textbf{Etapa} & \textbf{Host actual} & \textbf{Dedicado (est.)} & \textbf{\textquestiondown 139/h?} & \textbf{\textquestiondown 570/h?} \\ + & fact/h & fact/h & & \\ +\midrule +Gateway & $>$10\,000 & $>$20\,000 & \textcolor{passgreen}{\textbf{S\'I}} & \textcolor{passgreen}{\textbf{S\'I}} \\ +OCR (conteo) & $\sim$100--325 & $\sim$400 & \textcolor{warnorange}{\textbf{JUSTO}} & \textcolor{failred}{\textbf{NO}} \\ +LLM (extracci\'on) & $\sim$506 & $\sim$1\,000 (2\,w) & \textcolor{passgreen}{\textbf{S\'I}} & \textcolor{warnorange}{\textbf{JUSTO}} \\ +Descarga (agent) & $\sim$422 & $\sim$500+ & \textcolor{passgreen}{\textbf{S\'I}} & \textcolor{failred}{\textbf{NO}} \\ +Limpieza CSV & $\sim$800 & $\sim$3\,000 & \textcolor{passgreen}{\textbf{S\'I}} & \textcolor{warnorange}{\textbf{JUSTO}} \\ +\midrule +\textbf{Pipeline entero} & $\mathbf{\sim}$\textbf{100--325} & $\mathbf{\sim}$\textbf{400} & \textcolor{warnorange}{\textbf{JUSTO}}$^{\mathrm{P}}$ & \textcolor{failred}{\textbf{NO}} \\ +\bottomrule +\end{tabular} +\caption{Ritmo por etapa. Pipeline $\approx\min$ de etapas (cuello OCR). $^{\mathrm{P}}$Conservador +$\sim$100 $<$ 139; techo observado OCR $\sim$325 puede cubrir 139 si el host no degrada m\'as.} +\label{tab:capacidad} +\end{table} + +\section{C\'omo se obtiene el ritmo del pipeline} +\subsection*{Primero: qu\'e \emph{no} es ese n\'umero} +No corrimos 100\,000 PDFs de punta a punta midiendo el reloj. +El ritmo del ``pipeline entero'' es un \textbf{modelo}: se calcula a partir de las +etapas que s\'i medimos (agent, OCR, LLM, limpieza). + +\subsection*{Una factura es secuencial; muchas facturas no} +Para \textbf{una} factura el orden es obligatorio: +\begin{center} +descarga $\rightarrow$ conteo OCR $\rightarrow$ extracci\'on LLM $\rightarrow$ limpieza. +\end{center} +No puede empezar el LLM de esa factura si a\'un no termin\'o el OCR. + +Pero en el d\'ia a d\'ia no hay una sola factura: hay un \textbf{lote}. +Mientras la factura A est\'a en el LLM, la B puede estar en OCR y la C en descarga. +Es como una cadena de montaje: cada puesto trabaja en paralelo sobre piezas distintas. + +\subsection*{Analog\'ia del embudo} +El ritmo del sistema completo lo marca el \textbf{puesto m\'as lento} +(el cuello / embudo), no el m\'as r\'apido. + +\begin{center} +\begin{tabular}{@{}lrl@{}} +\toprule +\textbf{Puesto (etapa)} & \textbf{Ritmo medido} & \textbf{Lectura} \\ +\midrule +Descarga (agent Selenium) & $\approx$422 fact/h & r\'apido \\ +Conteo (OCR) & $\approx$100--325 fact/h & \textbf{el m\'as lento} \\ +Extracci\'on (LLM) & $\approx$506 fact/h & r\'apido \\ +Limpieza & $\approx$800 fact/h & r\'apido \\ +\midrule +\textbf{Pipeline entero} & $\approx$\textbf{100--325 fact/h} & $=\min$ = OCR \\ +\bottomrule +\end{tabular} +\end{center} + +Por eso da igual que el agent haga 422/h: si el OCR solo deja pasar 100--325/h, +el lote completo no puede ir m\'as r\'apido que eso. + +\subsection*{F\'ormula (r\'egimen normal / cadena de montaje)} +\[ + \text{ritmo del pipeline} \;\approx\; \min(\text{descarga},\,\text{OCR},\,\text{LLM},\,\text{limpieza}). +\] +Con los n\'umeros de arriba: $\min(422,\,100\text{--}325,\,506,\,800) = \mathbf{100\text{--}325}$. + +\subsection*{Y si proces\'aramos de una en una, sin solapar?} +Entonces s\'i se \textbf{suman} los tiempos de cada etapa por factura: +\[ + \text{fact/h} \approx 3600\big/\big(t_{\mathrm{descarga}}+t_{\mathrm{OCR}}+t_{\mathrm{LLM}}+t_{\mathrm{limpieza}}\big). +\] +Ejemplo con OCR lento ($\sim$36\,s): $\sim$70 fact/h. +Con OCR r\'apido en serie ($\sim$2\,s): $\sim$200 fact/h. + +Eso ser\'ia un laboratorio artificial (una factura y la siguiente solo cuando acaba). +En producci\'on real usamos el modelo del embudo ($\min$), que es el que importa. + +\subsection*{Qu\'e ponemos en tablas y gr\'aficos} +\begin{itemize} + \item Host actual: pipeline = $\sim$100--325 (cuello OCR). + \item Para el veredicto 24$\times$7 usamos el \textbf{piso} $\sim$100 (conservador; queda bajo 139). + \item Host dedicado (est.): $\sim$400 (OCR estimado $\sim$400). +\end{itemize} + +\section{Gr\'afico 1 --- etapas vs objetivos} +\begin{figure}[ht] +\centering +\begin{tikzpicture} +\begin{axis}[ + ybar, + width=\textwidth, + height=6.8cm, + ylabel={Facturas / hora}, + symbolic x coords={OCR,LLM,Agent,Limpieza,Pipeline}, + xtick=data, + xticklabels={OCR,LLM,{Agent Selenium},Limpieza,Pipeline}, + x tick label style={font=\small}, + legend style={at={(0.02,0.98)},anchor=north west,font=\small,draw=ipsgrey!40}, + bar width=11pt, + ymin=0, ymax=1100, + enlarge x limits=0.12, + grid=major, + major grid style={ipsgrey!20}, +] +\addplot[fill=ipsblue!75, draw=ipsblue] coordinates { + (OCR,200) (LLM,506) (Agent,422) (Limpieza,800) (Pipeline,200) +}; +\addlegendentry{Host actual} +\addplot[fill=passgreen!60, draw=passgreen] coordinates { + (OCR,400) (LLM,1012) (Agent,500) (Limpieza,1000) (Pipeline,400) +}; +\addlegendentry{Host dedicado (est.)} +\draw[failred, ultra thick] (axis cs:OCR,139) -- (axis cs:Pipeline,139) + node[pos=0, left, font=\scriptsize\bfseries, text=failred] {139}; +\draw[warnorange, ultra thick] (axis cs:OCR,570) -- (axis cs:Pipeline,570) + node[pos=0, left, font=\scriptsize\bfseries, text=warnorange] {570}; +\end{axis} +\end{tikzpicture} +\caption{Azul = host compartido (medido/estimado). Verde = host dedicado (estimado). +L\'ineas = m\'inimos para 100k/mes.} +\label{fig:capacidad} +\end{figure} + +\section{Gr\'afico 2 --- \textquestiondown llegamos a 100k/mes?} +\subsection*{C\'omo leer este gr\'afico} +Solo hay \textbf{dos barras} = cu\'anto puede el pipeline (modelo): +\begin{itemize} + \item Rojo = host \textbf{actual} (piso $\sim$100 fact/h). + \item Verde = host \textbf{dedicado} estimado ($\sim$400 fact/h). +\end{itemize} +Las \textbf{l\'ineas verticales} no son barras: son las metas que hay que superar: +\begin{itemize} + \item Azul punteada = 139/h (100k/mes trabajando 24$\times$7). + \item Naranja punteada = 570/h (100k/mes en jornada 8\,h). +\end{itemize} +Si la barra \textbf{pasa} la l\'inea $\Rightarrow$ esa meta es alcanzable. Si se queda corta $\Rightarrow$ no. + +\begin{figure}[H] +\centering +\begin{tikzpicture} +\begin{axis}[ + xbar, + width=0.78\textwidth, + height=4.8cm, + xlabel={Facturas / hora que aguanta el pipeline}, + symbolic y coords={dedicado,actual}, + ytick=data, + yticklabels={\empty,\empty}, + xmin=0, xmax=650, + enlarge y limits=0.45, + bar width=22pt, + bar shift=0pt, + grid=major, + major grid style={ipsgrey!25}, + axis y line*=left, + axis x line*=bottom, + clip=false, +] +\addplot[fill=failred!75, draw=failred] coordinates {(100,actual)}; +\addplot[fill=passgreen!75, draw=passgreen] coordinates {(400,dedicado)}; +% Etiquetas izquierda +\node[anchor=east, font=\small\bfseries, align=right] + at (axis cs:-5,actual) {Host actual\\(hoy)}; +\node[anchor=east, font=\small\bfseries, align=right] + at (axis cs:-5,dedicado) {Host dedicado\\(estimado)}; +% Valores +\node[anchor=west, font=\normalsize\bfseries] at (axis cs:110,actual) {$\sim$100 fact/h}; +\node[anchor=west, font=\normalsize\bfseries] at (axis cs:410,dedicado) {$\sim$400 fact/h}; +% Metas como l\'ineas (no barras) +\draw[ipsblue, ultra thick, dashed] + (axis cs:139,actual) -- (axis cs:139,dedicado); +\node[font=\scriptsize\bfseries, text=ipsblue, anchor=south, align=center] + at (axis cs:139,dedicado) {meta 139\\(24$\times$7)}; +\draw[warnorange, ultra thick, dashed] + (axis cs:570,actual) -- (axis cs:570,dedicado); +\node[font=\scriptsize\bfseries, text=warnorange, anchor=south, align=center] + at (axis cs:570,dedicado) {meta 570\\(jornada)}; +\end{axis} +\end{tikzpicture} +\caption{Solo capacidad (barras) vs metas (l\'ineas). Modelo por cuello OCR; no es E2E de 100k.} +\label{fig:pipeline-metas} +\end{figure} + +\begin{center} +\renewcommand{\arraystretch}{1.25} +\begin{tabular}{@{}lcc@{}} +\toprule + & \textbf{\textquestiondown Llega a 139/h?} & \textbf{\textquestiondown Llega a 570/h?} \\ + & (100k en 24$\times$7) & (100k en 8\,h) \\ +\midrule +Host actual $\sim$100 & \textcolor{failred}{\textbf{NO}} (falta margen) & \textcolor{failred}{\textbf{NO}} \\ +Host dedicado $\sim$400 & \textcolor{passgreen}{\textbf{S\'I}} & \textcolor{failred}{\textbf{NO}} (falta escalar) \\ +\bottomrule +\end{tabular} +\end{center} + + +\section{Qu\'e concluir aqu\'i} +\begin{enumerate} + \item Gateway, agent Selenium y LLM \textbf{no} son el cuello para 139/h (por etapa). + \item El pipeline sigue al OCR: $\sim$100--325/h $\Rightarrow$ 139/h \textbf{no garantizado} + (el piso conservador $\sim$100 queda bajo). + \item Host dedicado ($\sim$400/h): \textbf{s\'i} a 139/h; \textbf{no} a 570/h sin m\'as OCR/agents. +\end{enumerate} diff --git a/docs/plan_pruebas/capacidad_100k/sections/04_evidencias.tex b/docs/plan_pruebas/capacidad_100k/sections/04_evidencias.tex new file mode 100644 index 0000000..5a2b473 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/04_evidencias.tex @@ -0,0 +1,40 @@ +\chapter{Evidencias que alimentan el modelo} +\label{ch:evidencias} + +\section{Resumen} +\begin{table}[ht] +\centering +\small +\begin{tabular}{@{}llp{5.4cm}@{}} +\toprule +\textbf{Etapa} & \textbf{Cifra} & \textbf{Origen} \\ +\midrule +Gateway & no es cuello (p95 41\,ms) & ST-GW-01 (13-jul), 0\,\% fallos \\ +Agent Selenium (1 agent PC) & $\approx$422 fact/h & 633 PDFs / 90\,min \\ +LLM (1 worker) & $\approx$506 fact/h & ST-EXT-02 (17-jul): p50 7{,}114\,s \\ +OCR count-api & $\sim$100--325 fact/h & ST-COUNT-01 (17-jul): p50 35{,}8\,s @3 \\ +Cola ARQ (mock) & $\approx$4\,500 docs/h cola & ST-EXT-01: 30/30 en $\approx$24\,s \\ +Pipeline host actual & $\sim$100--325 fact/h & $=\min$ etapas; cuello OCR (no E2E) \\ +Pipeline dedicado (est.) & $\sim$400 fact/h & $\min$ con OCR $\sim$400 \\ +\bottomrule +\end{tabular} +\caption{Medido vs estimado (campa\~na julio 2026).} +\end{table} + +\section{Detalle por prueba} +\begin{description}[style=nextline,leftmargin=0.4cm] + \item[ST-EXT-02 (LLM)] + gpt-5.4-mini; 23/23 ok; p50 7{,}114\,s $\Rightarrow$ 506 fact/h (1\,w); 0$\times$429. + \item[ST-EXT-01 (ARQ mock)] + 30/30 drenados en 22{,}5\,s. Valida cola async; \textbf{no} es capacidad LLM real. + \item[ST-COUNT-01 (OCR)] + 9/9 ok; p50 35{,}8\,s con 3 concurrentes (vs $\sim$2\,s en serie). Degrada fuerte en shared8g. + \item[Agent Selenium] + 422 fact/h con 1 agent PC: supera 139/h; no alcanza 570/h sin m\'as agents. + \item[ST-GW-01 / ST-BOT-01] + Gateway OK. ST-BOT-01 mide el orquestador \texttt{bot-descarga} (heartbeats): host contendido malo (p95 2{,}28\,s); host m\'as libre OK (86\,ms). +\end{description} + +\section{A\'un no medido a escala} +ST-COUNT-01 lab, ST-EXT-01 lab (100--500), E2E punta a punta a cientos/miles de PDFs, +ni 100k reales. diff --git a/docs/plan_pruebas/capacidad_100k/sections/05_coste_llm.tex b/docs/plan_pruebas/capacidad_100k/sections/05_coste_llm.tex new file mode 100644 index 0000000..71edb3a --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/05_coste_llm.tex @@ -0,0 +1,32 @@ +\chapter{Coste LLM (100k que pasan conteo)} +\label{ch:coste} + +Hip\'otesis: el filtro de conteo deja pasar \textbf{100\,000} PDFs a extracci\'on +con modelo \textbf{gpt-5.4 mini} a USD\,0{,}0257 por documento. + +\begin{table}[ht] +\centering +\begin{tabular}{@{}lr@{}} +\toprule +\textbf{Concepto} & \textbf{Valor} \\ +\midrule +Modelo de extracci\'on & gpt-5.4 mini \\ +Coste unitario por documento & USD\,0{,}0257 \\ +Volumen (pasan filtro de conteo) & 100\,000 \\ +\textbf{Coste estimado extracci\'on / mes} & \textbf{USD\,2\,570} \\ +\bottomrule +\end{tabular} +\caption{Presupuesto LLM: $100\,000 \times 0{,}0257 = 2\,570$ USD. +No incluye agent Selenium, orquestaci\'on \texttt{bot-descarga}, OCR, infra ni reintentos.} +\label{tab:coste-llm-100k} +\end{table} + +\begin{itemize} + \item Si pasan $n < 100\,000$, el coste es lineal: $n \times 0{,}0257$. + \item Ejemplo: 60\,000 v\'alidos $\Rightarrow$ $\approx$ USD\,1\,542. + \item El unitario 0{,}0257 se us\'o como estimaci\'on en ST-EXT-02; contrastar con tokens reales si se instrumenta \texttt{usage}. +\end{itemize} + +\callout{\textbf{Coste de referencia}}{% +\textbf{USD\,2\,570}/mes si 100k PDFs llegan a extracci\'on con gpt-5.4 mini. +} diff --git a/docs/plan_pruebas/capacidad_100k/sections/06_veredicto.tex b/docs/plan_pruebas/capacidad_100k/sections/06_veredicto.tex new file mode 100644 index 0000000..3319c15 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/06_veredicto.tex @@ -0,0 +1,91 @@ +\chapter{Veredicto} +\label{ch:veredicto} + +\section{La respuesta corta} +\begin{center} +\renewcommand{\arraystretch}{1.35} +\begin{tabular}{@{}p{4.2cm}p{2.2cm}p{6.5cm}@{}} +\toprule +\textbf{Escenario} & \textbf{\textquestiondown 100k?} & \textbf{Por qu\'e} \\ +\midrule +Host \textbf{actual} (compartido) + & \parcial\ / \fail + & Pipeline $\approx$ OCR $\sim$100--325/h. + Piso $\sim$100 $<$ 139 (no garantizado). \\ +Host \textbf{dedicado} + dimensionado, 24$\times$7 + & \pass\ \emph{si} + & Estimado $\sim$400/h $>$ 139/h si OCR/agents/LLM OK. \\ +Misma infra, jornada 8\,h (570/h) + & \fail + & 400 $<$ 570: falta m\'as OCR, m\'as agents Selenium y cuota LLM. \\ +\bottomrule +\end{tabular} +\end{center} + +\callout{\large Resumen para decidir}{% +\textbf{Hoy (host compartido): 100k/mes 24$\times$7 no est\'a garantizado} +(pipeline $\approx$ cuello OCR; piso $\sim$100 $<$ 139).\\ +\textbf{Con host dedicado + OCR dimensionado: s\'i plausible a 24$\times$7.}\\ +\textbf{Jornada 8\,h: no} sin escalar m\'as.\\[0.35em] +\footnotesize El pipeline \textbf{no} se midi\'o E2E a 100k. Ritmo = $\min$ de etapas medidas +(cap.~\ref{ch:modelo}). +} + +\begin{figure}[H] +\centering +\begin{tikzpicture} +\begin{axis}[ + xbar, + width=0.78\textwidth, + height=4.6cm, + xlabel={Facturas / hora que aguanta el pipeline}, + symbolic y coords={dedicado,actual}, + ytick=data, + yticklabels={\empty,\empty}, + xmin=0, xmax=650, + enlarge y limits=0.45, + bar width=22pt, + bar shift=0pt, + grid=major, + major grid style={ipsgrey!25}, + clip=false, +] +\addplot[fill=failred!75, draw=failred] coordinates {(100,actual)}; +\addplot[fill=passgreen!75, draw=passgreen] coordinates {(400,dedicado)}; +\node[anchor=east, font=\small\bfseries, align=right] + at (axis cs:-5,actual) {Host actual\\(hoy)}; +\node[anchor=east, font=\small\bfseries, align=right] + at (axis cs:-5,dedicado) {Host dedicado\\(estimado)}; +\node[anchor=west, font=\normalsize\bfseries] at (axis cs:110,actual) {$\sim$100}; +\node[anchor=west, font=\normalsize\bfseries] at (axis cs:410,dedicado) {$\sim$400}; +\draw[ipsblue, ultra thick, dashed] + (axis cs:139,actual) -- (axis cs:139,dedicado); +\node[font=\scriptsize\bfseries, text=ipsblue, anchor=south, align=center] + at (axis cs:139,dedicado) {meta 139}; +\draw[warnorange, ultra thick, dashed] + (axis cs:570,actual) -- (axis cs:570,dedicado); +\node[font=\scriptsize\bfseries, text=warnorange, anchor=south, align=center] + at (axis cs:570,dedicado) {meta 570}; +\end{axis} +\end{tikzpicture} +\caption{Barras = capacidad. L\'ineas = metas. Rojo no llega a 139; verde llega a 139 pero no a 570.} +\label{fig:veredicto-brecha} +\end{figure} + +\section{Si hoy no est\'a garantizado: qu\'e hace falta} +\begin{enumerate} + \item \textbf{Host dedicado} (sin MLflow/Airflow/n8n); RAM $\geq$16--32\,GiB. + \item \textbf{OCR con margen} hasta sostener $\geq$139 fact/h bajo carga real. + \item \textbf{LLM async} (\texttt{ASYNC\_QUEUE=true}, 1--2 workers; ya $\approx$506/h medidos). + \item \textbf{Agent Selenium:} 1 PC basta para 139/h; $N$ PCs para 570/h. + \item \textbf{Presupuesto LLM} $\approx$ USD\,2\,570/mes si pasan 100k a extracci\'on. + \item \textbf{Validar E2E} de cientos de PDFs punta a punta (a\'un pendiente). +\end{enumerate} + +\section{Por meta operativa} +\begin{description} + \item[100k/mes en 24$\times$7 ($\geq$139 fact/h)] + Plausible en host dedicado. En compartido depende de que el OCR no se quede en el piso $\sim$100. + \item[100k/mes en jornada 8\,h ($\geq$570 fact/h)] + No con el estimado dedicado ($\sim$400). Escalar OCR, agents Selenium y LLM, y re-medir. +\end{description} diff --git a/docs/plan_pruebas/capacidad_100k/sections/07_requisitos.tex b/docs/plan_pruebas/capacidad_100k/sections/07_requisitos.tex new file mode 100644 index 0000000..d5f5141 --- /dev/null +++ b/docs/plan_pruebas/capacidad_100k/sections/07_requisitos.tex @@ -0,0 +1,36 @@ +\chapter{Requisitos sugeridos y pr\'oximos pasos} +\label{ch:requisitos} + +\section{Para lograr 100k/mes (lista simple)} +Si hoy es \fail\ en el host compartido, esto es lo m\'inimo para apuntar a \pass\ en 24$\times$7: + +\begin{center} +\renewcommand{\arraystretch}{1.3} +\begin{tabular}{@{}clp{9.2cm}@{}} +\toprule +\textbf{\#} & \textbf{Estado} & \textbf{Qu\'e} \\ +\midrule +1 & Obligatorio & Host \textbf{dedicado} IPS (sin MLflow/Airflow/n8n); RAM $\geq$16--32\,GiB \\ +2 & Obligatorio & OCR con margen (CPU/RAM o r\'eplicas) hasta $\geq$139 fact/h bajo carga \\ +3 & Obligatorio & \texttt{ASYNC\_QUEUE=true} + 1--2 workers LLM \\ +4 & Obligatorio & Presupuesto LLM $\approx$ USD\,2\,570/mes (si pasan 100k a extracci\'on) \\ +5 & Seg\'un meta & 1 agent Selenium basta para 139/h; $N$ agents PC para 570/h \\ +6 & Validaci\'on & Repetir ST-COUNT-01 / ST-EXT-01 lab + E2E de cientos de PDFs \\ +\bottomrule +\end{tabular} +\end{center} + +\section{Pr\'oximas mediciones} +\begin{enumerate} + \item ST-COUNT-01 perfil lab ($\geq$3\,GiB libres). + \item ST-EXT-01 perfil lab (100--500 docs, 2 workers). + \item E2E controlado (200--500 PDFs punta a punta). + \item Tokens reales del LLM para validar USD\,0{,}0257/doc. +\end{enumerate} + +\section{Documentos relacionados} +\begin{itemize} + \item \textbf{Plan de pruebas} --- estrategia e IDs. + \item \textbf{Informe de resultados} --- m\'etricas de la campa\~na. + \item Modelo Markdown: \texttt{docs/plan\_pruebas/scripts/estimate\_capacity.md}. +\end{itemize} diff --git a/docs/plan_pruebas/diagrams/arquitectura.tex b/docs/plan_pruebas/diagrams/arquitectura.tex index 6d9f6db..63635b9 100644 --- a/docs/plan_pruebas/diagrams/arquitectura.tex +++ b/docs/plan_pruebas/diagrams/arquitectura.tex @@ -2,35 +2,47 @@ \centering \begin{tikzpicture}[ font=\small, - box/.style={draw=ipsblue, rounded corners=2pt, align=center, inner sep=4pt, minimum height=0.85cm, minimum width=2.4cm}, + box/.style={draw=ipsblue, rounded corners=2pt, align=center, inner sep=4pt, minimum height=0.85cm, minimum width=2.3cm}, ext/.style={draw=ipsgrey, dashed, rounded corners=2pt, align=center, inner sep=4pt, minimum height=0.85cm}, - arr/.style={-{Latex}, thick, ipsblue} + arr/.style={-{Latex}, thick, ipsblue}, + dbarr/.style={-{Latex}, thick, ipsgrey} ] \node[box, fill=codebg] (desk) {Desktop\\Electron}; - \node[box, fill=codebg, right=1.8cm of desk] (gw) {api\_gateway\\:8189}; - \node[box, below=1.1cm of gw] (ext) {extraction\\:8001}; - \node[box, left=0.8cm of ext] (clean) {cleaning\\:8002}; - \node[box, right=0.8cm of ext] (count) {count-api\\:8080}; - \node[box, below=1.1cm of ext] (bot) {bot-descarga\\:8200}; - \node[box, below left=1.0cm and 0.2cm of bot] (pg) {PostgreSQL}; - \node[box, below right=1.0cm and 0.2cm of bot] (redis) {Redis}; - \node[ext, left=1.6cm of desk] (agent) {Agent PC\\Selenium}; - \node[ext, below=2.6cm of agent] (minio) {MinIO/S3}; - \node[ext, below=0.9cm of minio] (img) {Imaging}; + \node[box, fill=codebg, right=1.7cm of desk] (gw) {api\_gateway\\:8189}; + \node[box, below=1.05cm of gw] (ext) {extraction\\(+ workers)}; + \node[box, left=0.7cm of ext] (clean) {cleaning\\:8002}; + \node[box, right=0.7cm of ext] (count) {count-api\\:8080}; + \node[box, below=1.05cm of ext] (bot) {bot-descarga\\orquestador}; + \node[box, below left=1.15cm and 1.4cm of bot] (pg) {PostgreSQL}; + \node[box, below right=1.15cm and 1.4cm of bot] (redis) {Redis}; + \node[ext, left=1.5cm of desk] (agent) {Agent Selenium\\(PC externo)}; + \node[ext, below=2.5cm of agent] (minio) {MinIO/S3}; + \node[ext, below=0.85cm of minio] (img) {Imaging}; + % HTTP via gateway \draw[arr] (desk) -- (gw); \draw[arr] (gw) -- (ext); \draw[arr] (gw) -- (clean); \draw[arr] (gw) -- (count); \draw[arr] (gw) -- (bot); - \draw[arr] (bot) -- (pg); - \draw[arr] (ext) -- (pg); - \draw[arr] (ext) -- (redis); \draw[arr] (agent) |- (gw); + + % PostgreSQL: solo servicios de dominio (NO gateway, NO count-api) + \draw[dbarr] (ext) -- (pg); + \draw[dbarr] (clean) |- (pg); + \draw[dbarr] (bot) -- (pg); + + % Redis: cola ARQ (extraction/workers) + \draw[dbarr] (ext) -- (redis); + + % MinIO / Imaging \draw[arr] (agent) -- (minio); \draw[arr] (bot) -| (minio); \draw[arr] (agent) -- (img); \end{tikzpicture} -\caption{Arquitectura IPSExtractor bajo prueba.} +\caption{Arquitectura bajo prueba. El \textbf{gateway no habla con Postgres}: solo hace proxy HTTP. +Usan la BD compartida: \texttt{extraction} (+ workers), \texttt{cleaning} y \texttt{bot-descarga}. +\texttt{count-api} hace OCR y \textbf{devuelve JSON} (no escribe en PG); el Desktop/extraction +persiste esos resultados con \texttt{POST /count-jobs} en Postgres.} \label{fig:arquitectura} \end{figure} diff --git a/docs/plan_pruebas/diagrams/patrones.tex b/docs/plan_pruebas/diagrams/patrones.tex new file mode 100644 index 0000000..7257cf3 --- /dev/null +++ b/docs/plan_pruebas/diagrams/patrones.tex @@ -0,0 +1,40 @@ +\begin{figure}[H] +\centering +\resizebox{\textwidth}{!}{% +\begin{tikzpicture}[ + font=\footnotesize, + node distance=0.45cm and 0.55cm, + box/.style={draw=ipsblue, rounded corners=3pt, align=center, inner sep=4pt, + minimum height=0.95cm, minimum width=2.55cm, fill=codebg, text=black}, + title/.style={font=\small\bfseries, text=ipsblue, anchor=west}, + arr/.style={-{Latex}, thick, draw=ipsblue} +] + % --- 1: API Gateway --- + \node[title] (t1) at (0,0) {API Gateway}; + \node[box, below=0.3cm of t1.west, anchor=west] (client) {Desktop / Agent}; + \node[box, right=of client] (gw) {Gateway :8189}; + \node[box, right=of gw] (svc) {Microservicios}; + \draw[arr] (client) -- (gw); + \draw[arr] (gw) -- (svc); + + % --- 2: ARQ --- + \node[title] (t2) at (0,-2.15) {Cola async (ARQ)}; + \node[box, below=0.3cm of t2.west, anchor=west] (api) {extraction API}; + \node[box, right=of api] (redis) {Redis / ARQ}; + \node[box, right=of redis] (w) {Workers $\times N$}; + \draw[arr] (api) -- (redis); + \draw[arr] (redis) -- (w); + + % --- 3: Claim --- + \node[title] (t3) at (0,-4.3) {Claim / lease (orquestaci\'on)}; + \node[box, below=0.3cm of t3.west, anchor=west] (store) {bot-descarga\\(orquestador)}; + \node[box, right=of store] (claim) {claim at\'omico}; + \node[box, right=of claim] (agent) {Agent Selenium\\(PC)}; + \draw[arr] (store) -- (claim); + \draw[arr] (claim) -- (agent); +\end{tikzpicture}% +} +\caption{Patrones: Gateway, cola ARQ y claim. El servicio \texttt{bot-descarga} \textbf{orquesta}; +el \textbf{agent Selenium} en el PC ejecuta la descarga.} +\label{fig:patrones} +\end{figure} diff --git a/docs/plan_pruebas/diagrams/pipeline_negocio.tex b/docs/plan_pruebas/diagrams/pipeline_negocio.tex new file mode 100644 index 0000000..be1dd84 --- /dev/null +++ b/docs/plan_pruebas/diagrams/pipeline_negocio.tex @@ -0,0 +1,30 @@ +\begin{figure}[ht] +\centering +\begin{tikzpicture}[ + font=\small, + stage/.style={draw=ipsblue, rounded corners=3pt, align=center, inner sep=5pt, + minimum height=1.0cm, minimum width=2.15cm, fill=codebg}, + arr/.style={-{Latex}, thick, ipsblue} +] + \node[stage] (dl) {1. Descarga\\agent Selenium}; + \node[stage, right=0.5cm of dl] (cnt) {2. Conteo\\OCR/ONNX}; + \node[stage, right=0.5cm of cnt] (ext) {3. Extracci\'on\\LLM + ARQ}; + \node[stage, right=0.5cm of ext] (cln) {4. Limpieza\\CSV/reglas}; + + \draw[arr] (dl) -- (cnt); + \draw[arr] (cnt) -- (ext); + \draw[arr] (ext) -- (cln); + + \node[below=0.35cm of dl, font=\footnotesize\itshape, text=ipsgrey, align=center] + {orquestado por\\bot-descarga}; + \node[below=0.35cm of cnt, font=\footnotesize\itshape, text=ipsgrey, align=center] + {filtro}; + \node[below=0.35cm of ext, font=\footnotesize\itshape, text=ipsgrey, align=center] + {filas PG}; + \node[below=0.35cm of cln, font=\footnotesize\itshape, text=ipsgrey, align=center] + {lote limpio}; +\end{tikzpicture} +\caption{Pipeline punta a punta. La descarga la hace el \textbf{agent Selenium} (PC); +el servicio \texttt{bot-descarga} solo orquesta jobs/claim/heartbeats.} +\label{fig:pipeline-negocio} +\end{figure} diff --git a/docs/plan_pruebas/informe/README.md b/docs/plan_pruebas/informe/README.md index a31f97f..924a07c 100644 --- a/docs/plan_pruebas/informe/README.md +++ b/docs/plan_pruebas/informe/README.md @@ -1,6 +1,8 @@ # Informe de Pruebas IPSExtractor -Entregable profesional de **resultados** (métricas, gráficas, capacidad 100k/mes). +Entregable de **resultados** (métricas, gráficas, interpretación). + +El análisis de **100 000 facturas/mes** está en [`../capacidad_100k/`](../capacidad_100k/). ## Compilar @@ -16,5 +18,5 @@ Salida: [`../InformePruebas_IPSExtractor.pdf`](../InformePruebas_IPSExtractor.pd ## Datos - `data/metricas.csv` — latencias k6 -- `data/capacidad.csv` — modelo 100k/mes +- `data/capacidad.csv` — modelo 100k/mes (referencia; detalle en PDF Capacidad) - `data/memoria.csv` — RAM contenedores diff --git a/docs/plan_pruebas/informe/main.aux b/docs/plan_pruebas/informe/main.aux index f3356c1..d8ba169 100644 --- a/docs/plan_pruebas/informe/main.aux +++ b/docs/plan_pruebas/informe/main.aux @@ -9,148 +9,191 @@ \providecommand\HyField@AuxAddToFields[1]{} \providecommand\HyField@AuxAddToCoFields[2]{} \babel@aux{spanish}{} -\@writefile{toc}{\contentsline {chapter}{\numberline {1}Resumen ejecutivo}{5}{chapter.1}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {1}Resumen ejecutivo}{4}{chapter.1}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:resumen}{{1}{5}{Resumen ejecutivo}{chapter.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {1.1}Veredicto global}{5}{section.1.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {1.2}KPIs de la campa\~na}{5}{section.1.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {1.1}{\ignorespaces KPIs ejecutivos de la campa\~na.}}{5}{table.1.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {1.3}Respuesta directa: 100\,000 facturas/mes}{5}{section.1.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {1.4}Riesgo principal}{6}{section.1.4}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {2}Objeto y alcance}{7}{chapter.2}\protected@file@percent } +\newlabel{ch:resumen}{{1}{4}{Resumen ejecutivo}{chapter.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {1.1}Veredicto global}{4}{section.1.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.2}KPIs de la campa\~na}{4}{section.1.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {1.1}{\ignorespaces KPIs (carga 13-jul; LLM/ARQ/OCR 17-jul).}}{4}{table.1.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.3}Capacidad 100k/mes (resumen)}{5}{section.1.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.4}Riesgo principal}{5}{section.1.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.5}Alcance de esta campa\~na}{5}{section.1.5}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {2}Arquitectura y patrones (contexto)}{6}{chapter.2}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:alcance}{{2}{7}{Objeto y alcance}{chapter.2}{}} -\@writefile{toc}{\contentsline {section}{\numberline {2.1}Objeto}{7}{section.2.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {2.2}Alcance incluido}{7}{section.2.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {2.3}Fuera de alcance de esta campa\~na}{7}{section.2.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {2.4}Definici\'on de factura}{7}{section.2.4}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {3}Entorno bajo prueba}{8}{chapter.3}\protected@file@percent } +\newlabel{ch:arq-informe}{{2}{6}{Arquitectura y patrones (contexto)}{chapter.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.1}Vista de componentes}{6}{section.2.1}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.1}{\ignorespaces Arquitectura bajo prueba. El \textbf {gateway no habla con Postgres}: solo hace proxy HTTP. Usan la BD compartida: \texttt {extraction} (+ workers), \texttt {cleaning} y \texttt {bot-descarga}. \texttt {count-api} es OCR sin Postgres.}}{6}{figure.2.1}\protected@file@percent } +\newlabel{fig:arquitectura}{{2.1}{6}{Arquitectura bajo prueba. El \textbf {gateway no habla con Postgres}: solo hace proxy HTTP. Usan la BD compartida: \texttt {extraction} (+ workers), \texttt {cleaning} y \texttt {bot-descarga}. \texttt {count-api} es OCR sin Postgres}{figure.2.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.2}Patrones de dise\~no}{7}{section.2.2}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.2}{\ignorespaces Patrones: Gateway, cola ARQ y claim. El servicio \texttt {bot-descarga} \textbf {orquesta}; el \textbf {agent Selenium} en el PC ejecuta la descarga.}}{7}{figure.2.2}\protected@file@percent } +\newlabel{fig:patrones}{{2.2}{7}{Patrones: Gateway, cola ARQ y claim. El servicio \texttt {bot-descarga} \textbf {orquesta}; el \textbf {agent Selenium} en el PC ejecuta la descarga}{figure.2.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.3}Pipeline de negocio}{7}{section.2.3}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.3}{\ignorespaces Pipeline punta a punta. La descarga la hace el \textbf {agent Selenium} (PC); el servicio \texttt {bot-descarga} solo orquesta jobs/claim/heartbeats.}}{8}{figure.2.3}\protected@file@percent } +\newlabel{fig:pipeline-negocio}{{2.3}{8}{Pipeline punta a punta. La descarga la hace el \textbf {agent Selenium} (PC); el servicio \texttt {bot-descarga} solo orquesta jobs/claim/heartbeats}{figure.2.3}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.4}Definici\'on de factura}{8}{section.2.4}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {3}Entorno bajo prueba}{9}{chapter.3}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:entorno}{{3}{8}{Entorno bajo prueba}{chapter.3}{}} -\@writefile{toc}{\contentsline {section}{\numberline {3.1}Hardware y sistema}{8}{section.3.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {3.1}{\ignorespaces Host de ejecuci\'on.}}{8}{table.3.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {3.2}Baseline de memoria (2026-07-13)}{8}{section.3.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {3.3}Salud del stack}{8}{section.3.3}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {3.1}{\ignorespaces Huella de memoria por contenedor (idle). MLflow domina el host.}}{9}{figure.3.1}\protected@file@percent } -\newlabel{fig:memoria}{{3.1}{9}{Huella de memoria por contenedor (idle). MLflow domina el host}{figure.3.1}{}} -\@writefile{toc}{\contentsline {chapter}{\numberline {4}Metodolog\'ia}{10}{chapter.4}\protected@file@percent } +\newlabel{ch:entorno}{{3}{9}{Entorno bajo prueba}{chapter.3}{}} +\@writefile{toc}{\contentsline {section}{\numberline {3.1}Hardware y sistema}{9}{section.3.1}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {3.1}{\ignorespaces Host de ejecuci\'on.}}{9}{table.3.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.2}Baseline de memoria (2026-07-13)}{9}{section.3.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.3}Salud del stack}{9}{section.3.3}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {3.1}{\ignorespaces Huella de memoria por contenedor (idle). MLflow domina el host.}}{10}{figure.3.1}\protected@file@percent } +\newlabel{fig:memoria}{{3.1}{10}{Huella de memoria por contenedor (idle). MLflow domina el host}{figure.3.1}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {4}C\'omo leer este informe}{11}{chapter.4}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:metodologia}{{4}{10}{Metodolog\'ia}{chapter.4}{}} -\@writefile{toc}{\contentsline {section}{\numberline {4.1}Tipos de prueba}{10}{section.4.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.2}Herramientas}{10}{section.4.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.3}SLOs perfil \texttt {shared8g}}{10}{section.4.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.4}Comandos representativos}{10}{section.4.4}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {5}Resultados --- pruebas unitarias}{11}{chapter.5}\protected@file@percent } +\newlabel{ch:como-leer}{{4}{11}{C\'omo leer este informe}{chapter.4}{}} +\@writefile{toc}{\contentsline {section}{\numberline {4.1}Plantilla de interpretaci\'on}{11}{section.4.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.2}Leyenda de estados}{11}{section.4.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.3}Perfil de carga \texttt {shared8g}}{11}{section.4.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.4}Herramientas}{11}{section.4.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.5}Mapa de cap\'itulos de resultados}{12}{section.4.5}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {5}Resultados --- pruebas unitarias}{13}{chapter.5}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:unitarias}{{5}{11}{Resultados --- pruebas unitarias}{chapter.5}{}} -\@writefile{toc}{\contentsline {section}{\numberline {5.1}Scorecard}{11}{section.5.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {5.1}{\ignorespaces Resultados unitarios.}}{11}{table.5.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {5.2}Cobertura agent}{11}{section.5.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {5.3}Cobertura API store}{11}{section.5.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {5.4}Cobertura count-api}{11}{section.5.4}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {5.1}{\ignorespaces Unitarias ejecutadas vs pendientes.}}{12}{figure.5.1}\protected@file@percent } -\newlabel{fig:unitarias}{{5.1}{12}{Unitarias ejecutadas vs pendientes}{figure.5.1}{}} -\@writefile{toc}{\contentsline {chapter}{\numberline {6}Resultados --- humo y funcional}{13}{chapter.6}\protected@file@percent } +\newlabel{ch:unitarias}{{5}{13}{Resultados --- pruebas unitarias}{chapter.5}{}} +\@writefile{toc}{\contentsline {section}{\numberline {5.1}Qu\'e eval\'uan}{13}{section.5.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.2}Scorecard}{13}{section.5.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {5.1}{\ignorespaces Resultados unitarios.}}{13}{table.5.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.3}Cobertura agent Selenium}{13}{section.5.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.4}Cobertura API \texttt {bot-descarga} (orquestador)}{14}{section.5.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.5}Cobertura count-api}{14}{section.5.5}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.6}Cobertura desktop (Vitest)}{14}{section.5.6}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {5.1}{\ignorespaces Unitarias ejecutadas (todas en verde tras Vitest desktop).}}{14}{figure.5.1}\protected@file@percent } +\newlabel{fig:unitarias}{{5.1}{14}{Unitarias ejecutadas (todas en verde tras Vitest desktop)}{figure.5.1}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {6}Resultados --- humo y funcional}{15}{chapter.6}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:humo}{{6}{13}{Resultados --- humo y funcional}{chapter.6}{}} -\@writefile{toc}{\contentsline {section}{\numberline {6.1}Humo gateway}{13}{section.6.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {6.1}{\ignorespaces Humo HTTP.}}{13}{table.6.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {6.2}Funcional E2E-07 (worker offline $\rightarrow $ 409)}{13}{section.6.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {6.3}Script preparado}{13}{section.6.3}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {7}Resultados --- pruebas de carga}{14}{chapter.7}\protected@file@percent } +\newlabel{ch:humo}{{6}{15}{Resultados --- humo y funcional}{chapter.6}{}} +\@writefile{toc}{\contentsline {section}{\numberline {6.1}Qu\'e eval\'uan}{15}{section.6.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {6.2}Humo gateway}{15}{section.6.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {6.1}{\ignorespaces Humo HTTP.}}{15}{table.6.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {6.3}Funcional E2E-07 (worker offline $\rightarrow $ 409)}{15}{section.6.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {6.4}Scripts}{16}{section.6.4}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {7}Resultados --- pruebas de carga}{17}{chapter.7}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:carga}{{7}{14}{Resultados --- pruebas de carga}{chapter.7}{}} -\@writefile{toc}{\contentsline {section}{\numberline {7.1}Tabla consolidada}{14}{section.7.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {7.1}{\ignorespaces M\'etricas de carga k6 (perfil shared8g).}}{14}{table.7.1}\protected@file@percent } -\newlabel{tab:carga}{{7.1}{14}{M\'etricas de carga k6 (perfil shared8g)}{table.7.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {7.2}ST-GW-01}{14}{section.7.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {7.3}ST-BOT-01 --- comparativa}{14}{section.7.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {7.4}Throughput gateway}{14}{section.7.4}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {7.1}{\ignorespaces p95 y max por caso. BOT1-max (23\,s) fuera de escala visual relativa --- ver tabla.}}{15}{figure.7.1}\protected@file@percent } -\newlabel{fig:latencias}{{7.1}{15}{p95 y max por caso. BOT1-max (23\,s) fuera de escala visual relativa --- ver tabla}{figure.7.1}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {7.2}{\ignorespaces Before/after heartbeats (eje limitado; valores exactos en tabla~\ref {tab:carga}).}}{15}{figure.7.2}\protected@file@percent } -\newlabel{fig:bot-before-after}{{7.2}{15}{Before/after heartbeats (eje limitado; valores exactos en tabla~\ref {tab:carga})}{figure.7.2}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {7.3}{\ignorespaces Throughput observado en health liveness (no equivale a PDFs/h).}}{15}{figure.7.3}\protected@file@percent } -\newlabel{fig:throughput}{{7.3}{15}{Throughput observado en health liveness (no equivale a PDFs/h)}{figure.7.3}{}} -\@writefile{toc}{\contentsline {chapter}{\numberline {8}Capacidad: 100\,000 facturas al mes}{16}{chapter.8}\protected@file@percent } +\newlabel{ch:carga}{{7}{17}{Resultados --- pruebas de carga}{chapter.7}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.1}Qu\'e eval\'uan}{17}{section.7.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {7.2}Tabla consolidada}{17}{section.7.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {7.1}{\ignorespaces M\'etricas de carga k6 (perfil shared8g).}}{17}{table.7.1}\protected@file@percent } +\newlabel{tab:carga}{{7.1}{17}{M\'etricas de carga k6 (perfil shared8g)}{table.7.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.3}ST-GW-01}{17}{section.7.3}\protected@file@percent } +\newlabel{sec:st-gw-01}{{7.3}{17}{ST-GW-01}{section.7.3}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.4}ST-BOT-01 --- comparativa}{18}{section.7.4}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {7.1}{\ignorespaces Misma prueba ST-BOT-01, distinto estado del host. (*) El m\'aximo real de la corrida 1 fue 23{,}1\,s; la barra est\'a recortada solo para que se lean el resto de valores. Detalle num\'erico en la tabla~\ref {tab:carga}.}}{19}{figure.7.1}\protected@file@percent } +\newlabel{fig:bot-before-after}{{7.1}{19}{Misma prueba ST-BOT-01, distinto estado del host. (*) El m\'aximo real de la corrida 1 fue 23{,}1\,s; la barra est\'a recortada solo para que se lean el resto de valores. Detalle num\'erico en la tabla~\ref {tab:carga}}{figure.7.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.5}Throughput gateway}{19}{section.7.5}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {7.2}{\ignorespaces Ritmo del gateway en health liveness ($\approx $57\,req/s). Solo mide HTTP ligero, no facturas procesadas.}}{19}{figure.7.2}\protected@file@percent } +\newlabel{fig:throughput}{{7.2}{19}{Ritmo del gateway en health liveness ($\approx $57\,req/s). Solo mide HTTP ligero, no facturas procesadas}{figure.7.2}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {8}Count-api: qu\'e eval\'ua cada prueba}{20}{chapter.8}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:capacidad}{{8}{16}{Capacidad: 100\,000 facturas al mes}{chapter.8}{}} -\@writefile{toc}{\contentsline {section}{\numberline {8.1}Traducci\'on del objetivo}{16}{section.8.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {8.1}{\ignorespaces Throughput m\'inimo para cumplir 100k/mes.}}{16}{table.8.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {8.2}Modelo por etapa}{16}{section.8.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {8.2}{\ignorespaces Facturas/hora estimadas. $^{*}$Sujeto a cuota LLM. $^{\dagger }$Con $N$ agents en paralelo.}}{16}{table.8.2}\protected@file@percent } -\newlabel{tab:capacidad}{{8.2}{16}{Facturas/hora estimadas. $^{*}$Sujeto a cuota LLM. $^{\dagger }$Con $N$ agents en paralelo}{table.8.2}{}} -\@writefile{toc}{\contentsline {section}{\numberline {8.3}Interpretaci\'on}{16}{section.8.3}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {8.1}{\ignorespaces Brecha de capacidad. Gateway real $\gg $1000/h; se trunca para legibilidad.}}{17}{figure.8.1}\protected@file@percent } -\newlabel{fig:capacidad}{{8.1}{17}{Brecha de capacidad. Gateway real $\gg $1000/h; se trunca para legibilidad}{figure.8.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {8.4}Veredicto}{17}{section.8.4}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {8.5}Requisitos m\'inimos sugeridos para 100k/mes}{17}{section.8.5}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {9}An\'alisis y hallazgos}{18}{chapter.9}\protected@file@percent } +\newlabel{ch:count-latency}{{8}{20}{Count-api: qu\'e eval\'ua cada prueba}{chapter.8}{}} +\@writefile{toc}{\contentsline {section}{\numberline {8.1}Qu\'e eval\'ua / resultado / significado}{20}{section.8.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {8.2}Pipeline bajo prueba}{20}{section.8.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {8.1}{\ignorespaces Scorecard count-api --- campa\~na 23 PDFs (13 jul 2026).}}{21}{table.8.1}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {8.1}{\ignorespaces Cobertura count-api: 11 unitarias + 23 PDFs medidos (HTTP y Go).}}{21}{figure.8.1}\protected@file@percent } +\newlabel{fig:count-score}{{8.1}{21}{Cobertura count-api: 11 unitarias + 23 PDFs medidos (HTTP y Go)}{figure.8.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {8.3}Resultado global count-api}{21}{section.8.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {8.4}Features: qu\'e se eval\'ua (con ejemplo)}{21}{section.8.4}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.1}TestExtractRowFeaturesServiceTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{21}{subsection.8.4.1}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.2}TestBuildFeaturesContinuation --- \textcolor {passgreen}{\textbf {PASS}}}{21}{subsection.8.4.2}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.3}TestExtractRowFeaturesHeaderKeyword --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.3}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.4}TestExtractRowFeaturesServiceCode --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.4}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.5}TestExtractRowFeaturesNoTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.5}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.6}TestBuildFeatureRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.6}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.4.7}TestFeatureCount --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.7}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {8.5}Clustering: qu\'e se eval\'ua (con ejemplo)}{22}{section.8.5}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.5.1}TestClusterRowsTwoBands --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.1}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.5.2}TestClusterRowsSameBand --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.2}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.5.3}TestClusterRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.3}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.5.4}TestClusterRowsNoiseMinSamples --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.4}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {8.2}{\ignorespaces Ilustraci\'on del test de dos bandas (no son coordenadas OCR reales).}}{23}{figure.8.2}\protected@file@percent } +\newlabel{fig:cluster-idea}{{8.2}{23}{Ilustraci\'on del test de dos bandas (no son coordenadas OCR reales)}{figure.8.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {8.6}Pruebas con PDFs reales}{23}{section.8.6}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.6.1}Fixtures}{23}{subsection.8.6.1}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.6.2}TestProcessPDFTimingFixtures}{23}{subsection.8.6.2}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {8.6.3}Script HTTP \texttt {measure\_count\_latency}}{23}{subsection.8.6.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {8.7}Resultados agregados (23 PDFs)}{23}{section.8.7}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {8.2}{\ignorespaces Estad\'isticas sobre 23 facturas. HTTP v\'ia gateway; Go v\'ia \texttt {ProcessPDF} directo.}}{23}{table.8.2}\protected@file@percent } +\newlabel{tab:count-stats}{{8.2}{23}{Estad\'isticas sobre 23 facturas. HTTP v\'ia gateway; Go v\'ia \texttt {ProcessPDF} directo}{table.8.2}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {8.3}{\ignorespaces El OCR representa $\sim $93\,\% del tiempo de procesamiento.}}{24}{figure.8.3}\protected@file@percent } +\newlabel{fig:count-breakdown}{{8.3}{24}{El OCR representa $\sim $93\,\% del tiempo de procesamiento}{figure.8.3}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {8.4}{\ignorespaces Serie de tiempos totales. L\'inea punteada: promedio 2008\,ms.}}{24}{figure.8.4}\protected@file@percent } +\newlabel{fig:count-series}{{8.4}{24}{Serie de tiempos totales. L\'inea punteada: promedio 2008\,ms}{figure.8.4}{}} +\@writefile{toc}{\contentsline {section}{\numberline {8.8}Resultados de latencia HTTP}{24}{section.8.8}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {8.3}{\ignorespaces Resumen HTTP (sin concurrencia). Fuente: CSV del 13 jul 2026.}}{24}{table.8.3}\protected@file@percent } +\newlabel{tab:count-http}{{8.3}{24}{Resumen HTTP (sin concurrencia). Fuente: CSV del 13 jul 2026}{table.8.3}{}} +\@writefile{toc}{\contentsline {section}{\numberline {8.9}Detalle por archivo}{25}{section.8.9}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {8.10}C\'omo reproducir}{25}{section.8.10}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {9}Extracci\'on LLM: bench de latencia (ST-EXT-02)}{26}{chapter.9}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:analisis}{{9}{18}{An\'alisis y hallazgos}{chapter.9}{}} -\@writefile{toc}{\contentsline {section}{\numberline {9.1}Hallazgos}{18}{section.9.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {9.2}Scorecard global}{18}{section.9.2}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {9.1}{\ignorespaces Distribuci\'on de resultados por tipo de prueba.}}{18}{figure.9.1}\protected@file@percent } -\newlabel{fig:scorecard}{{9.1}{18}{Distribuci\'on de resultados por tipo de prueba}{figure.9.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {9.3}Limitaciones}{18}{section.9.3}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {10}Conclusiones y recomendaciones}{19}{chapter.10}\protected@file@percent } +\newlabel{ch:llm-latency}{{9}{26}{Extracci\'on LLM: bench de latencia (ST-EXT-02)}{chapter.9}{}} +\@writefile{toc}{\contentsline {section}{\numberline {9.1}Qu\'e eval\'ua / resultado / significado}{26}{section.9.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {9.2}C\'omo ejecutar}{26}{section.9.2}\protected@file@percent } +\@writefile{lol}{\contentsline {lstlisting}{\numberline {9.1}ST-EXT-02 (Windows PowerShell)}{26}{lstlisting.9.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {9.3}Resultados (2026-07-17)}{27}{section.9.3}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {9.1}{\ignorespaces ST-EXT-02 --- latencia LLM medida (mismo schema/campos).}}{27}{table.9.1}\protected@file@percent } +\newlabel{tab:llm-latency}{{9.1}{27}{ST-EXT-02 --- latencia LLM medida (mismo schema/campos)}{table.9.1}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {9.1}{\ignorespaces La etapa LLM sola supera 139/h; con 2 workers supera 570/h (sin cuota).}}{27}{figure.9.1}\protected@file@percent } +\newlabel{fig:llm-vs-obj}{{9.1}{27}{La etapa LLM sola supera 139/h; con 2 workers supera 570/h (sin cuota)}{figure.9.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {9.4}Lectura para capacidad}{27}{section.9.4}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {10}Cola ARQ: bench ST-EXT-01 (LLM mock)}{28}{chapter.10}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:conclusiones}{{10}{19}{Conclusiones y recomendaciones}{chapter.10}{}} -\@writefile{toc}{\contentsline {section}{\numberline {10.1}Conclusiones}{19}{section.10.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {10.2}Recomendaciones}{19}{section.10.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {10.3}Pr\'oxima campa\~na sugerida}{19}{section.10.3}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {11}Anexos}{20}{chapter.11}\protected@file@percent } +\newlabel{ch:arq-ext01}{{10}{28}{Cola ARQ: bench ST-EXT-01 (LLM mock)}{chapter.10}{}} +\@writefile{toc}{\contentsline {section}{\numberline {10.1}Qu\'e eval\'ua / resultado / significado}{28}{section.10.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {10.2}Condiciones de la corrida}{28}{section.10.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {10.1}{\ignorespaces ST-EXT-01 --- cola ARQ con LLM mock (shared8g, 30 docs).}}{29}{table.10.1}\protected@file@percent } +\newlabel{tab:st-ext-01}{{10.1}{29}{ST-EXT-01 --- cola ARQ con LLM mock (shared8g, 30 docs)}{table.10.1}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {10.1}{\ignorespaces Casi todo el tiempo es drenado por workers; el enqueue es despreciable.}}{29}{figure.10.1}\protected@file@percent } +\newlabel{fig:st-ext-01-times}{{10.1}{29}{Casi todo el tiempo es drenado por workers; el enqueue es despreciable}{figure.10.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {10.3}Resultados}{29}{section.10.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {10.4}Lectura}{29}{section.10.4}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {11}Count-api bajo carga (ST-COUNT-01)}{30}{chapter.11}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:anexos}{{11}{20}{Anexos}{chapter.11}{}} -\@writefile{toc}{\contentsline {section}{\numberline {11.1}Datos fuente}{20}{section.11.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {11.2}Extracto ST-GW-01}{20}{section.11.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {11.3}Extracto ST-BOT-01 \#2}{20}{section.11.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {11.4}Unitarias}{20}{section.11.4}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {11.5}Scripts}{20}{section.11.5}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {11.6}Reproducibilidad}{21}{section.11.6}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {12}Count-api: qu\'e eval\'ua cada prueba}{22}{chapter.12}\protected@file@percent } +\newlabel{ch:st-count-01}{{11}{30}{Count-api bajo carga (ST-COUNT-01)}{chapter.11}{}} +\@writefile{toc}{\contentsline {section}{\numberline {11.1}Qu\'e eval\'ua / resultado / significado}{30}{section.11.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {11.2}Condiciones}{30}{section.11.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {11.3}Resultados}{30}{section.11.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {11.4}Lectura}{30}{section.11.4}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {11.1}{\ignorespaces ST-COUNT-01 --- carga OCR (PDFs reales, shared8g).}}{31}{table.11.1}\protected@file@percent } +\newlabel{tab:st-count-01}{{11.1}{31}{ST-COUNT-01 --- carga OCR (PDFs reales, shared8g)}{table.11.1}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {11.1}{\ignorespaces Bajo 3 concurrentes el OCR cubre 139/h en esta muestra; no alcanza 570/h. La latencia por PDF se degrada mucho frente a la serie ($\sim $2\,s).}}{31}{figure.11.1}\protected@file@percent } +\newlabel{fig:st-count-01}{{11.1}{31}{Bajo 3 concurrentes el OCR cubre 139/h en esta muestra; no alcanza 570/h. La latencia por PDF se degrada mucho frente a la serie ($\sim $2\,s)}{figure.11.1}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {12}An\'alisis y hallazgos}{32}{chapter.12}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:count-latency}{{12}{22}{Count-api: qu\'e eval\'ua cada prueba}{chapter.12}{}} -\@writefile{toc}{\contentsline {section}{\numberline {12.1}Pipeline bajo prueba}{22}{section.12.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.2}Resultado global count-api}{22}{section.12.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {12.1}{\ignorespaces Scorecard count-api en esta campa\~na.}}{22}{table.12.1}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {12.1}{\ignorespaces Cobertura count-api por bloque.}}{22}{figure.12.1}\protected@file@percent } -\newlabel{fig:count-score}{{12.1}{22}{Cobertura count-api por bloque}{figure.12.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {12.3}Features: qu\'e se eval\'ua (con ejemplo)}{23}{section.12.3}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.1}TestExtractRowFeaturesServiceTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.1}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.2}TestBuildFeaturesContinuation --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.3}TestExtractRowFeaturesHeaderKeyword --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.3}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.4}TestExtractRowFeaturesServiceCode --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.4}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.5}TestExtractRowFeaturesNoTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.5}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.6}TestBuildFeatureRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.6}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.7}TestFeatureCount --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.7}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.4}Clustering: qu\'e se eval\'ua (con ejemplo)}{24}{section.12.4}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.4.1}TestClusterRowsTwoBands --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.1}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.4.2}TestClusterRowsSameBand --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.4.3}TestClusterRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.3}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.4.4}TestClusterRowsNoiseMinSamples --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.4}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {12.2}{\ignorespaces Ilustraci\'on del test de dos bandas (no son coordenadas OCR reales).}}{24}{figure.12.2}\protected@file@percent } -\newlabel{fig:cluster-idea}{{12.2}{24}{Ilustraci\'on del test de dos bandas (no son coordenadas OCR reales)}{figure.12.2}{}} -\@writefile{toc}{\contentsline {section}{\numberline {12.5}Pruebas con PDFs reales}{24}{section.12.5}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.5.1}Fixtures}{24}{subsection.12.5.1}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.5.2}TestProcessPDFTimingFixtures}{25}{subsection.12.5.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.5.3}Script HTTP \texttt {measure\_count\_latency}}{25}{subsection.12.5.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.6}Resultados de latencia HTTP (medidos)}{25}{section.12.6}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {12.2}{\ignorespaces Wall-clock HTTP. Fuente: \texttt {informes/count\_latency\_20260713.csv}.}}{25}{table.12.2}\protected@file@percent } -\newlabel{tab:count-http}{{12.2}{25}{Wall-clock HTTP. Fuente: \texttt {informes/count\_latency\_20260713.csv}}{table.12.2}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {12.3}{\ignorespaces Barras: tiempo por PDF. L\'inea naranja: promedio 2{,}98\,s.}}{25}{figure.12.3}\protected@file@percent } -\newlabel{fig:count-http-bars}{{12.3}{25}{Barras: tiempo por PDF. L\'inea naranja: promedio 2{,}98\,s}{figure.12.3}{}} -\@writefile{toc}{\contentsline {section}{\numberline {12.7}C\'omo reproducir}{25}{section.12.7}\protected@file@percent } -\gdef \@abspage@last{27} +\newlabel{ch:analisis}{{12}{32}{An\'alisis y hallazgos}{chapter.12}{}} +\@writefile{toc}{\contentsline {section}{\numberline {12.1}Hallazgos}{32}{section.12.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {12.2}Scorecard global}{32}{section.12.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {12.3}Limitaciones}{32}{section.12.3}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {12.1}{\ignorespaces Distribuci\'on de resultados por tipo de prueba.}}{33}{figure.12.1}\protected@file@percent } +\newlabel{fig:scorecard}{{12.1}{33}{Distribuci\'on de resultados por tipo de prueba}{figure.12.1}{}} +\@writefile{toc}{\contentsline {chapter}{\numberline {13}Conclusiones y recomendaciones}{34}{chapter.13}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:conclusiones}{{13}{34}{Conclusiones y recomendaciones}{chapter.13}{}} +\@writefile{toc}{\contentsline {section}{\numberline {13.1}Conclusiones}{34}{section.13.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {13.2}Recomendaciones}{34}{section.13.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {13.3}Documentos relacionados}{34}{section.13.3}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {14}Anexos}{35}{chapter.14}\protected@file@percent } +\@writefile{lof}{\addvspace {10\p@ }} +\@writefile{lot}{\addvspace {10\p@ }} +\newlabel{ch:anexos}{{14}{35}{Anexos}{chapter.14}{}} +\@writefile{toc}{\contentsline {section}{\numberline {14.1}Datos fuente}{35}{section.14.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.2}Extracto ST-GW-01}{35}{section.14.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.3}Extracto ST-BOT-01 \#2}{35}{section.14.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.4}Unitarias}{35}{section.14.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.5}Scripts}{35}{section.14.5}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.6}Extracto ST-EXT-01}{36}{section.14.6}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.7}Documentos hermanos}{36}{section.14.7}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {14.8}Reproducibilidad}{36}{section.14.8}\protected@file@percent } +\gdef \@abspage@last{37} diff --git a/docs/plan_pruebas/informe/main.out b/docs/plan_pruebas/informe/main.out index 305cf5d..1666359 100644 --- a/docs/plan_pruebas/informe/main.out +++ b/docs/plan_pruebas/informe/main.out @@ -1,76 +1,96 @@ \BOOKMARK [0][-]{chapter.1}{\376\377\000R\000e\000s\000u\000m\000e\000n\000\040\000e\000j\000e\000c\000u\000t\000i\000v\000o}{}% 1 \BOOKMARK [1][-]{section.1.1}{\376\377\000V\000e\000r\000e\000d\000i\000c\000t\000o\000\040\000g\000l\000o\000b\000a\000l}{chapter.1}% 2 \BOOKMARK [1][-]{section.1.2}{\376\377\000K\000P\000I\000s\000\040\000d\000e\000\040\000l\000a\000\040\000c\000a\000m\000p\000a\000\361\000a}{chapter.1}% 3 -\BOOKMARK [1][-]{section.1.3}{\376\377\000R\000e\000s\000p\000u\000e\000s\000t\000a\000\040\000d\000i\000r\000e\000c\000t\000a\000:\000\040\0001\0000\0000\0000\0000\0000\000\040\000f\000a\000c\000t\000u\000r\000a\000s\000/\000m\000e\000s}{chapter.1}% 4 +\BOOKMARK [1][-]{section.1.3}{\376\377\000C\000a\000p\000a\000c\000i\000d\000a\000d\000\040\0001\0000\0000\000k\000/\000m\000e\000s\000\040\000\050\000r\000e\000s\000u\000m\000e\000n\000\051}{chapter.1}% 4 \BOOKMARK [1][-]{section.1.4}{\376\377\000R\000i\000e\000s\000g\000o\000\040\000p\000r\000i\000n\000c\000i\000p\000a\000l}{chapter.1}% 5 -\BOOKMARK [0][-]{chapter.2}{\376\377\000O\000b\000j\000e\000t\000o\000\040\000y\000\040\000a\000l\000c\000a\000n\000c\000e}{}% 6 -\BOOKMARK [1][-]{section.2.1}{\376\377\000O\000b\000j\000e\000t\000o}{chapter.2}% 7 -\BOOKMARK [1][-]{section.2.2}{\376\377\000A\000l\000c\000a\000n\000c\000e\000\040\000i\000n\000c\000l\000u\000i\000d\000o}{chapter.2}% 8 -\BOOKMARK [1][-]{section.2.3}{\376\377\000F\000u\000e\000r\000a\000\040\000d\000e\000\040\000a\000l\000c\000a\000n\000c\000e\000\040\000d\000e\000\040\000e\000s\000t\000a\000\040\000c\000a\000m\000p\000a\000\361\000a}{chapter.2}% 9 -\BOOKMARK [1][-]{section.2.4}{\376\377\000D\000e\000f\000i\000n\000i\000c\000i\000\363\000n\000\040\000d\000e\000\040\000f\000a\000c\000t\000u\000r\000a}{chapter.2}% 10 -\BOOKMARK [0][-]{chapter.3}{\376\377\000E\000n\000t\000o\000r\000n\000o\000\040\000b\000a\000j\000o\000\040\000p\000r\000u\000e\000b\000a}{}% 11 -\BOOKMARK [1][-]{section.3.1}{\376\377\000H\000a\000r\000d\000w\000a\000r\000e\000\040\000y\000\040\000s\000i\000s\000t\000e\000m\000a}{chapter.3}% 12 -\BOOKMARK [1][-]{section.3.2}{\376\377\000B\000a\000s\000e\000l\000i\000n\000e\000\040\000d\000e\000\040\000m\000e\000m\000o\000r\000i\000a\000\040\000\050\0002\0000\0002\0006\000-\0000\0007\000-\0001\0003\000\051}{chapter.3}% 13 -\BOOKMARK [1][-]{section.3.3}{\376\377\000S\000a\000l\000u\000d\000\040\000d\000e\000l\000\040\000s\000t\000a\000c\000k}{chapter.3}% 14 -\BOOKMARK [0][-]{chapter.4}{\376\377\000M\000e\000t\000o\000d\000o\000l\000o\000g\000\355\000a}{}% 15 -\BOOKMARK [1][-]{section.4.1}{\376\377\000T\000i\000p\000o\000s\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a}{chapter.4}% 16 -\BOOKMARK [1][-]{section.4.2}{\376\377\000H\000e\000r\000r\000a\000m\000i\000e\000n\000t\000a\000s}{chapter.4}% 17 -\BOOKMARK [1][-]{section.4.3}{\376\377\000S\000L\000O\000s\000\040\000p\000e\000r\000f\000i\000l\000\040\000s\000h\000a\000r\000e\000d\0008\000g}{chapter.4}% 18 -\BOOKMARK [1][-]{section.4.4}{\376\377\000C\000o\000m\000a\000n\000d\000o\000s\000\040\000r\000e\000p\000r\000e\000s\000e\000n\000t\000a\000t\000i\000v\000o\000s}{chapter.4}% 19 -\BOOKMARK [0][-]{chapter.5}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\040\024\000\040\000p\000r\000u\000e\000b\000a\000s\000\040\000u\000n\000i\000t\000a\000r\000i\000a\000s}{}% 20 -\BOOKMARK [1][-]{section.5.1}{\376\377\000S\000c\000o\000r\000e\000c\000a\000r\000d}{chapter.5}% 21 -\BOOKMARK [1][-]{section.5.2}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000a\000g\000e\000n\000t}{chapter.5}% 22 -\BOOKMARK [1][-]{section.5.3}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000A\000P\000I\000\040\000s\000t\000o\000r\000e}{chapter.5}% 23 -\BOOKMARK [1][-]{section.5.4}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000c\000o\000u\000n\000t\000-\000a\000p\000i}{chapter.5}% 24 -\BOOKMARK [0][-]{chapter.6}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\040\024\000\040\000h\000u\000m\000o\000\040\000y\000\040\000f\000u\000n\000c\000i\000o\000n\000a\000l}{}% 25 -\BOOKMARK [1][-]{section.6.1}{\376\377\000H\000u\000m\000o\000\040\000g\000a\000t\000e\000w\000a\000y}{chapter.6}% 26 -\BOOKMARK [1][-]{section.6.2}{\376\377\000F\000u\000n\000c\000i\000o\000n\000a\000l\000\040\000E\0002\000E\000-\0000\0007\000\040\000\050\000w\000o\000r\000k\000e\000r\000\040\000o\000f\000f\000l\000i\000n\000e\000\040\000\040\0004\0000\0009\000\051}{chapter.6}% 27 -\BOOKMARK [1][-]{section.6.3}{\376\377\000S\000c\000r\000i\000p\000t\000\040\000p\000r\000e\000p\000a\000r\000a\000d\000o}{chapter.6}% 28 -\BOOKMARK [0][-]{chapter.7}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\040\024\000\040\000p\000r\000u\000e\000b\000a\000s\000\040\000d\000e\000\040\000c\000a\000r\000g\000a}{}% 29 -\BOOKMARK [1][-]{section.7.1}{\376\377\000T\000a\000b\000l\000a\000\040\000c\000o\000n\000s\000o\000l\000i\000d\000a\000d\000a}{chapter.7}% 30 -\BOOKMARK [1][-]{section.7.2}{\376\377\000S\000T\000-\000G\000W\000-\0000\0001}{chapter.7}% 31 -\BOOKMARK [1][-]{section.7.3}{\376\377\000S\000T\000-\000B\000O\000T\000-\0000\0001\000\040\040\024\000\040\000c\000o\000m\000p\000a\000r\000a\000t\000i\000v\000a}{chapter.7}% 32 -\BOOKMARK [1][-]{section.7.4}{\376\377\000T\000h\000r\000o\000u\000g\000h\000p\000u\000t\000\040\000g\000a\000t\000e\000w\000a\000y}{chapter.7}% 33 -\BOOKMARK [0][-]{chapter.8}{\376\377\000C\000a\000p\000a\000c\000i\000d\000a\000d\000:\000\040\0001\0000\0000\0000\0000\0000\000\040\000f\000a\000c\000t\000u\000r\000a\000s\000\040\000a\000l\000\040\000m\000e\000s}{}% 34 -\BOOKMARK [1][-]{section.8.1}{\376\377\000T\000r\000a\000d\000u\000c\000c\000i\000\363\000n\000\040\000d\000e\000l\000\040\000o\000b\000j\000e\000t\000i\000v\000o}{chapter.8}% 35 -\BOOKMARK [1][-]{section.8.2}{\376\377\000M\000o\000d\000e\000l\000o\000\040\000p\000o\000r\000\040\000e\000t\000a\000p\000a}{chapter.8}% 36 -\BOOKMARK [1][-]{section.8.3}{\376\377\000I\000n\000t\000e\000r\000p\000r\000e\000t\000a\000c\000i\000\363\000n}{chapter.8}% 37 -\BOOKMARK [1][-]{section.8.4}{\376\377\000V\000e\000r\000e\000d\000i\000c\000t\000o}{chapter.8}% 38 -\BOOKMARK [1][-]{section.8.5}{\376\377\000R\000e\000q\000u\000i\000s\000i\000t\000o\000s\000\040\000m\000\355\000n\000i\000m\000o\000s\000\040\000s\000u\000g\000e\000r\000i\000d\000o\000s\000\040\000p\000a\000r\000a\000\040\0001\0000\0000\000k\000/\000m\000e\000s}{chapter.8}% 39 -\BOOKMARK [0][-]{chapter.9}{\376\377\000A\000n\000\341\000l\000i\000s\000i\000s\000\040\000y\000\040\000h\000a\000l\000l\000a\000z\000g\000o\000s}{}% 40 -\BOOKMARK [1][-]{section.9.1}{\376\377\000H\000a\000l\000l\000a\000z\000g\000o\000s}{chapter.9}% 41 -\BOOKMARK [1][-]{section.9.2}{\376\377\000S\000c\000o\000r\000e\000c\000a\000r\000d\000\040\000g\000l\000o\000b\000a\000l}{chapter.9}% 42 -\BOOKMARK [1][-]{section.9.3}{\376\377\000L\000i\000m\000i\000t\000a\000c\000i\000o\000n\000e\000s}{chapter.9}% 43 -\BOOKMARK [0][-]{chapter.10}{\376\377\000C\000o\000n\000c\000l\000u\000s\000i\000o\000n\000e\000s\000\040\000y\000\040\000r\000e\000c\000o\000m\000e\000n\000d\000a\000c\000i\000o\000n\000e\000s}{}% 44 -\BOOKMARK [1][-]{section.10.1}{\376\377\000C\000o\000n\000c\000l\000u\000s\000i\000o\000n\000e\000s}{chapter.10}% 45 -\BOOKMARK [1][-]{section.10.2}{\376\377\000R\000e\000c\000o\000m\000e\000n\000d\000a\000c\000i\000o\000n\000e\000s}{chapter.10}% 46 -\BOOKMARK [1][-]{section.10.3}{\376\377\000P\000r\000\363\000x\000i\000m\000a\000\040\000c\000a\000m\000p\000a\000\361\000a\000\040\000s\000u\000g\000e\000r\000i\000d\000a}{chapter.10}% 47 -\BOOKMARK [0][-]{chapter.11}{\376\377\000A\000n\000e\000x\000o\000s}{}% 48 -\BOOKMARK [1][-]{section.11.1}{\376\377\000D\000a\000t\000o\000s\000\040\000f\000u\000e\000n\000t\000e}{chapter.11}% 49 -\BOOKMARK [1][-]{section.11.2}{\376\377\000E\000x\000t\000r\000a\000c\000t\000o\000\040\000S\000T\000-\000G\000W\000-\0000\0001}{chapter.11}% 50 -\BOOKMARK [1][-]{section.11.3}{\376\377\000E\000x\000t\000r\000a\000c\000t\000o\000\040\000S\000T\000-\000B\000O\000T\000-\0000\0001\000\040\000\043\0002}{chapter.11}% 51 -\BOOKMARK [1][-]{section.11.4}{\376\377\000U\000n\000i\000t\000a\000r\000i\000a\000s}{chapter.11}% 52 -\BOOKMARK [1][-]{section.11.5}{\376\377\000S\000c\000r\000i\000p\000t\000s}{chapter.11}% 53 -\BOOKMARK [1][-]{section.11.6}{\376\377\000R\000e\000p\000r\000o\000d\000u\000c\000i\000b\000i\000l\000i\000d\000a\000d}{chapter.11}% 54 -\BOOKMARK [0][-]{chapter.12}{\376\377\000C\000o\000u\000n\000t\000-\000a\000p\000i\000:\000\040\000q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000\040\000c\000a\000d\000a\000\040\000p\000r\000u\000e\000b\000a}{}% 55 -\BOOKMARK [1][-]{section.12.1}{\376\377\000P\000i\000p\000e\000l\000i\000n\000e\000\040\000b\000a\000j\000o\000\040\000p\000r\000u\000e\000b\000a}{chapter.12}% 56 -\BOOKMARK [1][-]{section.12.2}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000\040\000g\000l\000o\000b\000a\000l\000\040\000c\000o\000u\000n\000t\000-\000a\000p\000i}{chapter.12}% 57 -\BOOKMARK [1][-]{section.12.3}{\376\377\000F\000e\000a\000t\000u\000r\000e\000s\000:\000\040\000q\000u\000\351\000\040\000s\000e\000\040\000e\000v\000a\000l\000\372\000a\000\040\000\050\000c\000o\000n\000\040\000e\000j\000e\000m\000p\000l\000o\000\051}{chapter.12}% 58 -\BOOKMARK [2][-]{subsection.12.3.1}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000S\000e\000r\000v\000i\000c\000e\000T\000a\000b\000l\000e\000P\000a\000t\000t\000e\000r\000n\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 59 -\BOOKMARK [2][-]{subsection.12.3.2}{\376\377\000T\000e\000s\000t\000B\000u\000i\000l\000d\000F\000e\000a\000t\000u\000r\000e\000s\000C\000o\000n\000t\000i\000n\000u\000a\000t\000i\000o\000n\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 60 -\BOOKMARK [2][-]{subsection.12.3.3}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000H\000e\000a\000d\000e\000r\000K\000e\000y\000w\000o\000r\000d\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 61 -\BOOKMARK [2][-]{subsection.12.3.4}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000S\000e\000r\000v\000i\000c\000e\000C\000o\000d\000e\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 62 -\BOOKMARK [2][-]{subsection.12.3.5}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000N\000o\000T\000a\000b\000l\000e\000P\000a\000t\000t\000e\000r\000n\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 63 -\BOOKMARK [2][-]{subsection.12.3.6}{\376\377\000T\000e\000s\000t\000B\000u\000i\000l\000d\000F\000e\000a\000t\000u\000r\000e\000R\000o\000w\000s\000E\000m\000p\000t\000y\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 64 -\BOOKMARK [2][-]{subsection.12.3.7}{\376\377\000T\000e\000s\000t\000F\000e\000a\000t\000u\000r\000e\000C\000o\000u\000n\000t\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.3}% 65 -\BOOKMARK [1][-]{section.12.4}{\376\377\000C\000l\000u\000s\000t\000e\000r\000i\000n\000g\000:\000\040\000q\000u\000\351\000\040\000s\000e\000\040\000e\000v\000a\000l\000\372\000a\000\040\000\050\000c\000o\000n\000\040\000e\000j\000e\000m\000p\000l\000o\000\051}{chapter.12}% 66 -\BOOKMARK [2][-]{subsection.12.4.1}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000T\000w\000o\000B\000a\000n\000d\000s\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.4}% 67 -\BOOKMARK [2][-]{subsection.12.4.2}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000S\000a\000m\000e\000B\000a\000n\000d\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.4}% 68 -\BOOKMARK [2][-]{subsection.12.4.3}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000E\000m\000p\000t\000y\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.4}% 69 -\BOOKMARK [2][-]{subsection.12.4.4}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000N\000o\000i\000s\000e\000M\000i\000n\000S\000a\000m\000p\000l\000e\000s\000\040\040\024\000\040\000P\000A\000S\000S}{section.12.4}% 70 -\BOOKMARK [1][-]{section.12.5}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000c\000o\000n\000\040\000P\000D\000F\000s\000\040\000r\000e\000a\000l\000e\000s}{chapter.12}% 71 -\BOOKMARK [2][-]{subsection.12.5.1}{\376\377\000F\000i\000x\000t\000u\000r\000e\000s}{section.12.5}% 72 -\BOOKMARK [2][-]{subsection.12.5.2}{\376\377\000T\000e\000s\000t\000P\000r\000o\000c\000e\000s\000s\000P\000D\000F\000T\000i\000m\000i\000n\000g\000F\000i\000x\000t\000u\000r\000e\000s}{section.12.5}% 73 -\BOOKMARK [2][-]{subsection.12.5.3}{\376\377\000S\000c\000r\000i\000p\000t\000\040\000H\000T\000T\000P\000\040\000m\000e\000a\000s\000u\000r\000e\000\137\000c\000o\000u\000n\000t\000\137\000l\000a\000t\000e\000n\000c\000y}{section.12.5}% 74 -\BOOKMARK [1][-]{section.12.6}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\000d\000e\000\040\000l\000a\000t\000e\000n\000c\000i\000a\000\040\000H\000T\000T\000P\000\040\000\050\000m\000e\000d\000i\000d\000o\000s\000\051}{chapter.12}% 75 -\BOOKMARK [1][-]{section.12.7}{\376\377\000C\000\363\000m\000o\000\040\000r\000e\000p\000r\000o\000d\000u\000c\000i\000r}{chapter.12}% 76 +\BOOKMARK [1][-]{section.1.5}{\376\377\000A\000l\000c\000a\000n\000c\000e\000\040\000d\000e\000\040\000e\000s\000t\000a\000\040\000c\000a\000m\000p\000a\000\361\000a}{chapter.1}% 6 +\BOOKMARK [0][-]{chapter.2}{\376\377\000A\000r\000q\000u\000i\000t\000e\000c\000t\000u\000r\000a\000\040\000y\000\040\000p\000a\000t\000r\000o\000n\000e\000s\000\040\000\050\000c\000o\000n\000t\000e\000x\000t\000o\000\051}{}% 7 +\BOOKMARK [1][-]{section.2.1}{\376\377\000V\000i\000s\000t\000a\000\040\000d\000e\000\040\000c\000o\000m\000p\000o\000n\000e\000n\000t\000e\000s}{chapter.2}% 8 +\BOOKMARK [1][-]{section.2.2}{\376\377\000P\000a\000t\000r\000o\000n\000e\000s\000\040\000d\000e\000\040\000d\000i\000s\000e\000\361\000o}{chapter.2}% 9 +\BOOKMARK [1][-]{section.2.3}{\376\377\000P\000i\000p\000e\000l\000i\000n\000e\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o}{chapter.2}% 10 +\BOOKMARK [1][-]{section.2.4}{\376\377\000D\000e\000f\000i\000n\000i\000c\000i\000\363\000n\000\040\000d\000e\000\040\000f\000a\000c\000t\000u\000r\000a}{chapter.2}% 11 +\BOOKMARK [0][-]{chapter.3}{\376\377\000E\000n\000t\000o\000r\000n\000o\000\040\000b\000a\000j\000o\000\040\000p\000r\000u\000e\000b\000a}{}% 12 +\BOOKMARK [1][-]{section.3.1}{\376\377\000H\000a\000r\000d\000w\000a\000r\000e\000\040\000y\000\040\000s\000i\000s\000t\000e\000m\000a}{chapter.3}% 13 +\BOOKMARK [1][-]{section.3.2}{\376\377\000B\000a\000s\000e\000l\000i\000n\000e\000\040\000d\000e\000\040\000m\000e\000m\000o\000r\000i\000a\000\040\000\050\0002\0000\0002\0006\000-\0000\0007\000-\0001\0003\000\051}{chapter.3}% 14 +\BOOKMARK [1][-]{section.3.3}{\376\377\000S\000a\000l\000u\000d\000\040\000d\000e\000l\000\040\000s\000t\000a\000c\000k}{chapter.3}% 15 +\BOOKMARK [0][-]{chapter.4}{\376\377\000C\000\363\000m\000o\000\040\000l\000e\000e\000r\000\040\000e\000s\000t\000e\000\040\000i\000n\000f\000o\000r\000m\000e}{}% 16 +\BOOKMARK [1][-]{section.4.1}{\376\377\000P\000l\000a\000n\000t\000i\000l\000l\000a\000\040\000d\000e\000\040\000i\000n\000t\000e\000r\000p\000r\000e\000t\000a\000c\000i\000\363\000n}{chapter.4}% 17 +\BOOKMARK [1][-]{section.4.2}{\376\377\000L\000e\000y\000e\000n\000d\000a\000\040\000d\000e\000\040\000e\000s\000t\000a\000d\000o\000s}{chapter.4}% 18 +\BOOKMARK [1][-]{section.4.3}{\376\377\000P\000e\000r\000f\000i\000l\000\040\000d\000e\000\040\000c\000a\000r\000g\000a\000\040\000s\000h\000a\000r\000e\000d\0008\000g}{chapter.4}% 19 +\BOOKMARK [1][-]{section.4.4}{\376\377\000H\000e\000r\000r\000a\000m\000i\000e\000n\000t\000a\000s}{chapter.4}% 20 +\BOOKMARK [1][-]{section.4.5}{\376\377\000M\000a\000p\000a\000\040\000d\000e\000\040\000c\000a\000p\000\355\000t\000u\000l\000o\000s\000\040\000d\000e\000\040\000r\000e\000s\000u\000l\000t\000a\000d\000o\000s}{chapter.4}% 21 +\BOOKMARK [0][-]{chapter.5}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\040\024\000\040\000p\000r\000u\000e\000b\000a\000s\000\040\000u\000n\000i\000t\000a\000r\000i\000a\000s}{}% 22 +\BOOKMARK [1][-]{section.5.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000n}{chapter.5}% 23 +\BOOKMARK [1][-]{section.5.2}{\376\377\000S\000c\000o\000r\000e\000c\000a\000r\000d}{chapter.5}% 24 +\BOOKMARK [1][-]{section.5.3}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000a\000g\000e\000n\000t\000\040\000S\000e\000l\000e\000n\000i\000u\000m}{chapter.5}% 25 +\BOOKMARK [1][-]{section.5.4}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000A\000P\000I\000\040\000b\000o\000t\000-\000d\000e\000s\000c\000a\000r\000g\000a\000\040\000\050\000o\000r\000q\000u\000e\000s\000t\000a\000d\000o\000r\000\051}{chapter.5}% 26 +\BOOKMARK [1][-]{section.5.5}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000c\000o\000u\000n\000t\000-\000a\000p\000i}{chapter.5}% 27 +\BOOKMARK [1][-]{section.5.6}{\376\377\000C\000o\000b\000e\000r\000t\000u\000r\000a\000\040\000d\000e\000s\000k\000t\000o\000p\000\040\000\050\000V\000i\000t\000e\000s\000t\000\051}{chapter.5}% 28 +\BOOKMARK [0][-]{chapter.6}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\040\024\000\040\000h\000u\000m\000o\000\040\000y\000\040\000f\000u\000n\000c\000i\000o\000n\000a\000l}{}% 29 +\BOOKMARK [1][-]{section.6.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000n}{chapter.6}% 30 +\BOOKMARK [1][-]{section.6.2}{\376\377\000H\000u\000m\000o\000\040\000g\000a\000t\000e\000w\000a\000y}{chapter.6}% 31 +\BOOKMARK [1][-]{section.6.3}{\376\377\000F\000u\000n\000c\000i\000o\000n\000a\000l\000\040\000E\0002\000E\000-\0000\0007\000\040\000\050\000w\000o\000r\000k\000e\000r\000\040\000o\000f\000f\000l\000i\000n\000e\000\040\000\040\0004\0000\0009\000\051}{chapter.6}% 32 +\BOOKMARK [1][-]{section.6.4}{\376\377\000S\000c\000r\000i\000p\000t\000s}{chapter.6}% 33 +\BOOKMARK [0][-]{chapter.7}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\040\024\000\040\000p\000r\000u\000e\000b\000a\000s\000\040\000d\000e\000\040\000c\000a\000r\000g\000a}{}% 34 +\BOOKMARK [1][-]{section.7.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000n}{chapter.7}% 35 +\BOOKMARK [1][-]{section.7.2}{\376\377\000T\000a\000b\000l\000a\000\040\000c\000o\000n\000s\000o\000l\000i\000d\000a\000d\000a}{chapter.7}% 36 +\BOOKMARK [1][-]{section.7.3}{\376\377\000S\000T\000-\000G\000W\000-\0000\0001}{chapter.7}% 37 +\BOOKMARK [1][-]{section.7.4}{\376\377\000S\000T\000-\000B\000O\000T\000-\0000\0001\000\040\040\024\000\040\000c\000o\000m\000p\000a\000r\000a\000t\000i\000v\000a}{chapter.7}% 38 +\BOOKMARK [1][-]{section.7.5}{\376\377\000T\000h\000r\000o\000u\000g\000h\000p\000u\000t\000\040\000g\000a\000t\000e\000w\000a\000y}{chapter.7}% 39 +\BOOKMARK [0][-]{chapter.8}{\376\377\000C\000o\000u\000n\000t\000-\000a\000p\000i\000:\000\040\000q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000\040\000c\000a\000d\000a\000\040\000p\000r\000u\000e\000b\000a}{}% 40 +\BOOKMARK [1][-]{section.8.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000\040\000/\000\040\000r\000e\000s\000u\000l\000t\000a\000d\000o\000\040\000/\000\040\000s\000i\000g\000n\000i\000f\000i\000c\000a\000d\000o}{chapter.8}% 41 +\BOOKMARK [1][-]{section.8.2}{\376\377\000P\000i\000p\000e\000l\000i\000n\000e\000\040\000b\000a\000j\000o\000\040\000p\000r\000u\000e\000b\000a}{chapter.8}% 42 +\BOOKMARK [1][-]{section.8.3}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000\040\000g\000l\000o\000b\000a\000l\000\040\000c\000o\000u\000n\000t\000-\000a\000p\000i}{chapter.8}% 43 +\BOOKMARK [1][-]{section.8.4}{\376\377\000F\000e\000a\000t\000u\000r\000e\000s\000:\000\040\000q\000u\000\351\000\040\000s\000e\000\040\000e\000v\000a\000l\000\372\000a\000\040\000\050\000c\000o\000n\000\040\000e\000j\000e\000m\000p\000l\000o\000\051}{chapter.8}% 44 +\BOOKMARK [2][-]{subsection.8.4.1}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000S\000e\000r\000v\000i\000c\000e\000T\000a\000b\000l\000e\000P\000a\000t\000t\000e\000r\000n\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 45 +\BOOKMARK [2][-]{subsection.8.4.2}{\376\377\000T\000e\000s\000t\000B\000u\000i\000l\000d\000F\000e\000a\000t\000u\000r\000e\000s\000C\000o\000n\000t\000i\000n\000u\000a\000t\000i\000o\000n\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 46 +\BOOKMARK [2][-]{subsection.8.4.3}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000H\000e\000a\000d\000e\000r\000K\000e\000y\000w\000o\000r\000d\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 47 +\BOOKMARK [2][-]{subsection.8.4.4}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000S\000e\000r\000v\000i\000c\000e\000C\000o\000d\000e\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 48 +\BOOKMARK [2][-]{subsection.8.4.5}{\376\377\000T\000e\000s\000t\000E\000x\000t\000r\000a\000c\000t\000R\000o\000w\000F\000e\000a\000t\000u\000r\000e\000s\000N\000o\000T\000a\000b\000l\000e\000P\000a\000t\000t\000e\000r\000n\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 49 +\BOOKMARK [2][-]{subsection.8.4.6}{\376\377\000T\000e\000s\000t\000B\000u\000i\000l\000d\000F\000e\000a\000t\000u\000r\000e\000R\000o\000w\000s\000E\000m\000p\000t\000y\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 50 +\BOOKMARK [2][-]{subsection.8.4.7}{\376\377\000T\000e\000s\000t\000F\000e\000a\000t\000u\000r\000e\000C\000o\000u\000n\000t\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.4}% 51 +\BOOKMARK [1][-]{section.8.5}{\376\377\000C\000l\000u\000s\000t\000e\000r\000i\000n\000g\000:\000\040\000q\000u\000\351\000\040\000s\000e\000\040\000e\000v\000a\000l\000\372\000a\000\040\000\050\000c\000o\000n\000\040\000e\000j\000e\000m\000p\000l\000o\000\051}{chapter.8}% 52 +\BOOKMARK [2][-]{subsection.8.5.1}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000T\000w\000o\000B\000a\000n\000d\000s\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.5}% 53 +\BOOKMARK [2][-]{subsection.8.5.2}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000S\000a\000m\000e\000B\000a\000n\000d\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.5}% 54 +\BOOKMARK [2][-]{subsection.8.5.3}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000E\000m\000p\000t\000y\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.5}% 55 +\BOOKMARK [2][-]{subsection.8.5.4}{\376\377\000T\000e\000s\000t\000C\000l\000u\000s\000t\000e\000r\000R\000o\000w\000s\000N\000o\000i\000s\000e\000M\000i\000n\000S\000a\000m\000p\000l\000e\000s\000\040\040\024\000\040\000P\000A\000S\000S}{section.8.5}% 56 +\BOOKMARK [1][-]{section.8.6}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000c\000o\000n\000\040\000P\000D\000F\000s\000\040\000r\000e\000a\000l\000e\000s}{chapter.8}% 57 +\BOOKMARK [2][-]{subsection.8.6.1}{\376\377\000F\000i\000x\000t\000u\000r\000e\000s}{section.8.6}% 58 +\BOOKMARK [2][-]{subsection.8.6.2}{\376\377\000T\000e\000s\000t\000P\000r\000o\000c\000e\000s\000s\000P\000D\000F\000T\000i\000m\000i\000n\000g\000F\000i\000x\000t\000u\000r\000e\000s}{section.8.6}% 59 +\BOOKMARK [2][-]{subsection.8.6.3}{\376\377\000S\000c\000r\000i\000p\000t\000\040\000H\000T\000T\000P\000\040\000m\000e\000a\000s\000u\000r\000e\000\137\000c\000o\000u\000n\000t\000\137\000l\000a\000t\000e\000n\000c\000y}{section.8.6}% 60 +\BOOKMARK [1][-]{section.8.7}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\000a\000g\000r\000e\000g\000a\000d\000o\000s\000\040\000\050\0002\0003\000\040\000P\000D\000F\000s\000\051}{chapter.8}% 61 +\BOOKMARK [1][-]{section.8.8}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\000d\000e\000\040\000l\000a\000t\000e\000n\000c\000i\000a\000\040\000H\000T\000T\000P}{chapter.8}% 62 +\BOOKMARK [1][-]{section.8.9}{\376\377\000D\000e\000t\000a\000l\000l\000e\000\040\000p\000o\000r\000\040\000a\000r\000c\000h\000i\000v\000o}{chapter.8}% 63 +\BOOKMARK [1][-]{section.8.10}{\376\377\000C\000\363\000m\000o\000\040\000r\000e\000p\000r\000o\000d\000u\000c\000i\000r}{chapter.8}% 64 +\BOOKMARK [0][-]{chapter.9}{\376\377\000E\000x\000t\000r\000a\000c\000c\000i\000\363\000n\000\040\000L\000L\000M\000:\000\040\000b\000e\000n\000c\000h\000\040\000d\000e\000\040\000l\000a\000t\000e\000n\000c\000i\000a\000\040\000\050\000S\000T\000-\000E\000X\000T\000-\0000\0002\000\051}{}% 65 +\BOOKMARK [1][-]{section.9.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000\040\000/\000\040\000r\000e\000s\000u\000l\000t\000a\000d\000o\000\040\000/\000\040\000s\000i\000g\000n\000i\000f\000i\000c\000a\000d\000o}{chapter.9}% 66 +\BOOKMARK [1][-]{section.9.2}{\376\377\000C\000\363\000m\000o\000\040\000e\000j\000e\000c\000u\000t\000a\000r}{chapter.9}% 67 +\BOOKMARK [1][-]{section.9.3}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\000\050\0002\0000\0002\0006\000-\0000\0007\000-\0001\0007\000\051}{chapter.9}% 68 +\BOOKMARK [1][-]{section.9.4}{\376\377\000L\000e\000c\000t\000u\000r\000a\000\040\000p\000a\000r\000a\000\040\000c\000a\000p\000a\000c\000i\000d\000a\000d}{chapter.9}% 69 +\BOOKMARK [0][-]{chapter.10}{\376\377\000C\000o\000l\000a\000\040\000A\000R\000Q\000:\000\040\000b\000e\000n\000c\000h\000\040\000S\000T\000-\000E\000X\000T\000-\0000\0001\000\040\000\050\000L\000L\000M\000\040\000m\000o\000c\000k\000\051}{}% 70 +\BOOKMARK [1][-]{section.10.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000\040\000/\000\040\000r\000e\000s\000u\000l\000t\000a\000d\000o\000\040\000/\000\040\000s\000i\000g\000n\000i\000f\000i\000c\000a\000d\000o}{chapter.10}% 71 +\BOOKMARK [1][-]{section.10.2}{\376\377\000C\000o\000n\000d\000i\000c\000i\000o\000n\000e\000s\000\040\000d\000e\000\040\000l\000a\000\040\000c\000o\000r\000r\000i\000d\000a}{chapter.10}% 72 +\BOOKMARK [1][-]{section.10.3}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s}{chapter.10}% 73 +\BOOKMARK [1][-]{section.10.4}{\376\377\000L\000e\000c\000t\000u\000r\000a}{chapter.10}% 74 +\BOOKMARK [0][-]{chapter.11}{\376\377\000C\000o\000u\000n\000t\000-\000a\000p\000i\000\040\000b\000a\000j\000o\000\040\000c\000a\000r\000g\000a\000\040\000\050\000S\000T\000-\000C\000O\000U\000N\000T\000-\0000\0001\000\051}{}% 75 +\BOOKMARK [1][-]{section.11.1}{\376\377\000Q\000u\000\351\000\040\000e\000v\000a\000l\000\372\000a\000\040\000/\000\040\000r\000e\000s\000u\000l\000t\000a\000d\000o\000\040\000/\000\040\000s\000i\000g\000n\000i\000f\000i\000c\000a\000d\000o}{chapter.11}% 76 +\BOOKMARK [1][-]{section.11.2}{\376\377\000C\000o\000n\000d\000i\000c\000i\000o\000n\000e\000s}{chapter.11}% 77 +\BOOKMARK [1][-]{section.11.3}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s}{chapter.11}% 78 +\BOOKMARK [1][-]{section.11.4}{\376\377\000L\000e\000c\000t\000u\000r\000a}{chapter.11}% 79 +\BOOKMARK [0][-]{chapter.12}{\376\377\000A\000n\000\341\000l\000i\000s\000i\000s\000\040\000y\000\040\000h\000a\000l\000l\000a\000z\000g\000o\000s}{}% 80 +\BOOKMARK [1][-]{section.12.1}{\376\377\000H\000a\000l\000l\000a\000z\000g\000o\000s}{chapter.12}% 81 +\BOOKMARK [1][-]{section.12.2}{\376\377\000S\000c\000o\000r\000e\000c\000a\000r\000d\000\040\000g\000l\000o\000b\000a\000l}{chapter.12}% 82 +\BOOKMARK [1][-]{section.12.3}{\376\377\000L\000i\000m\000i\000t\000a\000c\000i\000o\000n\000e\000s}{chapter.12}% 83 +\BOOKMARK [0][-]{chapter.13}{\376\377\000C\000o\000n\000c\000l\000u\000s\000i\000o\000n\000e\000s\000\040\000y\000\040\000r\000e\000c\000o\000m\000e\000n\000d\000a\000c\000i\000o\000n\000e\000s}{}% 84 +\BOOKMARK [1][-]{section.13.1}{\376\377\000C\000o\000n\000c\000l\000u\000s\000i\000o\000n\000e\000s}{chapter.13}% 85 +\BOOKMARK [1][-]{section.13.2}{\376\377\000R\000e\000c\000o\000m\000e\000n\000d\000a\000c\000i\000o\000n\000e\000s}{chapter.13}% 86 +\BOOKMARK [1][-]{section.13.3}{\376\377\000D\000o\000c\000u\000m\000e\000n\000t\000o\000s\000\040\000r\000e\000l\000a\000c\000i\000o\000n\000a\000d\000o\000s}{chapter.13}% 87 +\BOOKMARK [0][-]{chapter.14}{\376\377\000A\000n\000e\000x\000o\000s}{}% 88 +\BOOKMARK [1][-]{section.14.1}{\376\377\000D\000a\000t\000o\000s\000\040\000f\000u\000e\000n\000t\000e}{chapter.14}% 89 +\BOOKMARK [1][-]{section.14.2}{\376\377\000E\000x\000t\000r\000a\000c\000t\000o\000\040\000S\000T\000-\000G\000W\000-\0000\0001}{chapter.14}% 90 +\BOOKMARK [1][-]{section.14.3}{\376\377\000E\000x\000t\000r\000a\000c\000t\000o\000\040\000S\000T\000-\000B\000O\000T\000-\0000\0001\000\040\000\043\0002}{chapter.14}% 91 +\BOOKMARK [1][-]{section.14.4}{\376\377\000U\000n\000i\000t\000a\000r\000i\000a\000s}{chapter.14}% 92 +\BOOKMARK [1][-]{section.14.5}{\376\377\000S\000c\000r\000i\000p\000t\000s}{chapter.14}% 93 +\BOOKMARK [1][-]{section.14.6}{\376\377\000E\000x\000t\000r\000a\000c\000t\000o\000\040\000S\000T\000-\000E\000X\000T\000-\0000\0001}{chapter.14}% 94 +\BOOKMARK [1][-]{section.14.7}{\376\377\000D\000o\000c\000u\000m\000e\000n\000t\000o\000s\000\040\000h\000e\000r\000m\000a\000n\000o\000s}{chapter.14}% 95 +\BOOKMARK [1][-]{section.14.8}{\376\377\000R\000e\000p\000r\000o\000d\000u\000c\000i\000b\000i\000l\000i\000d\000a\000d}{chapter.14}% 96 diff --git a/docs/plan_pruebas/informe/main.tex b/docs/plan_pruebas/informe/main.tex index bd5462b..1db155e 100644 --- a/docs/plan_pruebas/informe/main.tex +++ b/docs/plan_pruebas/informe/main.tex @@ -6,33 +6,22 @@ \makecover -\chapter*{Control de cambios} -\begin{tabularx}{\textwidth}{@{}l l X@{}} -\toprule -\textbf{Versi\'on} & \textbf{Fecha} & \textbf{Descripci\'on} \\ -\midrule -1.0.0 & 13 jul 2026 & Informe profesional: campa\~na shared8g, m\'etricas, capacidad 100k/mes. \\ -1.1.0 & 13 jul 2026 & Unitarias count-api pipeline 3/3 PASS (Ubuntu). \\ -1.2.0 & 13 jul 2026 & Unitarias features/clustering ampliadas; timings PDF + scripts latencia. \\ -1.3.0 & 13 jul 2026 & Latencia HTTP 3 PDFs reales (2{,}2--4{,}5\,s); fix path Timing test. \\ -1.4.0 & 13 jul 2026 & Cap.\ count-api: explicaci\'on test a test, ejemplos y gr\'aficas. \\ -\bottomrule -\end{tabularx} - \tableofcontents \clearpage \input{sections/01_resumen_ejecutivo} -\input{sections/02_objeto_alcance} +\input{sections/02_arquitectura_contexto} \input{sections/03_entorno} -\input{sections/04_metodologia} +\input{sections/04_como_leer} \input{sections/05_resultados_unitarias} \input{sections/06_resultados_humo_funcional} \input{sections/07_resultados_carga} -\input{sections/08_capacidad_100k} +\input{sections/12_count_latency} +\input{sections/13_llm_latency} +\input{sections/14_arq_ext01} +\input{sections/15_st_count_01} \input{sections/09_analisis_hallazgos} \input{sections/10_conclusiones} \input{sections/11_anexos} -\input{sections/12_count_latency} \end{document} diff --git a/docs/plan_pruebas/informe/main.toc b/docs/plan_pruebas/informe/main.toc index 9c8551c..b9827a1 100644 --- a/docs/plan_pruebas/informe/main.toc +++ b/docs/plan_pruebas/informe/main.toc @@ -1,77 +1,97 @@ \babel@toc {spanish}{}\relax -\contentsline {chapter}{\numberline {1}Resumen ejecutivo}{5}{chapter.1}% -\contentsline {section}{\numberline {1.1}Veredicto global}{5}{section.1.1}% -\contentsline {section}{\numberline {1.2}KPIs de la campa\~na}{5}{section.1.2}% -\contentsline {section}{\numberline {1.3}Respuesta directa: 100\,000 facturas/mes}{5}{section.1.3}% -\contentsline {section}{\numberline {1.4}Riesgo principal}{6}{section.1.4}% -\contentsline {chapter}{\numberline {2}Objeto y alcance}{7}{chapter.2}% -\contentsline {section}{\numberline {2.1}Objeto}{7}{section.2.1}% -\contentsline {section}{\numberline {2.2}Alcance incluido}{7}{section.2.2}% -\contentsline {section}{\numberline {2.3}Fuera de alcance de esta campa\~na}{7}{section.2.3}% -\contentsline {section}{\numberline {2.4}Definici\'on de factura}{7}{section.2.4}% -\contentsline {chapter}{\numberline {3}Entorno bajo prueba}{8}{chapter.3}% -\contentsline {section}{\numberline {3.1}Hardware y sistema}{8}{section.3.1}% -\contentsline {section}{\numberline {3.2}Baseline de memoria (2026-07-13)}{8}{section.3.2}% -\contentsline {section}{\numberline {3.3}Salud del stack}{8}{section.3.3}% -\contentsline {chapter}{\numberline {4}Metodolog\'ia}{10}{chapter.4}% -\contentsline {section}{\numberline {4.1}Tipos de prueba}{10}{section.4.1}% -\contentsline {section}{\numberline {4.2}Herramientas}{10}{section.4.2}% -\contentsline {section}{\numberline {4.3}SLOs perfil \texttt {shared8g}}{10}{section.4.3}% -\contentsline {section}{\numberline {4.4}Comandos representativos}{10}{section.4.4}% -\contentsline {chapter}{\numberline {5}Resultados --- pruebas unitarias}{11}{chapter.5}% -\contentsline {section}{\numberline {5.1}Scorecard}{11}{section.5.1}% -\contentsline {section}{\numberline {5.2}Cobertura agent}{11}{section.5.2}% -\contentsline {section}{\numberline {5.3}Cobertura API store}{11}{section.5.3}% -\contentsline {section}{\numberline {5.4}Cobertura count-api}{11}{section.5.4}% -\contentsline {chapter}{\numberline {6}Resultados --- humo y funcional}{13}{chapter.6}% -\contentsline {section}{\numberline {6.1}Humo gateway}{13}{section.6.1}% -\contentsline {section}{\numberline {6.2}Funcional E2E-07 (worker offline $\rightarrow $ 409)}{13}{section.6.2}% -\contentsline {section}{\numberline {6.3}Script preparado}{13}{section.6.3}% -\contentsline {chapter}{\numberline {7}Resultados --- pruebas de carga}{14}{chapter.7}% -\contentsline {section}{\numberline {7.1}Tabla consolidada}{14}{section.7.1}% -\contentsline {section}{\numberline {7.2}ST-GW-01}{14}{section.7.2}% -\contentsline {section}{\numberline {7.3}ST-BOT-01 --- comparativa}{14}{section.7.3}% -\contentsline {section}{\numberline {7.4}Throughput gateway}{14}{section.7.4}% -\contentsline {chapter}{\numberline {8}Capacidad: 100\,000 facturas al mes}{16}{chapter.8}% -\contentsline {section}{\numberline {8.1}Traducci\'on del objetivo}{16}{section.8.1}% -\contentsline {section}{\numberline {8.2}Modelo por etapa}{16}{section.8.2}% -\contentsline {section}{\numberline {8.3}Interpretaci\'on}{16}{section.8.3}% -\contentsline {section}{\numberline {8.4}Veredicto}{17}{section.8.4}% -\contentsline {section}{\numberline {8.5}Requisitos m\'inimos sugeridos para 100k/mes}{17}{section.8.5}% -\contentsline {chapter}{\numberline {9}An\'alisis y hallazgos}{18}{chapter.9}% -\contentsline {section}{\numberline {9.1}Hallazgos}{18}{section.9.1}% -\contentsline {section}{\numberline {9.2}Scorecard global}{18}{section.9.2}% -\contentsline {section}{\numberline {9.3}Limitaciones}{18}{section.9.3}% -\contentsline {chapter}{\numberline {10}Conclusiones y recomendaciones}{19}{chapter.10}% -\contentsline {section}{\numberline {10.1}Conclusiones}{19}{section.10.1}% -\contentsline {section}{\numberline {10.2}Recomendaciones}{19}{section.10.2}% -\contentsline {section}{\numberline {10.3}Pr\'oxima campa\~na sugerida}{19}{section.10.3}% -\contentsline {chapter}{\numberline {11}Anexos}{20}{chapter.11}% -\contentsline {section}{\numberline {11.1}Datos fuente}{20}{section.11.1}% -\contentsline {section}{\numberline {11.2}Extracto ST-GW-01}{20}{section.11.2}% -\contentsline {section}{\numberline {11.3}Extracto ST-BOT-01 \#2}{20}{section.11.3}% -\contentsline {section}{\numberline {11.4}Unitarias}{20}{section.11.4}% -\contentsline {section}{\numberline {11.5}Scripts}{20}{section.11.5}% -\contentsline {section}{\numberline {11.6}Reproducibilidad}{21}{section.11.6}% -\contentsline {chapter}{\numberline {12}Count-api: qu\'e eval\'ua cada prueba}{22}{chapter.12}% -\contentsline {section}{\numberline {12.1}Pipeline bajo prueba}{22}{section.12.1}% -\contentsline {section}{\numberline {12.2}Resultado global count-api}{22}{section.12.2}% -\contentsline {section}{\numberline {12.3}Features: qu\'e se eval\'ua (con ejemplo)}{23}{section.12.3}% -\contentsline {subsection}{\numberline {12.3.1}TestExtractRowFeaturesServiceTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.1}% -\contentsline {subsection}{\numberline {12.3.2}TestBuildFeaturesContinuation --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.2}% -\contentsline {subsection}{\numberline {12.3.3}TestExtractRowFeaturesHeaderKeyword --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.3}% -\contentsline {subsection}{\numberline {12.3.4}TestExtractRowFeaturesServiceCode --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.4}% -\contentsline {subsection}{\numberline {12.3.5}TestExtractRowFeaturesNoTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.5}% -\contentsline {subsection}{\numberline {12.3.6}TestBuildFeatureRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.6}% -\contentsline {subsection}{\numberline {12.3.7}TestFeatureCount --- \textcolor {passgreen}{\textbf {PASS}}}{23}{subsection.12.3.7}% -\contentsline {section}{\numberline {12.4}Clustering: qu\'e se eval\'ua (con ejemplo)}{24}{section.12.4}% -\contentsline {subsection}{\numberline {12.4.1}TestClusterRowsTwoBands --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.1}% -\contentsline {subsection}{\numberline {12.4.2}TestClusterRowsSameBand --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.2}% -\contentsline {subsection}{\numberline {12.4.3}TestClusterRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.3}% -\contentsline {subsection}{\numberline {12.4.4}TestClusterRowsNoiseMinSamples --- \textcolor {passgreen}{\textbf {PASS}}}{24}{subsection.12.4.4}% -\contentsline {section}{\numberline {12.5}Pruebas con PDFs reales}{24}{section.12.5}% -\contentsline {subsection}{\numberline {12.5.1}Fixtures}{24}{subsection.12.5.1}% -\contentsline {subsection}{\numberline {12.5.2}TestProcessPDFTimingFixtures}{25}{subsection.12.5.2}% -\contentsline {subsection}{\numberline {12.5.3}Script HTTP \texttt {measure\_count\_latency}}{25}{subsection.12.5.3}% -\contentsline {section}{\numberline {12.6}Resultados de latencia HTTP (medidos)}{25}{section.12.6}% -\contentsline {section}{\numberline {12.7}C\'omo reproducir}{25}{section.12.7}% +\contentsline {chapter}{\numberline {1}Resumen ejecutivo}{4}{chapter.1}% +\contentsline {section}{\numberline {1.1}Veredicto global}{4}{section.1.1}% +\contentsline {section}{\numberline {1.2}KPIs de la campa\~na}{4}{section.1.2}% +\contentsline {section}{\numberline {1.3}Capacidad 100k/mes (resumen)}{5}{section.1.3}% +\contentsline {section}{\numberline {1.4}Riesgo principal}{5}{section.1.4}% +\contentsline {section}{\numberline {1.5}Alcance de esta campa\~na}{5}{section.1.5}% +\contentsline {chapter}{\numberline {2}Arquitectura y patrones (contexto)}{6}{chapter.2}% +\contentsline {section}{\numberline {2.1}Vista de componentes}{6}{section.2.1}% +\contentsline {section}{\numberline {2.2}Patrones de dise\~no}{7}{section.2.2}% +\contentsline {section}{\numberline {2.3}Pipeline de negocio}{7}{section.2.3}% +\contentsline {section}{\numberline {2.4}Definici\'on de factura}{8}{section.2.4}% +\contentsline {chapter}{\numberline {3}Entorno bajo prueba}{9}{chapter.3}% +\contentsline {section}{\numberline {3.1}Hardware y sistema}{9}{section.3.1}% +\contentsline {section}{\numberline {3.2}Baseline de memoria (2026-07-13)}{9}{section.3.2}% +\contentsline {section}{\numberline {3.3}Salud del stack}{9}{section.3.3}% +\contentsline {chapter}{\numberline {4}C\'omo leer este informe}{11}{chapter.4}% +\contentsline {section}{\numberline {4.1}Plantilla de interpretaci\'on}{11}{section.4.1}% +\contentsline {section}{\numberline {4.2}Leyenda de estados}{11}{section.4.2}% +\contentsline {section}{\numberline {4.3}Perfil de carga \texttt {shared8g}}{11}{section.4.3}% +\contentsline {section}{\numberline {4.4}Herramientas}{11}{section.4.4}% +\contentsline {section}{\numberline {4.5}Mapa de cap\'itulos de resultados}{12}{section.4.5}% +\contentsline {chapter}{\numberline {5}Resultados --- pruebas unitarias}{13}{chapter.5}% +\contentsline {section}{\numberline {5.1}Qu\'e eval\'uan}{13}{section.5.1}% +\contentsline {section}{\numberline {5.2}Scorecard}{13}{section.5.2}% +\contentsline {section}{\numberline {5.3}Cobertura agent Selenium}{13}{section.5.3}% +\contentsline {section}{\numberline {5.4}Cobertura API \texttt {bot-descarga} (orquestador)}{14}{section.5.4}% +\contentsline {section}{\numberline {5.5}Cobertura count-api}{14}{section.5.5}% +\contentsline {section}{\numberline {5.6}Cobertura desktop (Vitest)}{14}{section.5.6}% +\contentsline {chapter}{\numberline {6}Resultados --- humo y funcional}{15}{chapter.6}% +\contentsline {section}{\numberline {6.1}Qu\'e eval\'uan}{15}{section.6.1}% +\contentsline {section}{\numberline {6.2}Humo gateway}{15}{section.6.2}% +\contentsline {section}{\numberline {6.3}Funcional E2E-07 (worker offline $\rightarrow $ 409)}{15}{section.6.3}% +\contentsline {section}{\numberline {6.4}Scripts}{16}{section.6.4}% +\contentsline {chapter}{\numberline {7}Resultados --- pruebas de carga}{17}{chapter.7}% +\contentsline {section}{\numberline {7.1}Qu\'e eval\'uan}{17}{section.7.1}% +\contentsline {section}{\numberline {7.2}Tabla consolidada}{17}{section.7.2}% +\contentsline {section}{\numberline {7.3}ST-GW-01}{17}{section.7.3}% +\contentsline {section}{\numberline {7.4}ST-BOT-01 --- comparativa}{18}{section.7.4}% +\contentsline {section}{\numberline {7.5}Throughput gateway}{19}{section.7.5}% +\contentsline {chapter}{\numberline {8}Count-api: qu\'e eval\'ua cada prueba}{20}{chapter.8}% +\contentsline {section}{\numberline {8.1}Qu\'e eval\'ua / resultado / significado}{20}{section.8.1}% +\contentsline {section}{\numberline {8.2}Pipeline bajo prueba}{20}{section.8.2}% +\contentsline {section}{\numberline {8.3}Resultado global count-api}{21}{section.8.3}% +\contentsline {section}{\numberline {8.4}Features: qu\'e se eval\'ua (con ejemplo)}{21}{section.8.4}% +\contentsline {subsection}{\numberline {8.4.1}TestExtractRowFeaturesServiceTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{21}{subsection.8.4.1}% +\contentsline {subsection}{\numberline {8.4.2}TestBuildFeaturesContinuation --- \textcolor {passgreen}{\textbf {PASS}}}{21}{subsection.8.4.2}% +\contentsline {subsection}{\numberline {8.4.3}TestExtractRowFeaturesHeaderKeyword --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.3}% +\contentsline {subsection}{\numberline {8.4.4}TestExtractRowFeaturesServiceCode --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.4}% +\contentsline {subsection}{\numberline {8.4.5}TestExtractRowFeaturesNoTablePattern --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.5}% +\contentsline {subsection}{\numberline {8.4.6}TestBuildFeatureRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.6}% +\contentsline {subsection}{\numberline {8.4.7}TestFeatureCount --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.4.7}% +\contentsline {section}{\numberline {8.5}Clustering: qu\'e se eval\'ua (con ejemplo)}{22}{section.8.5}% +\contentsline {subsection}{\numberline {8.5.1}TestClusterRowsTwoBands --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.1}% +\contentsline {subsection}{\numberline {8.5.2}TestClusterRowsSameBand --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.2}% +\contentsline {subsection}{\numberline {8.5.3}TestClusterRowsEmpty --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.3}% +\contentsline {subsection}{\numberline {8.5.4}TestClusterRowsNoiseMinSamples --- \textcolor {passgreen}{\textbf {PASS}}}{22}{subsection.8.5.4}% +\contentsline {section}{\numberline {8.6}Pruebas con PDFs reales}{23}{section.8.6}% +\contentsline {subsection}{\numberline {8.6.1}Fixtures}{23}{subsection.8.6.1}% +\contentsline {subsection}{\numberline {8.6.2}TestProcessPDFTimingFixtures}{23}{subsection.8.6.2}% +\contentsline {subsection}{\numberline {8.6.3}Script HTTP \texttt {measure\_count\_latency}}{23}{subsection.8.6.3}% +\contentsline {section}{\numberline {8.7}Resultados agregados (23 PDFs)}{23}{section.8.7}% +\contentsline {section}{\numberline {8.8}Resultados de latencia HTTP}{24}{section.8.8}% +\contentsline {section}{\numberline {8.9}Detalle por archivo}{25}{section.8.9}% +\contentsline {section}{\numberline {8.10}C\'omo reproducir}{25}{section.8.10}% +\contentsline {chapter}{\numberline {9}Extracci\'on LLM: bench de latencia (ST-EXT-02)}{26}{chapter.9}% +\contentsline {section}{\numberline {9.1}Qu\'e eval\'ua / resultado / significado}{26}{section.9.1}% +\contentsline {section}{\numberline {9.2}C\'omo ejecutar}{26}{section.9.2}% +\contentsline {section}{\numberline {9.3}Resultados (2026-07-17)}{27}{section.9.3}% +\contentsline {section}{\numberline {9.4}Lectura para capacidad}{27}{section.9.4}% +\contentsline {chapter}{\numberline {10}Cola ARQ: bench ST-EXT-01 (LLM mock)}{28}{chapter.10}% +\contentsline {section}{\numberline {10.1}Qu\'e eval\'ua / resultado / significado}{28}{section.10.1}% +\contentsline {section}{\numberline {10.2}Condiciones de la corrida}{28}{section.10.2}% +\contentsline {section}{\numberline {10.3}Resultados}{29}{section.10.3}% +\contentsline {section}{\numberline {10.4}Lectura}{29}{section.10.4}% +\contentsline {chapter}{\numberline {11}Count-api bajo carga (ST-COUNT-01)}{30}{chapter.11}% +\contentsline {section}{\numberline {11.1}Qu\'e eval\'ua / resultado / significado}{30}{section.11.1}% +\contentsline {section}{\numberline {11.2}Condiciones}{30}{section.11.2}% +\contentsline {section}{\numberline {11.3}Resultados}{30}{section.11.3}% +\contentsline {section}{\numberline {11.4}Lectura}{30}{section.11.4}% +\contentsline {chapter}{\numberline {12}An\'alisis y hallazgos}{32}{chapter.12}% +\contentsline {section}{\numberline {12.1}Hallazgos}{32}{section.12.1}% +\contentsline {section}{\numberline {12.2}Scorecard global}{32}{section.12.2}% +\contentsline {section}{\numberline {12.3}Limitaciones}{32}{section.12.3}% +\contentsline {chapter}{\numberline {13}Conclusiones y recomendaciones}{34}{chapter.13}% +\contentsline {section}{\numberline {13.1}Conclusiones}{34}{section.13.1}% +\contentsline {section}{\numberline {13.2}Recomendaciones}{34}{section.13.2}% +\contentsline {section}{\numberline {13.3}Documentos relacionados}{34}{section.13.3}% +\contentsline {chapter}{\numberline {14}Anexos}{35}{chapter.14}% +\contentsline {section}{\numberline {14.1}Datos fuente}{35}{section.14.1}% +\contentsline {section}{\numberline {14.2}Extracto ST-GW-01}{35}{section.14.2}% +\contentsline {section}{\numberline {14.3}Extracto ST-BOT-01 \#2}{35}{section.14.3}% +\contentsline {section}{\numberline {14.4}Unitarias}{35}{section.14.4}% +\contentsline {section}{\numberline {14.5}Scripts}{35}{section.14.5}% +\contentsline {section}{\numberline {14.6}Extracto ST-EXT-01}{36}{section.14.6}% +\contentsline {section}{\numberline {14.7}Documentos hermanos}{36}{section.14.7}% +\contentsline {section}{\numberline {14.8}Reproducibilidad}{36}{section.14.8}% diff --git a/docs/plan_pruebas/informe/preamble.tex b/docs/plan_pruebas/informe/preamble.tex index 0070f03..a6c2933 100644 --- a/docs/plan_pruebas/informe/preamble.tex +++ b/docs/plan_pruebas/informe/preamble.tex @@ -16,7 +16,7 @@ \fancyhf{} \fancyhead[L]{\small IPSExtractor --- Informe de Pruebas} \fancyhead[R]{\small\thepage} -\fancyfoot[C]{\footnotesize Confidencial --- Uso interno} +\fancyfoot[C]{\footnotesize Documento para compartir} \renewcommand{\headrulewidth}{0.4pt} \usepackage{titlesec} @@ -58,9 +58,10 @@ \usepgfplotslibrary{statistics} \usepackage{tikz} -\usetikzlibrary{arrows.meta,positioning} +\usetikzlibrary{arrows.meta,positioning,fit,calc,shapes.geometric} \usepackage{graphicx} +\usepackage{float} \usepackage{hyperref} \hypersetup{ colorlinks=true, @@ -70,14 +71,19 @@ pdfauthor={IPSExtractor QA} } -\newcommand{\docversion}{1.4.0} -\newcommand{\docdate}{13 de julio de 2026} +\newcommand{\docdate}{17 de julio de 2026} \newcommand{\pass}{\textcolor{passgreen}{\textbf{PASS}}} \newcommand{\fail}{\textcolor{failred}{\textbf{FAIL}}} \newcommand{\parcial}{\textcolor{warnorange}{\textbf{PARCIAL}}} \newcommand{\neje}{\textcolor{ipsgrey}{\textbf{N/E}}} +\newcommand{\callout}[2]{% + \begin{center} + \fbox{\parbox{0.92\textwidth}{\centering #1\\[0.3em]\normalsize #2}} + \end{center} +} + \newcommand{\makecover}{% \begin{titlepage} \centering @@ -87,16 +93,16 @@ {\Large\color{ipsgrey} Extracci\'on y an\'alisis de facturas m\'edicas}\\[0.35cm] {\color{ipsblue}\rule{\textwidth}{2.5pt}}\\[1.5cm] {\LARGE\bfseries Informe de Pruebas}\\[0.25cm] - {\large Resultados $\cdot$ M\'etricas $\cdot$ Capacidad 100\,000/mes}\\[1.8cm] + {\large Resultados $\cdot$ M\'etricas $\cdot$ Interpretaci\'on}\\[1.8cm] \begin{tabular}{@{}ll@{}} - \textbf{Versi\'on} & \docversion \\ \textbf{Fecha} & \docdate \\ - \textbf{Campa\~na} & 2026-07-13 (perfil \texttt{shared8g}) \\ + \textbf{Campa\~na carga} & 13 de julio de 2026 (perfil \texttt{shared8g}) \\ + \textbf{Bench LLM} & 17 de julio de 2026 (ST-EXT-02) \\ \textbf{Host} & ubuntu --- ASUS X441UAK, 8\,GiB \\ - \textbf{Clasificaci\'on} & Uso interno \\ + \textbf{Audiencia} & Documento para compartir \\ \end{tabular} \vfill - {\small Documento de \emph{resultados} (no plan). Incluye an\'alisis de viabilidad de 100\,000 facturas/mes.}\\[0.25cm] + {\small Resultados de pruebas. El an\'alisis de 100\,000 facturas/mes est\'a en el PDF \emph{Capacidad 100k}.}\\[0.25cm] {\color{ipsblue}\rule{0.45\textwidth}{0.6pt}} \end{titlepage} } diff --git a/docs/plan_pruebas/informe/sections/01_resumen_ejecutivo.tex b/docs/plan_pruebas/informe/sections/01_resumen_ejecutivo.tex index 38e0f82..215447b 100644 --- a/docs/plan_pruebas/informe/sections/01_resumen_ejecutivo.tex +++ b/docs/plan_pruebas/informe/sections/01_resumen_ejecutivo.tex @@ -2,15 +2,20 @@ \label{ch:resumen} \section{Veredicto global} -La campa\~na de pruebas del \textbf{13 de julio de 2026} sobre el stack IPSExtractor desplegado en el host \texttt{ubuntu} (perfil \texttt{shared8g}) concluye: +La campa\~na de pruebas (carga 13-jul-2026 + LLM/ARQ 17-jul-2026) sobre el stack +IPSExtractor en el host \texttt{ubuntu} (perfil \texttt{shared8g}) concluye: -\begin{center} -\large -Stack \textbf{operativo y estable} para orquestaci\'on y control remoto del bot.\\[0.3em] -\normalsize -Carga ligera del gateway y heartbeats: \pass\ bajo contenci\'on moderada.\\[0.2em] -Pipeline completo a \textbf{100\,000 facturas/mes} en este host: \fail\ (ver cap.~\ref{ch:capacidad}). -\end{center} +\callout{\large Stack operativo: orquestador y agent}{% +Gateway y heartbeats del orquestador \texttt{bot-descarga} (ST-BOT-*): \pass\ bajo contenci\'on moderada.\\ +Unitarias agent Selenium / API \texttt{bot-descarga}: \pass.\\ +Vitest desktop: \pass\ (25/25).\\ +LLM ST-EXT-02 (23 PDFs): \pass\ --- p50 7{,}1\,s $\approx$506 fact/h.\\ +Cola ARQ ST-EXT-01 (30 docs, mock): \pass\ --- drenado en 22{,}5\,s.\\ +OCR ST-COUNT-01 (9 PDFs, 3 conc.): \pass\ --- p50 35{,}8\,s; $\approx$325 fact/h obs.\\ +E2E-07 (agent offline $\rightarrow$ 409): \pass.\\[0.3em] +\textbf{100\,000 facturas/mes} en este host: \fail\ del pipeline completo\\ +(detalle en el documento \emph{Capacidad 100k/mes}). +} \section{KPIs de la campa\~na} \begin{table}[ht] @@ -22,20 +27,35 @@ Pipeline completo a \textbf{100\,000 facturas/mes} en este host: \fail\ (ver cap ST-GW-01 p95 & 40{,}85\,ms & $<$500\,ms & \pass \\ ST-GW-01 fail rate & 0\,\% & $<$1\,\% & \pass \\ ST-BOT-01 p95 (reintento) & 85{,}55\,ms & $<$800\,ms & \pass \\ -Unitarias agent bot & 19/19 & 100\,\% & \pass \\ -Unitarias API bot store & 7/7 & 100\,\% & \pass \\ +Unitarias agent Selenium & 19/19 & 100\,\% & \pass \\ +Unitarias API \texttt{bot-descarga} (orquestador) & 7/7 & 100\,\% & \pass \\ Unitarias count-api (pipeline) & 3/3 & 100\,\% & \pass \\ +ST-EXT-02 LLM p50 & 7{,}114\,s & --- & $\approx$506 fact/h \\ +ST-EXT-01 ARQ mock & 30/30 drain & --- & 22{,}5\,s; $\approx$4\,5k docs/h cola \\ +ST-COUNT-01 OCR & p50 35{,}8\,s & --- & 9/9; $\approx$325 fact/h obs. \\ \bottomrule \end{tabular} -\caption{KPIs ejecutivos de la campa\~na.} +\caption{KPIs (carga 13-jul; LLM/ARQ/OCR 17-jul).} \end{table} -\section{Respuesta directa: 100\,000 facturas/mes} +\section{Capacidad 100k/mes (resumen)} \begin{description} - \item[Host actual (8\,GiB + MLflow/n8n/MinIO/\ldots)] \fail\ para el \emph{pipeline completo} (conteo + extracci\'on + bot + limpieza). - \item[Infra dedicada dimensionada] \pass\ \emph{condicional}: workers LLM, OCR con RAM $\geq$16--32\,GiB, $N$ agents Selenium, cuota API, sin zoo Docker competidor. + \item[Host actual] \parcial\ / \fail\ --- pipeline $\approx$ OCR $\sim$100--325/h; 139/h no garantizado. + \item[Infra dedicada] \pass\ condicional (OCR, workers, $N$ agents, cuota LLM). + \item[Coste LLM] $\approx$ USD\,2\,570/mes si 100k pasan a extracci\'on (gpt-5.4 mini). \end{description} -Detalle cuantitativo en el cap\'itulo~\ref{ch:capacidad}. +El modelo por etapa, gr\'aficas y requisitos est\'an en el PDF +\textbf{Capacidad100k\_IPSExtractor.pdf} (carpeta \texttt{docs/plan\_pruebas/capacidad\_100k/}). \section{Riesgo principal} -Contenci\'on de plataforma: swap $\approx$2{,}1\,GiB y \texttt{mlflow-server} $\approx$1{,}84\,GiB provocaron un outlier de 23\,s en heartbeats (corrida 1). Tras aliviar carga, p95 cay\'o a 86\,ms. Los fallos de latencia deben correlacionarse siempre con \texttt{free -h} / \texttt{docker stats}. +Contenci\'on de plataforma: swap $\approx$2{,}1\,GiB y \texttt{mlflow-server} $\approx$1{,}84\,GiB +provocaron un outlier de 23\,s en heartbeats (corrida 1). Tras aliviar carga, p95 cay\'o a 86\,ms. +Correlacionar siempre latencias con \texttt{free -h} / \texttt{docker stats}. + +\section{Alcance de esta campa\~na} +\textbf{Incluido:} stack Docker IPS, perfil shared8g, unitarias agent Selenium / orquestador \texttt{bot-descarga} / count / Vitest desktop (25/25), +humo gateway, E2E-07 (worker offline $\rightarrow$ 409), k6 ST-GW-01/ST-BOT-01 (orquestador), +bench LLM ST-EXT-02 (23 PDFs), cola ARQ ST-EXT-01 (30 docs, mock), +ST-COUNT-01 (9 PDFs reales, 3 concurrentes).\\ +\textbf{Fuera de alcance:} ST-COUNT-01 lab agresivo, ST-EXT-01 lab (100--500), E2E Imaging/VPN, +ST-BOT-03 events, procesar 100k PDFs reales. diff --git a/docs/plan_pruebas/informe/sections/02_arquitectura_contexto.tex b/docs/plan_pruebas/informe/sections/02_arquitectura_contexto.tex new file mode 100644 index 0000000..b865fa4 --- /dev/null +++ b/docs/plan_pruebas/informe/sections/02_arquitectura_contexto.tex @@ -0,0 +1,43 @@ +\chapter{Arquitectura y patrones (contexto)} +\label{ch:arq-informe} + +Este cap\'itulo resume c\'omo est\'a construido IPSExtractor para que los resultados +de las pruebas tengan sentido sin leer el plan completo. + +\callout{\textbf{Nombres (importante)}}{% +\textbf{bot-descarga} = servicio de orquestaci\'on (API Docker): jobs, claim, heartbeats.\\ +\textbf{Agent Selenium} = programa en el PC que descarga PDFs.\\ +ST-BOT-* mide el orquestador; $\sim$422 fact/h es capacidad del agent. +} + +\section{Vista de componentes} +El host solo expone el Gateway en \textbf{:8189}. El resto de APIs est\'a en la red Docker. +Los agents Selenium corren en PCs externas. + +\input{../diagrams/arquitectura} + +\section{Patrones de dise\~no} +\begin{description}[style=nextline,leftmargin=0.5cm] + \item[API Gateway] + Puerta \'unica. Las pruebas ST-GW-* miden latencia/errores del proxy, no del OCR ni del LLM. + \item[Microservicios] + count-api (OCR), extraction+workers (LLM), \texttt{bot-descarga} (orquestador), cleaning (CSV). + Cada uno tiene su propio cuello de botella. + \item[Cola ARQ] + Con \texttt{ASYNC\_QUEUE=true} el ingest encola y los workers drenan. + El bench LLM de esta campa\~na corri\'o en modo sync (respuesta en el mismo request). + \item[Claim del orquestador (\texttt{bot-descarga})] + Un job pending lo toma un solo agent Selenium (claim at\'omico). Heartbeats marcan al worker online. +\end{description} + +\input{../diagrams/patrones} + +\section{Pipeline de negocio} +\input{../diagrams/pipeline_negocio} + +Cuando este informe habla de ``capacidad'', se refiere a cu\'antas facturas/hora +puede sostener \textbf{cada etapa} y, en el documento de Capacidad 100k, el pipeline completo. + +\section{Definici\'on de factura} +Una \textbf{factura} = un PDF de cuenta m\'edica evaluado en una etapa +(conteo OCR, extracci\'on LLM, descarga agent Selenium o limpieza). diff --git a/docs/plan_pruebas/informe/sections/02_objeto_alcance.tex b/docs/plan_pruebas/informe/sections/02_objeto_alcance.tex index 361bde8..2c9a8f7 100644 --- a/docs/plan_pruebas/informe/sections/02_objeto_alcance.tex +++ b/docs/plan_pruebas/informe/sections/02_objeto_alcance.tex @@ -8,17 +8,18 @@ Documentar de forma profesional los \textbf{resultados} de pruebas de IPSExtract \begin{itemize} \item Stack Docker IPS: gateway, extraction, workers, cleaning, count-api, bot-descarga, PostgreSQL, Redis. \item Perfil de carga \texttt{shared8g} en host de staging compartido. - \item Unitarias del agent (\texttt{bots/bot\_descarga}) y del store API (\texttt{services/bot\_descarga}). + \item Unitarias del agent Selenium (\texttt{bots/bot\_descarga}) y del orquestador API (\texttt{services/bot\_descarga}). \item Modelo anal\'itico de capacidad por etapa del pipeline. + \item E2E-07: rechazo de job si el agent Selenium est\'a offline (HTTP 409). \end{itemize} \section{Fuera de alcance de esta campa\~na} \begin{itemize} \item ST-COUNT-01 / OCR masivo y ST-EXT bajo LLM real a escala. - \item E2E Imaging/Selenium con VPN. + \item E2E Imaging/Selenium con VPN (E2E-05/08 a volumen). \item ST-BOT-03 (events) --- requiere \texttt{DOWNLOAD\_JOB\_ID} de un job real (\neje). \item Procesar 100\,000 PDFs reales. \end{itemize} \section{Definici\'on de factura} -Una \textbf{factura} = un PDF de cuenta m\'edica evaluado por etapa (conteo OCR, extracci\'on LLM, descarga bot, limpieza). El veredicto 100k/mes se expresa por etapa y para el pipeline completo. +Una \textbf{factura} = un PDF de cuenta m\'edica evaluado por etapa (conteo OCR, extracci\'on LLM, descarga agent Selenium, limpieza). El veredicto 100k/mes se expresa por etapa y para el pipeline completo. diff --git a/docs/plan_pruebas/informe/sections/04_como_leer.tex b/docs/plan_pruebas/informe/sections/04_como_leer.tex new file mode 100644 index 0000000..3ae5ed8 --- /dev/null +++ b/docs/plan_pruebas/informe/sections/04_como_leer.tex @@ -0,0 +1,51 @@ +\chapter{C\'omo leer este informe} +\label{ch:como-leer} + +\section{Plantilla de interpretaci\'on} +Cada bloque de resultados sigue (expl\'icita o impl\'icitamente) cuatro preguntas: + +\begin{center} +\begin{tabular}{@{}p{3.2cm}p{9.5cm}@{}} +\toprule +\textbf{Pregunta} & \textbf{Qu\'e responde} \\ +\midrule +\textbf{Qu\'e eval\'ua} & Riesgo o propiedad del sistema bajo prueba \\ +\textbf{C\'omo se midi\'o} & Herramienta, perfil, comando o script \\ +\textbf{Resultado} & N\'umeros, PASS/FAIL/PARCIAL/N/E \\ +\textbf{Qu\'e significa} & Implicaci\'on pr\'actica (seguir, dimensionar o investigar) \\ +\bottomrule +\end{tabular} +\end{center} + +\section{Leyenda de estados} +\begin{itemize} + \item \pass --- cumple el umbral o criterio acordado. + \item \fail --- no cumple; requiere acci\'on o redespliegue de entorno. + \item \parcial --- funcional OK pero latencia/SLO degradado (t\'ipico: contenci\'on host). + \item \neje --- no ejecutado en esta campa\~na (falta dato, VPN, job id, etc.). +\end{itemize} + +\section{Perfil de carga \texttt{shared8g}} +Host compartido 8\,GiB con MLflow, n8n, MinIO, etc. Los SLOs son m\'as permisivos +que en laboratorio dedicado: + +\begin{itemize} + \item Gateway \texttt{/health/live}: p95 $<$500\,ms; fail $<$1\,\%. + \item Heartbeats bot: p95 $<$800\,ms; checks $>$95\,\%. + \item No usar \texttt{/health} agregado como SLO de latencia (puede superar 10\,s). +\end{itemize} + +\section{Herramientas} +k6, pytest, go test, Docker stats, curl, scripts en \texttt{docs/plan\_pruebas/scripts/}. + +\section{Mapa de cap\'itulos de resultados} +\begin{enumerate} + \item Unitarias --- correctitud de dominio (agent, store, count-api). + \item Humo / funcional --- el stack responde. + \item Carga k6 --- gateway y heartbeats bajo VUs. + \item Count-api --- qu\'e prueba el OCR y latencias medidas. + \item LLM ST-EXT-02 --- latencia real gpt-5.4-mini. + \item Cola ARQ ST-EXT-01 --- enqueue + drenado con mock. + \item ST-COUNT-01 --- OCR bajo carga concurrente. + \item Hallazgos y conclusiones --- s\'intesis. +\end{enumerate} diff --git a/docs/plan_pruebas/informe/sections/05_resultados_unitarias.tex b/docs/plan_pruebas/informe/sections/05_resultados_unitarias.tex index 97e8fa2..269c5ee 100644 --- a/docs/plan_pruebas/informe/sections/05_resultados_unitarias.tex +++ b/docs/plan_pruebas/informe/sections/05_resultados_unitarias.tex @@ -1,6 +1,24 @@ \chapter{Resultados --- pruebas unitarias} \label{ch:unitarias} +\callout{\textbf{Veredicto unitarias:} \pass}{% +54 casos verdes: 19 agent Selenium + 7 API \texttt{bot-descarga} + 11 count-api + \textbf{25 Vitest desktop}. +} + +\section{Qu\'e eval\'uan} +\begin{center} +\begin{tabular}{@{}p{2.8cm}p{4.2cm}p{2.2cm}p{3.5cm}@{}} +\toprule +\textbf{Suite} & \textbf{Qu\'e eval\'ua} & \textbf{Resultado} & \textbf{Significado} \\ +\midrule +Agent Selenium & Parsers, cancel, MinIO keys, GUI & 19/19 \pass & Dominio del PC agent est\'able \\ +API \texttt{bot-descarga} & Register, claim, events, auth & 7/7 \pass & Orquestador estable en unitario \\ +count-api & Features + clustering OCR & 11/11 \pass & Pipeline de conteo en izolaci\'on \\ +Desktop (Vitest) & UI shell, API client, CSV, schema & 25/25 \pass & Cliente Electron coherente \\ +\bottomrule +\end{tabular} +\end{center} + \section{Scorecard} \begin{table}[ht] \centering @@ -11,43 +29,72 @@ \texttt{bots/bot\_descarga/tests} & \pass & 19/19 & 3{,}21\,s \\ \texttt{services/bot\_descarga/tests} & \pass & 7/7 & 1{,}45\,s \\ \texttt{go test} count-api (\texttt{internal/pipeline}) & \pass & 11/11 & + Timing/HTTP en cap.~\ref{ch:count-latency} \\ -Vitest desktop & \neje & --- & No ejecutado en esta sesi\'on \\ +Vitest \texttt{services/desktop\_app} & \pass & 25/25 & $\approx$40\,s (7 archivos) \\ \bottomrule \end{tabular} \caption{Resultados unitarios.} \end{table} -\section{Cobertura agent} +\section{Cobertura agent Selenium} Incluye excel reader, PDF validator, file manager, cancel, browser fatal, MinIO keys, GUI settings (19 casos). -\section{Cobertura API store} +\section{Cobertura API \texttt{bot-descarga} (orquestador)} Incluye helpers MinIO, register/heartbeat/offline, claim at\'omico, cancel, events, filtros por job padre (7 casos). \section{Cobertura count-api} -Ver cap\'itulo~\ref{ch:count-latency}: explicaci\'on test a test con ejemplos, -gr\'aficas y resultados (11 unitarias PASS; HTTP 3 PDFs ok en 2{,}2--4{,}5\,s). +Ver cap\'itulo~\ref{ch:count-latency}: 11 unitarias PASS + \textbf{23 PDFs} medidos (HTTP y Go ok; promedio $\sim$2\,s/PDF). + +\section{Cobertura desktop (Vitest)} +Ejecutado: \texttt{cd services/desktop\_app \&\& npm test}\\ +7 archivos / 25 tests \pass\ (warnings \texttt{act(...)} en \texttt{App.test.tsx}, sin fallos). +Incluye: \texttt{App} (shell/home/config), \texttt{apiClient}, CSV, path mapping, payloads, +cleaning settings, schema display. \begin{figure}[ht] \centering \begin{tikzpicture} \begin{axis}[ - xbar, - width=0.85\textwidth, - height=4.2cm, + xbar stacked, + width=0.88\textwidth, + height=5.2cm, xlabel={Casos}, - symbolic y coords={agent,api-store,count-api,desktop}, + symbolic y coords={desktop,count-api,api-store,agent}, ytick=data, - xmin=0, xmax=22, - nodes near coords, - bar width=10pt, - legend style={at={(0.98,0.35)},anchor=east,font=\small} + yticklabels={desktop Vitest,count-api,bot-descarga API,agent Selenium}, + xmin=0, xmax=28, + enlarge y limits=0.18, + bar width=12pt, + axis y line*=left, + axis x line*=bottom, + tick align=outside, + legend style={ + at={(0.98,0.12)}, + anchor=south east, + font=\small, + draw=ipsgrey!40, + fill=white + }, + visualization depends on={rawx \as \rawxval}, + nodes near coords={% + \pgfmathparse{abs(\rawxval)<0.5 ? "":int(\rawxval)}% + \pgfmathresult + }, + every node near coord/.append style={ + font=\footnotesize\bfseries, + text=black, + /pgf/number format/assume math mode=true + }, ] -\addplot[fill=passgreen!70] coordinates {(19,agent) (7,api-store) (11,count-api) (0,desktop)}; +\addplot[fill=passgreen!75, draw=passgreen] coordinates { + (25,desktop) (11,count-api) (7,api-store) (19,agent) +}; \addlegendentry{PASS} -\addplot[fill=ipsgrey!40] coordinates {(0,agent) (0,api-store) (0,count-api) (1,desktop)}; +\addplot[fill=ipsgrey!35, draw=ipsgrey] coordinates { + (0,desktop) (0,count-api) (0,api-store) (0,agent) +}; \addlegendentry{N/E} \end{axis} \end{tikzpicture} -\caption{Unitarias ejecutadas vs pendientes.} +\caption{Unitarias ejecutadas (todas en verde tras Vitest desktop).} \label{fig:unitarias} \end{figure} diff --git a/docs/plan_pruebas/informe/sections/06_resultados_humo_funcional.tex b/docs/plan_pruebas/informe/sections/06_resultados_humo_funcional.tex index 5d5e035..d3660a6 100644 --- a/docs/plan_pruebas/informe/sections/06_resultados_humo_funcional.tex +++ b/docs/plan_pruebas/informe/sections/06_resultados_humo_funcional.tex @@ -1,6 +1,24 @@ \chapter{Resultados --- humo y funcional} \label{ch:humo} +\callout{\textbf{Veredicto humo / funcional:} \pass}{% +Gateway live y health agregado OK.\\ +E2E-07 (worker offline $\rightarrow$ 409): \pass. +} + +\section{Qu\'e eval\'uan} +\begin{center} +\begin{tabular}{@{}p{3.2cm}p{3.2cm}p{2.4cm}p{4.0cm}@{}} +\toprule +\textbf{Check} & \textbf{Qu\'e eval\'ua} & \textbf{Resultado} & \textbf{Significado} \\ +\midrule +\texttt{/health/live} & Liveness gateway & \pass\ 11\,ms & Responde sin depender de upstreams \\ +\texttt{/health} & Health agregado & \pass\ 194\,ms & extraction, cleaning, counting, bot-descarga ok \\ +E2E-07 & Job con agent offline & \pass\ 409 & Orquestador no asigna sin agent online \\ +\bottomrule +\end{tabular} +\end{center} + \section{Humo gateway} \begin{table}[ht] \centering @@ -10,14 +28,49 @@ \midrule \texttt{GET /health/live} & \pass & 11\,ms & Liveness \\ \texttt{GET /health} & \pass & 194\,ms & 4 servicios ok \\ -Contenedores IPS healthy & \pass & --- & 17\,h uptime \\ +Contenedores IPS healthy & \pass & --- & Stack arriba \\ \bottomrule \end{tabular} \caption{Humo HTTP.} \end{table} \section{Funcional E2E-07 (worker offline $\rightarrow$ 409)} -\neje\ en esta campa\~na (no se envi\'o \texttt{POST /bot-descarga/jobs} contra worker offline). Queda como follow-up de 2 minutos en staging. +\subsection*{Qu\'e es} +Comprueba que el orquestador \texttt{bot-descarga} \textbf{no} crea un job de descarga +si el \textbf{agent Selenium} no est\'a online (sin heartbeat). Debe responder \textbf{HTTP 409}. -\section{Script preparado} -Se a\~nadi\'o \texttt{docs/plan\_pruebas/scripts/k6\_smoke\_suite.js} para repeticiones automatizadas de live/health/config. +\subsection*{C\'omo se ejecut\'o} +Script: \texttt{docs/plan\_pruebas/scripts/e2e\_07\_worker\_offline.py} +\begin{enumerate} + \item \texttt{POST /jobs} --- crea job Desktop padre. + \item Verifica que el worker \texttt{e2e07-offline-pc} no est\'a online. + \item \texttt{POST /bot-descarga/jobs} (multipart Excel + \texttt{worker\_id} offline). +\end{enumerate} + +\subsection*{Resultado} +\begin{center} +\begin{tabular}{@{}ll@{}} +\toprule +\textbf{Paso} & \textbf{Resultado} \\ +\midrule +Health live & 200 \\ +Job padre Desktop & 200 (id creado) \\ +Worker online? & no \\ +Crear job descarga & \textbf{409} (esperado) \\ +Detalle API & Worker no est\'a online; enciende el agent primero \\ +\midrule +\textbf{Veredicto} & \pass \\ +\bottomrule +\end{tabular} +\end{center} + +\callout{\textbf{Significado}}{% +El orquestador protege el flujo: sin agent Selenium encendido no se encola descarga.\\ +Evidencia en \texttt{docs/plan\_pruebas/informes/e2e\_07\_latest.json}. +} + +\section{Scripts} +\begin{itemize} + \item Humo: \texttt{docs/plan\_pruebas/scripts/k6\_smoke\_suite.js} + \item E2E-07: \texttt{docs/plan\_pruebas/scripts/e2e\_07\_worker\_offline.py} +\end{itemize} diff --git a/docs/plan_pruebas/informe/sections/07_resultados_carga.tex b/docs/plan_pruebas/informe/sections/07_resultados_carga.tex index 98e878c..350dee8 100644 --- a/docs/plan_pruebas/informe/sections/07_resultados_carga.tex +++ b/docs/plan_pruebas/informe/sections/07_resultados_carga.tex @@ -1,6 +1,24 @@ \chapter{Resultados --- pruebas de carga} \label{ch:carga} +\callout{\textbf{Veredicto carga:} \pass\ (con matiz)}{% +ST-GW-01 \pass. ST-BOT-01: corrida 1 \parcial\ (contenci\'on); corrida 2 \pass.\\ +ST-BOT-03 \neje\ (falt\'o DOWNLOAD\_JOB\_ID). +} + +\section{Qu\'e eval\'uan} +\begin{center} +\begin{tabular}{@{}p{2.2cm}p{3.8cm}p{3.0cm}p{3.8cm}@{}} +\toprule +\textbf{ID} & \textbf{Qu\'e eval\'ua} & \textbf{Resultado} & \textbf{Significado} \\ +\midrule +ST-GW-01 & Gateway bajo VUs (\texttt{/health/live}) & p95 41\,ms, 0\,\% fail \pass & Orquestaci\'on no es cuello \\ +ST-BOT-01 & Heartbeats masivos (orquestador \texttt{bot-descarga}) & \#2 p95 86\,ms \pass & Orquestador OK si hay RAM \\ +ST-BOT-03 & Tormenta de events & \neje & Pendiente job real \\ +\bottomrule +\end{tabular} +\end{center} + \section{Tabla consolidada} \begin{table}[ht] \centering @@ -19,46 +37,68 @@ ST-BOT-03 & --- & --- & --- & --- & --- & \neje \\ \end{table} \section{ST-GW-01} +\label{sec:st-gw-01} + +\subsection*{Qu\'e es} +Prueba de \textbf{carga del API Gateway} (puerto :8189). Con la herramienta k6 se simulan +varios usuarios virtuales (VUs) que llaman una y otra vez a \texttt{GET /health/live} +(``\textquestiondown est\'as vivo?''). + +No procesa facturas: no pasa por OCR, LLM ni agent Selenium. Solo comprueba que la \textbf{puerta de entrada} +sigue respondiendo r\'apido cuando hay muchas peticiones a la vez. + +\subsection*{Umbrales que deb\'ia cumplir} \begin{itemize} - \item Checks 13768/13768 (100\,\%); throughput $\approx$57\,req/s. - \item Thresholds p95$<$500\,ms y fail$<$1\,\% en verde. + \item p95 (el 95\,\% de las respuestas) $<$ 500\,ms + \item porcentaje de fallos $<$ 1\,\% \end{itemize} -\section{ST-BOT-01 --- comparativa} -Corrida 1: funcional OK, latencia FAIL (contenci\'on host).\\ -Corrida 2: funcional OK, latencia PASS (host m\'as libre). +\subsection*{Resultado} +\begin{center} +\begin{tabular}{@{}lrl@{}} +\toprule +\textbf{M\'etrica} & \textbf{Valor} & \textbf{Lectura} \\ +\midrule +Peticiones & 6\,884 & carga real enviada \\ +Fallos & 0\,\% & ninguna petici\'on rota \\ +Checks k6 & 13\,768/13\,768 (100\,\%) & todas las aserciones OK \\ +Promedio & 12{,}6\,ms & muy por debajo del umbral \\ +p95 & 40{,}9\,ms & \pass\ (l\'imite 500\,ms) \\ +M\'aximo & 125\,ms & sin outliers graves \\ +Ritmo & $\approx$57\,req/s & ver secci\'on~7.5 \\ +\bottomrule +\end{tabular} +\end{center} -\begin{figure}[ht] -\centering -\begin{tikzpicture} -\begin{axis}[ - ybar, - width=0.95\textwidth, - height=7cm, - ylabel={Latencia (ms)}, - symbolic x coords={GW-p95,BOT1-p95,BOT2-p95,GW-max,BOT1-max,BOT2-max}, - xtick=data, - x tick label style={rotate=30, anchor=east, font=\small}, - nodes near coords, - nodes near coords style={font=\scriptsize}, - bar width=12pt, - ymin=0, - enlarge x limits=0.12, - grid=major, - legend entries={medici\'on, umbral GW 500, umbral BOT 800}, - legend style={at={(0.02,0.98)},anchor=north west,font=\scriptsize} -] -\addplot[fill=ipsblue!75] coordinates { - (GW-p95,40.85) (BOT1-p95,2280) (BOT2-p95,85.55) - (GW-max,125.37) (BOT1-max,23120) (BOT2-max,98.31) -}; -\addplot[red, sharp plot, update limits=false, forget plot] coordinates {(GW-p95,500) (BOT2-max,500)}; -\addplot[orange, sharp plot, update limits=false] coordinates {(BOT1-p95,800) (BOT2-p95,800)}; -\end{axis} -\end{tikzpicture} -\caption{p95 y max por caso. BOT1-max (23\,s) fuera de escala visual relativa --- ver tabla.} -\label{fig:latencias} -\end{figure} +\callout{\textbf{Veredicto ST-GW-01:} \pass}{% +El gateway aguanta carga concurrente ligera sin errores y con latencia baja.\\ +\textbf{Significado:} la orquestaci\'on HTTP \textbf{no} es el cuello de botella del sistema; +el l\'imite de capacidad est\'a en OCR / LLM / agent Selenium (otros cap\'itulos), no en :8189. +} + + +\section{ST-BOT-01 --- comparativa} +\subsection*{Qu\'e es} +Prueba de \textbf{carga del orquestador \texttt{bot-descarga}} (API Docker en :8200, v\'ia gateway). +k6 env\'ia 80 \texttt{POST /bot-descarga/workers/\{id\}/heartbeat} simulando agents online. +\textbf{No} mide descarga Selenium ni la capacidad $\sim$422 fact/h del agent PC; solo la API de control +(jobs, claim, heartbeats) bajo presi\'on. + +Misma prueba (80 heartbeats k6 contra el orquestador \texttt{bot-descarga}), dos momentos del host: + +\begin{center} +\begin{tabular}{@{}p{3.6cm}p{4.6cm}p{4.6cm}@{}} +\toprule + & \textbf{Corrida 1 --- host compartido} & \textbf{Corrida 2 --- host m\'as libre} \\ +\midrule +Qu\'e pasaba & Swap $\approx$2{,}1\,GiB; MLflow y otros servicios compitiendo & Menos carga; m\'as RAM libre \\ +Funcional & 100\,\% HTTP 200 & 100\,\% HTTP 200 \\ +p95 & \textbf{2{,}28\,s} (umbral 800\,ms) & \textbf{86\,ms} \\ +max & \textbf{23{,}1\,s} & \textbf{98\,ms} \\ +Veredicto & \parcial\ (latencia) & \pass \\ +\bottomrule +\end{tabular} +\end{center} \begin{figure}[ht] \centering @@ -66,45 +106,91 @@ Corrida 2: funcional OK, latencia PASS (host m\'as libre). \begin{axis}[ ybar, width=0.9\textwidth, - height=6cm, - ylabel={ms}, - symbolic x coords={avg,med,p95,max}, + height=6.4cm, + ylabel={Latencia heartbeat (ms)}, + symbolic x coords={promedio,p95,maximo}, xtick=data, - legend style={at={(0.02,0.98)},anchor=north west}, - bar width=10pt, - ymin=0, ymax=600, - enlarge x limits=0.15, + xticklabels={promedio,p95,m\'aximo}, + enlarge x limits=0.25, + ymin=0, ymax=2800, + bar width=16pt, grid=major, - title={ST-BOT-01: corrida 1 vs 2 (max de \#1 truncado en eje)} + major grid style={ipsgrey!25}, + legend style={ + at={(0.5,-0.2)}, + anchor=north, + legend columns=2, + font=\small, + draw=ipsgrey!40 + }, + point meta=explicit symbolic, + nodes near coords, + every node near coord/.append style={font=\scriptsize\bfseries, anchor=south, yshift=1pt}, ] -\addplot[fill=failred!60] coordinates {(avg,511) (med,57) (p95,500) (max,600)}; -\addlegendentry{\#1 (p95 real 2280; max 23120)} -\addplot[fill=passgreen!70] coordinates {(avg,57) (med,56) (p95,86) (max,98)}; -\addlegendentry{\#2 PASS} +% Maximo corrida 1: valor visual 2500 (recortado); etiqueta indica 23,1 s real +\addplot[fill=failred!65, draw=failred] coordinates { + (promedio,511) [511 ms] + (p95,2280) [2,28 s] + (maximo,2500) [23,1 s*] +}; +\addlegendentry{Corrida 1 --- host compartido} +\addplot[fill=passgreen!75, draw=passgreen] coordinates { + (promedio,57) [57 ms] + (p95,86) [86 ms] + (maximo,98) [98 ms] +}; +\addlegendentry{Corrida 2 --- host m\'as libre} +\draw[orange, thick, dashed] (axis cs:promedio,800) -- (axis cs:maximo,800) + node[midway, above, font=\scriptsize, text=orange] {umbral p95 = 800\,ms}; \end{axis} \end{tikzpicture} -\caption{Before/after heartbeats (eje limitado; valores exactos en tabla~\ref{tab:carga}).} +\caption{Misma prueba ST-BOT-01, distinto estado del host. (*) El m\'aximo real de la corrida 1 +fue 23{,}1\,s; la barra est\'a recortada solo para que se lean el resto de valores. +Detalle num\'erico en la tabla~\ref{tab:carga}.} \label{fig:bot-before-after} \end{figure} +\callout{\textbf{Conclusi\'on de esta prueba:}}{% +Con un \textbf{host dedicado} (sin competir con MLflow/swap/otros stacks) el servicio \texttt{bot-descarga} se comporta +mucho mejor: p95 baja de 2{,}28\,s a 86\,ms y desaparece el outlier de 23\,s. +El fallo de la corrida 1 no es un bug del heartbeat (siempre HTTP 200), sino +\textbf{contenci\'on del host compartido}. Para SLOs estables en staging/prod hace falta +aislar IPSExtractor en un nodo dedicado. +} + + \section{Throughput gateway} +\textbf{Qu\'e mide.} En ST-GW-01, k6 martillea \texttt{GET /health/live} del gateway +(:8189). Ese endpoint solo comprueba que el proceso responde; \textbf{no} cuenta PDFs, +\textbf{no} pasa por OCR ni por el LLM. + +\textbf{Resultado.} $\approx$57 peticiones por segundo (req/s), con p95 41\,ms y 0\,\% de fallos. +Eso indica que la ``puerta\'\' del sistema aguanta bien la orquestaci\'on HTTP ligera. + +\textbf{Qu\'e \emph{no} significa.} 57\,req/s \textbf{no} se traduce a 57 facturas/hora ni a +capacidad del pipeline. La capacidad real la marcan las etapas pesadas (OCR, LLM, agent Selenium), +documentadas en otros cap\'itulos y en Capacidad 100k. + \begin{figure}[ht] \centering \begin{tikzpicture} \begin{axis}[ - width=0.7\textwidth, - height=5cm, + width=0.65\textwidth, + height=4.2cm, ybar, symbolic x coords={ST-GW-01}, xtick=data, - ylabel={req/s}, + ylabel={peticiones / segundo}, nodes near coords, - ymin=0, - bar width=30pt + every node near coord/.append style={font=\small\bfseries}, + ymin=0, ymax=80, + bar width=36pt, + enlarge x limits=0.6 ] -\addplot[fill=ipsblue] coordinates {(ST-GW-01,57.33)}; +\addplot[fill=ipsblue!80, draw=ipsblue] coordinates {(ST-GW-01,57.33)}; \end{axis} \end{tikzpicture} -\caption{Throughput observado en health liveness (no equivale a PDFs/h).} +\caption{Ritmo del gateway en health liveness ($\approx$57\,req/s). Solo mide HTTP ligero, +no facturas procesadas.} \label{fig:throughput} \end{figure} diff --git a/docs/plan_pruebas/informe/sections/08_capacidad_100k.tex b/docs/plan_pruebas/informe/sections/08_capacidad_100k.tex index 5b55c11..f5350d6 100644 --- a/docs/plan_pruebas/informe/sections/08_capacidad_100k.tex +++ b/docs/plan_pruebas/informe/sections/08_capacidad_100k.tex @@ -1,104 +1,10 @@ -\chapter{Capacidad: 100\,000 facturas al mes} -\label{ch:capacidad} +\chapter*{Movido} +\label{ch:capacidad-moved} -\section{Traducci\'on del objetivo} -\begin{table}[ht] -\centering -\begin{tabular}{@{}lrr@{}} -\toprule -\textbf{Escenario operativo} & \textbf{Facturas/d\'ia} & \textbf{Facturas/hora} \\ -\midrule -100\,000 / mes, 24$\times$7 & $\approx$3\,333 & $\approx$\textbf{139} \\ -100\,000 / mes, 8\,h $\times$ 22\,d & $\approx$4\,545 & $\approx$\textbf{570} \\ -\bottomrule -\end{tabular} -\caption{Throughput m\'inimo para cumplir 100k/mes.} -\end{table} +El an\'alisis de \textbf{100\,000 facturas/mes} vive ahora en el documento independiente: -\section{Modelo por etapa} -Supuestos: host compartido medido el 2026-07-13; estimaciones \textbf{conservadoras} (no bench OCR/LLM masivo). Detalle en \texttt{scripts/estimate\_capacity.md}. - -\begin{table}[ht] -\centering -\begin{tabular}{@{}lrrll@{}} -\toprule -\textbf{Etapa} & \textbf{Host act.} & \textbf{Dedicado} & \textbf{vs 139/h} & \textbf{vs 570/h} \\ -\midrule -Gateway (orquestaci\'on) & $\gg$10\,000 & $\gg$20\,000 & OK & OK \\ -count-api OCR & $\sim$40 & $\sim$400 & RIESGO & NO \\ -extraction LLM & $\sim$100 & $\sim$800 & \'AMBAR & NO$^{*}$ \\ -bot Selenium (1 agent) & $\sim$60 & $\sim$500$^{\dagger}$ & RIESGO & NO \\ -cleaning & $\sim$800 & $\sim$3\,000 & OK & \'AMBAR \\ -\textbf{Pipeline completo} & $\sim$35 & $\sim$450 & \fail & \'AMBAR \\ -\bottomrule -\end{tabular} -\caption{Facturas/hora estimadas. $^{*}$Sujeto a cuota LLM. $^{\dagger}$Con $N$ agents en paralelo.} -\label{tab:capacidad} -\end{table} - -\begin{figure}[ht] -\centering -\begin{tikzpicture} -\begin{axis}[ - ybar, - width=\textwidth, - height=7.5cm, - ylabel={Facturas / hora (estimadas)}, - symbolic x coords={Gateway,OCR,LLM,Bot,Cleaning,Pipeline}, - xtick=data, - x tick label style={font=\small}, - legend style={at={(0.02,0.98)},anchor=north west,font=\small}, - bar width=8pt, - ymin=0, ymax=1200, - enlarge x limits=0.08, - grid=major, - title={Capacidad estimada vs objetivos 139/h y 570/h} -] -\addplot[fill=ipsblue!70] coordinates { - (Gateway,1000) (OCR,40) (LLM,100) (Bot,60) (Cleaning,800) (Pipeline,35) -}; -\addlegendentry{Host actual (Gateway truncado)} -\addplot[fill=passgreen!55] coordinates { - (Gateway,1000) (OCR,400) (LLM,800) (Bot,500) (Cleaning,1000) (Pipeline,450) -}; -\addlegendentry{Dedicado (estim.)} -\addplot[failred, ultra thick, sharp plot, update limits=false] coordinates { - (Gateway,139) (Pipeline,139) -}; -\addlegendentry{Objetivo 139/h} -\addplot[warnorange, ultra thick, sharp plot, update limits=false] coordinates { - (Gateway,570) (Pipeline,570) -}; -\addlegendentry{Objetivo 570/h} -\end{axis} -\end{tikzpicture} -\caption{Brecha de capacidad. Gateway real $\gg$1000/h; se trunca para legibilidad.} -\label{fig:capacidad} -\end{figure} - -\section{Interpretaci\'on} -\begin{enumerate} - \item El \textbf{gateway} no es el cuello de botella (evidencia ST-GW-01). - \item En el \textbf{host actual}, OCR + Selenium + LLM compiten por CPU/RAM/swap con MLflow: el pipeline completo queda $\ll$139/h. - \item Con infra \textbf{dedicada} (sin zoo, m\'as RAM, workers, $N$ PCs bot, cuota LLM), acercarse a 139/h (24$\times$7) es plausible; 570/h exige dimensionamiento agresivo y prueba de carga espec\'ifica. -\end{enumerate} - -\section{Veredicto} \begin{center} -\fbox{\parbox{0.92\textwidth}{\centering -\large \textbf{Se pueden procesar +100\,000 facturas/mes?}\\[0.4em] -\normalsize -En el servidor actual compartido: \fail.\\[0.2em] -En arquitectura dedicada y dimensionada: \pass\ condicional (cuota LLM, OCR, agents).\\[0.3em] -\footnotesize Esta campa\~na no ejecut\'o 100k PDFs; el veredicto es modelo + evidencia de carga ligera. -}} +\texttt{docs/plan\_pruebas/capacidad\_100k/} $\rightarrow$ \textbf{Capacidad100k\_IPSExtractor.pdf} \end{center} -\section{Requisitos m\'inimos sugeridos para 100k/mes} -\begin{itemize} - \item Host(s) dedicados IPS: RAM $\geq$16--32\,GiB; SSD; sin MLflow/Airflow en el mismo nodo. - \item count-api: pool OCR dimensionado; l\'imite de memoria realista. - \item extraction: r\'eplicas worker + concurrencia acorde a RPM del LLM; presupuesto de coste. - \item bot: varios agents PC en paralelo hacia Imaging/MinIO. - \item Observabilidad: colas ARQ, p95 por etapa, alertas de swap. -\end{itemize} +Este archivo se conserva solo como marcador para no romper enlaces antiguos. diff --git a/docs/plan_pruebas/informe/sections/09_analisis_hallazgos.tex b/docs/plan_pruebas/informe/sections/09_analisis_hallazgos.tex index ed39690..3eb3535 100644 --- a/docs/plan_pruebas/informe/sections/09_analisis_hallazgos.tex +++ b/docs/plan_pruebas/informe/sections/09_analisis_hallazgos.tex @@ -3,10 +3,21 @@ \section{Hallazgos} \begin{enumerate} - \item \textbf{H1 --- Contenci\'on de plataforma:} ST-BOT-01 \#1 (p95 2{,}28\,s, max 23\,s) vs \#2 (p95 86\,ms) demuestra sensibilidad a swap/MLflow, no a un bug funcional del heartbeat (100\,\% HTTP 200 en ambas). + \item \textbf{H1 --- Contenci\'on de plataforma:} ST-BOT-01 \#1 (p95 2{,}28\,s, max 23\,s) vs \#2 (p95 86\,ms) + demuestra que un \textbf{host dedicado / sin contenci\'on} se comporta mucho mejor. + No es un bug del heartbeat (100\,\% HTTP 200 en ambas); es sensibilidad a swap/MLflow + en el host compartido. \item \textbf{H2 --- Gateway saludable:} 0\,\% errores y p95 41\,ms bajo 25 VUs en liveness. - \item \textbf{H3 --- Unitarias s\'olidas:} 29 casos verdes (19 agent + 7 API store + 3 count-api pipeline). - \item \textbf{H4 --- Capacidad 100k:} imposible en host compartido actual; viable solo con escalado dedicado. + \item \textbf{H3 --- Unitarias s\'olidas:} agent Selenium + \texttt{bot-descarga} + count-api + + Vitest desktop (25/25) en verde. + \item \textbf{H4 --- LLM medido:} p50 7{,}1\,s $\approx$506 fact/h; etapa viable vs 139/h. + \item \textbf{H5 --- Cola ARQ validada:} ST-EXT-01 30/30 drenados en 22{,}5\,s (mock); prod debe usar \texttt{ASYNC\_QUEUE=true}. + \item \textbf{H6 --- OCR bajo carga:} ST-COUNT-01 9/9 ok; p50 35{,}8\,s con 3 concurrentes + (vs $\sim$2\,s en serie) $\Rightarrow$ $\approx$100--325 fact/h en shared8g. + \item \textbf{H7 --- Capacidad 100k:} imposible en host compartido actual (OCR/contenci\'on); + ver PDF \emph{Capacidad 100k/mes}. + \item \textbf{H8 --- E2E-07:} crear job con agent offline $\rightarrow$ HTTP 409 \pass; + el orquestador \texttt{bot-descarga} no asigna descarga sin agent online. \end{enumerate} \section{Scorecard global} @@ -18,18 +29,18 @@ width=0.9\textwidth, height=5.5cm, ylabel={Cantidad de casos}, - symbolic x coords={Unitaria,Humo,Carga,Funcional,Capacidad}, + symbolic x coords={Unitaria,Humo,Carga,LLM,ARQ,Count}, xtick=data, legend style={at={(0.5,-0.22)},anchor=north,legend columns=3,font=\small}, - bar width=18pt, + bar width=14pt, ymin=0, ymax=8, - enlarge x limits=0.12 + enlarge x limits=0.1 ] -\addplot[fill=passgreen!70] coordinates {(Unitaria,3) (Humo,3) (Carga,2) (Funcional,0) (Capacidad,0)}; +\addplot[fill=passgreen!70] coordinates {(Unitaria,4) (Humo,4) (Carga,2) (LLM,1) (ARQ,1) (Count,3)}; \addlegendentry{PASS} -\addplot[fill=warnorange!70] coordinates {(Unitaria,0) (Humo,0) (Carga,1) (Funcional,0) (Capacidad,0)}; +\addplot[fill=warnorange!70] coordinates {(Unitaria,0) (Humo,0) (Carga,1) (LLM,0) (ARQ,0) (Count,0)}; \addlegendentry{PARCIAL} -\addplot[fill=ipsgrey!50] coordinates {(Unitaria,1) (Humo,0) (Carga,1) (Funcional,1) (Capacidad,1)}; +\addplot[fill=ipsgrey!50] coordinates {(Unitaria,0) (Humo,0) (Carga,1) (LLM,0) (ARQ,0) (Count,0)}; \addlegendentry{N/E} \end{axis} \end{tikzpicture} @@ -38,4 +49,6 @@ \end{figure} \section{Limitaciones} -No se midieron tiempos reales OCR/LLM por PDF en esta campa\~na; las cifras de capacidad son estimaciones documentadas para decisi\'on de arquitectura, no un compromiso de SLA. +No se ejecut\'o ST-COUNT-01 lab agresivo ni E2E Imaging a volumen; ST-EXT-01 lab (100--500) pendiente. +El veredicto 100k/mes del pipeline completo es modelo + evidencia parcial +(documento Capacidad 100k), no un compromiso de SLA. diff --git a/docs/plan_pruebas/informe/sections/10_conclusiones.tex b/docs/plan_pruebas/informe/sections/10_conclusiones.tex index e3968b2..9e47917 100644 --- a/docs/plan_pruebas/informe/sections/10_conclusiones.tex +++ b/docs/plan_pruebas/informe/sections/10_conclusiones.tex @@ -3,24 +3,34 @@ \section{Conclusiones} \begin{enumerate} - \item IPSExtractor en staging responde correctamente a humo y carga ligera de control (gateway + heartbeats). - \item La calidad de latencia depende cr\'iticamente de no saturar el host compartido. - \item \textbf{100\,000 facturas/mes no son alcanzables} en el laptop Ubuntu actual con el zoo Docker observado. - \item Con infraestructura dedicada y dimensionada, el objetivo es \textbf{condicional\-mente viable}; requiere prueba de carga espec\'ifica por etapa (OCR, LLM, bot). + \item IPSExtractor en staging responde correctamente a humo y carga ligera (gateway + heartbeats). + \item \textbf{Host dedicado vs compartido:} ST-BOT-01 muestra que, con la misma prueba, + un host libre (sin MLflow/swap compitiendo) baja el p95 de 2{,}28\,s a 86\,ms y elimina + el outlier de 23\,s. El comportamiento es claramente mejor en nodo dedicado; + la latencia mala no fue un bug funcional (HTTP 200 en ambas corridas). + \item Agent Selenium ($\approx$422\,fact/h) y LLM ($\approx$506\,fact/h) cubren el objetivo 24$\times$7 \emph{por etapa}. + \item La cola ARQ (ST-EXT-01) dren\'o 30 docs en $\approx$23\,s con mock; arquitectura async validada. + \item OCR bajo 3 concurrentes (ST-COUNT-01): p50 35{,}8\,s; $\approx$100--325 fact/h en shared8g; + degradaci\'on fuerte vs serie ($\sim$2\,s). + \item El pipeline a 100k/mes en el host actual \textbf{no est\'a garantizado} + (ritmo $\approx\min$ etapas $=$ OCR $\sim$100--325/h; piso $\sim$100 $<$ 139); + detalle en \textbf{Capacidad100k\_IPSExtractor.pdf}. + \item Con infra dedicada + OCR dimensionado, 24$\times$7 es \textbf{condicionalmente viable}. \end{enumerate} \section{Recomendaciones} \begin{enumerate} - \item Separar IPS de MLflow/Airflow/n8n en el nodo de producci\'on/staging de rendimiento. - \item Ejecutar ST-COUNT-01 y bench LLM solo con $\geq$3\,GiB libres y sin swap activo. - \item Completar E2E-07 y ST-BOT-03; archivar JSON de k6 en \texttt{informes/}. - \item Dimensionar agents bot ($N$) antes de comprometer vol\'umenes mensuales de descarga. - \item Incorporar CI: pytest agent + store en cada PR. + \item Separar IPSExtractor en un \textbf{host dedicado} (sin MLflow/Airflow/n8n en el mismo nodo + de rendimiento); ST-BOT-01 lo justifica con evidencia. + \item Producci\'on: \texttt{ASYNC\_QUEUE=true} + workers dimensionados a la cuota LLM. + \item Repetir ST-COUNT-01 en perfil lab (m\'as rps / VUs) con $\geq$3\,GiB libres. + \item Completar ST-BOT-03, ST-EXT-01 perfil lab (100--500) y E2E pendientes. + \item Dimensionar agents Selenium ($N$) y workers extraction antes de comprometer vol\'umenes. + \item Incorporar CI: pytest agent Selenium + orquestador \texttt{bot-descarga} en cada PR. \end{enumerate} -\section{Pr\'oxima campa\~na sugerida} +\section{Documentos relacionados} \begin{itemize} - \item Ventana con MLflow detenido. - \item Bench: 50--200 PDFs reales por etapa (OCR, LLM) midiendo p50/p95 y coste. - \item Actualizar gr\'afica de capacidad con datos medidos (reemplazar estimaciones). + \item \textbf{Plan de pruebas} --- estrategia e IDs. + \item \textbf{Capacidad 100k/mes} --- modelo, coste LLM, veredicto de volumen. \end{itemize} diff --git a/docs/plan_pruebas/informe/sections/11_anexos.tex b/docs/plan_pruebas/informe/sections/11_anexos.tex index c8cbcf5..e8be474 100644 --- a/docs/plan_pruebas/informe/sections/11_anexos.tex +++ b/docs/plan_pruebas/informe/sections/11_anexos.tex @@ -26,11 +26,27 @@ services/bot_descarga/tests: 7 passed in 1.45s count-api internal/pipeline: 3 passed (+8 features/clustering nuevos) Timing: go test -run Timing -v (3 PDFs en docs/plan_pruebas/data/) HTTP: bash docs/plan_pruebas/scripts/measure_count_latency.sh +LLM: python docs/plan_pruebas/scripts/measure_llm_latency.py \end{lstlisting} \section{Scripts} \texttt{k6\_gateway\_health.js}, \texttt{k6\_bot\_heartbeats.js}, \texttt{k6\_bot\_events.js}, -\texttt{k6\_count\_batches.js}, \texttt{k6\_smoke\_suite.js}, \texttt{estimate\_capacity.md}. +\texttt{k6\_count\_batches.js}, \texttt{k6\_smoke\_suite.js}, +\texttt{st\_ext\_01\_arq\_bench.py}, \texttt{st\_ext\_02\_llm\_bench.py}, +\texttt{llm\_mock\_server.py}, \texttt{measure\_count\_latency.*}, \texttt{estimate\_capacity.md}. + +\section{Extracto ST-EXT-01} +\begin{lstlisting} +mode=async queued=30/30 drained=True +enqueue_wall_s=1.307 drain_s=22.544 total_s=23.851 +throughput_docs_per_h ~ 4528 (mock; no SLO LLM) +\end{lstlisting} + +\section{Documentos hermanos} +\begin{itemize} + \item Plan: \texttt{PlanPruebas\_IPSExtractor.pdf} + \item Capacidad 100k: \texttt{Capacidad100k\_IPSExtractor.pdf} +\end{itemize} \section{Reproducibilidad} \begin{enumerate} diff --git a/docs/plan_pruebas/informe/sections/12_count_latency.tex b/docs/plan_pruebas/informe/sections/12_count_latency.tex index ead1bf0..918cd59 100644 --- a/docs/plan_pruebas/informe/sections/12_count_latency.tex +++ b/docs/plan_pruebas/informe/sections/12_count_latency.tex @@ -1,9 +1,26 @@ \chapter{Count-api: qu\'e eval\'ua cada prueba} \label{ch:count-latency} -Este cap\'itulo explica, con ejemplos, las pruebas del microservicio \textbf{count-api} -(conteo de servicios en facturas PDF) y resume los \textbf{resultados} obtenidos -en el host Ubuntu el 13 de julio de 2026. +\callout{\textbf{Veredicto count-api:} \pass\ en muestra}{% +11/11 unitarias; 23/23 PDFs HTTP ok; latencia tipica $\sim$2\,s/PDF.\\ +No sustituye ST-COUNT-01 agresivo (capacidad OCR bajo carga). +} + +\section{Qu\'e eval\'ua / resultado / significado} +\begin{center} +\begin{tabular}{@{}p{3.0cm}p{4.0cm}p{5.5cm}@{}} +\toprule +\textbf{Bloque} & \textbf{Resultado} & \textbf{Qu\'e significa} \\ +\midrule +Features + clustering & 11/11 \pass & L\'ogica OCR de conteo correcta sin PDF \\ +HTTP 23 PDFs & 23/23 \pass & Endpoint responde con PDFs reales \\ +Latencia $\sim$2\,s/PDF & medido & En host dedicado $\sim$1800/h te\'oricos; en shared8g contenci\'on baja a $\sim$40/h estimados \\ +\bottomrule +\end{tabular} +\end{center} + +Este cap\'itulo explica las pruebas del microservicio \textbf{count-api} +(conteo de servicios en facturas PDF) y resume los \textbf{resultados} del 13 de julio de 2026. \section{Pipeline bajo prueba} \begin{center} @@ -27,7 +44,7 @@ en el host Ubuntu el 13 de julio de 2026. \end{center} Las unitarias de \texttt{features} y \texttt{clustering} \textbf{no} abren el PDF: -simulan ``cajas'' OCR \texttt{\{texto, X, Y\}}. Timing y HTTP s\'i usan los 3 PDFs reales. +simulan ``cajas'' OCR \texttt{\{texto, X, Y\}}. Timing y HTTP usan los \textbf{23 PDFs} reales en \texttt{docs/plan\_pruebas/data/}. \section{Resultado global count-api} \begin{table}[ht] @@ -37,11 +54,11 @@ simulan ``cajas'' OCR \texttt{\{texto, X, Y\}}. Timing y HTTP s\'i usan los 3 PD \textbf{Bloque} & \textbf{Resultado} & \textbf{Casos} & \textbf{Notas} \\ \midrule Features + clustering & \pass & 11/11 & Ubuntu, \texttt{go test -v} \\ -HTTP 3 PDFs reales & \pass & 3/3 & 200, status ok, $\sim$2--4{,}5\,s \\ -Timing Go por etapa & pendiente / skip path & --- & re-ejecutar \texttt{-run Timing -count=1} \\ +HTTP 23 PDFs reales & \pass & 23/23 & 200, status ok \\ +Timing Go por etapa & \pass & 23/23 & 46{,}3\,s total; todos ok \\ \bottomrule \end{tabular} -\caption{Scorecard count-api en esta campa\~na.} +\caption{Scorecard count-api --- campa\~na 23 PDFs (13 jul 2026).} \end{table} \begin{figure}[ht] @@ -55,18 +72,16 @@ Timing Go por etapa & pendiente / skip path & --- & re-ejecutar \texttt{-run Tim symbolic x coords={Features,Clustering,HTTP-PDF,Timing-Go}, xtick=data, nodes near coords, - ymin=0, ymax=12, + ymin=0, ymax=25, bar width=14pt, enlarge x limits=0.15, legend style={at={(0.98,0.98)},anchor=north east,font=\small} ] -\addplot[fill=passgreen!75] coordinates {(Features,7) (Clustering,4) (HTTP-PDF,3) (Timing-Go,0)}; +\addplot[fill=passgreen!75] coordinates {(Features,11) (Clustering,0) (HTTP-PDF,23) (Timing-Go,23)}; \addlegendentry{PASS} -\addplot[fill=ipsgrey!45] coordinates {(Features,0) (Clustering,0) (HTTP-PDF,0) (Timing-Go,1)}; -\addlegendentry{Pendiente} \end{axis} \end{tikzpicture} -\caption{Cobertura count-api por bloque.} +\caption{Cobertura count-api: 11 unitarias + 23 PDFs medidos (HTTP y Go).} \label{fig:count-score} \end{figure} @@ -168,12 +183,8 @@ un par cercano forma cluster. \section{Pruebas con PDFs reales} \subsection{Fixtures} -\begin{itemize} - \item \texttt{500000284335937.pdf} - \item \texttt{500000284335940.pdf} - \item \texttt{500000284335954.pdf} -\end{itemize} -Ubicaci\'on: \texttt{docs/plan\_pruebas/data/}. +\textbf{23 PDFs} en \texttt{docs/plan\_pruebas/data/} (facturas m\'edicas reales, $\sim$280--294\,KB c/u). +Listado completo en el CSV \texttt{informes/count\_latency\_20260713.csv}. \subsection{TestProcessPDFTimingFixtures} \textbf{Eval\'ua:} pipeline completo (render$\rightarrow$OCR$\rightarrow$cluster$\rightarrow$conteo) y que @@ -182,55 +193,125 @@ Ubicaci\'on: \texttt{docs/plan\_pruebas/data/}. \subsection{Script HTTP \texttt{measure\_count\_latency}} \textbf{Eval\'ua:} el camino real del usuario/Desktop: gateway \texttt{POST /count/v1/count}. -\section{Resultados de latencia HTTP (medidos)} -Host \texttt{ubuntu}, gateway \texttt{127.0.0.1:8189}, 13 jul 2026: +\section{Resultados agregados (23 PDFs)} +Host \texttt{ubuntu}, 13 jul 2026. Corrida secuencial (\texttt{-count=1}). \begin{table}[ht] \centering -\begin{tabular}{@{}lrrll@{}} +\begin{tabular}{@{}lrrrr@{}} \toprule -\textbf{PDF} & \textbf{t (s)} & \textbf{HTTP} & \textbf{servicios} & \textbf{status} \\ +\textbf{M\'etrica} & \textbf{HTTP (s)} & \textbf{Total Go (ms)} & \textbf{OCR (ms)} & \textbf{Render (ms)} \\ \midrule -500000284335937.pdf & 4{,}45 & 200 & 1 & ok \\ -500000284335940.pdf & 2{,}29 & 200 & 1 & ok \\ -500000284335954.pdf & 2{,}19 & 200 & 1 & ok \\ +Promedio & 1{,}81 & 2008 & 1876 & 131 \\ +M\'inimo & 1{,}62 & 1823 & 1689 & 108 \\ +M\'aximo & 2{,}42 & 2435 & 2285 & 155 \\ +Desv.\ est.\ aprox. & 0{,}21 & 150 & 140 & 14 \\ \midrule -Promedio & 2{,}98 & --- & --- & --- \\ -M\'in / M\'ax & 2{,}19 / 4{,}45 & --- & --- & --- \\ +Servicios detectados/factura & \multicolumn{4}{c}{41--48 (promedio 43{,}5)} \\ +Cluster (ms) & \multicolumn{4}{c}{$<$2 (despreciable)} \\ +Infer (ms) & \multicolumn{4}{c}{0 (heur\'istica sin ONNX en test)} \\ \bottomrule \end{tabular} -\caption{Wall-clock HTTP. Fuente: \texttt{informes/count\_latency\_20260713.csv}.} -\label{tab:count-http} +\caption{Estad\'isticas sobre 23 facturas. HTTP v\'ia gateway; Go v\'ia \texttt{ProcessPDF} directo.} +\label{tab:count-stats} \end{table} \begin{figure}[ht] \centering \begin{tikzpicture} \begin{axis}[ - ybar, - width=0.95\textwidth, + ybar stacked, + width=0.92\textwidth, height=6cm, - ylabel={Segundos (HTTP)}, - symbolic x coords={...937,...940,...954}, + ylabel={Milisegundos (promedio)}, + symbolic x coords={Pipeline}, xtick=data, - nodes near coords, - nodes near coords style={font=\small}, - ymin=0, ymax=5.5, - bar width=22pt, - enlarge x limits=0.2, - title={Tiempo de conteo por PDF (gateway)} + ymin=0, ymax=2200, + legend style={at={(0.5,-0.18)},anchor=north,legend columns=3,font=\small}, + title={Desglose promedio del tiempo por etapa (Go)} ] -\addplot[fill=ipsblue!80] coordinates {(...937,4.45) (...940,2.29) (...954,2.19)}; -\addplot[warnorange, ultra thick, sharp plot, update limits=false] coordinates {(...937,2.98) (...954,2.98)}; +\addplot[fill=ipsblue!60] coordinates {(Pipeline,131)}; +\addlegendentry{Render} +\addplot[fill=warnorange!70] coordinates {(Pipeline,1876)}; +\addlegendentry{OCR} +\addplot[fill=passgreen!50] coordinates {(Pipeline,1)}; +\addlegendentry{Cluster} \end{axis} \end{tikzpicture} -\caption{Barras: tiempo por PDF. L\'inea naranja: promedio 2{,}98\,s.} -\label{fig:count-http-bars} +\caption{El OCR representa $\sim$93\,\% del tiempo de procesamiento.} +\label{fig:count-breakdown} \end{figure} -\textbf{Lectura:} en este host, contar \emph{una} factura tarda del orden de \textbf{2--5 segundos} -(OCR suele dominar). Extrapolaci\'on ingenua a 3\,s/PDF $\approx$ 1\,200 facturas/hora de conteo -si el nodo estuviera dedicado y sin colas --- ver cap\'itulo de capacidad 100k/mes para el contexto completo. +\begin{figure}[ht] +\centering +\begin{tikzpicture} +\begin{axis}[ + width=0.95\textwidth, + height=5.5cm, + xlabel={PDF (orden alfab\'etico, n=23)}, + ylabel={Total (ms)}, + title={Tiempo total por factura (TestProcessPDFTimingFixtures)}, + grid=major, + ymin=1700, ymax=2500, +] +\addplot[blue, mark=*, thick] coordinates { + (1,2435) (2,1954) (3,1980) (4,1994) (5,1823) (6,1955) (7,1965) (8,1873) + (9,1937) (10,1947) (11,2430) (12,1999) (13,2016) (14,1963) (15,1875) + (16,2186) (17,1901) (18,2002) (19,2024) (20,1840) (21,2280) (22,1884) (23,1916) +}; +\addplot[warnorange, dashed, thick] coordinates {(0,2008) (24,2008)}; +\end{axis} +\end{tikzpicture} +\caption{Serie de tiempos totales. L\'inea punteada: promedio 2008\,ms.} +\label{fig:count-series} +\end{figure} + +\section{Resultados de latencia HTTP} +Gateway \texttt{127.0.0.1:8189}, \texttt{POST /count/v1/count}. Los 23 PDFs respondieron \textbf{200 / ok}. + +\begin{table}[ht] +\centering +\small +\begin{tabular}{@{}rr@{}} +\toprule +\textbf{HTTP wall-clock} & \textbf{Valor} \\ +\midrule +Promedio & 1{,}81\,s \\ +M\'in / M\'ax & 1{,}62 / 2{,}42\,s \\ +Throughput secuencial & $\approx$0{,}55 PDF/s ($\approx$1\,980/h) \\ +Tiempo total corrida HTTP & $\approx$42\,s (23 archivos) \\ +\bottomrule +\end{tabular} +\caption{Resumen HTTP (sin concurrencia). Fuente: CSV del 13 jul 2026.} +\label{tab:count-http} +\end{table} + +\textbf{Lectura:} +\begin{itemize} + \item \textbf{100\,\% PASS} en 23/23 PDFs (HTTP y Go). + \item El cuello de botella es \textbf{OCR Tesseract} ($\sim$1{,}7--2{,}3\,s por factura). + \item Render PDF $\sim$130\,ms; clustering $<$2\,ms. + \item Cada factura devuelve $\sim$41--48 l\'ineas de servicio detectadas. + \item A $\sim$2\,s/PDF secuencial $\Rightarrow$ $\approx$1\,800--2\,000 conteos/hora en este host si estuviera dedicado al count-api. +\end{itemize} + +\section{Detalle por archivo} +El listado completo (HTTP + ms por etapa) est\'a en: +\texttt{docs/plan\_pruebas/informes/count\_latency\_20260713.csv}. + +Ejemplo de fila (\texttt{500000284335937.pdf}): +\begin{center} +\small +\begin{tabular}{@{}lr@{}} +Render & 155\,ms \\ +OCR & 2279\,ms \\ +Cluster & 1\,ms \\ +Total Go & 2435\,ms \\ +HTTP & 2{,}42\,s \\ +Servicios & 44 \\ +Status & ok \\ +\end{tabular} +\end{center} \section{C\'omo reproducir} \begin{lstlisting}[language=bash] diff --git a/docs/plan_pruebas/informe/sections/13_llm_latency.tex b/docs/plan_pruebas/informe/sections/13_llm_latency.tex new file mode 100644 index 0000000..f2ee218 --- /dev/null +++ b/docs/plan_pruebas/informe/sections/13_llm_latency.tex @@ -0,0 +1,90 @@ +\chapter{Extracci\'on LLM: bench de latencia (ST-EXT-02)} +\label{ch:llm-latency} + +\callout{\textbf{Veredicto LLM:} \pass\ (23/23)}{% +Modelo \textbf{gpt-5.4-mini}; 20 campos congelados.\\ +p50 $=\mathbf{7{,}114}$\,s $\Rightarrow$ $\approx\mathbf{506}$ fact/h (1 worker).\\ +0 errores, 0$\times$429. Coste corrida est.\ USD 0{,}59. +} + +\section{Qu\'e eval\'ua / resultado / significado} +\begin{center} +\begin{tabular}{@{}p{3.2cm}p{4.2cm}p{5.2cm}@{}} +\toprule +\textbf{Pregunta} & \textbf{Respuesta} & \textbf{Implicaci\'on} \\ +\midrule +Qu\'e eval\'ua & Latencia punta a punta ingest+LLM con mismos campos & Comparabilidad entre PDFs \\ +C\'omo & Script \texttt{st\_ext\_02\_llm\_bench} & Schema congelado en JSON \\ +Resultado & 23 ok; p50 7{,}1\,s; p95 8{,}6\,s & Etapa LLM cubre 139/h \\ +Significado & $\approx$506 fact/h (1\,w); $\approx$1012 (2\,w) & Cuello del mes no es el LLM en esta muestra \\ +\bottomrule +\end{tabular} +\end{center} + +El script mide wall-clock por PDF +(\texttt{POST /extraction/schemas/upload} $\rightarrow$ \texttt{POST /tasks/extraction} +$\rightarrow$ \texttt{POST /tasks/\{id\}/ingest}). + +\section{C\'omo ejecutar} +\begin{lstlisting}[language=bash,caption={ST-EXT-02 (Windows PowerShell)}] +.\docs\plan_pruebas\scripts\st_ext_02_llm_bench.ps1 +.\docs\plan_pruebas\scripts\st_ext_02_llm_bench.ps1 -Limit 10 -Model gpt-5.4-mini +\end{lstlisting} + +Schema congelado: \texttt{docs/plan\_pruebas/informes/st\_ext\_02\_schema.json}. +CSV: \texttt{informes/llm\_latency\_20260717.csv}. + +\section{Resultados (2026-07-17)} +\texttt{task\_id=aeee04f9-34c5-4cb0-a303-353dde128265}; modo ingest sync en esta campa\~na. + +\begin{table}[ht] +\centering +\begin{tabular}{@{}llrl@{}} +\toprule +\textbf{M\'etrica} & \textbf{Valor} & \textbf{Unidad} & \textbf{Notas} \\ +\midrule +Modelo & gpt-5.4-mini & --- & 20 campos fijos \\ +PDFs ok / total & 23 / 23 & --- & 0 fallos, 0$\times$429 \\ +wall\_s promedio & 7{,}326 & s & \\ +wall\_s p50 & \textbf{7{,}114} & s & \\ +wall\_s p95 & 8{,}582 & s & min 6{,}463 / max 9{,}944 \\ +Throughput 1 worker & \textbf{$\approx$506} & fact/h & $3600/\mathrm{p50}$ \\ +Throughput 2 workers & $\approx$1\,012 & fact/h & sin cuota RPM \\ +Coste corrida (est.) & 0{,}5911 & USD & $23\times 0{,}0257$ \\ +\bottomrule +\end{tabular} +\caption{ST-EXT-02 --- latencia LLM medida (mismo schema/campos).} +\label{tab:llm-latency} +\end{table} + +\begin{figure}[ht] +\centering +\begin{tikzpicture} +\begin{axis}[ + ybar, + width=0.85\textwidth, + height=5.5cm, + ylabel={Facturas / hora}, + symbolic x coords={LLM-1w,LLM-2w,Obj-139,Obj-570}, + xtick=data, + nodes near coords, + bar width=14pt, + ymin=0, ymax=1200, + enlarge x limits=0.15, + grid=major, + title={Throughput LLM vs objetivos 100k/mes} +] +\addplot[fill=ipsblue!70] coordinates {(LLM-1w,506) (LLM-2w,1012) (Obj-139,139) (Obj-570,570)}; +\end{axis} +\end{tikzpicture} +\caption{La etapa LLM sola supera 139/h; con 2 workers supera 570/h (sin cuota).} +\label{fig:llm-vs-obj} +\end{figure} + +\section{Lectura para capacidad} +Detalle del modelo 100k/mes (pipeline, OCR, coste USD\,2\,570) en el documento +\textbf{Capacidad100k\_IPSExtractor.pdf}. Aqu\'i solo: la etapa LLM \textbf{no} es el cuello +en el host actual frente a OCR/contenci\'on. + +\subsection*{Nota agent Selenium} +633 PDFs / 90\,min $\Rightarrow$ $\approx$422 fact/h (1 agent PC) --- tambi\'en cubre 139/h en descarga. diff --git a/docs/plan_pruebas/informe/sections/14_arq_ext01.tex b/docs/plan_pruebas/informe/sections/14_arq_ext01.tex new file mode 100644 index 0000000..6a0a6ef --- /dev/null +++ b/docs/plan_pruebas/informe/sections/14_arq_ext01.tex @@ -0,0 +1,83 @@ +\chapter{Cola ARQ: bench ST-EXT-01 (LLM mock)} +\label{ch:arq-ext01} + +\callout{\textbf{Veredicto ST-EXT-01:} \pass}{% +30/30 encolados y drenados; modo \textbf{async}; mock LLM.\\ +Enqueue 1{,}3\,s; drain 22{,}5\,s; throughput cola $\approx$4\,500 docs/h (mock, no LLM real). +} + +\section{Qu\'e eval\'ua / resultado / significado} +\begin{center} +\begin{tabular}{@{}p{3.2cm}p{4.5cm}p{5.0cm}@{}} +\toprule +\textbf{Pregunta} & \textbf{Respuesta} & \textbf{Implicaci\'on} \\ +\midrule +Qu\'e eval\'ua & Enqueue + drenado ARQ con LLM mock & Capacidad de cola, no latencia OpenAI \\ +C\'omo & \texttt{st\_ext\_01\_arq\_bench} + mock :8099 & \texttt{ASYNC\_QUEUE=true}, 1 worker \\ +Resultado & 30/30 queued; drained; 23{,}9\,s total & Cola operativa en shared8g \\ +Significado & Prod debe usar async + workers & El cuello real sigue siendo OCR/LLM real \\ +\bottomrule +\end{tabular} +\end{center} + +\section{Condiciones de la corrida} +\begin{itemize} + \item Fecha: 2026-07-17; perfil \texttt{shared8g}; \texttt{Limit=30}. + \item \texttt{task\_id=25890e3d-9e07-46a7-b961-046421d26eb6}. + \item Mock OpenAI-compatible (\texttt{llm\_mock\_server}, delay $\sim$50\,ms). + \item \texttt{ASYNC\_QUEUE=true}; \texttt{EXTRACTOR\_BASE\_URL}$\rightarrow$ mock; 1 r\'eplica worker. + \item CSV: \texttt{informes/st\_ext\_01\_arq\_20260717.csv}. +\end{itemize} + +\section{Resultados} +\begin{table}[ht] +\centering +\begin{tabular}{@{}llrl@{}} +\toprule +\textbf{M\'etrica} & \textbf{Valor} & \textbf{Unidad} & \textbf{Notas} \\ +\midrule +mode & async & --- & \texttt{queued=true} en ingest \\ +Encolados & 30 / 30 & docs & 0 fallos HTTP \\ +enqueue wall & 1{,}307 & s & avg 0{,}043\,s/doc \\ +drain & 22{,}544 & s & backlog vaciado \\ +total & 23{,}851 & s & enqueue + drain \\ +processed & 30 & docs & \pass\ drained \\ +Throughput cola & $\approx$4\,528 & docs/h & \textbf{solo mock}; no usar como SLO LLM \\ +\bottomrule +\end{tabular} +\caption{ST-EXT-01 --- cola ARQ con LLM mock (shared8g, 30 docs).} +\label{tab:st-ext-01} +\end{table} + +\begin{figure}[ht] +\centering +\begin{tikzpicture} +\begin{axis}[ + ybar, + width=0.75\textwidth, + height=5.2cm, + ylabel={Segundos}, + symbolic x coords={Enqueue,Drain,Total}, + xtick=data, + nodes near coords, + bar width=18pt, + ymin=0, ymax=30, + enlarge x limits=0.25, + grid=major, + title={ST-EXT-01 --- tiempos (30 docs, mock)} +] +\addplot[fill=ipsblue!70] coordinates {(Enqueue,1.307) (Drain,22.544) (Total,23.851)}; +\end{axis} +\end{tikzpicture} +\caption{Casi todo el tiempo es drenado por workers; el enqueue es despreciable.} +\label{fig:st-ext-01-times} +\end{figure} + +\section{Lectura} +\begin{itemize} + \item La arquitectura \textbf{Gateway $\rightarrow$ Redis/ARQ $\rightarrow$ workers} funciona bajo el perfil shared8g. + \item El throughput $\approx$4\,500 docs/h \textbf{no} es capacidad de extracci\'on real: + ST-EXT-02 midió $\approx$506 fact/h con gpt-5.4-mini. + \item Recomendaci\'on prod: \texttt{ASYNC\_QUEUE=true} + workers dimensionados a la cuota LLM. + \item Pendiente de escala: perfil \texttt{lab} (100--500 docs, 2 workers). +\end{itemize} diff --git a/docs/plan_pruebas/informe/sections/15_st_count_01.tex b/docs/plan_pruebas/informe/sections/15_st_count_01.tex new file mode 100644 index 0000000..85b3d6f --- /dev/null +++ b/docs/plan_pruebas/informe/sections/15_st_count_01.tex @@ -0,0 +1,85 @@ +\chapter{Count-api bajo carga (ST-COUNT-01)} +\label{ch:st-count-01} + +\callout{\textbf{Veredicto ST-COUNT-01:} \pass\ (shared8g)}{% +9/9 PDFs reales OK; 0$\times$5xx; sin OOM observado.\\ +Bajo concurrencia 3, p50 sube a \textbf{35{,}8\,s} (vs $\sim$2\,s en serie).\\ +Throughput observado $\approx$\textbf{325} fact/h en la ventana. +} + +\section{Qu\'e eval\'ua / resultado / significado} +\begin{center} +\begin{tabular}{@{}p{3.2cm}p{4.5cm}p{5.0cm}@{}} +\toprule +\textbf{Pregunta} & \textbf{Respuesta} & \textbf{Implicaci\'on} \\ +\midrule +Qu\'e eval\'ua & OCR count-api con lotes concurrentes & Cuello de 100k/mes \\ +C\'omo & \texttt{st\_count\_01\_bench} 60\,s, max 3 workers & PDFs reales en \texttt{data/} \\ +Resultado & 9 ok; p50 35{,}8\,s; 0 fallos & Contenci\'on fuerte vs serie \\ +Significado & Host shared: $\sim$100--325 fact/h & vs 139/h \'ambar/OK; vs 570/h NO \\ +\bottomrule +\end{tabular} +\end{center} + +\section{Condiciones} +\begin{itemize} + \item Fecha: 2026-07-17; perfil \texttt{shared8g}; rate objetivo 1/s; max 3 concurrentes; 60\,s. + \item Endpoint: \texttt{POST /count/v1/count} (campo multipart \texttt{files}). + \item CSV: \texttt{informes/st\_count\_01\_20260717.csv}. + \item Nota: k6 no estaba en el PATH de Windows; se us\'o el bench Python equivalente. + El script k6 se corrigi\'o (\texttt{files} en lugar de \texttt{file}). +\end{itemize} + +\section{Resultados} +\begin{table}[ht] +\centering +\begin{tabular}{@{}llrl@{}} +\toprule +\textbf{M\'etrica} & \textbf{Valor} & \textbf{Unidad} & \textbf{Notas} \\ +\midrule +ok / total & 9 / 9 & --- & 0 fallos \\ +wall\_s p50 & \textbf{35{,}825} & s & bajo 3 concurrentes \\ +wall\_s p95 & 43{,}927 & s & min 17{,}6 / max 44{,}7 \\ +wall total & 99{,}7 & s & incluye drenado final \\ +Throughput observado & $\approx$325 & fact/h & $9/99{,}7\times 3600$ \\ +Throughput serie (p50) & $\approx$100 & fact/h & $3600/\mathrm{p50}$ \\ +$\times$3 ideal & $\approx$301 & fact/h & sin m\'as contenci\'on \\ +\bottomrule +\end{tabular} +\caption{ST-COUNT-01 --- carga OCR (PDFs reales, shared8g).} +\label{tab:st-count-01} +\end{table} + +\begin{figure}[ht] +\centering +\begin{tikzpicture} +\begin{axis}[ + ybar, + width=0.8\textwidth, + height=5.5cm, + ylabel={Facturas / hora}, + symbolic x coords={Serie-p50,Obs-3conc,Obj-139,Obj-570}, + xtick=data, + x tick label style={font=\small}, + nodes near coords, + bar width=12pt, + ymin=0, ymax=700, + enlarge x limits=0.15, + grid=major, + title={OCR ST-COUNT-01 vs objetivos 100k} +] +\addplot[fill=ipsblue!70] coordinates {(Serie-p50,100) (Obs-3conc,325) (Obj-139,139) (Obj-570,570)}; +\end{axis} +\end{tikzpicture} +\caption{Bajo 3 concurrentes el OCR cubre 139/h en esta muestra; no alcanza 570/h. +La latencia por PDF se degrada mucho frente a la serie ($\sim$2\,s).} +\label{fig:st-count-01} +\end{figure} + +\section{Lectura} +\begin{itemize} + \item \pass\ funcional: count-api aguanta concurrencia ligera sin 5xx/OOM en esta corrida. + \item La degradaci\'on 2\,s $\rightarrow$ 36\,s confirma que el OCR es sensible a CPU/RAM compartida. + \item Actualiza la fila OCR del modelo 100k: de $\sim$40 estimados a $\sim$100--325 medidos (shared8g). + \item Para afirmar 570/h hace falta host dedicado + pool OCR (perfil lab / ST-COUNT agresivo). +\end{itemize} diff --git a/docs/plan_pruebas/informes/count_latency_20260713.csv b/docs/plan_pruebas/informes/count_latency_20260713.csv index aad73e0..2db59ae 100644 --- a/docs/plan_pruebas/informes/count_latency_20260713.csv +++ b/docs/plan_pruebas/informes/count_latency_20260713.csv @@ -1,4 +1,24 @@ -file,http_time_s,http_code,service_count,status -500000284335937.pdf,4.449556,200,1,ok -500000284335940.pdf,2.290720,200,1,ok -500000284335954.pdf,2.190165,200,1,ok +file,http_time_s,http_code,service_count,status,render_ms,ocr_ms,cluster_ms,total_ms +500000284335937.pdf,2.419290,200,1,ok,155,2279,1,2435 +500000284335940.pdf,2.189024,200,1,ok,134,1820,0,1954 +500000284335954.pdf,1.615784,200,1,ok,127,1851,0,1980 +500000284335956.pdf,1.956593,200,1,ok,147,1846,1,1994 +500000284335963.pdf,1.981622,200,1,ok,132,1689,1,1823 +500000284335964.pdf,1.696154,200,1,ok,117,1837,0,1955 +500000284335968.pdf,1.948234,200,1,ok,150,1813,1,1965 +500000284335997.pdf,1.659651,200,1,ok,125,1746,1,1873 +500000284336071.pdf,1.667741,200,1,ok,127,1808,0,1937 +500000284336521.pdf,1.741292,200,1,ok,130,1816,0,1947 +500000284336836.pdf,1.916852,200,1,ok,143,2285,1,2430 +500000284336915.pdf,1.679965,200,1,ok,144,1853,0,1999 +500000284337016.pdf,1.666228,200,1,ok,138,1877,0,2016 +500000284337418.pdf,1.642242,200,1,ok,133,1829,1,1963 +500000284337861.pdf,1.768077,200,1,ok,142,1730,1,1875 +500000284338027.pdf,1.677103,200,1,ok,116,2068,0,2186 +500000284338092.pdf,1.667427,200,1,ok,109,1792,0,1901 +500000284338214.pdf,1.781158,200,1,ok,128,1873,0,2002 +500000284338327.pdf,1.796004,200,1,ok,126,1897,0,2024 +500000284338353.pdf,1.803265,200,1,ok,108,1731,0,1840 +500000284338458.pdf,1.824801,200,1,ok,135,2144,0,2280 +500000284338503.pdf,1.897579,200,1,ok,117,1766,0,1884 +500000284338624.pdf,1.635177,200,1,ok,126,1788,0,1916 diff --git a/docs/plan_pruebas/informes/llm_latency_20260717.csv b/docs/plan_pruebas/informes/llm_latency_20260717.csv new file mode 100644 index 0000000..86ef6fc --- /dev/null +++ b/docs/plan_pruebas/informes/llm_latency_20260717.csv @@ -0,0 +1,24 @@ +file,wall_s,http_code,status,queued,cost_usd_est,error +500000284335937.pdf,9.944,200,ok,0,0.0257, +500000284335940.pdf,7.925,200,ok,0,0.0257, +500000284335954.pdf,7.659,200,ok,0,0.0257, +500000284335956.pdf,7.114,200,ok,0,0.0257, +500000284335963.pdf,6.997,200,ok,0,0.0257, +500000284335964.pdf,7.889,200,ok,0,0.0257, +500000284335968.pdf,7.001,200,ok,0,0.0257, +500000284335997.pdf,6.822,200,ok,0,0.0257, +500000284336071.pdf,6.463,200,ok,0,0.0257, +500000284336521.pdf,7.472,200,ok,0,0.0257, +500000284336836.pdf,7.199,200,ok,0,0.0257, +500000284336915.pdf,7.094,200,ok,0,0.0257, +500000284337016.pdf,6.874,200,ok,0,0.0257, +500000284337418.pdf,7.146,200,ok,0,0.0257, +500000284337861.pdf,6.816,200,ok,0,0.0257, +500000284338027.pdf,7.161,200,ok,0,0.0257, +500000284338092.pdf,7.927,200,ok,0,0.0257, +500000284338214.pdf,6.563,200,ok,0,0.0257, +500000284338327.pdf,8.655,200,ok,0,0.0257, +500000284338353.pdf,6.963,200,ok,0,0.0257, +500000284338458.pdf,7.327,200,ok,0,0.0257, +500000284338503.pdf,6.672,200,ok,0,0.0257, +500000284338624.pdf,6.816,200,ok,0,0.0257, diff --git a/docs/plan_pruebas/informes/llm_latency_PENDING.csv b/docs/plan_pruebas/informes/llm_latency_PENDING.csv new file mode 100644 index 0000000..2e6e08d --- /dev/null +++ b/docs/plan_pruebas/informes/llm_latency_PENDING.csv @@ -0,0 +1 @@ +file,wall_s,http_code,status,queued,error diff --git a/docs/plan_pruebas/informes/st_count_01_20260717.csv b/docs/plan_pruebas/informes/st_count_01_20260717.csv new file mode 100644 index 0000000..155eeb9 --- /dev/null +++ b/docs/plan_pruebas/informes/st_count_01_20260717.csv @@ -0,0 +1,10 @@ +file,wall_s,http_code,status,service_count,error +500000284335954.pdf,17.562,200,ok,1, +500000284335940.pdf,18.557,200,ok,1, +500000284335937.pdf,19.556,200,ok,1, +500000284335956.pdf,35.423,200,ok,1, +500000284335963.pdf,35.825,200,ok,1, +500000284335964.pdf,35.909,200,ok,1, +500000284335997.pdf,42.798,200,ok,1, +500000284336071.pdf,41.739,200,ok,1, +500000284335968.pdf,44.679,200,ok,1, diff --git a/docs/plan_pruebas/informes/st_ext_01_arq_20260717.csv b/docs/plan_pruebas/informes/st_ext_01_arq_20260717.csv new file mode 100644 index 0000000..7cbe501 --- /dev/null +++ b/docs/plan_pruebas/informes/st_ext_01_arq_20260717.csv @@ -0,0 +1,31 @@ +radicado,file,enqueue_s,http_code,status,queued,error +500000284335937_0000,500000284335937.pdf,0.23,200,queued,1, +500000284335940_0001,500000284335940.pdf,0.042,200,queued,1, +500000284335954_0002,500000284335954.pdf,0.026,200,queued,1, +500000284335956_0003,500000284335956.pdf,0.042,200,queued,1, +500000284335963_0004,500000284335963.pdf,0.052,200,queued,1, +500000284335964_0005,500000284335964.pdf,0.025,200,queued,1, +500000284335968_0006,500000284335968.pdf,0.028,200,queued,1, +500000284335997_0007,500000284335997.pdf,0.035,200,queued,1, +500000284336071_0008,500000284336071.pdf,0.029,200,queued,1, +500000284336521_0009,500000284336521.pdf,0.028,200,queued,1, +500000284336836_0010,500000284336836.pdf,0.031,200,queued,1, +500000284336915_0011,500000284336915.pdf,0.041,200,queued,1, +500000284337016_0012,500000284337016.pdf,0.046,200,queued,1, +500000284337418_0013,500000284337418.pdf,0.043,200,queued,1, +500000284337861_0014,500000284337861.pdf,0.058,200,queued,1, +500000284338027_0015,500000284338027.pdf,0.045,200,queued,1, +500000284338092_0016,500000284338092.pdf,0.084,200,queued,1, +500000284338214_0017,500000284338214.pdf,0.041,200,queued,1, +500000284338327_0018,500000284338327.pdf,0.057,200,queued,1, +500000284338353_0019,500000284338353.pdf,0.027,200,queued,1, +500000284338458_0020,500000284338458.pdf,0.023,200,queued,1, +500000284338503_0021,500000284338503.pdf,0.028,200,queued,1, +500000284338624_0022,500000284338624.pdf,0.042,200,queued,1, +500000284335937_0023,500000284335937.pdf,0.026,200,queued,1, +500000284335940_0024,500000284335940.pdf,0.021,200,queued,1, +500000284335954_0025,500000284335954.pdf,0.026,200,queued,1, +500000284335956_0026,500000284335956.pdf,0.022,200,queued,1, +500000284335963_0027,500000284335963.pdf,0.023,200,queued,1, +500000284335964_0028,500000284335964.pdf,0.028,200,queued,1, +500000284335968_0029,500000284335968.pdf,0.033,200,queued,1, diff --git a/docs/plan_pruebas/main.aux b/docs/plan_pruebas/main.aux index a51283d..93b7961 100644 --- a/docs/plan_pruebas/main.aux +++ b/docs/plan_pruebas/main.aux @@ -9,59 +9,67 @@ \providecommand\HyField@AuxAddToFields[1]{} \providecommand\HyField@AuxAddToCoFields[2]{} \babel@aux{spanish}{} -\@writefile{toc}{\contentsline {chapter}{\numberline {1}Introducci\'on}{6}{chapter.1}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {1}Introducci\'on}{5}{chapter.1}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:intro}{{1}{6}{Introducci\'on}{chapter.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {1.1}Objetivo}{6}{section.1.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {1.2}Alcance}{6}{section.1.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {1.2.1}Dentro de alcance}{6}{subsection.1.2.1}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {1.2.2}Fuera de alcance de esta versi\'on documental}{6}{subsection.1.2.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {1.3}Estado actual del repositorio}{6}{section.1.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {1.4}Referencias del sistema}{6}{section.1.4}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {1.1}{\ignorespaces Cobertura existente y gaps.}}{7}{table.1.1}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {2}Arquitectura bajo prueba}{8}{chapter.2}\protected@file@percent } +\newlabel{ch:intro}{{1}{5}{Introducci\'on}{chapter.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {1.1}Objetivo}{5}{section.1.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.2}Alcance}{5}{section.1.2}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {1.2.1}Dentro de alcance}{5}{subsection.1.2.1}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {1.2.2}Fuera de alcance de esta versi\'on documental}{5}{subsection.1.2.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.3}Estado actual del repositorio}{5}{section.1.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {1.4}Referencias del sistema}{5}{section.1.4}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {1.1}{\ignorespaces Cobertura existente y gaps.}}{6}{table.1.1}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {2}Arquitectura bajo prueba}{7}{chapter.2}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:arquitectura}{{2}{8}{Arquitectura bajo prueba}{chapter.2}{}} -\@writefile{toc}{\contentsline {section}{\numberline {2.1}Vista de componentes}{8}{section.2.1}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {2.1}{\ignorespaces Arquitectura IPSExtractor bajo prueba.}}{8}{figure.2.1}\protected@file@percent } -\newlabel{fig:arquitectura}{{2.1}{8}{Arquitectura IPSExtractor bajo prueba}{figure.2.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {2.2}Inventario de componentes}{8}{section.2.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {2.3}Flujo cr\'itico: bot de descarga}{8}{section.2.3}\protected@file@percent } +\newlabel{ch:arquitectura}{{2}{7}{Arquitectura bajo prueba}{chapter.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.1}C\'omo est\'a dise\~nado el sistema}{7}{section.2.1}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.1}{\ignorespaces Arquitectura bajo prueba. El \textbf {gateway no habla con Postgres}: solo hace proxy HTTP. Usan la BD compartida: \texttt {extraction} (+ workers), \texttt {cleaning} y \texttt {bot-descarga}. \texttt {count-api} es OCR sin Postgres.}}{7}{figure.2.1}\protected@file@percent } +\newlabel{fig:arquitectura}{{2.1}{7}{Arquitectura bajo prueba. El \textbf {gateway no habla con Postgres}: solo hace proxy HTTP. Usan la BD compartida: \texttt {extraction} (+ workers), \texttt {cleaning} y \texttt {bot-descarga}. \texttt {count-api} es OCR sin Postgres}{figure.2.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.2}Patrones de dise\~no}{7}{section.2.2}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.2}{\ignorespaces Patrones: Gateway, cola ARQ y claim. El servicio \texttt {bot-descarga} \textbf {orquesta}; el \textbf {agent Selenium} en el PC ejecuta la descarga.}}{8}{figure.2.2}\protected@file@percent } +\newlabel{fig:patrones}{{2.2}{8}{Patrones: Gateway, cola ARQ y claim. El servicio \texttt {bot-descarga} \textbf {orquesta}; el \textbf {agent Selenium} en el PC ejecuta la descarga}{figure.2.2}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.3}Pipeline de negocio}{8}{section.2.3}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.3}{\ignorespaces Pipeline punta a punta. La descarga la hace el \textbf {agent Selenium} (PC); el servicio \texttt {bot-descarga} solo orquesta jobs/claim/heartbeats.}}{9}{figure.2.3}\protected@file@percent } +\newlabel{fig:pipeline-negocio}{{2.3}{9}{Pipeline punta a punta. La descarga la hace el \textbf {agent Selenium} (PC); el servicio \texttt {bot-descarga} solo orquesta jobs/claim/heartbeats}{figure.2.3}{}} \@writefile{lot}{\contentsline {table}{\numberline {2.1}{\ignorespaces Componentes del sistema bajo prueba.}}{9}{table.2.1}\protected@file@percent } -\@writefile{lof}{\contentsline {figure}{\numberline {2.2}{\ignorespaces Secuencia del flujo bot de descarga.}}{9}{figure.2.2}\protected@file@percent } -\newlabel{fig:flujo-bot}{{2.2}{9}{Secuencia del flujo bot de descarga}{figure.2.2}{}} -\@writefile{lol}{\contentsline {lstlisting}{\numberline {2.1}Prefijos MinIO del bot}{9}{lstlisting.2.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {2.4}Otros flujos de negocio}{9}{section.2.4}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {3}Estrategia de pruebas}{10}{chapter.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {2.4}Inventario de componentes}{9}{section.2.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {2.5}Flujo cr\'itico: descarga (orquestador + agent Selenium)}{9}{section.2.5}\protected@file@percent } +\@writefile{lol}{\contentsline {lstlisting}{\numberline {2.1}Prefijos MinIO del pipeline de descarga}{9}{lstlisting.2.1}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {2.4}{\ignorespaces Secuencia del flujo bot de descarga.}}{10}{figure.2.4}\protected@file@percent } +\newlabel{fig:flujo-bot}{{2.4}{10}{Secuencia del flujo bot de descarga}{figure.2.4}{}} +\@writefile{toc}{\contentsline {section}{\numberline {2.6}Otros flujos de negocio}{10}{section.2.6}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {3}Estrategia de pruebas}{11}{chapter.3}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:estrategia}{{3}{10}{Estrategia de pruebas}{chapter.3}{}} -\@writefile{toc}{\contentsline {section}{\numberline {3.1}Pir\'amide}{10}{section.3.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {3.1}{\ignorespaces Pir\'amide de pruebas IPSExtractor.}}{10}{table.3.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {3.2}Convenci\'on de identificadores}{10}{section.3.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {3.3}Entornos}{10}{section.3.3}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {3.2}{\ignorespaces TC = Testcontainers; LS = LocalStack.}}{11}{table.3.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {3.4}Mocks vs sistemas reales}{11}{section.3.4}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {3.5}Principios}{11}{section.3.5}\protected@file@percent } +\newlabel{ch:estrategia}{{3}{11}{Estrategia de pruebas}{chapter.3}{}} +\@writefile{toc}{\contentsline {section}{\numberline {3.1}Pir\'amide}{11}{section.3.1}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {3.1}{\ignorespaces Pir\'amide de pruebas IPSExtractor.}}{11}{table.3.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.2}Convenci\'on de identificadores}{11}{section.3.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.3}C\'omo leer un caso del cat\'alogo}{11}{section.3.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.4}Entornos}{11}{section.3.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.5}Mocks vs sistemas reales}{12}{section.3.5}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {3.2}{\ignorespaces TC = Testcontainers; LS = LocalStack.}}{12}{table.3.2}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {3.6}Principios}{12}{section.3.6}\protected@file@percent } \gdef \LT@i {\LT@entry - {2}{74.28644pt}\LT@entry - {1}{131.50148pt}\LT@entry - {1}{162.49014pt}} -\gdef \LT@ii {\LT@entry - {1}{79.97733pt}\LT@entry + {1}{68.59596pt}\LT@entry + {1}{111.58464pt}\LT@entry {1}{125.81102pt}\LT@entry - {1}{162.49014pt}} -\@writefile{toc}{\contentsline {chapter}{\numberline {4}Pruebas unitarias}{12}{chapter.4}\protected@file@percent } + {1}{91.35826pt}} +\gdef \LT@ii {\LT@entry + {1}{74.28644pt}\LT@entry + {1}{108.73918pt}\LT@entry + {1}{120.12054pt}\LT@entry + {1}{91.35826pt}} +\@writefile{toc}{\contentsline {chapter}{\numberline {4}Pruebas unitarias}{13}{chapter.4}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:unitarias}{{4}{12}{Pruebas unitarias}{chapter.4}{}} -\@writefile{toc}{\contentsline {section}{\numberline {4.1}Gateway}{12}{section.4.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {4.1}{Unitarias gateway.}}{12}{table.4.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.2}Bot-descarga API (\texttt {JobStore})}{12}{section.4.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {4.2}{Unitarias bot-descarga API.}}{12}{table.4.2}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.3}Agent bot (PC)}{12}{section.4.3}\protected@file@percent } +\newlabel{ch:unitarias}{{4}{13}{Pruebas unitarias}{chapter.4}{}} +\@writefile{toc}{\contentsline {section}{\numberline {4.1}Gateway}{13}{section.4.1}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {4.1}{Unitarias gateway.}}{13}{table.4.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.2}Bot-descarga API (\texttt {JobStore})}{13}{section.4.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {4.2}{Unitarias bot-descarga API.}}{13}{table.4.2}\protected@file@percent } \gdef \LT@iii {\LT@entry {1}{79.97733pt}\LT@entry {1}{125.81102pt}\LT@entry @@ -70,11 +78,12 @@ {1}{79.97733pt}\LT@entry {1}{131.50148pt}\LT@entry {1}{156.79968pt}} -\@writefile{lot}{\contentsline {table}{\numberline {4.3}{Unitarias agent.}}{13}{table.4.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.4}Extraction / Cleaning / Count / Desktop}{13}{section.4.4}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {4.4}{Unitarias resto de servicios.}}{13}{table.4.4}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {4.5}Comandos de ejecuci\'on}{13}{section.4.5}\protected@file@percent } -\@writefile{lol}{\contentsline {lstlisting}{\numberline {4.1}Suite unitaria r\'apida}{13}{lstlisting.4.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.3}Agent bot (PC)}{14}{section.4.3}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {4.3}{Unitarias agent.}}{14}{table.4.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.4}Extraction / Cleaning / Count / Desktop}{14}{section.4.4}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {4.4}{Unitarias resto de servicios.}}{14}{table.4.4}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {4.5}Comandos de ejecuci\'on}{14}{section.4.5}\protected@file@percent } +\@writefile{lol}{\contentsline {lstlisting}{\numberline {4.1}Suite unitaria r\'apida}{14}{lstlisting.4.1}\protected@file@percent } \gdef \LT@v {\LT@entry {1}{74.28644pt}\LT@entry {1}{131.50148pt}\LT@entry @@ -83,62 +92,57 @@ {1}{79.97733pt}\LT@entry {1}{140.0374pt}\LT@entry {1}{148.26378pt}} -\@writefile{toc}{\contentsline {chapter}{\numberline {5}Pruebas de integraci\'on}{14}{chapter.5}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {5}Pruebas de integraci\'on}{15}{chapter.5}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:integracion}{{5}{14}{Pruebas de integraci\'on}{chapter.5}{}} -\@writefile{toc}{\contentsline {section}{\numberline {5.1}Stack de integraci\'on}{14}{section.5.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {5.2}Gateway}{14}{section.5.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {5.1}{Integraci\'on gateway.}}{14}{table.5.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {5.3}Bot-descarga}{14}{section.5.3}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {5.2}{Integraci\'on bot-descarga.}}{14}{table.5.2}\protected@file@percent } +\newlabel{ch:integracion}{{5}{15}{Pruebas de integraci\'on}{chapter.5}{}} +\@writefile{toc}{\contentsline {section}{\numberline {5.1}Stack de integraci\'on}{15}{section.5.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.2}Gateway}{15}{section.5.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {5.1}{Integraci\'on gateway.}}{15}{table.5.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.3}Bot-descarga}{15}{section.5.3}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {5.2}{Integraci\'on bot-descarga.}}{15}{table.5.2}\protected@file@percent } \gdef \LT@vii {\LT@entry {1}{79.97733pt}\LT@entry {1}{140.0374pt}\LT@entry {1}{148.26378pt}} -\@writefile{toc}{\contentsline {section}{\numberline {5.4}Extraction / Cleaning / Count}{15}{section.5.4}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {5.3}{Integraci\'on extraction, cleaning, count y BD.}}{15}{table.5.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {5.5}Smoke m\'inimo post-compose}{15}{section.5.5}\protected@file@percent } -\@writefile{lol}{\contentsline {lstlisting}{\numberline {5.1}Smoke health}{15}{lstlisting.5.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.4}Extraction / Cleaning / Count}{16}{section.5.4}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {5.3}{Integraci\'on extraction, cleaning, count y BD.}}{16}{table.5.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {5.5}Smoke m\'inimo post-compose}{16}{section.5.5}\protected@file@percent } +\@writefile{lol}{\contentsline {lstlisting}{\numberline {5.1}Smoke health}{16}{lstlisting.5.1}\protected@file@percent } \gdef \LT@viii {\LT@entry {1}{57.21504pt}\LT@entry {1}{91.6678pt}\LT@entry {1}{154.26378pt}\LT@entry {1}{91.35826pt}} -\@writefile{toc}{\contentsline {chapter}{\numberline {6}Pruebas end-to-end}{16}{chapter.6}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {6}Pruebas end-to-end}{17}{chapter.6}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:e2e}{{6}{16}{Pruebas end-to-end}{chapter.6}{}} -\@writefile{toc}{\contentsline {section}{\numberline {6.1}Matriz de flujos de negocio}{16}{section.6.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {6.1}{Casos E2E cr\'iticos.}}{16}{table.6.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {6.2}Detalle E2E-05 (bot descarga)}{16}{section.6.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {6.2.1}Precondiciones}{16}{subsection.6.2.1}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {6.2.2}Pasos}{17}{subsection.6.2.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {6.2.3}Asserts}{17}{subsection.6.2.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {6.3}Desktop}{17}{section.6.3}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {7}Pruebas de estr\'es y carga}{18}{chapter.7}\protected@file@percent } +\newlabel{ch:e2e}{{6}{17}{Pruebas end-to-end}{chapter.6}{}} +\@writefile{toc}{\contentsline {section}{\numberline {6.1}Matriz de flujos de negocio}{17}{section.6.1}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {6.1}{Casos E2E cr\'iticos.}}{17}{table.6.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {6.2}Detalle E2E-05 (bot descarga)}{17}{section.6.2}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {6.2.1}Precondiciones}{17}{subsection.6.2.1}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {6.2.2}Pasos}{18}{subsection.6.2.2}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {6.2.3}Asserts}{18}{subsection.6.2.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {6.3}Desktop}{18}{section.6.3}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {7}Pruebas de estr\'es y carga}{19}{chapter.7}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:estres}{{7}{18}{Pruebas de estr\'es y carga}{chapter.7}{}} -\@writefile{toc}{\contentsline {section}{\numberline {7.1}Herramienta}{18}{section.7.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {7.2}Perfiles de carga}{18}{section.7.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {7.1}{\ignorespaces Cargas por perfil de entorno.}}{18}{table.7.1}\protected@file@percent } -\newlabel{tab:perfiles-carga}{{7.1}{18}{Cargas por perfil de entorno}{table.7.1}{}} -\@writefile{toc}{\contentsline {section}{\numberline {7.3}Escenarios (cat\'alogo completo)}{18}{section.7.3}\protected@file@percent } -\gdef \LT@ix {\LT@entry - {1}{74.28644pt}\LT@entry - {1}{103.04872pt}\LT@entry - {1}{111.58464pt}\LT@entry - {1}{97.04872pt}} -\@writefile{lot}{\contentsline {table}{\numberline {7.2}{Escenarios de estr\'es.}}{19}{table.7.2}\protected@file@percent } +\newlabel{ch:estres}{{7}{19}{Pruebas de estr\'es y carga}{chapter.7}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.1}Herramienta}{19}{section.7.1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {7.2}Perfiles de carga}{19}{section.7.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {7.1}{\ignorespaces Cargas por perfil de entorno.}}{19}{table.7.1}\protected@file@percent } +\newlabel{tab:perfiles-carga}{{7.1}{19}{Cargas por perfil de entorno}{table.7.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {7.3}Escenarios (cat\'alogo completo)}{19}{section.7.3}\protected@file@percent } \@writefile{toc}{\contentsline {section}{\numberline {7.4}Scripts k6 incluidos}{19}{section.7.4}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {7.3}{\ignorespaces Mapeo script $\leftrightarrow $ escenario.}}{19}{table.7.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {7.5}SLOs de aceptaci\'on}{19}{section.7.5}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {7.5.1}Perfil host-compartido-8gb (default)}{19}{subsection.7.5.1}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {7.2}{\ignorespaces Escenarios de estr\'es (objetivo y riesgo).}}{20}{table.7.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {7.3}{\ignorespaces Mapeo script $\leftrightarrow $ escenario.}}{20}{table.7.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {7.5}SLOs de aceptaci\'on}{20}{section.7.5}\protected@file@percent } +\@writefile{toc}{\contentsline {subsection}{\numberline {7.5.1}Perfil host-compartido-8gb (default)}{20}{subsection.7.5.1}\protected@file@percent } \@writefile{toc}{\contentsline {subsection}{\numberline {7.5.2}Perfil laboratorio}{20}{subsection.7.5.2}\protected@file@percent } \@writefile{toc}{\contentsline {section}{\numberline {7.6}Ejecuci\'on ejemplo}{20}{section.7.6}\protected@file@percent } \@writefile{lol}{\contentsline {lstlisting}{\numberline {7.1}Estr\'es gateway en host compartido}{20}{lstlisting.7.1}\protected@file@percent } -\gdef \LT@x {\LT@entry +\gdef \LT@ix {\LT@entry {1}{57.21504pt}\LT@entry {1}{111.58464pt}\LT@entry {1}{125.81102pt}\LT@entry @@ -167,7 +171,7 @@ \@writefile{toc}{\contentsline {subsection}{\numberline {9.1.3}Carga concurrente del host (no IPS)}{24}{subsection.9.1.3}\protected@file@percent } \@writefile{toc}{\contentsline {subsection}{\numberline {9.1.4}Requisitos previos a una campa\~na de estr\'es}{24}{subsection.9.1.4}\protected@file@percent } \@writefile{toc}{\contentsline {section}{\numberline {9.2}Fixtures recomendadas}{24}{section.9.2}\protected@file@percent } -\gdef \LT@xi {\LT@entry +\gdef \LT@x {\LT@entry {1}{125.50148pt}\LT@entry {1}{233.62204pt}} \@writefile{toc}{\contentsline {section}{\numberline {9.3}Variables de entorno relevantes}{25}{section.9.3}\protected@file@percent } @@ -183,7 +187,7 @@ \@writefile{toc}{\contentsline {section}{\numberline {10.3}Compilar este documento}{26}{section.10.3}\protected@file@percent } \@writefile{lol}{\contentsline {lstlisting}{\numberline {10.1}latexmk}{26}{lstlisting.10.1}\protected@file@percent } \@writefile{toc}{\contentsline {section}{\numberline {10.4}Prioridad de implementaci\'on de tests (ROI)}{27}{section.10.4}\protected@file@percent } -\gdef \LT@xii {\LT@entry +\gdef \LT@xi {\LT@entry {1}{48.67912pt}\LT@entry {1}{140.0374pt}\LT@entry {1}{182.407pt}} @@ -198,29 +202,23 @@ \@writefile{toc}{\contentsline {section}{\numberline {11.4}Plantilla de informe de ejecuci\'on}{29}{section.11.4}\protected@file@percent } \@writefile{lol}{\contentsline {lstlisting}{\numberline {11.1}Campos m\'inimos del informe}{29}{lstlisting.11.1}\protected@file@percent } \@writefile{toc}{\contentsline {section}{\numberline {11.5}Responsables (roles)}{29}{section.11.5}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {12}Informe de ejecuci\'on --- campa\~na 2026-07-13}{30}{chapter.12}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {12}Estado de campa\~na --- 2026-07-13 (+ LLM 17-jul)}{30}{chapter.12}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\newlabel{ch:informe-2026-07-13}{{12}{30}{Informe de ejecuci\'on --- campa\~na 2026-07-13}{chapter.12}{}} +\newlabel{ch:informe-2026-07-13}{{12}{30}{Estado de campa\~na --- 2026-07-13 (+ LLM 17-jul)}{chapter.12}{}} \@writefile{toc}{\contentsline {section}{\numberline {12.1}Metadatos}{30}{section.12.1}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {12.1}{\ignorespaces Metadatos de la campa\~na.}}{30}{table.12.1}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.2}Baseline del entorno}{30}{section.12.2}\protected@file@percent } -\@writefile{lot}{\contentsline {table}{\numberline {12.2}{\ignorespaces Resumen de la campa\~na 2026-07-13.}}{31}{table.12.2}\protected@file@percent } -\newlabel{tab:campana-2026-07-13}{{12.2}{31}{Resumen de la campa\~na 2026-07-13}{table.12.2}{}} -\@writefile{toc}{\contentsline {section}{\numberline {12.3}Resultados por caso}{31}{section.12.3}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.1}ST-GW-01 --- gateway health}{31}{subsection.12.3.1}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.2}ST-BOT-01 --- heartbeats (corrida 1)}{31}{subsection.12.3.2}\protected@file@percent } -\@writefile{toc}{\contentsline {subsection}{\numberline {12.3.3}ST-BOT-01 --- heartbeats (corrida 2, reintento)}{31}{subsection.12.3.3}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.4}Hallazgos}{31}{section.12.4}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.5}No ejecutado en esta campa\~na}{32}{section.12.5}\protected@file@percent } -\@writefile{toc}{\contentsline {section}{\numberline {12.6}Follow-ups}{32}{section.12.6}\protected@file@percent } -\@writefile{toc}{\contentsline {chapter}{\numberline {A}Glosario}{33}{appendix.Alph1}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {12.2}Resultados resumidos}{30}{section.12.2}\protected@file@percent } +\@writefile{lot}{\contentsline {table}{\numberline {12.1}{\ignorespaces Resumen; detalle en el Informe de Pruebas.}}{30}{table.12.1}\protected@file@percent } +\newlabel{tab:campana-2026-07-13}{{12.1}{30}{Resumen; detalle en el Informe de Pruebas}{table.12.1}{}} +\@writefile{toc}{\contentsline {section}{\numberline {12.3}Lecci\'on operativa}{30}{section.12.3}\protected@file@percent } +\@writefile{toc}{\contentsline {section}{\numberline {12.4}Pendiente respecto a este plan}{31}{section.12.4}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {A}Glosario}{32}{appendix.Alph1}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\@writefile{toc}{\contentsline {chapter}{\numberline {B}Checklist de release}{34}{appendix.Alph2}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {B}Checklist de release}{33}{appendix.Alph2}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\@writefile{toc}{\contentsline {chapter}{\numberline {C}Mapa de rutas relevantes}{35}{appendix.Alph3}\protected@file@percent } +\@writefile{toc}{\contentsline {chapter}{\numberline {C}Mapa de rutas relevantes}{34}{appendix.Alph3}\protected@file@percent } \@writefile{lof}{\addvspace {10\p@ }} \@writefile{lot}{\addvspace {10\p@ }} -\gdef \@abspage@last{36} +\gdef \@abspage@last{35} diff --git a/docs/plan_pruebas/main.out b/docs/plan_pruebas/main.out index 3ab47a0..a79dc82 100644 --- a/docs/plan_pruebas/main.out +++ b/docs/plan_pruebas/main.out @@ -6,79 +6,77 @@ \BOOKMARK [1][-]{section.1.3}{\376\377\000E\000s\000t\000a\000d\000o\000\040\000a\000c\000t\000u\000a\000l\000\040\000d\000e\000l\000\040\000r\000e\000p\000o\000s\000i\000t\000o\000r\000i\000o}{chapter.1}% 6 \BOOKMARK [1][-]{section.1.4}{\376\377\000R\000e\000f\000e\000r\000e\000n\000c\000i\000a\000s\000\040\000d\000e\000l\000\040\000s\000i\000s\000t\000e\000m\000a}{chapter.1}% 7 \BOOKMARK [0][-]{chapter.2}{\376\377\000A\000r\000q\000u\000i\000t\000e\000c\000t\000u\000r\000a\000\040\000b\000a\000j\000o\000\040\000p\000r\000u\000e\000b\000a}{}% 8 -\BOOKMARK [1][-]{section.2.1}{\376\377\000V\000i\000s\000t\000a\000\040\000d\000e\000\040\000c\000o\000m\000p\000o\000n\000e\000n\000t\000e\000s}{chapter.2}% 9 -\BOOKMARK [1][-]{section.2.2}{\376\377\000I\000n\000v\000e\000n\000t\000a\000r\000i\000o\000\040\000d\000e\000\040\000c\000o\000m\000p\000o\000n\000e\000n\000t\000e\000s}{chapter.2}% 10 -\BOOKMARK [1][-]{section.2.3}{\376\377\000F\000l\000u\000j\000o\000\040\000c\000r\000\355\000t\000i\000c\000o\000:\000\040\000b\000o\000t\000\040\000d\000e\000\040\000d\000e\000s\000c\000a\000r\000g\000a}{chapter.2}% 11 -\BOOKMARK [1][-]{section.2.4}{\376\377\000O\000t\000r\000o\000s\000\040\000f\000l\000u\000j\000o\000s\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o}{chapter.2}% 12 -\BOOKMARK [0][-]{chapter.3}{\376\377\000E\000s\000t\000r\000a\000t\000e\000g\000i\000a\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a\000s}{}% 13 -\BOOKMARK [1][-]{section.3.1}{\376\377\000P\000i\000r\000\341\000m\000i\000d\000e}{chapter.3}% 14 -\BOOKMARK [1][-]{section.3.2}{\376\377\000C\000o\000n\000v\000e\000n\000c\000i\000\363\000n\000\040\000d\000e\000\040\000i\000d\000e\000n\000t\000i\000f\000i\000c\000a\000d\000o\000r\000e\000s}{chapter.3}% 15 -\BOOKMARK [1][-]{section.3.3}{\376\377\000E\000n\000t\000o\000r\000n\000o\000s}{chapter.3}% 16 -\BOOKMARK [1][-]{section.3.4}{\376\377\000M\000o\000c\000k\000s\000\040\000v\000s\000\040\000s\000i\000s\000t\000e\000m\000a\000s\000\040\000r\000e\000a\000l\000e\000s}{chapter.3}% 17 -\BOOKMARK [1][-]{section.3.5}{\376\377\000P\000r\000i\000n\000c\000i\000p\000i\000o\000s}{chapter.3}% 18 -\BOOKMARK [0][-]{chapter.4}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000u\000n\000i\000t\000a\000r\000i\000a\000s}{}% 19 -\BOOKMARK [1][-]{section.4.1}{\376\377\000G\000a\000t\000e\000w\000a\000y}{chapter.4}% 20 -\BOOKMARK [1][-]{section.4.2}{\376\377\000B\000o\000t\000-\000d\000e\000s\000c\000a\000r\000g\000a\000\040\000A\000P\000I\000\040\000\050\000J\000o\000b\000S\000t\000o\000r\000e\000\051}{chapter.4}% 21 -\BOOKMARK [1][-]{section.4.3}{\376\377\000A\000g\000e\000n\000t\000\040\000b\000o\000t\000\040\000\050\000P\000C\000\051}{chapter.4}% 22 -\BOOKMARK [1][-]{section.4.4}{\376\377\000E\000x\000t\000r\000a\000c\000t\000i\000o\000n\000\040\000/\000\040\000C\000l\000e\000a\000n\000i\000n\000g\000\040\000/\000\040\000C\000o\000u\000n\000t\000\040\000/\000\040\000D\000e\000s\000k\000t\000o\000p}{chapter.4}% 23 -\BOOKMARK [1][-]{section.4.5}{\376\377\000C\000o\000m\000a\000n\000d\000o\000s\000\040\000d\000e\000\040\000e\000j\000e\000c\000u\000c\000i\000\363\000n}{chapter.4}% 24 -\BOOKMARK [0][-]{chapter.5}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000d\000e\000\040\000i\000n\000t\000e\000g\000r\000a\000c\000i\000\363\000n}{}% 25 -\BOOKMARK [1][-]{section.5.1}{\376\377\000S\000t\000a\000c\000k\000\040\000d\000e\000\040\000i\000n\000t\000e\000g\000r\000a\000c\000i\000\363\000n}{chapter.5}% 26 -\BOOKMARK [1][-]{section.5.2}{\376\377\000G\000a\000t\000e\000w\000a\000y}{chapter.5}% 27 -\BOOKMARK [1][-]{section.5.3}{\376\377\000B\000o\000t\000-\000d\000e\000s\000c\000a\000r\000g\000a}{chapter.5}% 28 -\BOOKMARK [1][-]{section.5.4}{\376\377\000E\000x\000t\000r\000a\000c\000t\000i\000o\000n\000\040\000/\000\040\000C\000l\000e\000a\000n\000i\000n\000g\000\040\000/\000\040\000C\000o\000u\000n\000t}{chapter.5}% 29 -\BOOKMARK [1][-]{section.5.5}{\376\377\000S\000m\000o\000k\000e\000\040\000m\000\355\000n\000i\000m\000o\000\040\000p\000o\000s\000t\000-\000c\000o\000m\000p\000o\000s\000e}{chapter.5}% 30 -\BOOKMARK [0][-]{chapter.6}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000e\000n\000d\000-\000t\000o\000-\000e\000n\000d}{}% 31 -\BOOKMARK [1][-]{section.6.1}{\376\377\000M\000a\000t\000r\000i\000z\000\040\000d\000e\000\040\000f\000l\000u\000j\000o\000s\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o}{chapter.6}% 32 -\BOOKMARK [1][-]{section.6.2}{\376\377\000D\000e\000t\000a\000l\000l\000e\000\040\000E\0002\000E\000-\0000\0005\000\040\000\050\000b\000o\000t\000\040\000d\000e\000s\000c\000a\000r\000g\000a\000\051}{chapter.6}% 33 -\BOOKMARK [2][-]{subsection.6.2.1}{\376\377\000P\000r\000e\000c\000o\000n\000d\000i\000c\000i\000o\000n\000e\000s}{section.6.2}% 34 -\BOOKMARK [2][-]{subsection.6.2.2}{\376\377\000P\000a\000s\000o\000s}{section.6.2}% 35 -\BOOKMARK [2][-]{subsection.6.2.3}{\376\377\000A\000s\000s\000e\000r\000t\000s}{section.6.2}% 36 -\BOOKMARK [1][-]{section.6.3}{\376\377\000D\000e\000s\000k\000t\000o\000p}{chapter.6}% 37 -\BOOKMARK [0][-]{chapter.7}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000d\000e\000\040\000e\000s\000t\000r\000\351\000s\000\040\000y\000\040\000c\000a\000r\000g\000a}{}% 38 -\BOOKMARK [1][-]{section.7.1}{\376\377\000H\000e\000r\000r\000a\000m\000i\000e\000n\000t\000a}{chapter.7}% 39 -\BOOKMARK [1][-]{section.7.2}{\376\377\000P\000e\000r\000f\000i\000l\000e\000s\000\040\000d\000e\000\040\000c\000a\000r\000g\000a}{chapter.7}% 40 -\BOOKMARK [1][-]{section.7.3}{\376\377\000E\000s\000c\000e\000n\000a\000r\000i\000o\000s\000\040\000\050\000c\000a\000t\000\341\000l\000o\000g\000o\000\040\000c\000o\000m\000p\000l\000e\000t\000o\000\051}{chapter.7}% 41 -\BOOKMARK [1][-]{section.7.4}{\376\377\000S\000c\000r\000i\000p\000t\000s\000\040\000k\0006\000\040\000i\000n\000c\000l\000u\000i\000d\000o\000s}{chapter.7}% 42 -\BOOKMARK [1][-]{section.7.5}{\376\377\000S\000L\000O\000s\000\040\000d\000e\000\040\000a\000c\000e\000p\000t\000a\000c\000i\000\363\000n}{chapter.7}% 43 -\BOOKMARK [2][-]{subsection.7.5.1}{\376\377\000P\000e\000r\000f\000i\000l\000\040\000h\000o\000s\000t\000-\000c\000o\000m\000p\000a\000r\000t\000i\000d\000o\000-\0008\000g\000b\000\040\000\050\000d\000e\000f\000a\000u\000l\000t\000\051}{section.7.5}% 44 -\BOOKMARK [2][-]{subsection.7.5.2}{\376\377\000P\000e\000r\000f\000i\000l\000\040\000l\000a\000b\000o\000r\000a\000t\000o\000r\000i\000o}{section.7.5}% 45 -\BOOKMARK [1][-]{section.7.6}{\376\377\000E\000j\000e\000c\000u\000c\000i\000\363\000n\000\040\000e\000j\000e\000m\000p\000l\000o}{chapter.7}% 46 -\BOOKMARK [0][-]{chapter.8}{\376\377\000R\000e\000s\000i\000l\000i\000e\000n\000c\000i\000a\000\040\000y\000\040\000c\000h\000a\000o\000s}{}% 47 -\BOOKMARK [1][-]{section.8.1}{\376\377\000O\000b\000j\000e\000t\000i\000v\000o}{chapter.8}% 48 -\BOOKMARK [1][-]{section.8.2}{\376\377\000C\000a\000s\000o\000s}{chapter.8}% 49 -\BOOKMARK [1][-]{section.8.3}{\376\377\000M\000o\000d\000o\000s\000\040\000d\000e\000\040\000f\000a\000l\000l\000o\000\040\000c\000o\000n\000o\000c\000i\000d\000o\000s\000\040\000\050\000b\000o\000t\000\051}{chapter.8}% 50 -\BOOKMARK [1][-]{section.8.4}{\376\377\000C\000r\000i\000t\000e\000r\000i\000o\000\040\000g\000l\000o\000b\000a\000l}{chapter.8}% 51 -\BOOKMARK [0][-]{chapter.9}{\376\377\000D\000a\000t\000o\000s\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a\000\040\000y\000\040\000e\000n\000t\000o\000r\000n\000o\000s}{}% 52 -\BOOKMARK [1][-]{section.9.1}{\376\377\000H\000o\000s\000t\000\040\000d\000o\000n\000d\000e\000\040\000s\000e\000\040\000e\000j\000e\000c\000u\000t\000a\000n\000\040\000l\000a\000s\000\040\000p\000r\000u\000e\000b\000a\000s}{chapter.9}% 53 -\BOOKMARK [2][-]{subsection.9.1.1}{\376\377\000H\000a\000r\000d\000w\000a\000r\000e\000\040\000y\000\040\000s\000i\000s\000t\000e\000m\000a}{section.9.1}% 54 -\BOOKMARK [2][-]{subsection.9.1.2}{\376\377\000S\000t\000a\000c\000k\000\040\000I\000P\000S\000E\000x\000t\000r\000a\000c\000t\000o\000r\000\040\000o\000b\000s\000e\000r\000v\000a\000d\000o\000\040\000\050\000i\000d\000l\000e\000\051}{section.9.1}% 55 -\BOOKMARK [2][-]{subsection.9.1.3}{\376\377\000C\000a\000r\000g\000a\000\040\000c\000o\000n\000c\000u\000r\000r\000e\000n\000t\000e\000\040\000d\000e\000l\000\040\000h\000o\000s\000t\000\040\000\050\000n\000o\000\040\000I\000P\000S\000\051}{section.9.1}% 56 -\BOOKMARK [2][-]{subsection.9.1.4}{\376\377\000R\000e\000q\000u\000i\000s\000i\000t\000o\000s\000\040\000p\000r\000e\000v\000i\000o\000s\000\040\000a\000\040\000u\000n\000a\000\040\000c\000a\000m\000p\000a\000\361\000a\000\040\000d\000e\000\040\000e\000s\000t\000r\000\351\000s}{section.9.1}% 57 -\BOOKMARK [1][-]{section.9.2}{\376\377\000F\000i\000x\000t\000u\000r\000e\000s\000\040\000r\000e\000c\000o\000m\000e\000n\000d\000a\000d\000a\000s}{chapter.9}% 58 -\BOOKMARK [1][-]{section.9.3}{\376\377\000V\000a\000r\000i\000a\000b\000l\000e\000s\000\040\000d\000e\000\040\000e\000n\000t\000o\000r\000n\000o\000\040\000r\000e\000l\000e\000v\000a\000n\000t\000e\000s}{chapter.9}% 59 -\BOOKMARK [1][-]{section.9.4}{\376\377\000S\000e\000g\000u\000r\000i\000d\000a\000d\000\040\000e\000n\000\040\000e\000n\000t\000o\000r\000n\000o\000s\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a}{chapter.9}% 60 -\BOOKMARK [0][-]{chapter.10}{\376\377\000H\000e\000r\000r\000a\000m\000i\000e\000n\000t\000a\000s\000\040\000p\000r\000o\000f\000e\000s\000i\000o\000n\000a\000l\000e\000s\000\040\000y\000\040\000C\000I}{}% 61 -\BOOKMARK [1][-]{section.10.1}{\376\377\000S\000t\000a\000c\000k\000\040\000d\000e\000\040\000t\000o\000o\000l\000i\000n\000g}{chapter.10}% 62 -\BOOKMARK [1][-]{section.10.2}{\376\377\000P\000i\000p\000e\000l\000i\000n\000e\000\040\000C\000I\000\040\000o\000b\000j\000e\000t\000i\000v\000o\000\040\000\050\000n\000o\000\040\000i\000m\000p\000l\000e\000m\000e\000n\000t\000a\000d\000o\000\040\000e\000n\000\040\000e\000s\000t\000a\000\040\000e\000n\000t\000r\000e\000g\000a\000\051}{chapter.10}% 63 -\BOOKMARK [1][-]{section.10.3}{\376\377\000C\000o\000m\000p\000i\000l\000a\000r\000\040\000e\000s\000t\000e\000\040\000d\000o\000c\000u\000m\000e\000n\000t\000o}{chapter.10}% 64 -\BOOKMARK [1][-]{section.10.4}{\376\377\000P\000r\000i\000o\000r\000i\000d\000a\000d\000\040\000d\000e\000\040\000i\000m\000p\000l\000e\000m\000e\000n\000t\000a\000c\000i\000\363\000n\000\040\000d\000e\000\040\000t\000e\000s\000t\000s\000\040\000\050\000R\000O\000I\000\051}{chapter.10}% 65 -\BOOKMARK [0][-]{chapter.11}{\376\377\000C\000r\000o\000n\000o\000g\000r\000a\000m\000a\000\040\000y\000\040\000c\000r\000i\000t\000e\000r\000i\000o\000s\000\040\000d\000e\000\040\000a\000c\000e\000p\000t\000a\000c\000i\000\363\000n}{}% 66 -\BOOKMARK [1][-]{section.11.1}{\376\377\000F\000a\000s\000e\000s\000\040\000s\000u\000g\000e\000r\000i\000d\000a\000s}{chapter.11}% 67 -\BOOKMARK [1][-]{section.11.2}{\376\377\000D\000e\000f\000i\000n\000i\000t\000i\000o\000n\000\040\000o\000f\000\040\000D\000o\000n\000e\000\040\000p\000o\000r\000\040\000r\000e\000l\000e\000a\000s\000e}{chapter.11}% 68 -\BOOKMARK [1][-]{section.11.3}{\376\377\000C\000a\000m\000p\000a\000\361\000a\000\040\000r\000e\000g\000i\000s\000t\000r\000a\000d\000a}{chapter.11}% 69 -\BOOKMARK [1][-]{section.11.4}{\376\377\000P\000l\000a\000n\000t\000i\000l\000l\000a\000\040\000d\000e\000\040\000i\000n\000f\000o\000r\000m\000e\000\040\000d\000e\000\040\000e\000j\000e\000c\000u\000c\000i\000\363\000n}{chapter.11}% 70 -\BOOKMARK [1][-]{section.11.5}{\376\377\000R\000e\000s\000p\000o\000n\000s\000a\000b\000l\000e\000s\000\040\000\050\000r\000o\000l\000e\000s\000\051}{chapter.11}% 71 -\BOOKMARK [0][-]{chapter.12}{\376\377\000I\000n\000f\000o\000r\000m\000e\000\040\000d\000e\000\040\000e\000j\000e\000c\000u\000c\000i\000\363\000n\000\040\040\024\000\040\000c\000a\000m\000p\000a\000\361\000a\000\040\0002\0000\0002\0006\000-\0000\0007\000-\0001\0003}{}% 72 -\BOOKMARK [1][-]{section.12.1}{\376\377\000M\000e\000t\000a\000d\000a\000t\000o\000s}{chapter.12}% 73 -\BOOKMARK [1][-]{section.12.2}{\376\377\000B\000a\000s\000e\000l\000i\000n\000e\000\040\000d\000e\000l\000\040\000e\000n\000t\000o\000r\000n\000o}{chapter.12}% 74 -\BOOKMARK [1][-]{section.12.3}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\000p\000o\000r\000\040\000c\000a\000s\000o}{chapter.12}% 75 -\BOOKMARK [2][-]{subsection.12.3.1}{\376\377\000S\000T\000-\000G\000W\000-\0000\0001\000\040\040\024\000\040\000g\000a\000t\000e\000w\000a\000y\000\040\000h\000e\000a\000l\000t\000h}{section.12.3}% 76 -\BOOKMARK [2][-]{subsection.12.3.2}{\376\377\000S\000T\000-\000B\000O\000T\000-\0000\0001\000\040\040\024\000\040\000h\000e\000a\000r\000t\000b\000e\000a\000t\000s\000\040\000\050\000c\000o\000r\000r\000i\000d\000a\000\040\0001\000\051}{section.12.3}% 77 -\BOOKMARK [2][-]{subsection.12.3.3}{\376\377\000S\000T\000-\000B\000O\000T\000-\0000\0001\000\040\040\024\000\040\000h\000e\000a\000r\000t\000b\000e\000a\000t\000s\000\040\000\050\000c\000o\000r\000r\000i\000d\000a\000\040\0002\000,\000\040\000r\000e\000i\000n\000t\000e\000n\000t\000o\000\051}{section.12.3}% 78 -\BOOKMARK [1][-]{section.12.4}{\376\377\000H\000a\000l\000l\000a\000z\000g\000o\000s}{chapter.12}% 79 -\BOOKMARK [1][-]{section.12.5}{\376\377\000N\000o\000\040\000e\000j\000e\000c\000u\000t\000a\000d\000o\000\040\000e\000n\000\040\000e\000s\000t\000a\000\040\000c\000a\000m\000p\000a\000\361\000a}{chapter.12}% 80 -\BOOKMARK [1][-]{section.12.6}{\376\377\000F\000o\000l\000l\000o\000w\000-\000u\000p\000s}{chapter.12}% 81 -\BOOKMARK [0][-]{appendix.Alph1}{\376\377\000G\000l\000o\000s\000a\000r\000i\000o}{}% 82 -\BOOKMARK [0][-]{appendix.Alph2}{\376\377\000C\000h\000e\000c\000k\000l\000i\000s\000t\000\040\000d\000e\000\040\000r\000e\000l\000e\000a\000s\000e}{}% 83 -\BOOKMARK [0][-]{appendix.Alph3}{\376\377\000M\000a\000p\000a\000\040\000d\000e\000\040\000r\000u\000t\000a\000s\000\040\000r\000e\000l\000e\000v\000a\000n\000t\000e\000s}{}% 84 +\BOOKMARK [1][-]{section.2.1}{\376\377\000C\000\363\000m\000o\000\040\000e\000s\000t\000\341\000\040\000d\000i\000s\000e\000\361\000a\000d\000o\000\040\000e\000l\000\040\000s\000i\000s\000t\000e\000m\000a}{chapter.2}% 9 +\BOOKMARK [1][-]{section.2.2}{\376\377\000P\000a\000t\000r\000o\000n\000e\000s\000\040\000d\000e\000\040\000d\000i\000s\000e\000\361\000o}{chapter.2}% 10 +\BOOKMARK [1][-]{section.2.3}{\376\377\000P\000i\000p\000e\000l\000i\000n\000e\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o}{chapter.2}% 11 +\BOOKMARK [1][-]{section.2.4}{\376\377\000I\000n\000v\000e\000n\000t\000a\000r\000i\000o\000\040\000d\000e\000\040\000c\000o\000m\000p\000o\000n\000e\000n\000t\000e\000s}{chapter.2}% 12 +\BOOKMARK [1][-]{section.2.5}{\376\377\000F\000l\000u\000j\000o\000\040\000c\000r\000\355\000t\000i\000c\000o\000:\000\040\000d\000e\000s\000c\000a\000r\000g\000a\000\040\000\050\000o\000r\000q\000u\000e\000s\000t\000a\000d\000o\000r\000\040\000+\000\040\000a\000g\000e\000n\000t\000\040\000S\000e\000l\000e\000n\000i\000u\000m\000\051}{chapter.2}% 13 +\BOOKMARK [1][-]{section.2.6}{\376\377\000O\000t\000r\000o\000s\000\040\000f\000l\000u\000j\000o\000s\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o}{chapter.2}% 14 +\BOOKMARK [0][-]{chapter.3}{\376\377\000E\000s\000t\000r\000a\000t\000e\000g\000i\000a\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a\000s}{}% 15 +\BOOKMARK [1][-]{section.3.1}{\376\377\000P\000i\000r\000\341\000m\000i\000d\000e}{chapter.3}% 16 +\BOOKMARK [1][-]{section.3.2}{\376\377\000C\000o\000n\000v\000e\000n\000c\000i\000\363\000n\000\040\000d\000e\000\040\000i\000d\000e\000n\000t\000i\000f\000i\000c\000a\000d\000o\000r\000e\000s}{chapter.3}% 17 +\BOOKMARK [1][-]{section.3.3}{\376\377\000C\000\363\000m\000o\000\040\000l\000e\000e\000r\000\040\000u\000n\000\040\000c\000a\000s\000o\000\040\000d\000e\000l\000\040\000c\000a\000t\000\341\000l\000o\000g\000o}{chapter.3}% 18 +\BOOKMARK [1][-]{section.3.4}{\376\377\000E\000n\000t\000o\000r\000n\000o\000s}{chapter.3}% 19 +\BOOKMARK [1][-]{section.3.5}{\376\377\000M\000o\000c\000k\000s\000\040\000v\000s\000\040\000s\000i\000s\000t\000e\000m\000a\000s\000\040\000r\000e\000a\000l\000e\000s}{chapter.3}% 20 +\BOOKMARK [1][-]{section.3.6}{\376\377\000P\000r\000i\000n\000c\000i\000p\000i\000o\000s}{chapter.3}% 21 +\BOOKMARK [0][-]{chapter.4}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000u\000n\000i\000t\000a\000r\000i\000a\000s}{}% 22 +\BOOKMARK [1][-]{section.4.1}{\376\377\000G\000a\000t\000e\000w\000a\000y}{chapter.4}% 23 +\BOOKMARK [1][-]{section.4.2}{\376\377\000B\000o\000t\000-\000d\000e\000s\000c\000a\000r\000g\000a\000\040\000A\000P\000I\000\040\000\050\000J\000o\000b\000S\000t\000o\000r\000e\000\051}{chapter.4}% 24 +\BOOKMARK [1][-]{section.4.3}{\376\377\000A\000g\000e\000n\000t\000\040\000b\000o\000t\000\040\000\050\000P\000C\000\051}{chapter.4}% 25 +\BOOKMARK [1][-]{section.4.4}{\376\377\000E\000x\000t\000r\000a\000c\000t\000i\000o\000n\000\040\000/\000\040\000C\000l\000e\000a\000n\000i\000n\000g\000\040\000/\000\040\000C\000o\000u\000n\000t\000\040\000/\000\040\000D\000e\000s\000k\000t\000o\000p}{chapter.4}% 26 +\BOOKMARK [1][-]{section.4.5}{\376\377\000C\000o\000m\000a\000n\000d\000o\000s\000\040\000d\000e\000\040\000e\000j\000e\000c\000u\000c\000i\000\363\000n}{chapter.4}% 27 +\BOOKMARK [0][-]{chapter.5}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000d\000e\000\040\000i\000n\000t\000e\000g\000r\000a\000c\000i\000\363\000n}{}% 28 +\BOOKMARK [1][-]{section.5.1}{\376\377\000S\000t\000a\000c\000k\000\040\000d\000e\000\040\000i\000n\000t\000e\000g\000r\000a\000c\000i\000\363\000n}{chapter.5}% 29 +\BOOKMARK [1][-]{section.5.2}{\376\377\000G\000a\000t\000e\000w\000a\000y}{chapter.5}% 30 +\BOOKMARK [1][-]{section.5.3}{\376\377\000B\000o\000t\000-\000d\000e\000s\000c\000a\000r\000g\000a}{chapter.5}% 31 +\BOOKMARK [1][-]{section.5.4}{\376\377\000E\000x\000t\000r\000a\000c\000t\000i\000o\000n\000\040\000/\000\040\000C\000l\000e\000a\000n\000i\000n\000g\000\040\000/\000\040\000C\000o\000u\000n\000t}{chapter.5}% 32 +\BOOKMARK [1][-]{section.5.5}{\376\377\000S\000m\000o\000k\000e\000\040\000m\000\355\000n\000i\000m\000o\000\040\000p\000o\000s\000t\000-\000c\000o\000m\000p\000o\000s\000e}{chapter.5}% 33 +\BOOKMARK [0][-]{chapter.6}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000e\000n\000d\000-\000t\000o\000-\000e\000n\000d}{}% 34 +\BOOKMARK [1][-]{section.6.1}{\376\377\000M\000a\000t\000r\000i\000z\000\040\000d\000e\000\040\000f\000l\000u\000j\000o\000s\000\040\000d\000e\000\040\000n\000e\000g\000o\000c\000i\000o}{chapter.6}% 35 +\BOOKMARK [1][-]{section.6.2}{\376\377\000D\000e\000t\000a\000l\000l\000e\000\040\000E\0002\000E\000-\0000\0005\000\040\000\050\000b\000o\000t\000\040\000d\000e\000s\000c\000a\000r\000g\000a\000\051}{chapter.6}% 36 +\BOOKMARK [2][-]{subsection.6.2.1}{\376\377\000P\000r\000e\000c\000o\000n\000d\000i\000c\000i\000o\000n\000e\000s}{section.6.2}% 37 +\BOOKMARK [2][-]{subsection.6.2.2}{\376\377\000P\000a\000s\000o\000s}{section.6.2}% 38 +\BOOKMARK [2][-]{subsection.6.2.3}{\376\377\000A\000s\000s\000e\000r\000t\000s}{section.6.2}% 39 +\BOOKMARK [1][-]{section.6.3}{\376\377\000D\000e\000s\000k\000t\000o\000p}{chapter.6}% 40 +\BOOKMARK [0][-]{chapter.7}{\376\377\000P\000r\000u\000e\000b\000a\000s\000\040\000d\000e\000\040\000e\000s\000t\000r\000\351\000s\000\040\000y\000\040\000c\000a\000r\000g\000a}{}% 41 +\BOOKMARK [1][-]{section.7.1}{\376\377\000H\000e\000r\000r\000a\000m\000i\000e\000n\000t\000a}{chapter.7}% 42 +\BOOKMARK [1][-]{section.7.2}{\376\377\000P\000e\000r\000f\000i\000l\000e\000s\000\040\000d\000e\000\040\000c\000a\000r\000g\000a}{chapter.7}% 43 +\BOOKMARK [1][-]{section.7.3}{\376\377\000E\000s\000c\000e\000n\000a\000r\000i\000o\000s\000\040\000\050\000c\000a\000t\000\341\000l\000o\000g\000o\000\040\000c\000o\000m\000p\000l\000e\000t\000o\000\051}{chapter.7}% 44 +\BOOKMARK [1][-]{section.7.4}{\376\377\000S\000c\000r\000i\000p\000t\000s\000\040\000k\0006\000\040\000i\000n\000c\000l\000u\000i\000d\000o\000s}{chapter.7}% 45 +\BOOKMARK [1][-]{section.7.5}{\376\377\000S\000L\000O\000s\000\040\000d\000e\000\040\000a\000c\000e\000p\000t\000a\000c\000i\000\363\000n}{chapter.7}% 46 +\BOOKMARK [2][-]{subsection.7.5.1}{\376\377\000P\000e\000r\000f\000i\000l\000\040\000h\000o\000s\000t\000-\000c\000o\000m\000p\000a\000r\000t\000i\000d\000o\000-\0008\000g\000b\000\040\000\050\000d\000e\000f\000a\000u\000l\000t\000\051}{section.7.5}% 47 +\BOOKMARK [2][-]{subsection.7.5.2}{\376\377\000P\000e\000r\000f\000i\000l\000\040\000l\000a\000b\000o\000r\000a\000t\000o\000r\000i\000o}{section.7.5}% 48 +\BOOKMARK [1][-]{section.7.6}{\376\377\000E\000j\000e\000c\000u\000c\000i\000\363\000n\000\040\000e\000j\000e\000m\000p\000l\000o}{chapter.7}% 49 +\BOOKMARK [0][-]{chapter.8}{\376\377\000R\000e\000s\000i\000l\000i\000e\000n\000c\000i\000a\000\040\000y\000\040\000c\000h\000a\000o\000s}{}% 50 +\BOOKMARK [1][-]{section.8.1}{\376\377\000O\000b\000j\000e\000t\000i\000v\000o}{chapter.8}% 51 +\BOOKMARK [1][-]{section.8.2}{\376\377\000C\000a\000s\000o\000s}{chapter.8}% 52 +\BOOKMARK [1][-]{section.8.3}{\376\377\000M\000o\000d\000o\000s\000\040\000d\000e\000\040\000f\000a\000l\000l\000o\000\040\000c\000o\000n\000o\000c\000i\000d\000o\000s\000\040\000\050\000b\000o\000t\000\051}{chapter.8}% 53 +\BOOKMARK [1][-]{section.8.4}{\376\377\000C\000r\000i\000t\000e\000r\000i\000o\000\040\000g\000l\000o\000b\000a\000l}{chapter.8}% 54 +\BOOKMARK [0][-]{chapter.9}{\376\377\000D\000a\000t\000o\000s\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a\000\040\000y\000\040\000e\000n\000t\000o\000r\000n\000o\000s}{}% 55 +\BOOKMARK [1][-]{section.9.1}{\376\377\000H\000o\000s\000t\000\040\000d\000o\000n\000d\000e\000\040\000s\000e\000\040\000e\000j\000e\000c\000u\000t\000a\000n\000\040\000l\000a\000s\000\040\000p\000r\000u\000e\000b\000a\000s}{chapter.9}% 56 +\BOOKMARK [2][-]{subsection.9.1.1}{\376\377\000H\000a\000r\000d\000w\000a\000r\000e\000\040\000y\000\040\000s\000i\000s\000t\000e\000m\000a}{section.9.1}% 57 +\BOOKMARK [2][-]{subsection.9.1.2}{\376\377\000S\000t\000a\000c\000k\000\040\000I\000P\000S\000E\000x\000t\000r\000a\000c\000t\000o\000r\000\040\000o\000b\000s\000e\000r\000v\000a\000d\000o\000\040\000\050\000i\000d\000l\000e\000\051}{section.9.1}% 58 +\BOOKMARK [2][-]{subsection.9.1.3}{\376\377\000C\000a\000r\000g\000a\000\040\000c\000o\000n\000c\000u\000r\000r\000e\000n\000t\000e\000\040\000d\000e\000l\000\040\000h\000o\000s\000t\000\040\000\050\000n\000o\000\040\000I\000P\000S\000\051}{section.9.1}% 59 +\BOOKMARK [2][-]{subsection.9.1.4}{\376\377\000R\000e\000q\000u\000i\000s\000i\000t\000o\000s\000\040\000p\000r\000e\000v\000i\000o\000s\000\040\000a\000\040\000u\000n\000a\000\040\000c\000a\000m\000p\000a\000\361\000a\000\040\000d\000e\000\040\000e\000s\000t\000r\000\351\000s}{section.9.1}% 60 +\BOOKMARK [1][-]{section.9.2}{\376\377\000F\000i\000x\000t\000u\000r\000e\000s\000\040\000r\000e\000c\000o\000m\000e\000n\000d\000a\000d\000a\000s}{chapter.9}% 61 +\BOOKMARK [1][-]{section.9.3}{\376\377\000V\000a\000r\000i\000a\000b\000l\000e\000s\000\040\000d\000e\000\040\000e\000n\000t\000o\000r\000n\000o\000\040\000r\000e\000l\000e\000v\000a\000n\000t\000e\000s}{chapter.9}% 62 +\BOOKMARK [1][-]{section.9.4}{\376\377\000S\000e\000g\000u\000r\000i\000d\000a\000d\000\040\000e\000n\000\040\000e\000n\000t\000o\000r\000n\000o\000s\000\040\000d\000e\000\040\000p\000r\000u\000e\000b\000a}{chapter.9}% 63 +\BOOKMARK [0][-]{chapter.10}{\376\377\000H\000e\000r\000r\000a\000m\000i\000e\000n\000t\000a\000s\000\040\000p\000r\000o\000f\000e\000s\000i\000o\000n\000a\000l\000e\000s\000\040\000y\000\040\000C\000I}{}% 64 +\BOOKMARK [1][-]{section.10.1}{\376\377\000S\000t\000a\000c\000k\000\040\000d\000e\000\040\000t\000o\000o\000l\000i\000n\000g}{chapter.10}% 65 +\BOOKMARK [1][-]{section.10.2}{\376\377\000P\000i\000p\000e\000l\000i\000n\000e\000\040\000C\000I\000\040\000o\000b\000j\000e\000t\000i\000v\000o\000\040\000\050\000n\000o\000\040\000i\000m\000p\000l\000e\000m\000e\000n\000t\000a\000d\000o\000\040\000e\000n\000\040\000e\000s\000t\000a\000\040\000e\000n\000t\000r\000e\000g\000a\000\051}{chapter.10}% 66 +\BOOKMARK [1][-]{section.10.3}{\376\377\000C\000o\000m\000p\000i\000l\000a\000r\000\040\000e\000s\000t\000e\000\040\000d\000o\000c\000u\000m\000e\000n\000t\000o}{chapter.10}% 67 +\BOOKMARK [1][-]{section.10.4}{\376\377\000P\000r\000i\000o\000r\000i\000d\000a\000d\000\040\000d\000e\000\040\000i\000m\000p\000l\000e\000m\000e\000n\000t\000a\000c\000i\000\363\000n\000\040\000d\000e\000\040\000t\000e\000s\000t\000s\000\040\000\050\000R\000O\000I\000\051}{chapter.10}% 68 +\BOOKMARK [0][-]{chapter.11}{\376\377\000C\000r\000o\000n\000o\000g\000r\000a\000m\000a\000\040\000y\000\040\000c\000r\000i\000t\000e\000r\000i\000o\000s\000\040\000d\000e\000\040\000a\000c\000e\000p\000t\000a\000c\000i\000\363\000n}{}% 69 +\BOOKMARK [1][-]{section.11.1}{\376\377\000F\000a\000s\000e\000s\000\040\000s\000u\000g\000e\000r\000i\000d\000a\000s}{chapter.11}% 70 +\BOOKMARK [1][-]{section.11.2}{\376\377\000D\000e\000f\000i\000n\000i\000t\000i\000o\000n\000\040\000o\000f\000\040\000D\000o\000n\000e\000\040\000p\000o\000r\000\040\000r\000e\000l\000e\000a\000s\000e}{chapter.11}% 71 +\BOOKMARK [1][-]{section.11.3}{\376\377\000C\000a\000m\000p\000a\000\361\000a\000\040\000r\000e\000g\000i\000s\000t\000r\000a\000d\000a}{chapter.11}% 72 +\BOOKMARK [1][-]{section.11.4}{\376\377\000P\000l\000a\000n\000t\000i\000l\000l\000a\000\040\000d\000e\000\040\000i\000n\000f\000o\000r\000m\000e\000\040\000d\000e\000\040\000e\000j\000e\000c\000u\000c\000i\000\363\000n}{chapter.11}% 73 +\BOOKMARK [1][-]{section.11.5}{\376\377\000R\000e\000s\000p\000o\000n\000s\000a\000b\000l\000e\000s\000\040\000\050\000r\000o\000l\000e\000s\000\051}{chapter.11}% 74 +\BOOKMARK [0][-]{chapter.12}{\376\377\000E\000s\000t\000a\000d\000o\000\040\000d\000e\000\040\000c\000a\000m\000p\000a\000\361\000a\000\040\040\024\000\040\0002\0000\0002\0006\000-\0000\0007\000-\0001\0003\000\040\000\050\000+\000\040\000L\000L\000M\000\040\0001\0007\000-\000j\000u\000l\000\051}{}% 75 +\BOOKMARK [1][-]{section.12.1}{\376\377\000M\000e\000t\000a\000d\000a\000t\000o\000s}{chapter.12}% 76 +\BOOKMARK [1][-]{section.12.2}{\376\377\000R\000e\000s\000u\000l\000t\000a\000d\000o\000s\000\040\000r\000e\000s\000u\000m\000i\000d\000o\000s}{chapter.12}% 77 +\BOOKMARK [1][-]{section.12.3}{\376\377\000L\000e\000c\000c\000i\000\363\000n\000\040\000o\000p\000e\000r\000a\000t\000i\000v\000a}{chapter.12}% 78 +\BOOKMARK [1][-]{section.12.4}{\376\377\000P\000e\000n\000d\000i\000e\000n\000t\000e\000\040\000r\000e\000s\000p\000e\000c\000t\000o\000\040\000a\000\040\000e\000s\000t\000e\000\040\000p\000l\000a\000n}{chapter.12}% 79 +\BOOKMARK [0][-]{appendix.Alph1}{\376\377\000G\000l\000o\000s\000a\000r\000i\000o}{}% 80 +\BOOKMARK [0][-]{appendix.Alph2}{\376\377\000C\000h\000e\000c\000k\000l\000i\000s\000t\000\040\000d\000e\000\040\000r\000e\000l\000e\000a\000s\000e}{}% 81 +\BOOKMARK [0][-]{appendix.Alph3}{\376\377\000M\000a\000p\000a\000\040\000d\000e\000\040\000r\000u\000t\000a\000s\000\040\000r\000e\000l\000e\000v\000a\000n\000t\000e\000s}{}% 82 diff --git a/docs/plan_pruebas/main.tex b/docs/plan_pruebas/main.tex index c23d771..d5248a3 100644 --- a/docs/plan_pruebas/main.tex +++ b/docs/plan_pruebas/main.tex @@ -6,25 +6,22 @@ \makecover -\chapter*{Control de cambios} -\begin{tabularx}{\textwidth}{@{}l l X@{}} -\toprule -\textbf{Versi\'on} & \textbf{Fecha} & \textbf{Descripci\'on} \\ -\midrule -1.0.0 & 13 jul 2026 & Versi\'on inicial del plan de pruebas y estr\'es. \\ -1.1.0 & 13 jul 2026 & Entorno real de ejecuci\'on (host Ubuntu compartido 8\,GiB) y perfiles de carga. \\ -1.2.0 & 13 jul 2026 & Informe campa\~na k6: smoke, ST-GW-01 PASS, ST-BOT-01 PASS (reintento). \\ -\bottomrule -\end{tabularx} - \chapter*{Resumen ejecutivo} -Este documento define la estrategia y la matriz de pruebas del monorepo \textbf{IPSExtractor}: API Gateway, extraction/workers ARQ, cleaning, count-api (Go), bot-descarga (API + agent Selenium), PostgreSQL, Redis, MinIO y la aplicaci\'on de escritorio. +Este documento define la \textbf{estrategia y la matriz de pruebas} del monorepo IPSExtractor: +API Gateway, extraction/workers ARQ, cleaning, count-api, bot-descarga (API + agent Selenium), +PostgreSQL, Redis, MinIO y la aplicaci\'on de escritorio. -Hoy el repositorio cuenta principalmente con pruebas unitarias aisladas; no existe pipeline CI, ni suites de integraci\'on/E2E automatizadas. Este plan cierra ese gap con identificadores trazables (\texttt{UT-*}, \texttt{IT-*}, \texttt{E2E-*}, \texttt{ST-*}, \texttt{CH-*}), SLOs medibles y scripts \textbf{k6} de referencia en \texttt{docs/plan\_pruebas/scripts/}. - -\textbf{Importante:} las pruebas de integraci\'on, humo y estr\'es documentadas contra el stack vivo se ejecutan en el host descrito en la secci\'on~\ref{sec:host-pruebas} (laptop Ubuntu 24.04, 8\,GiB RAM, Docker compartido con MLflow, Airflow, n8n, etc.). Los resultados deben interpretarse en ese contexto: un OOM o p95 alto puede deberse a contenci\'on del host, no solo a IPSExtractor. - -\textbf{Estado campa\~na 2026-07-13:} smoke PASS; ST-GW-01 PASS; ST-BOT-01 PASS en reintento (detalle en cap.~\ref{ch:informe-2026-07-13}). +\begin{itemize} + \item \textbf{Objetivo:} cerrar el gap entre unitarias aisladas y un plan trazable + (UT / IT / E2E / ST / CH) con SLOs y scripts k6. + \item \textbf{Arquitectura:} microservicios + API Gateway + cola ARQ + claim del bot + (cap.~\ref{ch:arquitectura}). + \item \textbf{Entorno de referencia:} host Ubuntu 8\,GiB compartido (sec.~\ref{sec:host-pruebas}); + perfiles \texttt{shared8g} / \texttt{lab}. + \item \textbf{Estado campa\~na:} smoke PASS; ST-GW-01 PASS; ST-BOT-01 PASS (reintento); + ST-EXT-02 LLM medido --- detalle en el \emph{Informe de Pruebas}. + \item \textbf{Volumen 100k/mes:} modelo y veredicto en el PDF \emph{Capacidad 100k}. +\end{itemize} \tableofcontents \clearpage @@ -57,13 +54,13 @@ Hoy el repositorio cuenta principalmente con pruebas unitarias aisladas; no exis \chapter{Checklist de release} \begin{enumerate} \item Unitarios verdes: bots, bot-descarga store, Vitest, \texttt{go test}. - \item Migraciones Alembic en BD limpia (0001--0004). + \item Migraciones Alembic en BD limpia. \item Smoke Compose: \texttt{GET /health/live} y \texttt{GET /health}. \item E2E-01 a E2E-05 ejecutados en staging (E2E-05 con agent real si hay Imaging). \item Al menos ST-GW-01, ST-BOT-01, ST-COUNT-01 y ST-EXT-01 con umbrales cumplidos. - \item Informe: p95, error rate, picos RAM, jobs fallidos, costos LLM. - \item \textbf{Entorno citado:} hostname, \texttt{free -h}, \texttt{docker stats}, perfil k6 (\texttt{shared8g}/\texttt{lab}). - \item Sin secretos hardcodeados en compose para entornos de prueba. + \item Informe de resultados + PDF Capacidad 100k actualizados si aplica. + \item Entorno citado: hostname, \texttt{free -h}, \texttt{docker stats}, perfil k6. + \item Sin secretos hardcodeados en compose de prueba. \end{enumerate} \chapter{Mapa de rutas relevantes} diff --git a/docs/plan_pruebas/main.toc b/docs/plan_pruebas/main.toc index ac8b53e..851652a 100644 --- a/docs/plan_pruebas/main.toc +++ b/docs/plan_pruebas/main.toc @@ -1,48 +1,51 @@ \babel@toc {spanish}{}\relax -\contentsline {chapter}{\numberline {1}Introducci\'on}{6}{chapter.1}% -\contentsline {section}{\numberline {1.1}Objetivo}{6}{section.1.1}% -\contentsline {section}{\numberline {1.2}Alcance}{6}{section.1.2}% -\contentsline {subsection}{\numberline {1.2.1}Dentro de alcance}{6}{subsection.1.2.1}% -\contentsline {subsection}{\numberline {1.2.2}Fuera de alcance de esta versi\'on documental}{6}{subsection.1.2.2}% -\contentsline {section}{\numberline {1.3}Estado actual del repositorio}{6}{section.1.3}% -\contentsline {section}{\numberline {1.4}Referencias del sistema}{6}{section.1.4}% -\contentsline {chapter}{\numberline {2}Arquitectura bajo prueba}{8}{chapter.2}% -\contentsline {section}{\numberline {2.1}Vista de componentes}{8}{section.2.1}% -\contentsline {section}{\numberline {2.2}Inventario de componentes}{8}{section.2.2}% -\contentsline {section}{\numberline {2.3}Flujo cr\'itico: bot de descarga}{8}{section.2.3}% -\contentsline {section}{\numberline {2.4}Otros flujos de negocio}{9}{section.2.4}% -\contentsline {chapter}{\numberline {3}Estrategia de pruebas}{10}{chapter.3}% -\contentsline {section}{\numberline {3.1}Pir\'amide}{10}{section.3.1}% -\contentsline {section}{\numberline {3.2}Convenci\'on de identificadores}{10}{section.3.2}% -\contentsline {section}{\numberline {3.3}Entornos}{10}{section.3.3}% -\contentsline {section}{\numberline {3.4}Mocks vs sistemas reales}{11}{section.3.4}% -\contentsline {section}{\numberline {3.5}Principios}{11}{section.3.5}% -\contentsline {chapter}{\numberline {4}Pruebas unitarias}{12}{chapter.4}% -\contentsline {section}{\numberline {4.1}Gateway}{12}{section.4.1}% -\contentsline {section}{\numberline {4.2}Bot-descarga API (\texttt {JobStore})}{12}{section.4.2}% -\contentsline {section}{\numberline {4.3}Agent bot (PC)}{12}{section.4.3}% -\contentsline {section}{\numberline {4.4}Extraction / Cleaning / Count / Desktop}{13}{section.4.4}% -\contentsline {section}{\numberline {4.5}Comandos de ejecuci\'on}{13}{section.4.5}% -\contentsline {chapter}{\numberline {5}Pruebas de integraci\'on}{14}{chapter.5}% -\contentsline {section}{\numberline {5.1}Stack de integraci\'on}{14}{section.5.1}% -\contentsline {section}{\numberline {5.2}Gateway}{14}{section.5.2}% -\contentsline {section}{\numberline {5.3}Bot-descarga}{14}{section.5.3}% -\contentsline {section}{\numberline {5.4}Extraction / Cleaning / Count}{15}{section.5.4}% -\contentsline {section}{\numberline {5.5}Smoke m\'inimo post-compose}{15}{section.5.5}% -\contentsline {chapter}{\numberline {6}Pruebas end-to-end}{16}{chapter.6}% -\contentsline {section}{\numberline {6.1}Matriz de flujos de negocio}{16}{section.6.1}% -\contentsline {section}{\numberline {6.2}Detalle E2E-05 (bot descarga)}{16}{section.6.2}% -\contentsline {subsection}{\numberline {6.2.1}Precondiciones}{16}{subsection.6.2.1}% -\contentsline {subsection}{\numberline {6.2.2}Pasos}{17}{subsection.6.2.2}% -\contentsline {subsection}{\numberline {6.2.3}Asserts}{17}{subsection.6.2.3}% -\contentsline {section}{\numberline {6.3}Desktop}{17}{section.6.3}% -\contentsline {chapter}{\numberline {7}Pruebas de estr\'es y carga}{18}{chapter.7}% -\contentsline {section}{\numberline {7.1}Herramienta}{18}{section.7.1}% -\contentsline {section}{\numberline {7.2}Perfiles de carga}{18}{section.7.2}% -\contentsline {section}{\numberline {7.3}Escenarios (cat\'alogo completo)}{18}{section.7.3}% +\contentsline {chapter}{\numberline {1}Introducci\'on}{5}{chapter.1}% +\contentsline {section}{\numberline {1.1}Objetivo}{5}{section.1.1}% +\contentsline {section}{\numberline {1.2}Alcance}{5}{section.1.2}% +\contentsline {subsection}{\numberline {1.2.1}Dentro de alcance}{5}{subsection.1.2.1}% +\contentsline {subsection}{\numberline {1.2.2}Fuera de alcance de esta versi\'on documental}{5}{subsection.1.2.2}% +\contentsline {section}{\numberline {1.3}Estado actual del repositorio}{5}{section.1.3}% +\contentsline {section}{\numberline {1.4}Referencias del sistema}{5}{section.1.4}% +\contentsline {chapter}{\numberline {2}Arquitectura bajo prueba}{7}{chapter.2}% +\contentsline {section}{\numberline {2.1}C\'omo est\'a dise\~nado el sistema}{7}{section.2.1}% +\contentsline {section}{\numberline {2.2}Patrones de dise\~no}{7}{section.2.2}% +\contentsline {section}{\numberline {2.3}Pipeline de negocio}{8}{section.2.3}% +\contentsline {section}{\numberline {2.4}Inventario de componentes}{9}{section.2.4}% +\contentsline {section}{\numberline {2.5}Flujo cr\'itico: descarga (orquestador + agent Selenium)}{9}{section.2.5}% +\contentsline {section}{\numberline {2.6}Otros flujos de negocio}{10}{section.2.6}% +\contentsline {chapter}{\numberline {3}Estrategia de pruebas}{11}{chapter.3}% +\contentsline {section}{\numberline {3.1}Pir\'amide}{11}{section.3.1}% +\contentsline {section}{\numberline {3.2}Convenci\'on de identificadores}{11}{section.3.2}% +\contentsline {section}{\numberline {3.3}C\'omo leer un caso del cat\'alogo}{11}{section.3.3}% +\contentsline {section}{\numberline {3.4}Entornos}{11}{section.3.4}% +\contentsline {section}{\numberline {3.5}Mocks vs sistemas reales}{12}{section.3.5}% +\contentsline {section}{\numberline {3.6}Principios}{12}{section.3.6}% +\contentsline {chapter}{\numberline {4}Pruebas unitarias}{13}{chapter.4}% +\contentsline {section}{\numberline {4.1}Gateway}{13}{section.4.1}% +\contentsline {section}{\numberline {4.2}Bot-descarga API (\texttt {JobStore})}{13}{section.4.2}% +\contentsline {section}{\numberline {4.3}Agent bot (PC)}{14}{section.4.3}% +\contentsline {section}{\numberline {4.4}Extraction / Cleaning / Count / Desktop}{14}{section.4.4}% +\contentsline {section}{\numberline {4.5}Comandos de ejecuci\'on}{14}{section.4.5}% +\contentsline {chapter}{\numberline {5}Pruebas de integraci\'on}{15}{chapter.5}% +\contentsline {section}{\numberline {5.1}Stack de integraci\'on}{15}{section.5.1}% +\contentsline {section}{\numberline {5.2}Gateway}{15}{section.5.2}% +\contentsline {section}{\numberline {5.3}Bot-descarga}{15}{section.5.3}% +\contentsline {section}{\numberline {5.4}Extraction / Cleaning / Count}{16}{section.5.4}% +\contentsline {section}{\numberline {5.5}Smoke m\'inimo post-compose}{16}{section.5.5}% +\contentsline {chapter}{\numberline {6}Pruebas end-to-end}{17}{chapter.6}% +\contentsline {section}{\numberline {6.1}Matriz de flujos de negocio}{17}{section.6.1}% +\contentsline {section}{\numberline {6.2}Detalle E2E-05 (bot descarga)}{17}{section.6.2}% +\contentsline {subsection}{\numberline {6.2.1}Precondiciones}{17}{subsection.6.2.1}% +\contentsline {subsection}{\numberline {6.2.2}Pasos}{18}{subsection.6.2.2}% +\contentsline {subsection}{\numberline {6.2.3}Asserts}{18}{subsection.6.2.3}% +\contentsline {section}{\numberline {6.3}Desktop}{18}{section.6.3}% +\contentsline {chapter}{\numberline {7}Pruebas de estr\'es y carga}{19}{chapter.7}% +\contentsline {section}{\numberline {7.1}Herramienta}{19}{section.7.1}% +\contentsline {section}{\numberline {7.2}Perfiles de carga}{19}{section.7.2}% +\contentsline {section}{\numberline {7.3}Escenarios (cat\'alogo completo)}{19}{section.7.3}% \contentsline {section}{\numberline {7.4}Scripts k6 incluidos}{19}{section.7.4}% -\contentsline {section}{\numberline {7.5}SLOs de aceptaci\'on}{19}{section.7.5}% -\contentsline {subsection}{\numberline {7.5.1}Perfil host-compartido-8gb (default)}{19}{subsection.7.5.1}% +\contentsline {section}{\numberline {7.5}SLOs de aceptaci\'on}{20}{section.7.5}% +\contentsline {subsection}{\numberline {7.5.1}Perfil host-compartido-8gb (default)}{20}{subsection.7.5.1}% \contentsline {subsection}{\numberline {7.5.2}Perfil laboratorio}{20}{subsection.7.5.2}% \contentsline {section}{\numberline {7.6}Ejecuci\'on ejemplo}{20}{section.7.6}% \contentsline {chapter}{\numberline {8}Resiliencia y chaos}{21}{chapter.8}% @@ -70,16 +73,11 @@ \contentsline {section}{\numberline {11.3}Campa\~na registrada}{28}{section.11.3}% \contentsline {section}{\numberline {11.4}Plantilla de informe de ejecuci\'on}{29}{section.11.4}% \contentsline {section}{\numberline {11.5}Responsables (roles)}{29}{section.11.5}% -\contentsline {chapter}{\numberline {12}Informe de ejecuci\'on --- campa\~na 2026-07-13}{30}{chapter.12}% +\contentsline {chapter}{\numberline {12}Estado de campa\~na --- 2026-07-13 (+ LLM 17-jul)}{30}{chapter.12}% \contentsline {section}{\numberline {12.1}Metadatos}{30}{section.12.1}% -\contentsline {section}{\numberline {12.2}Baseline del entorno}{30}{section.12.2}% -\contentsline {section}{\numberline {12.3}Resultados por caso}{31}{section.12.3}% -\contentsline {subsection}{\numberline {12.3.1}ST-GW-01 --- gateway health}{31}{subsection.12.3.1}% -\contentsline {subsection}{\numberline {12.3.2}ST-BOT-01 --- heartbeats (corrida 1)}{31}{subsection.12.3.2}% -\contentsline {subsection}{\numberline {12.3.3}ST-BOT-01 --- heartbeats (corrida 2, reintento)}{31}{subsection.12.3.3}% -\contentsline {section}{\numberline {12.4}Hallazgos}{31}{section.12.4}% -\contentsline {section}{\numberline {12.5}No ejecutado en esta campa\~na}{32}{section.12.5}% -\contentsline {section}{\numberline {12.6}Follow-ups}{32}{section.12.6}% -\contentsline {chapter}{\numberline {A}Glosario}{33}{appendix.Alph1}% -\contentsline {chapter}{\numberline {B}Checklist de release}{34}{appendix.Alph2}% -\contentsline {chapter}{\numberline {C}Mapa de rutas relevantes}{35}{appendix.Alph3}% +\contentsline {section}{\numberline {12.2}Resultados resumidos}{30}{section.12.2}% +\contentsline {section}{\numberline {12.3}Lecci\'on operativa}{30}{section.12.3}% +\contentsline {section}{\numberline {12.4}Pendiente respecto a este plan}{31}{section.12.4}% +\contentsline {chapter}{\numberline {A}Glosario}{32}{appendix.Alph1}% +\contentsline {chapter}{\numberline {B}Checklist de release}{33}{appendix.Alph2}% +\contentsline {chapter}{\numberline {C}Mapa de rutas relevantes}{34}{appendix.Alph3}% diff --git a/docs/plan_pruebas/preamble.tex b/docs/plan_pruebas/preamble.tex index dadfea6..a8fa854 100644 --- a/docs/plan_pruebas/preamble.tex +++ b/docs/plan_pruebas/preamble.tex @@ -19,7 +19,7 @@ \fancyhf{} \fancyhead[L]{\small IPSExtractor --- Plan de pruebas} \fancyhead[R]{\small\thepage} -\fancyfoot[C]{\footnotesize Confidencial --- Uso interno} +\fancyfoot[C]{\footnotesize Documento para compartir} \renewcommand{\headrulewidth}{0.4pt} \renewcommand{\footrulewidth}{0.2pt} @@ -68,6 +68,7 @@ morestring=[b]' } +\usepackage{float} \usepackage{tikz} \usetikzlibrary{arrows.meta,positioning,fit,calc,shapes.geometric,shapes.multipart} @@ -83,8 +84,7 @@ pdfsubject={Pruebas unitarias, integracion, E2E, estres y resiliencia} } -\newcommand{\docversion}{1.2.0} -\newcommand{\docdate}{13 de julio de 2026} +\newcommand{\docdate}{17 de julio de 2026} \newcommand{\statusbadge}[1]{\texttt{#1}} \newcommand{\makecover}{% @@ -95,16 +95,15 @@ {\Huge\bfseries IPSExtractor}\\[0.35cm] {\Large\color{ipsgrey} Sistema de extracci\'on y an\'alisis de facturas m\'edicas}\\[0.4cm] {\color{ipsblue}\rule{\textwidth}{2pt}}\\[1.8cm] - {\LARGE\bfseries Plan completo de pruebas}\\[0.3cm] + {\LARGE\bfseries Plan de pruebas}\\[0.3cm] {\large Unitarias $\cdot$ Integraci\'on $\cdot$ E2E $\cdot$ Estr\'es $\cdot$ Resiliencia}\\[2.2cm] \begin{tabular}{@{}ll@{}} - \textbf{Versi\'on} & \docversion \\ \textbf{Fecha} & \docdate \\ - \textbf{Clasificaci\'on} & Uso interno \\ - \textbf{Herramientas} & pytest, Vitest, go test, k6, Docker Compose, latexmk \\ + \textbf{Audiencia} & Documento para compartir \\ + \textbf{Herramientas} & pytest, Vitest, go test, k6, Docker Compose \\ \end{tabular} \vfill - {\small Generado a partir de la arquitectura real del monorepo IPSExtractor.}\\[0.3cm] + {\small Estrategia y matriz de pruebas. Resultados en el Informe; volumen en Capacidad 100k.}\\[0.3cm] {\color{ipsblue}\rule{0.5\textwidth}{0.6pt}} \end{titlepage} } diff --git a/docs/plan_pruebas/scripts/README.md b/docs/plan_pruebas/scripts/README.md index 9f006d0..e3ac0cc 100644 --- a/docs/plan_pruebas/scripts/README.md +++ b/docs/plan_pruebas/scripts/README.md @@ -47,11 +47,31 @@ k6 run -e PROFILE=shared8g -e DOWNLOAD_JOB_ID= k6_bot_events.js k6 run -e PROFILE=shared8g k6_smoke_suite.js # Solo si free -h muestra ≥3GiB disponibles: k6 run -e PROFILE=shared8g k6_count_batches.js +# o Python (PDFs reales, Windows-friendly): +.\docs\plan_pruebas\scripts\st_count_01_bench.ps1 -Profile shared8g # Latencia count-api con PDFs reales: bash docs/plan_pruebas/scripts/measure_count_latency.sh + +# Latencia LLM ST-EXT-02 (mismo schema/campos, gpt-5.4-mini): +bash docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh +# Windows: .\docs\plan_pruebas\scripts\st_ext_02_llm_bench.ps1 -Limit 10 + +# Cola ARQ ST-EXT-01 (LLM mock, sin coste OpenAI): +# 1) Mock (otra terminal) + extraction con ASYNC_QUEUE=true y EXTRACTOR_BASE_URL→mock +bash docs/plan_pruebas/scripts/llm_mock_server.sh +# 2) Bench (shared8g ≈30 docs / lab ≈200): +bash docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh +PROFILE=lab ARQ_BENCH_LIMIT=100 bash docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh +# Windows: +# .\docs\plan_pruebas\scripts\llm_mock_server.ps1 +# .\docs\plan_pruebas\scripts\st_ext_01_arq_bench.ps1 -Profile shared8g -Limit 30 +# .\docs\plan_pruebas\scripts\st_ext_01_arq_bench.ps1 -StartMock -Limit 20 ``` Modelo de capacidad 100k/mes: [`estimate_capacity.md`](estimate_capacity.md). Informe PDF: [`../InformePruebas_IPSExtractor.pdf`](../InformePruebas_IPSExtractor.pdf). PDFs de prueba: [`../data/`](../data/) (`500000284335937.pdf`, `...940.pdf`, `...954.pdf`). + +Tras el bench LLM: resultados en [`../informe/sections/13_llm_latency.tex`](../informe/sections/13_llm_latency.tex) +(CSV `informes/llm_latency_20260717.csv`). Schema: `informes/st_ext_02_schema.json`. diff --git a/docs/plan_pruebas/scripts/e2e_07_worker_offline.py b/docs/plan_pruebas/scripts/e2e_07_worker_offline.py new file mode 100644 index 0000000..ce09b0c --- /dev/null +++ b/docs/plan_pruebas/scripts/e2e_07_worker_offline.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""E2E-07 — Crear job de descarga con worker offline debe devolver HTTP 409. + +Pasos: + 1. POST /jobs (Desktop padre vía gateway → extraction) + 2. POST /bot-descarga/jobs con worker_id que NO está online (sin heartbeat) + 3. Assert status == 409 + +Uso: + python e2e_07_worker_offline.py + python e2e_07_worker_offline.py --base-url http://127.0.0.1:8189 +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from io import BytesIO +from pathlib import Path + +try: + import requests +except ImportError: + print("ERROR: pip install requests", file=sys.stderr) + sys.exit(2) + +# Excel mínimo (ZIP/OOXML vacío no hace falta: el 409 ocurre antes de validar contenido) +_MIN_XLSX = ( + b"PK\x03\x04\x14\x00\x00\x00\x08\x00\x00\x00!\x00" + b"e2e07-dummy-xlsx-content-for-multipart" +) + + +def main() -> int: + p = argparse.ArgumentParser(description="E2E-07 worker offline → 409") + p.add_argument("--base-url", default="http://127.0.0.1:8189") + p.add_argument( + "--token", + default="", + help="BOT_API_TOKEN (default: env BOT_API_TOKEN o dev-token-change-me)", + ) + p.add_argument( + "--worker-id", + default="e2e07-offline-pc", + help="Worker que no debe estar online", + ) + args = p.parse_args() + + import os + + token = args.token or os.getenv("BOT_API_TOKEN", "dev-token-change-me") + base = args.base_url.rstrip("/") + headers_bot = {"Authorization": f"Bearer {token}"} + worker_id = args.worker_id + out: dict = { + "test_id": "E2E-07", + "base_url": base, + "worker_id": worker_id, + "steps": [], + "verdict": "FAIL", + } + + # 0) Health + t0 = time.perf_counter() + live = requests.get(f"{base}/health/live", timeout=10) + out["steps"].append( + {"step": "health_live", "status": live.status_code, "ms": round((time.perf_counter() - t0) * 1000, 1)} + ) + if live.status_code != 200: + out["error"] = "gateway /health/live no OK" + _write(out) + print(json.dumps(out, indent=2, ensure_ascii=False)) + return 1 + + # 1) Job Desktop padre + t0 = time.perf_counter() + parent = requests.post( + f"{base}/jobs", + json={"title": "E2E-07 worker offline"}, + timeout=15, + ) + out["steps"].append( + { + "step": "create_parent_job", + "status": parent.status_code, + "ms": round((time.perf_counter() - t0) * 1000, 1), + "body": _safe_json(parent), + } + ) + if parent.status_code not in (200, 201): + out["error"] = "no se pudo crear job Desktop padre" + _write(out) + print(json.dumps(out, indent=2, ensure_ascii=False)) + return 1 + parent_id = parent.json().get("id") + if not parent_id: + out["error"] = "respuesta padre sin id" + _write(out) + print(json.dumps(out, indent=2, ensure_ascii=False)) + return 1 + out["parent_job_id"] = parent_id + + # 2) Asegurar que el worker NO está online (no registrar / no heartbeat) + t0 = time.perf_counter() + workers = requests.get(f"{base}/bot-descarga/workers", headers=headers_bot, timeout=10) + online = False + if workers.status_code == 200: + for w in workers.json(): + if w.get("id") == worker_id and w.get("status") in ("online", "busy"): + online = True + out["steps"].append( + { + "step": "check_worker_not_online", + "status": workers.status_code, + "ms": round((time.perf_counter() - t0) * 1000, 1), + "worker_online": online, + } + ) + if online: + out["error"] = ( + f"worker '{worker_id}' está online; usa otro --worker-id o apaga el agent" + ) + _write(out) + print(json.dumps(out, indent=2, ensure_ascii=False)) + return 1 + + # 3) POST create download job → esperar 409 + t0 = time.perf_counter() + files = {"file": ("e2e07.xlsx", BytesIO(_MIN_XLSX), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")} + data = { + "worker_id": worker_id, + "job_id": parent_id, + "title": "E2E-07 offline", + } + create = requests.post( + f"{base}/bot-descarga/jobs", + headers=headers_bot, + data=data, + files=files, + timeout=30, + ) + elapsed = round((time.perf_counter() - t0) * 1000, 1) + detail = _safe_json(create) + out["steps"].append( + { + "step": "create_job_offline_worker", + "status": create.status_code, + "ms": elapsed, + "body": detail, + } + ) + out["expected"] = 409 + out["got"] = create.status_code + + if create.status_code == 409: + out["verdict"] = "PASS" + out["meaning"] = ( + "El orquestador bot-descarga rechaza crear job si el agent Selenium " + "no está online (sin heartbeat). Correcto." + ) + _write(out) + print(json.dumps(out, indent=2, ensure_ascii=False)) + return 0 + + out["verdict"] = "FAIL" + out["error"] = f"se esperaba HTTP 409, se obtuvo {create.status_code}" + _write(out) + print(json.dumps(out, indent=2, ensure_ascii=False)) + return 1 + + +def _safe_json(resp: requests.Response): + try: + return resp.json() + except Exception: + return (resp.text or "")[:500] + + +def _write(out: dict) -> None: + dest = Path(__file__).resolve().parents[1] / "informes" + dest.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + path = dest / f"e2e_07_{stamp}.json" + path.write_text(json.dumps(out, indent=2, ensure_ascii=False), encoding="utf-8") + # también latest + (dest / "e2e_07_latest.json").write_text( + json.dumps(out, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/plan_pruebas/scripts/estimate_capacity.md b/docs/plan_pruebas/scripts/estimate_capacity.md index 56e8354..f119bad 100644 --- a/docs/plan_pruebas/scripts/estimate_capacity.md +++ b/docs/plan_pruebas/scripts/estimate_capacity.md @@ -14,12 +14,96 @@ No sustituye una corrida real de 100k PDFs. Usa supuestos conservadores + eviden | Etapa | facturas/h estimadas | vs 139/h | vs 570/h | |-------|----------------------|----------|----------| | Gateway (orquestación) | >>10 000 (health ~57 rps) | OK | OK | -| count-api OCR | 20–80 (CPU/RAM contención) | RIESGO | NO | -| extraction LLM | 60–200 (si cuota LLM; 30–60s/PDF, pocos workers) | ÁMBAR | NO | -| bot Selenium (1 agent) | 30–120 | RIESGO | NO | +| count-api OCR | **≈100–325** (ST-COUNT-01 2026-07-17: p50 35,8 s @3 conc.; 9/9 ok) | ÁMBAR/OK | NO | +| extraction LLM | **≈506** (ST-EXT-02 2026-07-17: p50 7,114 s, gpt-5.4-mini, 23 PDFs) | OK | ÁMBAR (1w); OK≈1012/h con 2w sin cuota | +| Agent Selenium (1 agent PC) | **≈422** (medido: 633 PDFs / 90 min) | OK vs 139/h | NO vs 570/h | | cleaning CSV | 500–2000 | OK | ÁMBAR | -| Pipeline completo (este host) | <50 | NO | NO | +| Pipeline completo (este host) | **≈100–325** (= min etapas; cuello OCR; **no** medido E2E) | JUSTO/NO vs 139 | NO vs 570 | + +## Bench cola ARQ (ST-EXT-01) — LLM mock + +Mide enqueue + drenado de backlog **sin coste OpenAI**. Requiere mock + `ASYNC_QUEUE=true`. + +```bash +# Terminal 1 — mock OpenAI-compatible +bash docs/plan_pruebas/scripts/llm_mock_server.sh +# Apuntar extraction/workers: +# EXTRACTOR_BASE_URL=http://host.docker.internal:8099/v1 +# ASYNC_QUEUE=true +# WORKER_REPLICAS=1 # shared8g; lab → 2 + +# Terminal 2 — bench +bash docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh +PROFILE=lab ARQ_BENCH_LIMIT=100 bash docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh + +# Windows +.\docs\plan_pruebas\scripts\llm_mock_server.ps1 +.\docs\plan_pruebas\scripts\st_ext_01_arq_bench.ps1 -Profile shared8g -Limit 30 +``` + +Salida: `informes/st_ext_01_arq_YYYYMMDD.csv` + `.summary.json`. Schema: `st_ext_01_schema.json`. + +**Resultados 2026-07-17 (shared8g, Limit=30):** mode=async; 30/30 queued; drained en **22,5 s**; total **23,9 s**; throughput cola ≈ **4 528 docs/h** (mock ≈50 ms — no es SLO LLM). Producción: `ASYNC_QUEUE=true`. + +## Bench OCR carga (ST-COUNT-01) + +```bash +# Python (recomendado si no hay k6): +python docs/plan_pruebas/scripts/st_count_01_bench.py --profile shared8g +# Windows: +.\docs\plan_pruebas\scripts\st_count_01_bench.ps1 -Profile shared8g + +# k6 (campo multipart corregido a "files"): +k6 run -e PROFILE=shared8g k6_count_batches.js +``` + +**Resultados 2026-07-17:** 9/9 ok; p50 **35,8 s** @3 concurrentes; throughput obs. ≈ **325 fact/h**; sin 5xx/OOM. + +## Bench LLM (ST-EXT-02) — mismo schema/campos + +Congela `fields` en `informes/st_ext_02_schema.json` y los reutiliza en toda la corrida +(y en re-runs) para que latencia/coste sean comparables. Modelo default: **gpt-5.4-mini**. + +```bash +# Linux/macOS +bash docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh +LLM_BENCH_LIMIT=10 bash docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh + +# Windows +.\docs\plan_pruebas\scripts\st_ext_02_llm_bench.ps1 +.\docs\plan_pruebas\scripts\st_ext_02_llm_bench.ps1 -Limit 10 -Model gpt-5.4-mini +# Forzar redescubrimiento de schema: +.\docs\plan_pruebas\scripts\st_ext_02_llm_bench.ps1 -Rediscover +``` + +Salida: +- `docs/plan_pruebas/informes/llm_latency_YYYYMMDD.csv` +- `docs/plan_pruebas/informes/llm_latency_YYYYMMDD.summary.json` +- `docs/plan_pruebas/informes/st_ext_02_schema.json` (campos fijos) + +Coste de referencia: USD **0,0257**/doc → **USD 2 570** por 100 000 que pasan conteo. + +**Resultados 2026-07-17:** 23/23 ok; p50 **7,114** s; p95 **8,582** s; ≈**506** fact/h (1 worker). +Volcados en `informe/sections/13_llm_latency.tex` y fila LLM de `08_capacidad_100k.tex`. ## Escenario dedicado (condicional) -Con host ≥16–32 GiB solo IPS, OCR pool, 2+ workers extraction, cuota LLM, N agents bot: pipeline completo puede acercarse a 139–570/h según dimensionamiento. +Con host ≥16–32 GiB solo IPS, OCR pool, 2+ workers extraction, cuota LLM, N agents Selenium: pipeline completo puede acercarse a 139–570/h según dimensionamiento (y p50 LLM medido). + +## Coste LLM — 100 000 facturas que pasan el filtro de conteo + +Supuesto: el conteo filtra el lote y **100 000 PDFs** llegan a extracción. + +| Concepto | Valor | +|----------|-------| +| Modelo de extracción | **gpt-5.4 mini** | +| Coste por documento | **USD 0,0257** | +| Volumen (pasan filtro conteo) | 100 000 | +| **Coste estimado extracción/mes** | **USD 2 570** | + +Fórmula: `100_000 × 0.0257 = 2_570`. + +Notas: +- No incluye agent Selenium, orquestación `bot-descarga`, OCR de conteo, infra ni reintentos. +- Si el filtro de conteo deja pasar menos de 100k, el coste baja linealmente (`n_válidos × 0.0257`). +- ST-EXT-02 2026-07-17 usó el unitario 0.0257 como estimación; contrastar con tokens reales si se instrumenta usage. diff --git a/docs/plan_pruebas/scripts/k6_count_batches.js b/docs/plan_pruebas/scripts/k6_count_batches.js index b9bac70..402ad78 100644 --- a/docs/plan_pruebas/scripts/k6_count_batches.js +++ b/docs/plan_pruebas/scripts/k6_count_batches.js @@ -52,11 +52,13 @@ const tinyPdf = encoding.b64decode( export default function () { const bin = __ENV.PDF_B64 ? encoding.b64decode(__ENV.PDF_B64) : tinyPdf; + // count-api espera el campo multipart "files" (puede ser múltiple). const data = { - file: http.file(bin, 'sample.pdf', 'application/pdf'), + files: http.file(bin, 'sample.pdf', 'application/pdf'), }; const res = http.post(`${BASE_URL}/count/v1/count`, data); check(res, { + 'status 2xx': (r) => r.status >= 200 && r.status < 300, 'not 5xx': (r) => r.status < 500, }); sleep(0.1); diff --git a/docs/plan_pruebas/scripts/llm_mock_server.ps1 b/docs/plan_pruebas/scripts/llm_mock_server.ps1 new file mode 100644 index 0000000..58bd3c4 --- /dev/null +++ b/docs/plan_pruebas/scripts/llm_mock_server.ps1 @@ -0,0 +1,10 @@ +# Arranca mock OpenAI para ST-EXT-01 (Windows). +param( + [int]$Port = 8099, + [double]$DelayMs = 50 +) + +$Root = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$py = Join-Path $Root "docs\plan_pruebas\scripts\llm_mock_server.py" +Write-Host "LLM mock → python $py --port $Port --delay-ms $DelayMs" +python $py --port $Port --delay-ms $DelayMs diff --git a/docs/plan_pruebas/scripts/llm_mock_server.py b/docs/plan_pruebas/scripts/llm_mock_server.py new file mode 100644 index 0000000..64f856c --- /dev/null +++ b/docs/plan_pruebas/scripts/llm_mock_server.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Mock OpenAI-compatible para ST-EXT-01 (cola ARQ sin coste LLM real). + +Endpoints: + GET /v1/models + POST /v1/chat/completions + +Responde JSON fijo con delay configurable (default 50 ms). + +Ejemplo: + python docs/plan_pruebas/scripts/llm_mock_server.py --port 8099 --delay-ms 50 + +Apuntar extraction/workers (Docker): + EXTRACTOR_BASE_URL=http://host.docker.internal:8099/v1 + NORMALIZER_BASE_URL=http://host.docker.internal:8099/v1 + EXTRACTOR_API_KEY=mock-key + ASYNC_QUEUE=true +""" +from __future__ import annotations + +import argparse +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + + +MOCK_SCHEMA = { + "numero_factura": "str", + "fecha": "str", + "nit_prestador": "str", + "razon_social": "str", + "valor_total": "number", +} + +MOCK_EXTRACTION = { + "numero_factura": "MOCK-0001", + "fecha": "2026-01-15", + "nit_prestador": "900000000", + "razon_social": "IPS MOCK ST-EXT-01", + "valor_total": 1000, +} + + +class Stats: + def __init__(self) -> None: + self.lock = threading.Lock() + self.completions = 0 + self.models = 0 + + def inc(self, kind: str) -> None: + with self.lock: + if kind == "completions": + self.completions += 1 + else: + self.models += 1 + + +STATS = Stats() + + +def make_handler(delay_ms: float): + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args) -> None: # quieter + pass + + def _json(self, code: int, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path.rstrip("/") + if path in ("/v1/models", "/models"): + STATS.inc("models") + self._json( + 200, + { + "object": "list", + "data": [ + {"id": "mock-llm", "object": "model", "owned_by": "st-ext-01"}, + {"id": "gpt-5.4-mini", "object": "model", "owned_by": "st-ext-01"}, + ], + }, + ) + return + if path in ("/health", "/v1/health"): + self._json(200, {"status": "ok", "completions": STATS.completions}) + return + self._json(404, {"error": {"message": f"not found: {path}", "type": "not_found"}}) + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path.rstrip("/") + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + try: + req = json.loads(raw.decode("utf-8") or "{}") + except Exception: + req = {} + + if path not in ("/v1/chat/completions", "/chat/completions"): + self._json(404, {"error": {"message": f"not found: {path}", "type": "not_found"}}) + return + + if delay_ms > 0: + time.sleep(delay_ms / 1000.0) + + # Heurística: schema discovery suele pedir "campos" / schema; extracción pide factura. + messages = req.get("messages") or [] + blob = json.dumps(messages, ensure_ascii=False).lower() + if "schema" in blob or "campos" in blob or "field" in blob or "descub" in blob: + content = json.dumps(MOCK_SCHEMA, ensure_ascii=False) + else: + content = json.dumps(MOCK_EXTRACTION, ensure_ascii=False) + + model = req.get("model") or "mock-llm" + STATS.inc("completions") + self._json( + 200, + { + "id": f"chatcmpl-mock-{STATS.completions}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + }, + }, + ) + + return Handler + + +def main() -> int: + p = argparse.ArgumentParser(description="Mock OpenAI API for ST-EXT-01") + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int, default=int(__import__("os").environ.get("LLM_MOCK_PORT", "8099"))) + p.add_argument("--delay-ms", type=float, default=float(__import__("os").environ.get("LLM_MOCK_DELAY_MS", "50"))) + args = p.parse_args() + + httpd = ThreadingHTTPServer((args.host, args.port), make_handler(args.delay_ms)) + print(f"[llm-mock] http://{args.host}:{args.port}/v1 delay={args.delay_ms}ms") + print(f"[llm-mock] health: http://127.0.0.1:{args.port}/health") + print("[llm-mock] Ctrl+C para detener") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n[llm-mock] stopped") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/plan_pruebas/scripts/llm_mock_server.sh b/docs/plan_pruebas/scripts/llm_mock_server.sh new file mode 100644 index 0000000..ec4d06f --- /dev/null +++ b/docs/plan_pruebas/scripts/llm_mock_server.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Arranca mock OpenAI para ST-EXT-01. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +PORT="${LLM_MOCK_PORT:-8099}" +DELAY="${LLM_MOCK_DELAY_MS:-50}" +exec python3 "$ROOT/docs/plan_pruebas/scripts/llm_mock_server.py" --port "$PORT" --delay-ms "$DELAY" "$@" diff --git a/docs/plan_pruebas/scripts/measure_llm_latency.ps1 b/docs/plan_pruebas/scripts/measure_llm_latency.ps1 new file mode 100644 index 0000000..18e8a7d --- /dev/null +++ b/docs/plan_pruebas/scripts/measure_llm_latency.ps1 @@ -0,0 +1,11 @@ +# Alias → ST-EXT-02 +param( + [string]$BaseUrl = "http://127.0.0.1:8189", + [string]$DataDir = "", + [string]$OutCsv = "", + [int]$Limit = 0, + [string]$Model = "gpt-5.4-mini" +) +& (Join-Path $PSScriptRoot "st_ext_02_llm_bench.ps1") ` + -BaseUrl $BaseUrl -DataDir $DataDir -OutCsv $OutCsv -Limit $Limit -Model $Model +exit $LASTEXITCODE diff --git a/docs/plan_pruebas/scripts/measure_llm_latency.py b/docs/plan_pruebas/scripts/measure_llm_latency.py new file mode 100644 index 0000000..f42d54e --- /dev/null +++ b/docs/plan_pruebas/scripts/measure_llm_latency.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Alias de compatibilidad → ST-EXT-02 (st_ext_02_llm_bench.py).""" +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +sys.argv[0] = str(Path(__file__).with_name("st_ext_02_llm_bench.py")) +runpy.run_path(str(Path(__file__).with_name("st_ext_02_llm_bench.py")), run_name="__main__") diff --git a/docs/plan_pruebas/scripts/measure_llm_latency.sh b/docs/plan_pruebas/scripts/measure_llm_latency.sh new file mode 100644 index 0000000..6893086 --- /dev/null +++ b/docs/plan_pruebas/scripts/measure_llm_latency.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Alias → ST-EXT-02 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +exec bash "$ROOT/docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh" "$@" diff --git a/docs/plan_pruebas/scripts/st_count_01_bench.ps1 b/docs/plan_pruebas/scripts/st_count_01_bench.ps1 new file mode 100644 index 0000000..4b10f44 --- /dev/null +++ b/docs/plan_pruebas/scripts/st_count_01_bench.ps1 @@ -0,0 +1,20 @@ +# ST-COUNT-01 — carga count-api (Windows). +param( + [string]$BaseUrl = "http://127.0.0.1:8189", + [string]$Profile = "shared8g", + [int]$Duration = 0, + [double]$Rate = 0, + [int]$MaxWorkers = 0 +) + +$Root = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$py = Join-Path $Root "docs\plan_pruebas\scripts\st_count_01_bench.py" + +$argsList = @("--base-url", $BaseUrl, "--profile", $Profile) +if ($Duration -gt 0) { $argsList += @("--duration", "$Duration") } +if ($Rate -gt 0) { $argsList += @("--rate", "$Rate") } +if ($MaxWorkers -gt 0) { $argsList += @("--max-workers", "$MaxWorkers") } + +Write-Host "ST-COUNT-01 → python $py $($argsList -join ' ')" +python $py @argsList +exit $LASTEXITCODE diff --git a/docs/plan_pruebas/scripts/st_count_01_bench.py b/docs/plan_pruebas/scripts/st_count_01_bench.py new file mode 100644 index 0000000..d2b581b --- /dev/null +++ b/docs/plan_pruebas/scripts/st_count_01_bench.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""ST-COUNT-01 — carga concurrente hacia count-api (vía gateway). + +Equivalente práctico al k6 k6_count_batches.js para hosts sin k6 instalado. +Perfiles: + shared8g → ~1 req/s durante 60s, concurrencia máxima 3 (conservador) + lab → ~3 req/s durante 120s, concurrencia máxima 8 + +Usa PDFs reales de docs/plan_pruebas/data/ (rota entre ellos). + +Ejemplos: + python docs/plan_pruebas/scripts/st_count_01_bench.py + python docs/plan_pruebas/scripts/st_count_01_bench.py --profile shared8g + PROFILE=lab python docs/plan_pruebas/scripts/st_count_01_bench.py --duration 90 +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import statistics +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import date, datetime, timezone +from pathlib import Path + +try: + import requests +except ImportError: + print("Requiere 'requests': pip install requests", file=sys.stderr) + sys.exit(1) + +ROOT = Path(__file__).resolve().parents[3] +DEFAULT_DATA = ROOT / "docs" / "plan_pruebas" / "data" +DEFAULT_INFORMES = ROOT / "docs" / "plan_pruebas" / "informes" + +PROFILES = { + "shared8g": {"rate": 1.0, "duration_s": 60, "max_workers": 3}, + "lab": {"rate": 3.0, "duration_s": 120, "max_workers": 8}, +} + + +def list_pdfs(data_dir: Path) -> list[Path]: + if not data_dir.is_dir(): + raise SystemExit(f"No existe DATA_DIR={data_dir}") + pdfs = sorted({p.resolve() for p in list(data_dir.glob("*.pdf")) + list(data_dir.glob("*.PDF"))}) + if not pdfs: + raise SystemExit(f"No hay PDFs en {data_dir}") + return pdfs + + +def percentile(vals: list[float], p: float) -> float: + if not vals: + return float("nan") + s = sorted(vals) + if len(s) == 1: + return s[0] + k = (len(s) - 1) * (p / 100.0) + f = int(k) + c = min(f + 1, len(s) - 1) + if f == c: + return s[f] + return s[f] + (s[c] - s[f]) * (k - f) + + +def count_one(base_url: str, pdf: Path, timeout_s: float) -> dict: + t0 = time.perf_counter() + status = "error" + http_code = 0 + service_count = "" + err = "" + try: + with pdf.open("rb") as fh: + r = requests.post( + f"{base_url}/count/v1/count", + files={"files": (pdf.name, fh, "application/pdf")}, + timeout=timeout_s, + ) + http_code = r.status_code + try: + body = r.json() + except Exception: + body = {} + if r.status_code >= 500: + status = f"http_{r.status_code}" + err = (r.text or "")[:200].replace("\n", " ") + elif r.status_code >= 400: + status = f"http_{r.status_code}" + err = str(body.get("error") or r.text)[:200] + else: + results = body.get("results") or [] + if results: + service_count = results[0].get("service_count", "") + status = str(results[0].get("status") or "ok") + else: + status = "ok_empty" + except requests.Timeout: + status = "timeout" + except Exception as ex: + status = "exception" + err = str(ex)[:200] + + return { + "file": pdf.name, + "wall_s": round(time.perf_counter() - t0, 3), + "http_code": http_code, + "status": status, + "service_count": service_count, + "error": err, + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="ST-COUNT-01 count-api load bench") + p.add_argument("--base-url", default=os.environ.get("BASE_URL", "http://127.0.0.1:8189")) + p.add_argument("--data-dir", type=Path, default=Path(os.environ.get("COUNT_BENCH_PDF_DIR") or DEFAULT_DATA)) + p.add_argument( + "--profile", + choices=sorted(PROFILES.keys()), + default=os.environ.get("PROFILE") or "shared8g", + ) + p.add_argument("--duration", type=float, default=float(os.environ.get("COUNT_BENCH_DURATION_S") or "0")) + p.add_argument("--rate", type=float, default=float(os.environ.get("COUNT_BENCH_RATE") or "0")) + p.add_argument("--max-workers", type=int, default=int(os.environ.get("COUNT_BENCH_MAX_WORKERS") or "0")) + p.add_argument("--timeout", type=float, default=float(os.environ.get("COUNT_BENCH_TIMEOUT_S") or "120")) + p.add_argument( + "--out-csv", + type=Path, + default=Path( + os.environ.get("COUNT_BENCH_CSV") + or (DEFAULT_INFORMES / f"st_count_01_{date.today().strftime('%Y%m%d')}.csv") + ), + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + base_url = str(args.base_url).rstrip("/") + cfg = PROFILES[args.profile] + rate = args.rate if args.rate > 0 else cfg["rate"] + duration_s = args.duration if args.duration > 0 else cfg["duration_s"] + max_workers = args.max_workers if args.max_workers > 0 else cfg["max_workers"] + interval = 1.0 / rate if rate > 0 else 1.0 + + try: + h = requests.get(f"{base_url}/health/live", timeout=5) + print(f"health/live -> {h.status_code}") + except Exception as ex: + print(f"AVISO: health/live ({ex})", file=sys.stderr) + + pdfs = list_pdfs(args.data_dir) + print(f"BASE_URL={base_url}") + print(f"PROFILE={args.profile} rate={rate}/s duration={duration_s}s max_workers={max_workers}") + print(f"DATA_DIR={args.data_dir} ({len(pdfs)} PDFs)") + print(f"OUT={args.out_csv}") + print() + + rows: list[dict] = [] + lock = threading.Lock() + idx = {"i": 0} + stop_at = time.perf_counter() + duration_s + next_slot = time.perf_counter() + + def submit_one(ex: ThreadPoolExecutor, futures: set): + nonlocal next_slot + now = time.perf_counter() + if now < next_slot: + time.sleep(next_slot - now) + next_slot = max(next_slot + interval, time.perf_counter()) + with lock: + pdf = pdfs[idx["i"] % len(pdfs)] + idx["i"] += 1 + n = idx["i"] + print(f"[launch {n}] {pdf.name} ...", flush=True) + futures.add(ex.submit(count_one, base_url, pdf, args.timeout)) + + args.out_csv.parent.mkdir(parents=True, exist_ok=True) + fieldnames = ["file", "wall_s", "http_code", "status", "service_count", "error"] + t0 = time.perf_counter() + futures: set = set() + + with ThreadPoolExecutor(max_workers=max_workers) as ex: + while time.perf_counter() < stop_at: + # No saturar más allá de max_workers en vuelo + while len(futures) >= max_workers: + done = {f for f in futures if f.done()} + for f in done: + row = f.result() + rows.append(row) + print( + f" done wall_s={row['wall_s']} code={row['http_code']} status={row['status']}", + flush=True, + ) + futures.remove(f) + if len(futures) >= max_workers: + time.sleep(0.05) + if time.perf_counter() >= stop_at: + break + submit_one(ex, futures) + + for f in as_completed(futures): + row = f.result() + rows.append(row) + print( + f" done wall_s={row['wall_s']} code={row['http_code']} status={row['status']}", + flush=True, + ) + + wall_total = round(time.perf_counter() - t0, 3) + + with args.out_csv.open("w", encoding="utf-8", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=fieldnames) + w.writeheader() + for row in rows: + w.writerow(row) + + ok = [r for r in rows if r["http_code"] and 200 <= int(r["http_code"]) < 300] + fail = [r for r in rows if r not in ok] + times = [float(r["wall_s"]) for r in ok] + meta = { + "scenario": "ST-COUNT-01", + "profile": args.profile, + "rate_per_s": rate, + "duration_s_planned": duration_s, + "max_workers": max_workers, + "n_total": len(rows), + "n_ok": len(ok), + "n_fail": len(fail), + "wall_total_s": wall_total, + "achieved_rate_per_s": round(len(rows) / wall_total, 3) if wall_total else 0, + "csv": str(args.out_csv), + "finished_at": datetime.now(timezone.utc).isoformat(), + } + if times: + meta["wall_s_avg"] = round(statistics.mean(times), 3) + meta["wall_s_p50"] = round(percentile(times, 50), 3) + meta["wall_s_p95"] = round(percentile(times, 95), 3) + meta["wall_s_min"] = round(min(times), 3) + meta["wall_s_max"] = round(max(times), 3) + # Throughput efectivo si se procesara en serie con p50 (referencia conservadora) + meta["throughput_serial_p50_per_h"] = round(3600 / meta["wall_s_p50"], 1) if meta["wall_s_p50"] else None + meta["throughput_parallel_est_per_h"] = ( + round(meta["throughput_serial_p50_per_h"] * max_workers, 1) + if meta.get("throughput_serial_p50_per_h") + else None + ) + + meta_path = args.out_csv.with_suffix(".summary.json") + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + print() + print("=== Resumen ST-COUNT-01 (count-api carga) ===") + print(f"CSV: {args.out_csv}") + print(f"Summary: {meta_path}") + print(f"ok={len(ok)} fail={len(fail)} total={len(rows)} wall_total={wall_total}s") + print(f"achieved_rate ~ {meta['achieved_rate_per_s']}/s (plan {rate}/s)") + if times: + print( + f"wall_s avg={meta['wall_s_avg']:.3f} p50={meta['wall_s_p50']:.3f} " + f"p95={meta['wall_s_p95']:.3f} min={meta['wall_s_min']:.3f} max={meta['wall_s_max']:.3f}" + ) + print( + f"Throughput ref. serie(p50) ~ {meta['throughput_serial_p50_per_h']} fact/h; " + f"x{max_workers} workers ~ {meta['throughput_parallel_est_per_h']} fact/h (ideal)" + ) + oom_hint = any("timeout" in r["status"] or str(r["http_code"]).startswith("5") for r in rows) + if oom_hint: + print("AVISO: timeouts/5xx — posible contencion RAM/CPU; revisa docker stats.", file=sys.stderr) + # PASS si hay ok y tasa de fallo < 15% (umbral checks k6 rate>0.85) + fail_rate = len(fail) / max(len(rows), 1) + return 0 if len(ok) > 0 and fail_rate <= 0.15 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/plan_pruebas/scripts/st_count_01_bench.sh b/docs/plan_pruebas/scripts/st_count_01_bench.sh new file mode 100644 index 0000000..37e2130 --- /dev/null +++ b/docs/plan_pruebas/scripts/st_count_01_bench.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# ST-COUNT-01 — carga count-api. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +export BASE_URL="${BASE_URL:-http://127.0.0.1:8189}" +export PROFILE="${PROFILE:-shared8g}" +exec python3 "$ROOT/docs/plan_pruebas/scripts/st_count_01_bench.py" "$@" diff --git a/docs/plan_pruebas/scripts/st_ext_01_arq_bench.ps1 b/docs/plan_pruebas/scripts/st_ext_01_arq_bench.ps1 new file mode 100644 index 0000000..94c8ce9 --- /dev/null +++ b/docs/plan_pruebas/scripts/st_ext_01_arq_bench.ps1 @@ -0,0 +1,27 @@ +# ST-EXT-01 — bench cola ARQ con LLM mock (Windows). +param( + [string]$BaseUrl = "http://127.0.0.1:8189", + [string]$DataDir = "", + [string]$OutCsv = "", + [string]$SchemaFile = "", + [string]$Profile = "shared8g", + [int]$Limit = 0, + [string]$Model = "mock-llm", + [ValidateSet("one", "many")] + [string]$IngestMode = "one", + [switch]$StartMock +) + +$Root = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$py = Join-Path $Root "docs\plan_pruebas\scripts\st_ext_01_arq_bench.py" + +$argsList = @("--base-url", $BaseUrl, "--profile", $Profile, "--model", $Model, "--ingest-mode", $IngestMode) +if ($DataDir) { $argsList += @("--data-dir", $DataDir) } +if ($OutCsv) { $argsList += @("--out-csv", $OutCsv) } +if ($SchemaFile) { $argsList += @("--schema-file", $SchemaFile) } +if ($Limit -gt 0) { $argsList += @("--limit", "$Limit") } +if ($StartMock) { $argsList += "--start-mock" } + +Write-Host "ST-EXT-01 → python $py $($argsList -join ' ')" +python $py @argsList +exit $LASTEXITCODE diff --git a/docs/plan_pruebas/scripts/st_ext_01_arq_bench.py b/docs/plan_pruebas/scripts/st_ext_01_arq_bench.py new file mode 100644 index 0000000..4f0e4cb --- /dev/null +++ b/docs/plan_pruebas/scripts/st_ext_01_arq_bench.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +"""ST-EXT-01 — Bench de cola ARQ con LLM mock (sin coste OpenAI). + +Mide enqueue + drenado de backlog (no latencia LLM real; eso es ST-EXT-02). + +Prerrequisitos: + 1. Mock LLM en marcha: + python docs/plan_pruebas/scripts/llm_mock_server.py --port 8099 --delay-ms 50 + 2. Extraction/workers apuntando al mock y con cola async: + EXTRACTOR_BASE_URL=http://host.docker.internal:8099/v1 + NORMALIZER_BASE_URL=http://host.docker.internal:8099/v1 + EXTRACTOR_API_KEY=mock-key + ASYNC_QUEUE=true + WORKER_REPLICAS=1 # shared8g; lab → 2 + 3. Gateway en BASE_URL (default http://127.0.0.1:8189) + +Perfiles (--profile): + shared8g → default --limit 30 (plan: 20–50) + lab → default --limit 200 (plan: 100–500) + +Flujo: + 1. Carga schema congelado (informes/st_ext_01_schema.json) + 2. POST /tasks/extraction con N documentos + 3. Encola N PDFs (ingest o ingest-many) midiendo enqueue_s + 4. Sondea GET /tasks/{id} hasta processed >= N (drain_s) + 5. Escribe CSV + summary JSON + +Ejemplos: + python docs/plan_pruebas/scripts/st_ext_01_arq_bench.py + python docs/plan_pruebas/scripts/st_ext_01_arq_bench.py --profile lab --limit 100 + python docs/plan_pruebas/scripts/st_ext_01_arq_bench.py --start-mock --limit 20 +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import statistics +import subprocess +import sys +import time +import urllib.parse +from datetime import date, datetime, timezone +from pathlib import Path + +try: + import requests +except ImportError: + print("Requiere 'requests': pip install requests", file=sys.stderr) + sys.exit(1) + +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = Path(__file__).resolve().parent +DEFAULT_DATA = ROOT / "docs" / "plan_pruebas" / "data" +DEFAULT_INFORMES = ROOT / "docs" / "plan_pruebas" / "informes" +DEFAULT_SCHEMA = DEFAULT_INFORMES / "st_ext_01_schema.json" +MOCK_SCRIPT = SCRIPTS / "llm_mock_server.py" + +PROFILE_DEFAULTS = { + "shared8g": {"limit": 30, "workers_hint": 1}, + "lab": {"limit": 200, "workers_hint": 2}, +} + + +def list_pdfs(data_dir: Path) -> list[Path]: + if not data_dir.is_dir(): + raise SystemExit(f"No existe DATA_DIR={data_dir}") + pdfs = sorted(data_dir.glob("*.pdf")) + sorted(data_dir.glob("*.PDF")) + seen: set[str] = set() + unique: list[Path] = [] + for p in pdfs: + key = p.name.lower() + if key in seen: + continue + seen.add(key) + unique.append(p) + if not unique: + raise SystemExit(f"No hay PDFs en {data_dir}") + return unique + + +def expand_jobs(pdfs: list[Path], n: int) -> list[tuple[Path, str]]: + """Reutiliza PDFs con radicado único si n > len(pdfs).""" + jobs: list[tuple[Path, str]] = [] + for i in range(n): + pdf = pdfs[i % len(pdfs)] + rad = f"{pdf.stem}_{i:04d}" + jobs.append((pdf, rad)) + return jobs + + +def load_schema(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + fields = list(data.get("fields") or []) + if not fields: + raise SystemExit(f"Schema sin fields: {path}") + if "alias_map" not in data: + data["alias_map"] = {f: f.split("/")[-1] for f in fields} + print(f"[ST-EXT-01] Schema: {path} ({len(fields)} campos)") + return data + + +def create_task(base_url: str, jobs: list[tuple[Path, str]], freeze: dict, model: str) -> str: + fields = list(freeze["fields"]) + alias_map = dict(freeze.get("alias_map") or {f: f.split("/")[-1] for f in fields}) + structure_mode = str(freeze.get("structure_mode") or "general") + documents = [ + {"radicado": rad, "filename": pdf.name, "doc_type": None} for pdf, rad in jobs + ] + body = { + "source": "desktop", + "fields": fields, + "alias_map": alias_map, + "structure_mode": structure_mode, + "retry_on_empty": False, + "documents": documents, + "document_count": len(documents), + "model": model, + } + r = requests.post(f"{base_url}/tasks/extraction", json=body, timeout=60) + if r.status_code >= 400: + raise SystemExit(f"tasks/extraction HTTP {r.status_code}: {r.text[:500]}") + task = r.json() + task_id = task.get("id") + if not task_id: + raise SystemExit(f"Sin task id: {task}") + print(f"[ST-EXT-01] Tarea {task_id} — {len(jobs)} docs, model={model}") + return str(task_id) + + +def task_snapshot(base_url: str, task_id: str) -> dict: + r = requests.get(f"{base_url}/tasks/{urllib.parse.quote(task_id)}", timeout=30) + r.raise_for_status() + data = r.json() + result = data.get("result") or {} + processed = int(result.get("processed") or 0) + row_count = int(result.get("row_count") or 0) + + # Fuente de verdad más fiable ante carrera en result.processed: + # contar filas persistidas vía /tasks/{id}/rows. + rows_n = 0 + try: + rr = requests.get( + f"{base_url}/tasks/{urllib.parse.quote(task_id)}/rows", + params={"limit": 5000, "offset": 0}, + timeout=30, + ) + if rr.status_code < 400: + body = rr.json() + if isinstance(body, list): + rows_n = len(body) + elif isinstance(body, dict): + rows_n = int(body.get("count") or len(body.get("rows") or [])) + except Exception: + rows_n = 0 + + progress = max(processed, row_count, rows_n) + return { + "status": str(data.get("status") or "unknown"), + "processed": progress, + "processed_raw": processed, + "row_count": max(row_count, rows_n), + "total": int(result.get("total") or 0), + "errors": list(result.get("errors") or []), + } + + +def enqueue_one( + base_url: str, + task_id: str, + pdf: Path, + radicado: str, + timeout_s: float, +) -> dict: + t0 = time.perf_counter() + http_code = 0 + status = "error" + queued = False + err_msg = "" + try: + with pdf.open("rb") as fh: + r = requests.post( + f"{base_url}/tasks/{urllib.parse.quote(task_id)}/ingest", + files={"file": (pdf.name, fh, "application/pdf")}, + data={"radicado": radicado, "filename": pdf.name}, + timeout=timeout_s, + ) + http_code = r.status_code + try: + body = r.json() + except Exception: + body = {} + if r.status_code >= 400: + status = f"http_{r.status_code}" + err_msg = (r.text or "")[:200].replace("\n", " ") + elif body.get("queued") is True or body.get("queued") == 1: + queued = True + status = "queued" + else: + # sync: respuesta ya incluye progreso + task = body.get("task") or body + result = (task.get("result") if isinstance(task, dict) else None) or {} + processed = int(result.get("processed") or 0) + status = "sync_ok" if processed > 0 or r.status_code < 300 else "sync_no_progress" + errors = result.get("errors") or [] + if errors: + err_msg = str(errors[-1])[:200] + except requests.Timeout: + status = "timeout" + except Exception as ex: + status = "exception" + err_msg = str(ex)[:200] + + return { + "radicado": radicado, + "file": pdf.name, + "enqueue_s": round(time.perf_counter() - t0, 3), + "http_code": http_code, + "status": status, + "queued": "1" if queued else "0", + "error": err_msg, + } + + +def enqueue_many_batch( + base_url: str, + task_id: str, + batch: list[tuple[Path, str]], + timeout_s: float, +) -> list[dict]: + """POST /ingest-many; un row por PDF del lote.""" + t0 = time.perf_counter() + files = [] + open_handles = [] + try: + for pdf, _rad in batch: + fh = pdf.open("rb") + open_handles.append(fh) + files.append(("files", (pdf.name, fh, "application/pdf"))) + data = { + "radicados": "|".join(rad for _, rad in batch), + "filenames": "|".join(pdf.name for pdf, _ in batch), + } + r = requests.post( + f"{base_url}/tasks/{urllib.parse.quote(task_id)}/ingest-many", + files=files, + data=data, + timeout=timeout_s, + ) + finally: + for fh in open_handles: + fh.close() + + wall = round(time.perf_counter() - t0, 3) + per = round(wall / max(len(batch), 1), 3) + try: + body = r.json() + except Exception: + body = {} + + queued_flag = False + status = "error" + err_msg = "" + if r.status_code >= 400: + status = f"http_{r.status_code}" + err_msg = (r.text or "")[:200].replace("\n", " ") + elif isinstance(body.get("queued"), int) or body.get("queued") is True: + queued_flag = True + status = "queued" + else: + status = "sync_batch" + + rows = [] + for pdf, rad in batch: + rows.append( + { + "radicado": rad, + "file": pdf.name, + "enqueue_s": per, + "http_code": r.status_code, + "status": status, + "queued": "1" if queued_flag else "0", + "error": err_msg, + } + ) + return rows + + +def wait_drain( + base_url: str, + task_id: str, + target: int, + poll_timeout_s: float, + poll_interval_s: float, +) -> dict: + t0 = time.perf_counter() + deadline = t0 + poll_timeout_s + last = {"status": "unknown", "processed": 0, "total": target, "errors": []} + samples: list[tuple[float, int]] = [] + while time.perf_counter() < deadline: + last = task_snapshot(base_url, task_id) + elapsed = time.perf_counter() - t0 + samples.append((elapsed, last["processed"])) + if last["processed"] >= target: + break + if last["status"] in ("failed", "cancelled"): + break + print( + f" … processed={last['processed']}/{target} status={last['status']} " + f"elapsed={elapsed:.1f}s", + flush=True, + ) + time.sleep(poll_interval_s) + drain_s = round(time.perf_counter() - t0, 3) + return { + "drain_s": drain_s, + "processed": last["processed"], + "task_status": last["status"], + "errors_tail": (last.get("errors") or [])[-3:], + "samples": samples, + "drained": last["processed"] >= target, + } + + +def start_mock(port: int, delay_ms: float) -> subprocess.Popen: + if not MOCK_SCRIPT.is_file(): + raise SystemExit(f"No existe mock: {MOCK_SCRIPT}") + print(f"[ST-EXT-01] Arrancando mock LLM :{port} delay={delay_ms}ms") + proc = subprocess.Popen( + [sys.executable, str(MOCK_SCRIPT), "--port", str(port), "--delay-ms", str(delay_ms)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # Esperar health + url = f"http://127.0.0.1:{port}/health" + for _ in range(30): + try: + r = requests.get(url, timeout=1) + if r.status_code == 200: + print(f"[ST-EXT-01] Mock listo → {url}") + return proc + except Exception: + pass + time.sleep(0.2) + if proc.poll() is not None: + raise SystemExit(f"Mock salió con código {proc.returncode}") + raise SystemExit("Mock no respondió /health a tiempo") + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="ST-EXT-01 ARQ bench (LLM mock)") + p.add_argument("--base-url", default=os.environ.get("BASE_URL", "http://127.0.0.1:8189")) + p.add_argument("--data-dir", type=Path, default=Path(os.environ.get("ARQ_BENCH_PDF_DIR") or DEFAULT_DATA)) + p.add_argument( + "--out-csv", + type=Path, + default=Path( + os.environ.get("ARQ_BENCH_CSV") + or (DEFAULT_INFORMES / f"st_ext_01_arq_{date.today().strftime('%Y%m%d')}.csv") + ), + ) + p.add_argument( + "--schema-file", + type=Path, + default=Path(os.environ.get("ARQ_BENCH_SCHEMA_FILE") or DEFAULT_SCHEMA), + ) + p.add_argument( + "--profile", + choices=sorted(PROFILE_DEFAULTS.keys()), + default=os.environ.get("PROFILE") or "shared8g", + ) + p.add_argument("--limit", type=int, default=int(os.environ.get("ARQ_BENCH_LIMIT") or "0")) + p.add_argument("--model", default=os.environ.get("ARQ_BENCH_MODEL") or "mock-llm") + p.add_argument( + "--ingest-mode", + choices=["one", "many"], + default=os.environ.get("ARQ_BENCH_INGEST_MODE") or "one", + help="one=POST /ingest por PDF; many=POST /ingest-many por lote", + ) + p.add_argument("--batch-size", type=int, default=int(os.environ.get("ARQ_BENCH_BATCH_SIZE") or "10")) + p.add_argument("--poll-timeout", type=float, default=float(os.environ.get("ARQ_BENCH_POLL_TIMEOUT_S") or "600")) + p.add_argument("--poll-interval", type=float, default=float(os.environ.get("ARQ_BENCH_POLL_INTERVAL_S") or "1.0")) + p.add_argument("--enqueue-timeout", type=float, default=float(os.environ.get("ARQ_BENCH_ENQUEUE_TIMEOUT_S") or "120")) + p.add_argument("--start-mock", action="store_true", help="Arranca llm_mock_server.py en este proceso") + p.add_argument("--mock-port", type=int, default=int(os.environ.get("LLM_MOCK_PORT") or "8099")) + p.add_argument("--mock-delay-ms", type=float, default=float(os.environ.get("LLM_MOCK_DELAY_MS") or "50")) + return p.parse_args() + + +def main() -> int: + args = parse_args() + base_url = str(args.base_url).rstrip("/") + profile = PROFILE_DEFAULTS[args.profile] + limit = args.limit if args.limit > 0 else int(profile["limit"]) + + mock_proc: subprocess.Popen | None = None + if args.start_mock: + mock_proc = start_mock(args.mock_port, args.mock_delay_ms) + + try: + try: + h = requests.get(f"{base_url}/health/live", timeout=5) + print(f"health/live → {h.status_code}") + except Exception as ex: + print(f"AVISO: no responde {base_url}/health/live ({ex})", file=sys.stderr) + + pdfs = list_pdfs(args.data_dir) + jobs = expand_jobs(pdfs, limit) + freeze = load_schema(args.schema_file) + + print(f"BASE_URL={base_url}") + print(f"PROFILE={args.profile} LIMIT={limit} (plan workers≈{profile['workers_hint']})") + print(f"DATA_DIR={args.data_dir} ({len(pdfs)} PDFs base, {len(jobs)} jobs)") + print(f"OUT={args.out_csv}") + print(f"INGEST={args.ingest_mode} MODEL={args.model}") + print() + print( + "AVISO: ST-EXT-01 requiere ASYNC_QUEUE=true + workers + EXTRACTOR_BASE_URL→mock. " + "Si ves status=sync_* el stack está en modo síncrono.", + flush=True, + ) + print() + + task_id = create_task(base_url, jobs, freeze, args.model) + + args.out_csv.parent.mkdir(parents=True, exist_ok=True) + fieldnames = ["radicado", "file", "enqueue_s", "http_code", "status", "queued", "error"] + rows: list[dict] = [] + + t_enq0 = time.perf_counter() + with args.out_csv.open("w", encoding="utf-8", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=fieldnames) + w.writeheader() + + if args.ingest_mode == "many": + bs = max(1, args.batch_size) + for i in range(0, len(jobs), bs): + batch = jobs[i : i + bs] + print(f"[enqueue {i + 1}-{i + len(batch)}/{len(jobs)}] ingest-many …", flush=True) + batch_rows = enqueue_many_batch( + base_url, task_id, batch, args.enqueue_timeout + ) + for row in batch_rows: + rows.append(row) + w.writerow(row) + fh.flush() + if any(str(r["status"]).startswith("http_") for r in batch_rows): + print( + "AVISO: error HTTP en lote — abortando enqueue restante.", + file=sys.stderr, + ) + break + else: + consecutive_fail = 0 + for i, (pdf, rad) in enumerate(jobs, 1): + print(f"[enqueue {i}/{len(jobs)}] {rad} ({pdf.name}) …", flush=True) + row = enqueue_one(base_url, task_id, pdf, rad, args.enqueue_timeout) + rows.append(row) + w.writerow(row) + fh.flush() + print( + f" enqueue_s={row['enqueue_s']} code={row['http_code']} " + f"status={row['status']} queued={row['queued']}", + flush=True, + ) + if str(row["status"]).startswith("http_") or row["status"] in ( + "error", + "timeout", + "exception", + ): + consecutive_fail += 1 + if consecutive_fail >= 3: + print( + "AVISO: 3 fallos consecutivos de enqueue — abortando.", + file=sys.stderr, + ) + break + else: + consecutive_fail = 0 + + enqueue_wall_s = round(time.perf_counter() - t_enq0, 3) + n_queued = sum(1 for r in rows if r["queued"] == "1") + n_fail = sum(1 for r in rows if str(r["status"]).startswith("http_") or r["status"] in ("error", "timeout", "exception")) + mode = "async" if n_queued > 0 else "sync_or_unknown" + + print() + print(f"[ST-EXT-01] Enqueue wall={enqueue_wall_s}s queued={n_queued}/{len(rows)} mode={mode}") + print(f"[ST-EXT-01] Drenando hasta processed>={len(jobs)} (timeout={args.poll_timeout}s)…", flush=True) + + drain = wait_drain(base_url, task_id, len(jobs), args.poll_timeout, args.poll_interval) + total_s = round(enqueue_wall_s + drain["drain_s"], 3) + processed = int(drain["processed"]) + thr_h = round(processed / (total_s / 3600.0), 1) if total_s > 0 and processed else 0.0 + + enqueue_times = [float(r["enqueue_s"]) for r in rows if r.get("enqueue_s") is not None] + meta = { + "scenario": "ST-EXT-01", + "profile": args.profile, + "task_id": task_id, + "model": args.model, + "mode": mode, + "ingest_mode": args.ingest_mode, + "n_target": len(jobs), + "n_queued": n_queued, + "n_enqueue_fail": n_fail, + "processed": processed, + "drained": drain["drained"], + "task_status": drain["task_status"], + "enqueue_wall_s": enqueue_wall_s, + "drain_s": drain["drain_s"], + "total_s": total_s, + "throughput_docs_per_h": thr_h, + "workers_hint": profile["workers_hint"], + "schema_file": str(args.schema_file), + "csv": str(args.out_csv), + "errors_tail": drain.get("errors_tail") or [], + "finished_at": datetime.now(timezone.utc).isoformat(), + } + if enqueue_times: + meta["enqueue_s_avg"] = round(statistics.mean(enqueue_times), 3) + meta["enqueue_s_p50"] = round(sorted(enqueue_times)[len(enqueue_times) // 2], 3) + + meta_path = args.out_csv.with_suffix(".summary.json") + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + print() + print("=== Resumen ST-EXT-01 (cola ARQ / LLM mock) ===") + print(f"CSV: {args.out_csv}") + print(f"Summary: {meta_path}") + print(f"task_id: {task_id}") + print(f"mode: {mode} (ASYNC_QUEUE esperado=true)") + print(f"profile: {args.profile} target={len(jobs)} processed={processed} drained={drain['drained']}") + print(f"enqueue_s: wall={enqueue_wall_s} avg={meta.get('enqueue_s_avg', 'n/a')}") + print(f"drain_s: {drain['drain_s']} task_status={drain['task_status']}") + print(f"total_s: {total_s}") + print(f"throughput: ≈ {thr_h} docs/h (processed/total_s)") + if mode != "async": + print( + "AVISO: no se detectó queued=true — revisa ASYNC_QUEUE=true, Redis y workers.", + file=sys.stderr, + ) + if not drain["drained"]: + print( + "AVISO: backlog no drenado a tiempo — revisa WORKER_REPLICAS, mock LLM y logs.", + file=sys.stderr, + ) + print() + print("Checklist plan: shared8g 20–50 docs / 1 worker; lab 100–500 / WORKER_REPLICAS=2") + return 0 if drain["drained"] and n_fail == 0 else 1 + finally: + if mock_proc is not None and mock_proc.poll() is None: + mock_proc.terminate() + try: + mock_proc.wait(timeout=5) + except Exception: + mock_proc.kill() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh b/docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh new file mode 100644 index 0000000..e625e4c --- /dev/null +++ b/docs/plan_pruebas/scripts/st_ext_01_arq_bench.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# ST-EXT-01 — bench cola ARQ con LLM mock. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +export BASE_URL="${BASE_URL:-http://127.0.0.1:8189}" +export PROFILE="${PROFILE:-shared8g}" +export ARQ_BENCH_MODEL="${ARQ_BENCH_MODEL:-mock-llm}" +exec python3 "$ROOT/docs/plan_pruebas/scripts/st_ext_01_arq_bench.py" "$@" diff --git a/docs/plan_pruebas/scripts/st_ext_02_llm_bench.ps1 b/docs/plan_pruebas/scripts/st_ext_02_llm_bench.ps1 new file mode 100644 index 0000000..4c83cdc --- /dev/null +++ b/docs/plan_pruebas/scripts/st_ext_02_llm_bench.ps1 @@ -0,0 +1,27 @@ +# ST-EXT-02 — bench LLM con schema/campos congelados (Windows). +# Mismo schema para todos los PDFs; reutiliza st_ext_02_schema.json en re-runs. +param( + [string]$BaseUrl = "http://127.0.0.1:8189", + [string]$DataDir = "", + [string]$OutCsv = "", + [string]$SchemaFile = "", + [int]$Limit = 0, + [string]$Model = "gpt-5.4-mini", + [switch]$Rediscover +) + +$Root = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$py = Join-Path $Root "docs\plan_pruebas\scripts\st_ext_02_llm_bench.py" + +$argsList = @("--base-url", $BaseUrl, "--model", $Model) +if ($DataDir) { $argsList += @("--data-dir", $DataDir) } +if ($OutCsv) { $argsList += @("--out-csv", $OutCsv) } +if ($SchemaFile) { $argsList += @("--schema-file", $SchemaFile) } +if ($Limit -gt 0) { $argsList += @("--limit", "$Limit") } +if ($Rediscover) { $argsList += "--rediscover" } + +$env:LLM_BENCH_COST_PER_DOC = if ($env:LLM_BENCH_COST_PER_DOC) { $env:LLM_BENCH_COST_PER_DOC } else { "0.0257" } + +Write-Host "ST-EXT-02 → python $py $($argsList -join ' ')" +python $py @argsList +exit $LASTEXITCODE diff --git a/docs/plan_pruebas/scripts/st_ext_02_llm_bench.py b/docs/plan_pruebas/scripts/st_ext_02_llm_bench.py new file mode 100644 index 0000000..b6cc9cc --- /dev/null +++ b/docs/plan_pruebas/scripts/st_ext_02_llm_bench.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""ST-EXT-02 — Bench de latencia LLM con el mismo schema/campos para todos los PDFs. + +Garantiza comparabilidad de latencia y coste: + - Descubre schema UNA vez (o carga un JSON congelado). + - Congela fields + alias_map en informes/st_ext_02_schema.json. + - Usa exactamente esos campos en la tarea y en cada ingest. + - Modelo por defecto: gpt-5.4-mini. + - Estima coste @ USD 0.0257/doc (referencia 100k que pasan conteo). + +Flujo: + 1. POST /extraction/schemas/upload (solo si no hay schema congelado) + 2. Guarda/carga schema JSON (mismos campos) + 3. POST /tasks/extraction (documents[], fields fijos) + 4. Por cada PDF: POST /tasks/{id}/ingest → wall_s + +Ejemplos: + python docs/plan_pruebas/scripts/st_ext_02_llm_bench.py + python docs/plan_pruebas/scripts/st_ext_02_llm_bench.py --limit 10 + python docs/plan_pruebas/scripts/st_ext_02_llm_bench.py --schema-file docs/plan_pruebas/informes/st_ext_02_schema.json + LLM_BENCH_LIMIT=5 .\\docs\\plan_pruebas\\scripts\\st_ext_02_llm_bench.ps1 +""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import statistics +import sys +import time +import urllib.parse +from datetime import date, datetime, timezone +from pathlib import Path + +try: + import requests +except ImportError: + print("Requiere 'requests': pip install requests", file=sys.stderr) + sys.exit(1) + +ROOT = Path(__file__).resolve().parents[3] +DEFAULT_DATA = ROOT / "docs" / "plan_pruebas" / "data" +DEFAULT_INFORMES = ROOT / "docs" / "plan_pruebas" / "informes" +DEFAULT_SCHEMA = DEFAULT_INFORMES / "st_ext_02_schema.json" +COST_PER_DOC_USD = float(os.environ.get("LLM_BENCH_COST_PER_DOC", "0.0257")) +DEFAULT_MODEL = os.environ.get("LLM_BENCH_MODEL") or "gpt-5.4-mini" + + +def percentile(sorted_vals: list[float], p: float) -> float: + if not sorted_vals: + return float("nan") + if len(sorted_vals) == 1: + return sorted_vals[0] + k = (len(sorted_vals) - 1) * (p / 100.0) + f = int(k) + c = min(f + 1, len(sorted_vals) - 1) + if f == c: + return sorted_vals[f] + return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f) + + +def list_pdfs(data_dir: Path, limit: int) -> list[Path]: + if not data_dir.is_dir(): + raise SystemExit(f"No existe DATA_DIR={data_dir}") + pdfs = sorted(data_dir.glob("*.pdf")) + sorted(data_dir.glob("*.PDF")) + seen: set[str] = set() + unique: list[Path] = [] + for p in pdfs: + key = p.name.lower() + if key in seen: + continue + seen.add(key) + unique.append(p) + if not unique: + raise SystemExit(f"No hay PDFs en {data_dir}") + if limit > 0: + unique = unique[:limit] + return unique + + +def flatten_schema_payload(payload: dict) -> dict[str, str]: + schema = payload.get("schema") or {} + if schema: + return {str(k): str(v) for k, v in schema.items()} + flat: dict[str, str] = {} + for type_name, fields in (payload.get("schema_by_type") or {}).items(): + for path, kind in (fields or {}).items(): + flat[f"{type_name}/{path}"] = str(kind) + return flat + + +def discover_schema(base_url: str, sample: Path, structure_mode: str, max_fields: int) -> dict: + print(f"[ST-EXT-02] Descubriendo schema con muestra: {sample.name}") + with sample.open("rb") as fh: + r = requests.post( + f"{base_url}/extraction/schemas/upload", + files={"file": (sample.name, fh, "application/pdf")}, + data={"structure_mode": structure_mode}, + timeout=180, + ) + if r.status_code >= 400: + raise SystemExit(f"extraction/schemas/upload HTTP {r.status_code}: {r.text[:500]}") + payload = r.json() + schema_map = flatten_schema_payload(payload) + fields = sorted(schema_map.keys()) + if not fields: + raise SystemExit(f"Schema vacío: {json.dumps(payload)[:400]}") + if max_fields > 0: + fields = fields[:max_fields] + schema_map = {k: schema_map[k] for k in fields} + freeze = { + "version": 1, + "scenario": "ST-EXT-02", + "created_at": datetime.now(timezone.utc).isoformat(), + "sample_file": sample.name, + "structure_mode": structure_mode, + "model_hint": DEFAULT_MODEL, + "cost_per_doc_usd": COST_PER_DOC_USD, + "fields": fields, + "alias_map": {f: f.split("/")[-1] for f in fields}, + "schema": schema_map, + } + print(f" Campos congelados ({len(fields)}): {', '.join(fields[:8])}{'…' if len(fields) > 8 else ''}") + return freeze + + +def load_schema(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + fields = list(data.get("fields") or []) + if not fields: + raise SystemExit(f"Schema sin fields: {path}") + if "alias_map" not in data: + data["alias_map"] = {f: f.split("/")[-1] for f in fields} + print(f"[ST-EXT-02] Schema cargado: {path} ({len(fields)} campos, mismos para todos los PDFs)") + print(f" Campos: {', '.join(fields[:8])}{'…' if len(fields) > 8 else ''}") + return data + + +def save_schema(path: Path, freeze: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(freeze, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"[ST-EXT-02] Schema guardado → {path}") + + +def resolve_schema( + *, + base_url: str, + pdfs: list[Path], + schema_file: Path, + structure_mode: str, + max_fields: int, + rediscover: bool, +) -> dict: + if schema_file.is_file() and not rediscover: + return load_schema(schema_file) + freeze = discover_schema(base_url, pdfs[0], structure_mode, max_fields) + save_schema(schema_file, freeze) + return freeze + + +def create_task( + base_url: str, + pdfs: list[Path], + freeze: dict, + model: str | None, +) -> str: + fields = list(freeze["fields"]) + alias_map = dict(freeze.get("alias_map") or {f: f.split("/")[-1] for f in fields}) + structure_mode = str(freeze.get("structure_mode") or "general") + documents = [{"radicado": p.stem, "filename": p.name, "doc_type": None} for p in pdfs] + body = { + "source": "desktop", + "fields": fields, + "alias_map": alias_map, + "structure_mode": structure_mode, + "retry_on_empty": False, + "documents": documents, + "document_count": len(documents), + "model": model, + } + r = requests.post(f"{base_url}/tasks/extraction", json=body, timeout=60) + if r.status_code >= 400: + raise SystemExit(f"tasks/extraction HTTP {r.status_code}: {r.text[:500]}") + task = r.json() + task_id = task.get("id") + if not task_id: + raise SystemExit(f"Sin task id: {task}") + print(f"[ST-EXT-02] Tarea {task_id} — {len(pdfs)} docs, {len(fields)} campos fijos, model={model}") + return str(task_id) + + +def task_processed(base_url: str, task_id: str) -> int: + r = requests.get(f"{base_url}/tasks/{task_id}", timeout=30) + r.raise_for_status() + result = r.json().get("result") or {} + return int(result.get("processed") or 0) + + +def wait_processed( + base_url: str, + task_id: str, + before: int, + poll_timeout_s: float, + poll_interval_s: float, +) -> tuple[str, int]: + deadline = time.time() + poll_timeout_s + processed = before + while time.time() < deadline: + r = requests.get(f"{base_url}/tasks/{task_id}", timeout=30) + if r.status_code >= 400: + return f"poll_http_{r.status_code}", processed + data = r.json() + status = str(data.get("status") or "unknown") + result = data.get("result") or {} + processed = int(result.get("processed") or 0) + errors = result.get("errors") or [] + if processed > before: + return "ok", processed + if status in ("failed", "cancelled"): + err = errors[-1] if errors else status + return f"task_{err}", processed + time.sleep(poll_interval_s) + return "timeout", processed + + +def ingest_one( + base_url: str, + task_id: str, + pdf: Path, + poll_timeout_s: float, + poll_interval_s: float, +) -> dict: + before = task_processed(base_url, task_id) + t0 = time.perf_counter() + http_code = 0 + status = "error" + queued = False + err_msg = "" + try: + with pdf.open("rb") as fh: + r = requests.post( + f"{base_url}/tasks/{urllib.parse.quote(task_id)}/ingest", + files={"file": (pdf.name, fh, "application/pdf")}, + data={"radicado": pdf.stem, "filename": pdf.name}, + timeout=poll_timeout_s + 30, + ) + http_code = r.status_code + try: + body = r.json() + except Exception: + body = {} + if r.status_code >= 400: + status = f"http_{r.status_code}" + err_msg = (r.text or "")[:200].replace("\n", " ") + elif body.get("queued"): + queued = True + status, _ = wait_processed(base_url, task_id, before, poll_timeout_s, poll_interval_s) + else: + task = body.get("task") or body + result = (task.get("result") if isinstance(task, dict) else None) or {} + processed = int(result.get("processed") or 0) + status = "ok" if (processed > before or r.status_code < 300) else "no_progress" + errors = result.get("errors") or [] + if errors: + err_msg = str(errors[-1])[:200] + except requests.Timeout: + status = "timeout" + except Exception as ex: + status = "exception" + err_msg = str(ex)[:200] + + wall_s = round(time.perf_counter() - t0, 3) + cost_est = round(COST_PER_DOC_USD, 6) if status == "ok" else "" + return { + "file": pdf.name, + "wall_s": wall_s, + "http_code": http_code, + "status": status, + "queued": "1" if queued else "0", + "cost_usd_est": cost_est, + "error": err_msg, + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="ST-EXT-02 LLM bench (mismo schema/campos)") + p.add_argument("--base-url", default=os.environ.get("BASE_URL", "http://127.0.0.1:8189")) + p.add_argument("--data-dir", type=Path, default=Path(os.environ.get("LLM_BENCH_PDF_DIR") or DEFAULT_DATA)) + p.add_argument( + "--out-csv", + type=Path, + default=Path( + os.environ.get("LLM_LATENCY_CSV") + or (DEFAULT_INFORMES / f"llm_latency_{date.today().strftime('%Y%m%d')}.csv") + ), + ) + p.add_argument( + "--schema-file", + type=Path, + default=Path(os.environ.get("LLM_BENCH_SCHEMA_FILE") or DEFAULT_SCHEMA), + help="JSON congelado con fields (mismo schema para toda la corrida y re-runs)", + ) + p.add_argument("--rediscover", action="store_true", help="Ignora schema congelado y vuelve a descubrir") + p.add_argument("--limit", type=int, default=int(os.environ.get("LLM_BENCH_LIMIT") or "0")) + p.add_argument("--max-fields", type=int, default=int(os.environ.get("LLM_BENCH_MAX_FIELDS") or "20")) + p.add_argument("--model", default=DEFAULT_MODEL) + p.add_argument("--structure-mode", default=os.environ.get("LLM_BENCH_STRUCTURE_MODE") or "general") + p.add_argument("--poll-timeout", type=float, default=float(os.environ.get("LLM_BENCH_POLL_TIMEOUT_S") or "300")) + p.add_argument("--poll-interval", type=float, default=float(os.environ.get("LLM_BENCH_POLL_INTERVAL_S") or "1.5")) + return p.parse_args() + + +def main() -> int: + args = parse_args() + base_url = str(args.base_url).rstrip("/") + + try: + h = requests.get(f"{base_url}/health/live", timeout=5) + print(f"health/live → {h.status_code}") + except Exception as ex: + print(f"AVISO: no responde {base_url}/health/live ({ex})", file=sys.stderr) + + pdfs = list_pdfs(args.data_dir, args.limit) + print(f"BASE_URL={base_url}") + print(f"DATA_DIR={args.data_dir}") + print(f"OUT={args.out_csv}") + print(f"SCHEMA={args.schema_file}") + print(f"PDFs={len(pdfs)} MODEL={args.model} COST/doc=${COST_PER_DOC_USD}") + print() + + freeze = resolve_schema( + base_url=base_url, + pdfs=pdfs, + schema_file=args.schema_file, + structure_mode=args.structure_mode, + max_fields=args.max_fields, + rediscover=args.rediscover, + ) + # Asegurar que todos los PDFs usan exactamente freeze["fields"] + task_id = create_task(base_url, pdfs, freeze, args.model) + + args.out_csv.parent.mkdir(parents=True, exist_ok=True) + fieldnames = ["file", "wall_s", "http_code", "status", "queued", "cost_usd_est", "error"] + rows: list[dict] = [] + with args.out_csv.open("w", encoding="utf-8", newline="") as fh: + w = csv.DictWriter(fh, fieldnames=fieldnames) + w.writeheader() + for i, pdf in enumerate(pdfs, 1): + print(f"[{i}/{len(pdfs)}] {pdf.name} …", flush=True) + row = ingest_one(base_url, task_id, pdf, args.poll_timeout, args.poll_interval) + rows.append(row) + w.writerow(row) + fh.flush() + print( + f" wall_s={row['wall_s']} code={row['http_code']} status={row['status']} " + f"cost_est={row['cost_usd_est']}", + flush=True, + ) + + ok_times = sorted(float(r["wall_s"]) for r in rows if r["status"] == "ok") + n_ok = len(ok_times) + n_fail = len(rows) - n_ok + cost_run = round(n_ok * COST_PER_DOC_USD, 4) + cost_100k = round(100_000 * COST_PER_DOC_USD, 2) + + meta = { + "scenario": "ST-EXT-02", + "task_id": task_id, + "model": args.model, + "schema_file": str(args.schema_file), + "fields": freeze.get("fields"), + "n_fields": len(freeze.get("fields") or []), + "n_ok": n_ok, + "n_fail": n_fail, + "cost_per_doc_usd": COST_PER_DOC_USD, + "cost_run_usd": cost_run, + "cost_100k_usd": cost_100k, + "csv": str(args.out_csv), + "finished_at": datetime.now(timezone.utc).isoformat(), + } + if ok_times: + meta["wall_s_avg"] = round(statistics.mean(ok_times), 3) + meta["wall_s_p50"] = round(percentile(ok_times, 50), 3) + meta["wall_s_p95"] = round(percentile(ok_times, 95), 3) + meta["throughput_1w_per_h"] = round(3600 / meta["wall_s_p50"], 1) if meta["wall_s_p50"] else None + + meta_path = args.out_csv.with_suffix(".summary.json") + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + print() + print("=== Resumen ST-EXT-02 (mismo schema/campos) ===") + print(f"CSV: {args.out_csv}") + print(f"Schema: {args.schema_file} ({meta['n_fields']} campos fijos)") + print(f"Summary: {meta_path}") + print(f"task_id: {task_id}") + print(f"model: {args.model}") + print(f"ok={n_ok} fail={n_fail} total={len(rows)}") + if ok_times: + print( + f"wall_s avg={meta['wall_s_avg']:.3f} p50={meta['wall_s_p50']:.3f} " + f"p95={meta['wall_s_p95']:.3f} min={ok_times[0]:.3f} max={ok_times[-1]:.3f}" + ) + if meta.get("throughput_1w_per_h"): + print(f"Throughput 1 worker ≈ {meta['throughput_1w_per_h']:.0f} fact/h (p50)") + print(f"Throughput 2 workers ≈ {2 * meta['throughput_1w_per_h']:.0f} fact/h (sin cuota)") + else: + print("Sin muestras ok — revisa API key, workers y logs de extraction.") + print(f"Coste corrida (ok×{COST_PER_DOC_USD}): USD {cost_run}") + print(f"Coste 100k que pasan conteo: USD {cost_100k}") + print() + print("PENDIENTE docs: volcar p50/p95 en informe/sections/13_llm_latency.tex") + print(" y fila LLM en sections/08_capacidad_100k.tex") + return 0 if n_ok > 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh b/docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh new file mode 100644 index 0000000..1cdaa9f --- /dev/null +++ b/docs/plan_pruebas/scripts/st_ext_02_llm_bench.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# ST-EXT-02 — bench LLM con schema/campos congelados (gpt-5.4-mini por defecto). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +export BASE_URL="${BASE_URL:-http://127.0.0.1:8189}" +export LLM_BENCH_MODEL="${LLM_BENCH_MODEL:-gpt-5.4-mini}" +export LLM_BENCH_COST_PER_DOC="${LLM_BENCH_COST_PER_DOC:-0.0257}" +exec python3 "$ROOT/docs/plan_pruebas/scripts/st_ext_02_llm_bench.py" "$@" diff --git a/docs/plan_pruebas/sections/02_arquitectura.tex b/docs/plan_pruebas/sections/02_arquitectura.tex index 8766bfe..5dee9d9 100644 --- a/docs/plan_pruebas/sections/02_arquitectura.tex +++ b/docs/plan_pruebas/sections/02_arquitectura.tex @@ -1,11 +1,48 @@ \chapter{Arquitectura bajo prueba} \label{ch:arquitectura} -\section{Vista de componentes} -El host solo expone el Gateway en el puerto \textbf{8189}. El resto de APIs viven en la red Docker. El agent del bot corre en PCs externas y se comunica por HTTP + PutObject a MinIO. +\section{C\'omo est\'a dise\~nado el sistema} +IPSExtractor sigue una arquitectura de \textbf{microservicios} detrás de un +\textbf{API Gateway}. El host solo expone el puerto \textbf{8189}; el resto vive +en la red Docker. Los agents Selenium corren en PCs externas (HTTP + PutObject a MinIO). + +\noindent\textbf{Nombres:} \texttt{bot-descarga} = orquestador (API); \textbf{agent Selenium} = PC que descarga. +ST-BOT-* mide el orquestador; $\sim$422 fact/h es del agent. \input{diagrams/arquitectura} +\section{Patrones de dise\~no} +\begin{description}[style=nextline,leftmargin=0.8cm,labelwidth=3.2cm] + \item[API Gateway] + \'Unica puerta p\'ublica. Proxy a extraction, cleaning, count-api y bot-descarga. + Centraliza CORS, health y timeouts (JSON 30\,s; uploads del orquestador 600\,s). + \emph{Implicaci\'on de prueba:} ST-GW-* miden el gateway, no el OCR ni el LLM. + \item[Bounded contexts] + Cada servicio tiene un dominio claro: OCR (count-api), LLM (extraction+workers), + orquestaci\'on de descarga (\texttt{bot-descarga}), limpieza CSV (cleaning). + \emph{Implicaci\'on:} fallos y capacidad se prueban por servicio, no solo E2E. + \item[Cola as\'incrona (ARQ)] + Con \texttt{ASYNC\_QUEUE=true}, el ingest encola en Redis y los workers drenan. + Con \texttt{false} (default), el API procesa en el mismo request (sync). + \emph{Implicaci\'on:} ST-EXT-01 exige async + workers; ST-EXT-02 puede correr sync. + \item[Claim / lease] + Un job \texttt{pending} solo lo toma un agent Selenium (claim at\'omico v\'ia \texttt{bot-descarga}). Heartbeat + mantiene al worker online; sin heartbeat, no se asignan jobs. + \emph{Implicaci\'on:} ST-BOT-01/02 miden el orquestador (heartbeats y contenci\'on de claim). +\end{description} + +\input{diagrams/patrones} + +\section{Pipeline de negocio} +\input{diagrams/pipeline_negocio} + +\begin{enumerate} + \item \textbf{Descarga:} agent Selenium obtiene PDFs $\rightarrow$ MinIO. + \item \textbf{Conteo:} OCR/ONNX valida el documento (filtro de calidad). + \item \textbf{Extracci\'on:} LLM rellena campos $\rightarrow$ PostgreSQL. + \item \textbf{Limpieza:} reglas/CSV sobre el lote. +\end{enumerate} + \section{Inventario de componentes} \begin{table}[ht] \centering @@ -14,35 +51,35 @@ El host solo expone el Gateway en el puerto \textbf{8189}. El resto de APIs vive \textbf{Componente} & \textbf{Puerto} & \textbf{Responsabilidad} \\ \midrule \texttt{api\_gateway} & Host 8189 & Proxy HTTP, health agregado, CORS \\ -\texttt{extraction} & 8001 & Jobs/tasks, LLM, ingest PDFs \\ +\texttt{extraction} & 8001 & Tasks, LLM, ingest PDFs \\ \texttt{worker} (ARQ) & --- & Procesamiento async v\'ia Redis \\ \texttt{cleaning} & 8002 & Limpieza CSV / reglas \\ \texttt{count-api} & 8080 & OCR + ONNX, stateless \\ -\texttt{bot-descarga} & 8200 & Control remoto de bots \\ +\texttt{bot-descarga} & 8200 & Orquestador: jobs, claim, heartbeats, events \\ PostgreSQL 16 & 5432 & Persistencia compartida \\ Redis 7 & 6379 & Cola ARQ + rate limiter \\ Desktop & local & Cliente Electron/React \\ -Agent bot & local PC & Selenium + MinIO upload \\ -MinIO/S3 & externo & Excel y PDFs del bot \\ +Agent Selenium & local PC & Descarga PDFs (Selenium/Imaging) + MinIO upload \\ +MinIO/S3 & externo & Excel y PDFs del pipeline de descarga \\ \bottomrule \end{tabular} \caption{Componentes del sistema bajo prueba.} \end{table} -\section{Flujo cr\'itico: bot de descarga} +\section{Flujo cr\'itico: descarga (orquestador + agent Selenium)} \input{diagrams/flujo_bot} Secuencia resumida: \begin{enumerate} - \item Agent: \texttt{register} + loop \texttt{heartbeat} / \texttt{claim\_pending} ($\sim$8\,s). + \item Agent Selenium: \texttt{register} + loop \texttt{heartbeat} / \texttt{claim\_pending} ($\sim$8\,s). \item Desktop: \texttt{POST /bot-descarga/jobs} (Excel, \texttt{worker\_id}, \texttt{job\_id}). \item API: valida job padre y worker online; sube Excel a MinIO; estado \texttt{pending}. - \item Agent: claim $\rightarrow$ \texttt{RUNNING}; descarga Excel; Selenium/Imaging; PutObject PDFs. + \item Agent Selenium: claim $\rightarrow$ \texttt{RUNNING}; descarga Excel; Selenium/Imaging; PutObject PDFs. \item Eventos \texttt{POST /events}; cierre \texttt{PATCH /jobs/\{id\}} (\texttt{completed}/\texttt{failed}). \end{enumerate} Layout MinIO: -\begin{lstlisting}[language={},caption={Prefijos MinIO del bot}] +\begin{lstlisting}[language={},caption={Prefijos MinIO del pipeline de descarga}] uploads/{download_job_id}/input.xlsx downloads/{download_job_id}/{nit}/{radicado}.pdf downloads/{download_job_id}/_manifest.json @@ -51,6 +88,6 @@ downloads/{download_job_id}/_manifest.json \section{Otros flujos de negocio} \begin{description} \item[Conteo] Desktop $\rightarrow$ \texttt{POST /count/v1/count} $\rightarrow$ \texttt{POST /count-jobs} $\rightarrow$ validaci\'on humana. - \item[Extracci\'on] \texttt{from-count} o ingest de PDFs $\rightarrow$ workers ARQ $\rightarrow$ filas en BD. + \item[Extracci\'on] \texttt{from-count} o ingest de PDFs $\rightarrow$ (ARQ) workers $\rightarrow$ filas en BD. \item[Limpieza] Job cleaning desde \texttt{extraction\_batch\_id} o CSV upload. \end{description} diff --git a/docs/plan_pruebas/sections/03_estrategia.tex b/docs/plan_pruebas/sections/03_estrategia.tex index 5701876..53aa5e2 100644 --- a/docs/plan_pruebas/sections/03_estrategia.tex +++ b/docs/plan_pruebas/sections/03_estrategia.tex @@ -2,9 +2,13 @@ \label{ch:estrategia} \section{Pir\'amide} +Cada capa responde a una pregunta distinta: correctitud de dominio, +integraci\'on entre servicios, cierre del flujo de negocio, resistencia a carga +y degradaci\'on ante fallos. + \begin{table}[h] \centering -\begin{tabular}{@{}llp{4.5cm}l@{}} +\begin{tabular}{@{}llp{4.2cm}l@{}} \toprule \textbf{Capa} & \textbf{Qu\'e valida} & \textbf{D\'onde} & \textbf{Frecuencia} \\ \midrule @@ -21,13 +25,18 @@ Chaos & Ca\'idas de dependencias & Staging & Mensual \\ \section{Convenci\'on de identificadores} \begin{itemize} - \item \texttt{UT--} --- unitarias - \item \texttt{IT--} --- integraci\'on + \item \texttt{UT--} --- unitarias (riesgo de regresi\'on de dominio) + \item \texttt{IT--} --- integraci\'on (contratos HTTP + infra) \item \texttt{E2E-} --- end-to-end de negocio - \item \texttt{ST--} --- estr\'es / carga + \item \texttt{ST--} --- estr\'es / carga (capacidad y SLO) \item \texttt{CH-} --- chaos / resiliencia \end{itemize} +\section{C\'omo leer un caso del cat\'alogo} +Para cada ID: \textbf{caso} (qu\'e se hace) + \textbf{criterio} (c\'omo pasa) + +\textbf{riesgo cubierto} (por qu\'e importa). Los resultados medidos viven en el +\emph{Informe de Pruebas}; el modelo de volumen en \emph{Capacidad 100k}. + \section{Entornos} \begin{description} \item[local] Mocks de LLM, Imaging y MinIO (moto/LocalStack). @@ -58,6 +67,6 @@ Chrome & Mock & Mock & N/A & Real \\ \begin{enumerate} \item Un caso = un objetivo verificable (assert expl\'icito). \item Datos de prueba versionados (fixtures), no datos de producci\'on. - \item Estr\'es solo en staging aislado; umbrales escritos antes de ejecutar. - \item Fallos externos (LLM/Imaging) se aislan con mocks en CI. + \item Interpretar p95 altos junto con \texttt{free -h} / \texttt{docker stats} en shared8g. + \item Documentar entorno en cada informe de ejecuci\'on. \end{enumerate} diff --git a/docs/plan_pruebas/sections/04_unitarias.tex b/docs/plan_pruebas/sections/04_unitarias.tex index c2294db..dd2cb30 100644 --- a/docs/plan_pruebas/sections/04_unitarias.tex +++ b/docs/plan_pruebas/sections/04_unitarias.tex @@ -1,33 +1,36 @@ \chapter{Pruebas unitarias} \label{ch:unitarias} +Las unitarias cubren \textbf{regresiones de dominio} sin levantar el stack completo. +Un fallo aqu\'i suele ser m\'as barato de corregir que un fallo E2E. + \section{Gateway} -\begin{longtable}{@{}p{2.4cm}p{4.2cm}p{5.5cm}@{}} +\begin{longtable}{@{}p{2.2cm}p{3.5cm}p{4.0cm}p{3.0cm}@{}} \caption{Unitarias gateway.}\\ \toprule -\textbf{ID} & \textbf{Caso} & \textbf{Criterio de \'exito} \\ +\textbf{ID} & \textbf{Caso} & \textbf{Criterio de \'exito} & \textbf{Riesgo} \\ \midrule \endhead -UT-GW-01 & Clasificaci\'on path upload vs JSON & Timeouts 600\,s vs 30\,s correctos \\ -UT-GW-02 & Health live sin upstream & Respuesta inmediata \texttt{ok} \\ -UT-GW-03 & Health agregado degradado & Si extraction/cleaning fallan $\rightarrow$ \texttt{degraded} \\ +UT-GW-01 & Path upload vs JSON & Timeouts 600\,s vs 30\,s & Uploads cortados \\ +UT-GW-02 & Health live sin upstream & \texttt{ok} inmediato & Orquestaci\'on ciega \\ +UT-GW-03 & Health agregado degradado & Upstream down $\rightarrow$ \texttt{degraded} & Falsos ``todo ok'' \\ \bottomrule \end{longtable} \section{Bot-descarga API (\texttt{JobStore})} -Cobertura existente en \texttt{test\_store.py}; ampliar con: -\begin{longtable}{@{}p{2.6cm}p{4.0cm}p{5.5cm}@{}} +Cobertura en \texttt{test\_store.py}; ampliar con: +\begin{longtable}{@{}p{2.4cm}p{3.4cm}p{3.8cm}p{3.0cm}@{}} \caption{Unitarias bot-descarga API.}\\ \toprule -\textbf{ID} & \textbf{Caso} & \textbf{Criterio de \'exito} \\ +\textbf{ID} & \textbf{Caso} & \textbf{Criterio de \'exito} & \textbf{Riesgo} \\ \midrule \endhead -UT-BOT-01 & Register + heartbeat & Worker online; offline tras $>$45\,s \\ -UT-BOT-02 & Claim at\'omico & Un solo worker obtiene el job \\ -UT-BOT-03 & Cancel pending & Estado \texttt{cancelled} inmediato \\ -UT-BOT-04 & Cancel running & \texttt{cancel\_requested=true} \\ -UT-BOT-05 & Auth Bearer & Token inv\'alido $\rightarrow$ 401 \\ -UT-BOT-06 & Keys MinIO & Prefijos uploads/downloads correctos \\ +UT-BOT-01 & Register + heartbeat & Online; offline $>$45\,s & Jobs a PCs muertos \\ +UT-BOT-02 & Claim at\'omico & Un solo worker & Doble descarga \\ +UT-BOT-03 & Cancel pending & \texttt{cancelled} & Jobs fantasma \\ +UT-BOT-04 & Cancel running & \texttt{cancel\_requested} & Selenium sin freno \\ +UT-BOT-05 & Auth Bearer & 401 si inv\'alido & Acceso indebido \\ +UT-BOT-06 & Keys MinIO & Prefijos correctos & PDFs perdidos \\ \bottomrule \end{longtable} diff --git a/docs/plan_pruebas/sections/07_estres.tex b/docs/plan_pruebas/sections/07_estres.tex index 64182d5..5a4d48d 100644 --- a/docs/plan_pruebas/sections/07_estres.tex +++ b/docs/plan_pruebas/sections/07_estres.tex @@ -27,25 +27,28 @@ ST-EXT-01 & 20--50 tasks; 1 r\'eplica worker & 100--500 tasks; 2 r\'eplicas \\ Con el zoo Docker actual (MLflow $\sim$1{,}86\,GiB + Airflow + n8n, etc.) \textbf{no} ejecutar ST-COUNT-01 ni ST-EXT agresivo sin liberar memoria primero. \section{Escenarios (cat\'alogo completo)} -\begin{longtable}{@{}p{2.4cm}p{3.2cm}p{3.5cm}p{3.2cm}@{}} -\caption{Escenarios de estr\'es.}\\ +\begin{table}[ht] +\centering +\small +\begin{tabular}{@{}llll@{}} \toprule -\textbf{ID} & \textbf{Objetivo} & \textbf{Carga (laboratorio)} & \textbf{Observar} \\ +\textbf{ID} & \textbf{Objetivo} & \textbf{Carga (lab)} & \textbf{Observar / riesgo} \\ \midrule -\endhead -ST-GW-01 & Gateway concurrente & 50--100 VUs \texttt{/health/live} & p95, 5xx \\ -ST-BOT-01 & Heartbeats masivos & 20--50 workers / 8\,s & PG, offline falsos \\ -ST-BOT-02 & Claim contention & 10 workers, cola jobs & Un claim/job \\ -ST-BOT-03 & Event storm & Hasta 5k events/job & Escritura PG \\ -ST-BOT-04 & Excel grande & 10--50\,MB upload & Timeout 600\,s \\ -ST-MINIO-01 & PutObject paralelo & N PCs & Errores S3 \\ -ST-COUNT-01 & OCR paralelo & 5--20 lotes $\times$10 & RAM, OOM \\ -ST-EXT-01 & Cola ARQ & 100--500 tasks (LLM mock) & Backlog \\ -ST-EXT-02 & LLM real & 20--50 PDFs & 429, costos \\ -ST-DES-01 & Polling desktop & 10 clientes poll & CPU gateway \\ -ST-DB-01 & PG saturaci\'on & Mezcla ST-BOT+EXT & Locks \\ +ST-GW-01 & Gateway concurrente & 50--100 VUs live & p95, 5xx --- orquestaci\'on lenta \\ +ST-BOT-01 & Heartbeats masivos & 20--50 workers & Offline falsos --- PCs muertos \\ +ST-BOT-02 & Claim contention & 10 workers & Un claim/job --- doble descarga \\ +ST-BOT-03 & Event storm & Hasta 5k events & Escritura PG --- perdida eventos \\ +ST-BOT-04 & Excel grande & 10--50\,MB & Timeout 600\,s --- upload cortado \\ +ST-MINIO-01 & PutObject paralelo & N PCs & Errores S3 --- PDFs perdidos \\ +ST-COUNT-01 & OCR paralelo & 5--20 lotes x10 & RAM/OOM --- cuello 100k \\ +ST-EXT-01 & Cola ARQ & 100--500 (mock) & Backlog --- workers insuficientes \\ +ST-EXT-02 & LLM real & 20--50 PDFs & 429, coste --- cuota LLM \\ +ST-DES-01 & Polling desktop & 10 clientes & CPU GW --- UI lenta \\ +ST-DB-01 & PG saturaci\'on & Mezcla BOT+EXT & Locks --- deadlocks \\ \bottomrule -\end{longtable} +\end{tabular} +\caption{Escenarios de estr\'es (objetivo y riesgo).} +\end{table} \section{Scripts k6 incluidos} \begin{table}[h] diff --git a/docs/plan_pruebas/sections/12_informe_2026_07_13.tex b/docs/plan_pruebas/sections/12_informe_2026_07_13.tex index 1638ca9..acfc07a 100644 --- a/docs/plan_pruebas/sections/12_informe_2026_07_13.tex +++ b/docs/plan_pruebas/sections/12_informe_2026_07_13.tex @@ -1,94 +1,42 @@ -\chapter{Informe de ejecuci\'on --- campa\~na 2026-07-13} +\chapter{Estado de campa\~na --- 2026-07-13 (+ LLM 17-jul)} \label{ch:informe-2026-07-13} +Este cap\'itulo es un \textbf{resumen de estado} del plan. Las m\'etricas completas, +gr\'aficas e interpretaci\'on est\'an en el \emph{Informe de Pruebas} +(\texttt{InformePruebas\_IPSExtractor.pdf}). El modelo de volumen est\'a en +\emph{Capacidad 100k}. + \section{Metadatos} +Host \texttt{ubuntu} (ASUS X441UAK), Ubuntu 24.04, 8\,GiB, perfil \texttt{shared8g}, +gateway \texttt{http://127.0.0.1:8189}. + +\section{Resultados resumidos} \begin{table}[ht] \centering -\begin{tabular}{@{}lp{9cm}@{}} -\toprule -\textbf{Campo} & \textbf{Valor} \\ -\midrule -Fecha & 13 de julio de 2026 \\ -Host & \texttt{ubuntu} (ASUS X441UAK), Ubuntu 24.04.4 LTS \\ -CPU / RAM & Intel i5-7200U; 7{,}6\,GiB (Docker limit $\approx$ 7{,}64\,GiB) \\ -Perfil k6 & \texttt{shared8g} \\ -Gateway & \texttt{http://127.0.0.1:8189} \\ -Herramienta & k6 (Grafana), Docker 29.1.3 \\ -Rama / repo & \texttt{main} --- IPSExtractor \\ -Ejecutor & erik (host staging compartido) \\ -\bottomrule -\end{tabular} -\caption{Metadatos de la campa\~na.} -\end{table} - -\section{Baseline del entorno} -Medici\'on previa a las cargas: - -\begin{itemize} - \item Memoria: total 7{,}6\,GiB; usados $\approx$4{,}4\,GiB; disponibles $\approx$3{,}3\,GiB; \textbf{swap 2{,}1\,GiB en uso}. - \item Contenedores IPS: todos \texttt{Up} / healthy (api, bot-descarga, extraction, workers, count-api, cleaning, db, redis) --- $\sim$17\,h uptime. - \item Vecino dominante: \texttt{mlflow-server} $\approx$1{,}84\,GiB; tambi\'en n8n, MinIO, postgres compartido, etc. - \item \texttt{GET /health/live}: \texttt{ok}, $\approx$11\,ms. - \item \texttt{GET /health}: \texttt{ok} (extraction, cleaning, counting, bot\_descarga); $\approx$194\,ms. -\end{itemize} - -\section{Resultados por caso} -\begin{table}[ht] -\centering -\begin{tabular}{@{}llp{6.2cm}@{}} +\begin{tabular}{@{}llp{6.5cm}@{}} \toprule \textbf{ID} & \textbf{Resultado} & \textbf{Detalle} \\ \midrule -Smoke health & \textbf{PASS} & Live + agregado OK; servicios healthy \\ -ST-GW-01 & \textbf{PASS} & 6884 req; fail 0\,\%; p95 $=$ 40{,}85\,ms (SLO $<$500\,ms) \\ -ST-BOT-01 (1) & \textbf{PARCIAL} & 100\,\% HTTP 200; p95 $=$ 2{,}28\,s (SLO $<$800\,ms) \textbf{FAIL} latencia; max 23\,s \\ -ST-BOT-01 (2) & \textbf{PASS} & 100\,\% HTTP 200; p95 $=$ 85{,}55\,ms; max 98\,ms \\ +Smoke health & PASS & Live + agregado OK \\ +ST-GW-01 & PASS & p95 40{,}85\,ms; fail 0\,\% \\ +ST-BOT-01 \#1 & PARCIAL & Funcional OK; p95 latencia FAIL (contenci\'on) \\ +ST-BOT-01 \#2 & PASS & p95 85{,}55\,ms tras aliviar host \\ +ST-EXT-02 & PASS & 23/23; p50 7{,}1\,s $\approx$506 fact/h (17-jul) \\ +ST-EXT-01 & PASS & 30/30 async; drain 22{,}5\,s; cola $\approx$4\,5k docs/h mock \\ +ST-COUNT-01 & PASS & 9/9; p50 35{,}8\,s @3 conc.; $\approx$325 fact/h obs. \\ \bottomrule \end{tabular} -\caption{Resumen de la campa\~na 2026-07-13.} +\caption{Resumen; detalle en el Informe de Pruebas.} \label{tab:campana-2026-07-13} \end{table} -\subsection{ST-GW-01 --- gateway health} -\begin{itemize} - \item Script: \texttt{k6\_gateway\_health.js}, \texttt{PROFILE=shared8g}. - \item Duraci\'on: $\approx$2\,min; hasta 25 VUs. - \item Checks: 13768/13768 (100\,\%). - \item \texttt{http\_req\_failed}: 0\,\%; p95 $=$ 40{,}85\,ms; avg $=$ 12{,}55\,ms. - \item Thresholds: todos en verde. -\end{itemize} +\section{Lecci\'on operativa} +Un p95 alto en heartbeats con 100\,\% HTTP 200 suele ser \textbf{contenci\'on del host} +(swap + MLflow), no un bug del claim. Siempre adjuntar \texttt{free -h} y +\texttt{docker stats} al interpretar ST-BOT-*. +ST-EXT-01 confirma que la cola ARQ drena bien con mock; el throughput de cola +no sustituye ST-EXT-02 (LLM real). +ST-COUNT-01 muestra degradaci\'on fuerte del OCR bajo paralelismo (2\,s $\rightarrow$ 36\,s). -\subsection{ST-BOT-01 --- heartbeats (corrida 1)} -\begin{itemize} - \item Script: \texttt{k6\_bot\_heartbeats.js}, 5 VUs, 2\,min, token \texttt{dev-token-change-me}. - \item Checks: 75/75 (100\,\%) --- todos los heartbeats HTTP 200. - \item Latencia: avg 511\,ms; med 57\,ms; p95 2{,}28\,s; max 23{,}12\,s. - \item Threshold \texttt{p(95)<800}: \textbf{cruzado} (k6 exit con error de thresholds). - \item Interpretaci\'on: API correcta; picos atribuibles a contenci\'on del host (swap + MLflow/HDD), no a error de negocio. -\end{itemize} - -\subsection{ST-BOT-01 --- heartbeats (corrida 2, reintento)} -\begin{itemize} - \item Mismo script y perfil, tras aliviar presi\'on del host. - \item Checks: 75/75 (100\,\%). - \item Latencia: avg 57\,ms; med 56\,ms; p95 85{,}55\,ms; max 98\,ms. - \item Thresholds: todos en verde. - \item Conclusi\'on: el FAIL de latencia de la corrida 1 \textbf{no se reproduce} con menos contenci\'on; confirma causa ambiental. -\end{itemize} - -\section{Hallazgos} -\begin{enumerate} - \item IPSExtractor (gateway + bot-descarga heartbeat) cumple SLO funcional y de latencia del perfil \texttt{shared8g} cuando el host no est\'a saturado. - \item Con swap activo ($\sim$2\,GiB) y MLflow $\sim$1{,}8\,GiB, pueden aparecer outliers de varios segundos en endpoints ligeros; deben reportarse como riesgo de \emph{plataforma compartida}, no como regresi\'on de c\'odigo sin evidencia adicional. - \item El health agregado es usable ($\sim$200\,ms en esta sesi\'on); no usarlo como SLO de liveness (usar \texttt{/health/live}). -\end{enumerate} - -\section{No ejecutado en esta campa\~na} -ST-BOT-03 (events), ST-COUNT-01, ST-EXT-*, E2E Imaging, chaos. Motivo: swap/contenci\'on residual; se aplazan a una ventana con $\geq$3\,GiB libres o MLflow/Airflow detenidos. - -\section{Follow-ups} -\begin{itemize} - \item Repetir ST-BOT-01 peri\'odicamente; si p95 $>$800\,ms, adjuntar \texttt{free -h} y \texttt{docker stats}. - \item Ventana dedicada (parar MLflow) antes de ST-COUNT-01. - \item Opcional: excluir requests de \texttt{setup()} del threshold de latencia en k6 (registros de workers) para no mezclar cold-start con steady-state. -\end{itemize} +\section{Pendiente respecto a este plan} +ST-BOT-03, ST-COUNT-01 perfil lab, ST-EXT-01 perfil lab (100--500), E2E Imaging/VPN, chaos. diff --git a/services/bot_descarga/api/main.py b/services/bot_descarga/api/main.py index 53a212f..7800d14 100644 --- a/services/bot_descarga/api/main.py +++ b/services/bot_descarga/api/main.py @@ -182,6 +182,43 @@ def list_job_files(download_job_id: str, _: str = Depends(require_bearer)) -> di raise HTTPException(status_code=502, detail=f"Error listando MinIO: {e}") from e +@app.get("/jobs/{download_job_id}/object") +def get_job_object( + download_job_id: str, + key: str = Query(..., min_length=1, description="Object key bajo downloads_prefix"), + _: str = Depends(require_bearer), +): + """Stream de un PDF del resultado MinIO (solo keys del prefijo del job).""" + job = store.get_job(download_job_id) + if not job: + raise HTTPException(status_code=404, detail="Job no encontrado") + + prefix = (job.downloads_prefix or f"downloads/{download_job_id}/").lstrip("/") + if not prefix.endswith("/"): + prefix += "/" + normalized = key.lstrip("/") + if not normalized.startswith(prefix): + raise HTTPException(status_code=403, detail="Key fuera del prefijo del job") + if not normalized.lower().endswith(".pdf"): + raise HTTPException(status_code=400, detail="Solo se permiten objetos PDF") + if ".." in normalized.split("/"): + raise HTTPException(status_code=400, detail="Key no permitida") + if normalized.endswith("_manifest.json"): + raise HTTPException(status_code=400, detail="Key no permitida") + + try: + data = minio.get_bytes(normalized) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Error leyendo MinIO: {e}") from e + + filename = Path(normalized).name + return Response( + content=data, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) + + @app.get("/jobs/{download_job_id}/events") def get_events( download_job_id: str, diff --git a/services/bot_descarga/infrastructure/storage_minio.py b/services/bot_descarga/infrastructure/storage_minio.py index 3cd2720..7471c21 100644 --- a/services/bot_descarga/infrastructure/storage_minio.py +++ b/services/bot_descarga/infrastructure/storage_minio.py @@ -190,24 +190,42 @@ class MinioStorage: return key def list_download_files(self, job_id: str, prefix: str | None = None) -> dict: - """Lista PDFs de una corrida (manifest o list_keys).""" + """Lista PDFs de una corrida (manifest o list_keys). + + Si el manifest falta, falla o queda vacío, hace fallback a list_keys + bajo el prefijo (cubre varias carpetas NIT planas). + """ prefix = prefix or downloads_prefix(job_id) files: List[dict] = [] try: raw = self.get_bytes(manifest_key(job_id)) payload = json.loads(raw.decode("utf-8")) for key in payload.get("files") or []: - files.append(self._file_entry(str(key))) + entry = self._file_entry(str(key)) + if entry is not None: + files.append(entry) except Exception: - for key in self.list_keys(prefix): - if key.endswith("/_manifest.json") or key.endswith("_manifest.json"): - continue - files.append(self._file_entry(key)) + files = [] + + if not files: + files = self._list_pdf_entries(prefix) return {"job_id": job_id, "prefix": prefix, "files": files, "count": len(files)} + def _list_pdf_entries(self, prefix: str) -> List[dict]: + files: List[dict] = [] + for key in self.list_keys(prefix): + entry = self._file_entry(key) + if entry is not None: + files.append(entry) + return files + @staticmethod - def _file_entry(key: str) -> dict: + def _file_entry(key: str) -> dict | None: # downloads/{job_id}/{nit}/{radicado}.pdf + if key.endswith("/_manifest.json") or key.endswith("_manifest.json"): + return None + if not key.lower().endswith(".pdf"): + return None parts = key.strip("/").split("/") nit = parts[-2] if len(parts) >= 3 else None name = parts[-1] if parts else key diff --git a/services/bot_descarga/tests/test_list_download_files.py b/services/bot_descarga/tests/test_list_download_files.py new file mode 100644 index 0000000..994a153 --- /dev/null +++ b/services/bot_descarga/tests/test_list_download_files.py @@ -0,0 +1,38 @@ +"""Tests for MinIO list_download_files fallback on empty manifest.""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from infrastructure.storage_minio import MinioStorage + + +def test_list_download_files_falls_back_when_manifest_empty(monkeypatch): + storage = MinioStorage() + storage.get_bytes = MagicMock( + return_value=b'{"job_id": "j1", "files": [], "count": 0}' + ) + storage.list_keys = MagicMock( + return_value=[ + "downloads/j1/_manifest.json", + "downloads/j1/900808423/a.pdf", + "downloads/j1/900884937/b.pdf", + "downloads/j1/901679492/c.pdf", + ] + ) + result = storage.list_download_files("j1") + assert result["count"] == 3 + nits = {f["nit"] for f in result["files"]} + assert nits == {"900808423", "900884937", "901679492"} + + +def test_list_download_files_falls_back_when_manifest_missing(monkeypatch): + storage = MinioStorage() + storage.get_bytes = MagicMock(side_effect=Exception("NoSuchKey")) + storage.list_keys = MagicMock( + return_value=[ + "downloads/j1/900808423/a.pdf", + ] + ) + result = storage.list_download_files("j1") + assert result["count"] == 1 + assert result["files"][0]["radicado"] == "a" diff --git a/services/db/alembic/versions/0005_count_job_files_nit_object_key.py b/services/db/alembic/versions/0005_count_job_files_nit_object_key.py new file mode 100644 index 0000000..dfa73ba --- /dev/null +++ b/services/db/alembic/versions/0005_count_job_files_nit_object_key.py @@ -0,0 +1,45 @@ +"""Add nit and object_key to count_job_files for MinIO download bridge. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-07-17 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_exists(conn: sa.engine.Connection, table: str, column: str) -> bool: + cols = {c["name"] for c in sa.inspect(conn).get_columns(table)} + return column in cols + + +def upgrade() -> None: + conn = op.get_bind() + if not sa.inspect(conn).has_table("count_job_files"): + return + if not _column_exists(conn, "count_job_files", "nit"): + op.add_column("count_job_files", sa.Column("nit", sa.String(255), nullable=True)) + op.create_index("ix_count_job_files_nit", "count_job_files", ["nit"]) + if not _column_exists(conn, "count_job_files", "object_key"): + op.add_column("count_job_files", sa.Column("object_key", sa.Text(), nullable=True)) + + +def downgrade() -> None: + conn = op.get_bind() + if not sa.inspect(conn).has_table("count_job_files"): + return + if _column_exists(conn, "count_job_files", "object_key"): + op.drop_column("count_job_files", "object_key") + if _column_exists(conn, "count_job_files", "nit"): + op.drop_index("ix_count_job_files_nit", table_name="count_job_files") + op.drop_column("count_job_files", "nit") diff --git a/services/desktop_app/src/renderer/components/CountResultsTable.tsx b/services/desktop_app/src/renderer/components/CountResultsTable.tsx index b385cfc..9acb17a 100644 --- a/services/desktop_app/src/renderer/components/CountResultsTable.tsx +++ b/services/desktop_app/src/renderer/components/CountResultsTable.tsx @@ -8,7 +8,7 @@ import { Search, XCircle, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { Fragment, useMemo, useRef, useState } from "react"; import type { CountJobFile } from "../../shared/types"; interface Props { @@ -41,6 +41,7 @@ export function CountResultsTable({ const [search, setSearch] = useState(""); const [expanded, setExpanded] = useState>(new Set()); const [toggling, setToggling] = useState>(new Set()); + const [collapsedNits, setCollapsedNits] = useState>(new Set()); const tagInputRef = useRef(null); const summary = useMemo(() => { @@ -74,11 +75,47 @@ export function CountResultsTable({ else if (filter === "errors") list = list.filter((f) => f.status === "error"); const q = search.trim().toLowerCase(); - if (q) list = list.filter((f) => f.filename.toLowerCase().includes(q) || (f.radicado ?? "").toLowerCase().includes(q)); + if (q) { + list = list.filter( + (f) => + f.filename.toLowerCase().includes(q) || + (f.radicado ?? "").toLowerCase().includes(q) || + (f.nit ?? "").toLowerCase().includes(q), + ); + } return list; }, [files, filter, search, validCounts]); + const nitGroups = useMemo(() => { + const hasNit = files.some((f) => f.nit); + if (!hasNit) return null; + const map = new Map(); + for (const f of filtered) { + const nit = (f.nit || "").trim() || "Sin NIT"; + const list = map.get(nit) ?? []; + list.push(f); + map.set(nit, list); + } + return [...map.entries()] + .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) + .map(([nit, group]) => ({ nit, files: group })); + }, [files, filtered]); + + const folderCount = useMemo(() => { + const nits = new Set(files.map((f) => (f.nit || "").trim()).filter(Boolean)); + return nits.size; + }, [files]); + + function toggleNitGroup(nit: string) { + setCollapsedNits((prev) => { + const next = new Set(prev); + if (next.has(nit)) next.delete(nit); + else next.add(nit); + return next; + }); + } + function toggleExpand(id: string) { setExpanded((prev) => { const next = new Set(prev); @@ -142,6 +179,12 @@ export function CountResultsTable({ {summary.total} Total + {folderCount > 0 && ( +
+ {folderCount} + Carpetas +
+ )}
{summary.valid} Válidos @@ -157,6 +200,12 @@ export function CountResultsTable({
)} + {folderCount > 0 && ( +

+ {folderCount} carpeta{folderCount === 1 ? "" : "s"} · {summary.total} PDF + {summary.total === 1 ? "" : "s"} +

+ )} {/* ── Filtro por conteo exacto ── */}
@@ -241,7 +290,7 @@ export function CountResultsTable({
0 ? "Buscar archivo o NIT…" : "Buscar archivo…"} value={search} onChange={(e) => setSearch(e.target.value)} /> @@ -275,109 +324,231 @@ export function CountResultsTable({ )} - {filtered.map((f) => { - const st = rowStatus(f); - const isExpanded = expanded.has(f.id); - const hasServices = f.services && f.services.length > 0; - const isToggling = toggling.has(f.id); - const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename); - - return ( - <> - - -
- {hasServices ? ( + {nitGroups + ? nitGroups.map(({ nit, files: groupFiles }) => { + const collapsed = collapsedNits.has(nit); + const servicesSum = groupFiles.reduce( + (acc, f) => acc + (f.status === "ok" ? f.service_count ?? 0 : 0), + 0, + ); + return ( + + + - ) : ( - - )} -
-
- {f.filename} - {wasAutoAdjusted && ( - + + {!collapsed && + groupFiles.map((f) => { + const st = rowStatus(f); + const isExpanded = expanded.has(f.id); + const hasServices = f.services && f.services.length > 0; + const isToggling = toggling.has(f.id); + const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename); + return ( + + + +
+ {hasServices ? ( + + ) : ( + + )} +
+
+ {f.filename} + {wasAutoAdjusted && ( + + auto→1 + + )} +
+ {f.radicado && f.radicado !== f.filename && ( + {f.radicado} + )} + {f.status === "error" && f.error && ( + {f.error} + )} +
+
+ + + {f.status === "error" ? ( + — + ) : ( + {f.service_count ?? "?"} + )} + + + {st === "error" && } + {st === "valid" && } + {st === "invalid" && } + + + {f.status !== "error" && ( +
+ + +
+ )} + + + {isExpanded && hasServices && ( + + +
+ {f.services.map((svc, i) => ( +
+ {svc.text} + {(svc.probability * 100).toFixed(0)}% +
+ ))} +
+ + + )} +
+ ); + })} + + ); + }) + : filtered.map((f) => { + const st = rowStatus(f); + const isExpanded = expanded.has(f.id); + const hasServices = f.services && f.services.length > 0; + const isToggling = toggling.has(f.id); + const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename); + return ( + + + +
+ {hasServices ? ( + + ) : ( + )} -
- {f.radicado && f.radicado !== f.filename && ( - {f.radicado} - )} - {f.status === "error" && f.error && ( - {f.error} - )} -
-
- - - - {f.status === "error" ? ( - — - ) : ( - {f.service_count ?? "?"} - )} - - - - {st === "error" && } - {st === "valid" && } - {st === "invalid" && } - - - - {f.status !== "error" && ( -
- - -
- )} - - - - {isExpanded && hasServices && ( - - -
- {f.services.map((svc, i) => ( -
- {svc.text} - {(svc.probability * 100).toFixed(0)}% +
+
+ {f.filename} + {wasAutoAdjusted && ( + + auto→1 + + )} +
+ {f.radicado && f.radicado !== f.filename && ( + {f.radicado} + )} + {f.status === "error" && f.error && ( + {f.error} + )}
- ))} -
- - - )} - - ); - })} +
+ + + {f.status === "error" ? ( + — + ) : ( + {f.service_count ?? "?"} + )} + + + {st === "error" && } + {st === "valid" && } + {st === "invalid" && } + + + {f.status !== "error" && ( +
+ + +
+ )} + + + {isExpanded && hasServices && ( + + +
+ {f.services.map((svc, i) => ( +
+ {svc.text} + {(svc.probability * 100).toFixed(0)}% +
+ ))} +
+ + + )} +
+ ); + })}
diff --git a/services/desktop_app/src/renderer/components/CountingWizard.tsx b/services/desktop_app/src/renderer/components/CountingWizard.tsx index 31169f4..a2aa869 100644 --- a/services/desktop_app/src/renderer/components/CountingWizard.tsx +++ b/services/desktop_app/src/renderer/components/CountingWizard.tsx @@ -41,6 +41,9 @@ interface Props { onTaskCreated?: (task: Task) => void; /** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */ onProgress?: (progress: BackgroundProgress) => void; + /** Origen MinIO: corrida de bot de descarga. */ + downloadJobId?: string; + downloadFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>; } interface CountProgress { @@ -56,6 +59,32 @@ const BATCH_SIZE = 10; /** Peticiones HTTP simultáneas al count-api. */ const BATCH_CONCURRENCY = 2; +function downloadFilesToManifest( + files: Array<{ key: string; nit?: string | null; radicado?: string | null }>, +): ManifestEntry[] { + return files + .filter((f) => f.key.toLowerCase().endsWith(".pdf")) + .map((f) => { + const filename = f.key.split("/").pop() || f.key; + const radicado = + (f.radicado || "").trim() || + filename.replace(/\.pdf$/i, ""); + return { + radicado, + filename, + localPath: f.key, + size: 0, + nit: f.nit ?? null, + objectKey: f.key, + }; + }) + .sort((a, b) => { + const nitCmp = String(a.nit ?? "").localeCompare(String(b.nit ?? ""), undefined, { numeric: true }); + if (nitCmp !== 0) return nitCmp; + return a.radicado.localeCompare(b.radicado, undefined, { numeric: true }); + }); +} + function formatEta(progress: CountProgress): string | null { if (!progress.startedAt || progress.processed === 0) return null; const elapsed = (Date.now() - progress.startedAt) / 1000; @@ -68,7 +97,18 @@ function formatEta(progress: CountProgress): string | null { return `~${Math.ceil(secs / 60)} min · ${rateStr}`; } -export function CountingWizard({ client, localRoot, containerRoot, job, onStartExtraction, onTaskCreated, onProgress }: Props) { +export function CountingWizard({ + client, + localRoot, + containerRoot, + job, + onStartExtraction, + onTaskCreated, + onProgress, + downloadJobId, + downloadFiles, +}: Props) { + const fromDownload = Boolean(downloadJobId); const [step, setStep] = useState(1); const [maxReached, setMaxReached] = useState(1); const [form, setForm] = useState({ @@ -95,6 +135,7 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE const [error, setError] = useState(null); const [running, setRunning] = useState(false); const abortRef = useRef(false); + const downloadJobIdRef = useRef(downloadJobId); const indexLocalFolder = useCallback( async (dir: string) => { @@ -118,6 +159,33 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE [docTypes], ); + const indexFromDownload = useCallback( + async (jobId: string, seedFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>) => { + setIndexing(true); + setError(null); + try { + let files = seedFiles ?? []; + if (files.length === 0) { + const resp = await client.listDownloadJobFiles(jobId); + files = resp.files; + } + const entries = downloadFilesToManifest(files); + setManifest(entries); + if (entries.length === 0) { + setError("No se encontraron PDF en MinIO para esta corrida de descarga."); + } + return entries; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setManifest([]); + return []; + } finally { + setIndexing(false); + } + }, + [client], + ); + useEffect(() => { setForm((f) => ({ ...f, @@ -127,13 +195,22 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE }, [localRoot, containerRoot]); useEffect(() => { + downloadJobIdRef.current = downloadJobId; + if (downloadJobId) { + void indexFromDownload(downloadJobId, downloadFiles); + return; + } if (!localRoot) return; let cancelled = false; indexLocalFolder(localRoot).catch(() => { if (!cancelled) setError("Error al indexar la carpeta."); }); - return () => { cancelled = true; }; - }, [localRoot, indexLocalFolder]); + return () => { + cancelled = true; + }; + // Solo reindexar al cambiar de corrida/carpeta; seed files es opcional. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localRoot, downloadJobId, indexLocalFolder, indexFromDownload]); function goToStep(target: CountingStep) { setStep(target); @@ -154,6 +231,15 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE return norm.startsWith(root) ? container + norm.slice(root.length) : norm; } + async function readEntryBytes(entry: ManifestEntry): Promise { + const jobId = downloadJobIdRef.current; + if (jobId && entry.objectKey) { + return client.fetchDownloadObject(jobId, entry.objectKey); + } + const fileData = await desktopApi.readFile(entry.localPath); + return fileData.data; + } + async function runCounting() { if (!manifest.length) { setError("No hay archivos en la carpeta seleccionada."); @@ -215,7 +301,8 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE running: true, label: `Preparando archivos (${Math.min(batchStart + fi + 1, manifest.length)}/${manifest.length})…`, }); - return desktopApi.readFile(entry.localPath); + const data = await readEntryBytes(entry); + return { filename: entry.filename, data }; }), ); @@ -317,16 +404,27 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE }); try { + const nitCount = new Set(manifest.map((m) => m.nit).filter(Boolean)).size; + const titleBase = fromDownload + ? `Conteo desde descarga${nitCount ? ` · ${nitCount} carpeta${nitCount === 1 ? "" : "s"}` : ""}` + : `Conteo — ${form.localRoot.split(/[\\/]/).pop()}`; const payload: CreateCountJobPayload = { - title: `Conteo — ${form.localRoot.split(/[\\/]/).pop()} (${finalResults.length} archivos)`, + title: `${titleBase} (${finalResults.length} archivos)`, job_id: job?.id, - results: finalResults.map((r) => { - const entry = manifest.find((m) => m.filename === r.filename); + download_job_id: downloadJobIdRef.current ?? null, + source: fromDownload ? "from-download" : "desktop", + results: finalResults.map((r, i) => { + const entry = manifest[i]; return { radicado: entry?.radicado ?? null, filename: r.filename, - file_path: entry ? localToContainer(entry.localPath) : null, + file_path: + fromDownload || !entry + ? entry?.objectKey ?? null + : localToContainer(entry.localPath), doc_type: entry?.docType ?? null, + nit: entry?.nit ?? null, + object_key: entry?.objectKey ?? null, service_count: r.status === "ok" ? r.service_count : null, services: r.services ?? [], status: r.status === "ok" ? "ok" : "error", @@ -377,16 +475,22 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE function handleExportCsv() { if (!savedFiles.length) return; - const headers = ["filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"]; - const rows = savedFiles.map((f) => [ - f.filename, - f.radicado ?? "", - f.service_count ?? "", - autoAdjustedFilenames.has(f.filename) ? "si" : "", - f.status, - f.is_valid === null ? "" : String(f.is_valid), - f.error ?? "", - ]); + const hasNit = savedFiles.some((f) => f.nit); + const headers = hasNit + ? ["nit", "filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"] + : ["filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"]; + const rows = savedFiles.map((f) => { + const base = [ + f.filename, + f.radicado ?? "", + f.service_count ?? "", + autoAdjustedFilenames.has(f.filename) ? "si" : "", + f.status, + f.is_valid === null ? "" : String(f.is_valid), + f.error ?? "", + ]; + return hasNit ? [f.nit ?? "", ...base] : base; + }); downloadCsv(buildExportCsvFilename("conteo", job?.title), toCsv(headers, rows)); } @@ -399,8 +503,10 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE setAutoAdjustedFilenames(new Set()); setError(null); setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 }); - if (form.localRoot) { - indexLocalFolder(form.localRoot); + if (downloadJobIdRef.current) { + void indexFromDownload(downloadJobIdRef.current, downloadFiles); + } else if (form.localRoot) { + void indexLocalFolder(form.localRoot); } } @@ -423,58 +529,94 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE

Origen de las facturas

- Selecciona la carpeta local con los PDF de facturas médicas. - El servicio contará cuántos servicios contiene cada documento usando OCR. + {fromDownload + ? "Los PDF se leen directamente desde MinIO (todas las carpetas NIT de la corrida de descarga)." + : "Selecciona la carpeta local con los PDF de facturas médicas. El servicio contará cuántos servicios contiene cada documento usando OCR."}

-
- -
-
- {form.localRoot ? ( - <> - - {form.localRoot} - - ) : ( - Ninguna carpeta seleccionada - )} + {fromDownload ? ( +
+ +
+
+ + + downloads/{downloadJobId} + +
+
- + {manifest.length > 0 && !indexing && ( +
+ + + {manifest.length} PDF(s) en{" "} + {new Set(manifest.map((m) => m.nit).filter(Boolean)).size || 1}{" "} + carpeta(s) planas — listos para contar + +
+ )}
- - {manifest.length > 0 && !indexing && ( -
- - - {manifest.length} documento(s) encontrados y listos para contar - + ) : ( +
+ +
+
+ {form.localRoot ? ( + <> + + {form.localRoot} + + ) : ( + Ninguna carpeta seleccionada + )} +
+
- )} - {form.localRoot && manifest.length === 0 && !indexing && !error && ( -

No se encontraron PDF en esta carpeta.

- )} -
+ + {manifest.length > 0 && !indexing && ( +
+ + + {manifest.length} documento(s) encontrados y listos para contar + +
+ )} + {form.localRoot && manifest.length === 0 && !indexing && !error && ( +

No se encontraron PDF en esta carpeta.

+ )} +
+ )}
Opciones avanzadas
-
- - setForm((f) => ({ ...f, containerRoot: e.target.value }))} - placeholder="/data" - /> -

- Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD). -

-
+ {!fromDownload && ( +
+ + setForm((f) => ({ ...f, containerRoot: e.target.value }))} + placeholder="/data" + /> +

+ Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD). +

+
+ )}
@@ -236,15 +243,21 @@ function CountingResultsSection({ function exportCsv(subset: "valid" | "invalid") { const subset_files = subset === "valid" ? files.filter(isFileValid) : files.filter((f) => !isFileValid(f)); - const headers = ["filename", "radicado", "service_count", "status", "is_valid", "error"]; - const rows = subset_files.map((f) => [ - f.filename, - f.radicado ?? "", - f.service_count ?? "", - f.status, - f.is_valid === null ? "" : String(f.is_valid), - f.error ?? "", - ]); + const hasNit = subset_files.some((f) => f.nit); + const headers = hasNit + ? ["nit", "filename", "radicado", "service_count", "status", "is_valid", "error"] + : ["filename", "radicado", "service_count", "status", "is_valid", "error"]; + const rows = subset_files.map((f) => { + const base = [ + f.filename, + f.radicado ?? "", + f.service_count ?? "", + f.status, + f.is_valid === null ? "" : String(f.is_valid), + f.error ?? "", + ]; + return hasNit ? [f.nit ?? "", ...base] : base; + }); downloadCsv(buildExportCsvFilename("conteo", job.title, subset), toCsv(headers, rows)); } diff --git a/services/desktop_app/src/renderer/views/JobView.tsx b/services/desktop_app/src/renderer/views/JobView.tsx index d0ee822..0fa4401 100644 --- a/services/desktop_app/src/renderer/views/JobView.tsx +++ b/services/desktop_app/src/renderer/views/JobView.tsx @@ -149,6 +149,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio setCountingProgress(p.running ? p : null)} onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)} @@ -198,6 +199,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio onStartExtraction={(ctx?: Record) => onActivateSection("extraction", ctx)} onStartCleaning={(ctx?: Record) => onActivateSection("cleaning", ctx)} onStartDownload={() => onActivateSection("downloads")} + onContinueToCounting={(ctx) => onActivateSection("counting", ctx)} />
diff --git a/services/desktop_app/src/shared/types.ts b/services/desktop_app/src/shared/types.ts index e3922f8..5e8a2be 100644 --- a/services/desktop_app/src/shared/types.ts +++ b/services/desktop_app/src/shared/types.ts @@ -6,6 +6,10 @@ export interface ManifestEntry { docType?: string; localPath: string; size: number; + /** NIT / carpeta plana (resultados bot descarga MinIO). */ + nit?: string | null; + /** Object key en MinIO cuando el origen es descarga remota. */ + objectKey?: string | null; } export interface FileUploadData { @@ -117,6 +121,8 @@ export interface CountJobFile { is_valid: boolean | null; error: string | null; created_at: string; + nit?: string | null; + object_key?: string | null; } export interface CountJobFileCreateEntry { @@ -128,12 +134,16 @@ export interface CountJobFileCreateEntry { services?: Array<{ text: string; probability: number; is_service?: boolean | null }>; status?: "ok" | "error"; error?: string | null; + nit?: string | null; + object_key?: string | null; } export interface CreateCountJobPayload { title?: string; job_id?: string; results: CountJobFileCreateEntry[]; + download_job_id?: string | null; + source?: string | null; } export interface ExtractionFromCountPayload { diff --git a/services/extraction/api/main.py b/services/extraction/api/main.py index 90c48c1..7629c88 100644 --- a/services/extraction/api/main.py +++ b/services/extraction/api/main.py @@ -1107,12 +1107,16 @@ class CountJobFileCreate(BaseModel): services: list[dict[str, Any]] = Field(default_factory=list) status: str = "ok" error: str | None = None + nit: str | None = None + object_key: str | None = None class CreateCountJobRequest(BaseModel): title: str | None = None job_id: str | None = None # si se pasa, se usa el job existente results: list[CountJobFileCreate] + download_job_id: str | None = None + source: str | None = None class CountJobFileRead(BaseModel): @@ -1128,6 +1132,8 @@ class CountJobFileRead(BaseModel): is_valid: bool | None error: str | None created_at: str + nit: str | None = None + object_key: str | None = None class UpdateCountJobFileRequest(BaseModel): @@ -1163,6 +1169,8 @@ def serialize_count_file(f: CountJobFile) -> dict[str, Any]: "is_valid": f.is_valid, "error": f.error, "created_at": f.created_at.isoformat(), + "nit": getattr(f, "nit", None), + "object_key": getattr(f, "object_key", None), } @@ -1193,12 +1201,16 @@ def create_count_job(request: CreateCountJobRequest) -> dict[str, Any]: type="counting", status="completed", title=default_title, - request={}, + request={ + "source": request.source or ("from-download" if request.download_job_id else "desktop"), + "download_job_id": request.download_job_id, + }, result={ "total": total, "processed": total, "ok": sum(1 for r in request.results if r.status == "ok"), "failed": sum(1 for r in request.results if r.status != "ok"), + "nits": sorted({r.nit for r in request.results if r.nit}), }, started_at=utcnow(), finished_at=utcnow(), @@ -1215,6 +1227,8 @@ def create_count_job(request: CreateCountJobRequest) -> dict[str, Any]: filename=r.filename, file_path=r.file_path, doc_type=r.doc_type, + nit=r.nit, + object_key=r.object_key, service_count=r.service_count, services=r.services, status=r.status, diff --git a/services/extraction/infrastructure/database.py b/services/extraction/infrastructure/database.py index dfbc08a..46c8a01 100644 --- a/services/extraction/infrastructure/database.py +++ b/services/extraction/infrastructure/database.py @@ -111,6 +111,8 @@ class CountJobFile(Base): filename: Mapped[str] = mapped_column(String(512)) file_path: Mapped[str | None] = mapped_column(Text, nullable=True) doc_type: Mapped[str | None] = mapped_column(String(128), nullable=True) + nit: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + object_key: Mapped[str | None] = mapped_column(Text, nullable=True) service_count: Mapped[int | None] = mapped_column(Integer, nullable=True) services: Mapped[list[Any]] = mapped_column(JSON, default=list) status: Mapped[str] = mapped_column(String(32), default="ok", index=True) diff --git a/services/extraction/worker/tasks.py b/services/extraction/worker/tasks.py index f40a4be..252ea70 100644 --- a/services/extraction/worker/tasks.py +++ b/services/extraction/worker/tasks.py @@ -11,6 +11,7 @@ import logging import os from typing import Any +from sqlalchemy.orm.attributes import flag_modified from infrastructure.database import ExtractionRow, Task, session_scope, utcnow from infrastructure.serialization import flatten_extraction_result from api.desktop_ingest import ( @@ -128,7 +129,8 @@ async def process_document( # Persistir resultado en BD def _persist() -> dict[str, Any]: with session_scope() as session: - task = session.get(Task, task_id) + # Lock evita lost-update en result.processed con N workers concurrentes. + task = session.get(Task, task_id, with_for_update=True) if task is None: return {"skipped": True, "reason": "task_not_found"} @@ -185,6 +187,7 @@ async def process_document( result["row_count"] = row_index result["processed"] = int(result.get("processed") or 0) + 1 task.result = result + flag_modified(task, "result") task.updated_at = utcnow() session.flush() mark_task_completed_if_done(session, task, batch_id) @@ -206,7 +209,7 @@ async def process_document( def _record_error() -> None: try: with session_scope() as session: - task = session.get(Task, task_id) + task = session.get(Task, task_id, with_for_update=True) if task is None: return result = dict(task.result or {}) @@ -219,6 +222,7 @@ async def process_document( result["errors"] = errors result["processed"] = int(result.get("processed") or 0) + 1 task.result = result + flag_modified(task, "result") task.updated_at = utcnow() mark_task_completed_if_done(session, task, batch_id) except Exception as inner: