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.

This commit is contained in:
erik
2026-07-17 08:32:00 -05:00
parent e47ef4f8c6
commit dc3fcc2280
89 changed files with 5331 additions and 1210 deletions
Binary file not shown.
+19 -11
View File
@@ -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:
+29 -19
View File
@@ -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/)
@@ -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](../).
+78
View File
@@ -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}
+28
View File
@@ -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
+20
View File
@@ -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}
+29
View File
@@ -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}%
@@ -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}
}
@@ -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}
@@ -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}
@@ -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}
@@ -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.
@@ -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.
}
@@ -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}
@@ -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}
+28 -16
View File
@@ -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}
+40
View File
@@ -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}
@@ -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}
+4 -2
View File
@@ -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
+164 -121
View File
@@ -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}
+92 -72
View File
@@ -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
+6 -17
View File
@@ -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}
+96 -76
View File
@@ -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}%
+15 -9
View File
@@ -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}
}
@@ -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.
@@ -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).
@@ -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.
@@ -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}
@@ -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}
@@ -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}
@@ -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}
@@ -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.
@@ -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.
@@ -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}
@@ -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}
@@ -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]
@@ -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.
@@ -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}
@@ -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}
@@ -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
1 file http_time_s http_code service_count status render_ms ocr_ms cluster_ms total_ms
2 500000284335937.pdf 4.449556 2.419290 200 1 ok 155 2279 1 2435
3 500000284335940.pdf 2.290720 2.189024 200 1 ok 134 1820 0 1954
4 500000284335954.pdf 2.190165 1.615784 200 1 ok 127 1851 0 1980
5 500000284335956.pdf 1.956593 200 1 ok 147 1846 1 1994
6 500000284335963.pdf 1.981622 200 1 ok 132 1689 1 1823
7 500000284335964.pdf 1.696154 200 1 ok 117 1837 0 1955
8 500000284335968.pdf 1.948234 200 1 ok 150 1813 1 1965
9 500000284335997.pdf 1.659651 200 1 ok 125 1746 1 1873
10 500000284336071.pdf 1.667741 200 1 ok 127 1808 0 1937
11 500000284336521.pdf 1.741292 200 1 ok 130 1816 0 1947
12 500000284336836.pdf 1.916852 200 1 ok 143 2285 1 2430
13 500000284336915.pdf 1.679965 200 1 ok 144 1853 0 1999
14 500000284337016.pdf 1.666228 200 1 ok 138 1877 0 2016
15 500000284337418.pdf 1.642242 200 1 ok 133 1829 1 1963
16 500000284337861.pdf 1.768077 200 1 ok 142 1730 1 1875
17 500000284338027.pdf 1.677103 200 1 ok 116 2068 0 2186
18 500000284338092.pdf 1.667427 200 1 ok 109 1792 0 1901
19 500000284338214.pdf 1.781158 200 1 ok 128 1873 0 2002
20 500000284338327.pdf 1.796004 200 1 ok 126 1897 0 2024
21 500000284338353.pdf 1.803265 200 1 ok 108 1731 0 1840
22 500000284338458.pdf 1.824801 200 1 ok 135 2144 0 2280
23 500000284338503.pdf 1.897579 200 1 ok 117 1766 0 1884
24 500000284338624.pdf 1.635177 200 1 ok 126 1788 0 1916
@@ -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,
1 file wall_s http_code status queued cost_usd_est error
2 500000284335937.pdf 9.944 200 ok 0 0.0257
3 500000284335940.pdf 7.925 200 ok 0 0.0257
4 500000284335954.pdf 7.659 200 ok 0 0.0257
5 500000284335956.pdf 7.114 200 ok 0 0.0257
6 500000284335963.pdf 6.997 200 ok 0 0.0257
7 500000284335964.pdf 7.889 200 ok 0 0.0257
8 500000284335968.pdf 7.001 200 ok 0 0.0257
9 500000284335997.pdf 6.822 200 ok 0 0.0257
10 500000284336071.pdf 6.463 200 ok 0 0.0257
11 500000284336521.pdf 7.472 200 ok 0 0.0257
12 500000284336836.pdf 7.199 200 ok 0 0.0257
13 500000284336915.pdf 7.094 200 ok 0 0.0257
14 500000284337016.pdf 6.874 200 ok 0 0.0257
15 500000284337418.pdf 7.146 200 ok 0 0.0257
16 500000284337861.pdf 6.816 200 ok 0 0.0257
17 500000284338027.pdf 7.161 200 ok 0 0.0257
18 500000284338092.pdf 7.927 200 ok 0 0.0257
19 500000284338214.pdf 6.563 200 ok 0 0.0257
20 500000284338327.pdf 8.655 200 ok 0 0.0257
21 500000284338353.pdf 6.963 200 ok 0 0.0257
22 500000284338458.pdf 7.327 200 ok 0 0.0257
23 500000284338503.pdf 6.672 200 ok 0 0.0257
24 500000284338624.pdf 6.816 200 ok 0 0.0257
@@ -0,0 +1 @@
file,wall_s,http_code,status,queued,error
1 file wall_s http_code status queued error
@@ -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,
1 file wall_s http_code status service_count error
2 500000284335954.pdf 17.562 200 ok 1
3 500000284335940.pdf 18.557 200 ok 1
4 500000284335937.pdf 19.556 200 ok 1
5 500000284335956.pdf 35.423 200 ok 1
6 500000284335963.pdf 35.825 200 ok 1
7 500000284335964.pdf 35.909 200 ok 1
8 500000284335997.pdf 42.798 200 ok 1
9 500000284336071.pdf 41.739 200 ok 1
10 500000284335968.pdf 44.679 200 ok 1
@@ -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,
1 radicado file enqueue_s http_code status queued error
2 500000284335937_0000 500000284335937.pdf 0.23 200 queued 1
3 500000284335940_0001 500000284335940.pdf 0.042 200 queued 1
4 500000284335954_0002 500000284335954.pdf 0.026 200 queued 1
5 500000284335956_0003 500000284335956.pdf 0.042 200 queued 1
6 500000284335963_0004 500000284335963.pdf 0.052 200 queued 1
7 500000284335964_0005 500000284335964.pdf 0.025 200 queued 1
8 500000284335968_0006 500000284335968.pdf 0.028 200 queued 1
9 500000284335997_0007 500000284335997.pdf 0.035 200 queued 1
10 500000284336071_0008 500000284336071.pdf 0.029 200 queued 1
11 500000284336521_0009 500000284336521.pdf 0.028 200 queued 1
12 500000284336836_0010 500000284336836.pdf 0.031 200 queued 1
13 500000284336915_0011 500000284336915.pdf 0.041 200 queued 1
14 500000284337016_0012 500000284337016.pdf 0.046 200 queued 1
15 500000284337418_0013 500000284337418.pdf 0.043 200 queued 1
16 500000284337861_0014 500000284337861.pdf 0.058 200 queued 1
17 500000284338027_0015 500000284338027.pdf 0.045 200 queued 1
18 500000284338092_0016 500000284338092.pdf 0.084 200 queued 1
19 500000284338214_0017 500000284338214.pdf 0.041 200 queued 1
20 500000284338327_0018 500000284338327.pdf 0.057 200 queued 1
21 500000284338353_0019 500000284338353.pdf 0.027 200 queued 1
22 500000284338458_0020 500000284338458.pdf 0.023 200 queued 1
23 500000284338503_0021 500000284338503.pdf 0.028 200 queued 1
24 500000284338624_0022 500000284338624.pdf 0.042 200 queued 1
25 500000284335937_0023 500000284335937.pdf 0.026 200 queued 1
26 500000284335940_0024 500000284335940.pdf 0.021 200 queued 1
27 500000284335954_0025 500000284335954.pdf 0.026 200 queued 1
28 500000284335956_0026 500000284335956.pdf 0.022 200 queued 1
29 500000284335963_0027 500000284335963.pdf 0.023 200 queued 1
30 500000284335964_0028 500000284335964.pdf 0.028 200 queued 1
31 500000284335968_0029 500000284335968.pdf 0.033 200 queued 1
+101 -103
View File
@@ -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}
+74 -76
View File
@@ -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
+18 -21
View File
@@ -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}
+53 -55
View File
@@ -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}%
+7 -8
View File
@@ -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}
}
+20
View File
@@ -47,11 +47,31 @@ k6 run -e PROFILE=shared8g -e DOWNLOAD_JOB_ID=<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`.
@@ -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())
+89 -5
View File
@@ -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.
@@ -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);
@@ -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
@@ -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())
@@ -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" "$@"
@@ -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
@@ -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__")
@@ -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" "$@"
@@ -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
@@ -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())
@@ -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" "$@"
@@ -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
@@ -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())
@@ -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" "$@"
@@ -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
@@ -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())
@@ -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" "$@"
+48 -11
View File
@@ -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}
+15 -6
View File
@@ -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-<SERVICIO>-<NN>} --- unitarias
\item \texttt{IT-<SERVICIO>-<NN>} --- integraci\'on
\item \texttt{UT-<SERVICIO>-<NN>} --- unitarias (riesgo de regresi\'on de dominio)
\item \texttt{IT-<SERVICIO>-<NN>} --- integraci\'on (contratos HTTP + infra)
\item \texttt{E2E-<NN>} --- end-to-end de negocio
\item \texttt{ST-<AREA>-<NN>} --- estr\'es / carga
\item \texttt{ST-<AREA>-<NN>} --- estr\'es / carga (capacidad y SLO)
\item \texttt{CH-<NN>} --- 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}
+17 -14
View File
@@ -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}
+19 -16
View File
@@ -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]
@@ -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.
+37
View File
@@ -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,
@@ -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
@@ -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"
@@ -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")
@@ -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<Set<string>>(new Set());
const [toggling, setToggling] = useState<Set<string>>(new Set());
const [collapsedNits, setCollapsedNits] = useState<Set<string>>(new Set());
const tagInputRef = useRef<HTMLInputElement>(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<string, CountJobFile[]>();
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({
<span className="wiz-stat-value">{summary.total}</span>
<span className="wiz-stat-label">Total</span>
</div>
{folderCount > 0 && (
<div className="wiz-stat">
<span className="wiz-stat-value">{folderCount}</span>
<span className="wiz-stat-label">Carpetas</span>
</div>
)}
<div className="wiz-stat count-stat--valid">
<span className="wiz-stat-value">{summary.valid}</span>
<span className="wiz-stat-label">Válidos</span>
@@ -157,6 +200,12 @@ export function CountResultsTable({
</div>
)}
</div>
{folderCount > 0 && (
<p className="wiz-hint" style={{ marginTop: -8, marginBottom: 12 }}>
{folderCount} carpeta{folderCount === 1 ? "" : "s"} · {summary.total} PDF
{summary.total === 1 ? "" : "s"}
</p>
)}
{/* ── Filtro por conteo exacto ── */}
<div className="count-threshold">
@@ -241,7 +290,7 @@ export function CountResultsTable({
<div className="wiz-search-box">
<Search size={14} />
<input
placeholder="Buscar archivo…"
placeholder={folderCount > 0 ? "Buscar archivo o NIT…" : "Buscar archivo…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -275,109 +324,231 @@ export function CountResultsTable({
</td>
</tr>
)}
{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 (
<>
<tr key={f.id} className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{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 (
<Fragment key={`nit-${nit}`}>
<tr className="count-row count-row--group">
<td colSpan={4} className="count-td">
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
onClick={() => toggleNitGroup(nit)}
style={{ display: "inline-flex", alignItems: "center", gap: 8, width: "100%" }}
>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{collapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
<strong>NIT {nit}</strong>
<span className="count-radicado">
{groupFiles.length} PDF · {servicesSum} servicios
</span>
</button>
) : (
<span className="count-expand-placeholder" />
)}
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
</td>
</tr>
{!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 (
<Fragment key={f.id}>
<tr className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{hasServices ? (
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
) : (
<span className="count-expand-placeholder" />
)}
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
>
auto→1
</span>
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
</div>
))}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</Fragment>
);
})
: 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 (
<Fragment key={f.id}>
<tr className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{hasServices ? (
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
>
auto→1
</span>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
) : (
<span className="count-expand-placeholder" />
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr key={`${f.id}-services`} className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
>
auto→1
</span>
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
))}
</div>
</td>
</tr>
)}
</>
);
})}
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
</div>
))}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
@@ -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<CountingStep>(1);
const [maxReached, setMaxReached] = useState<CountingStep>(1);
const [form, setForm] = useState<CountingFormState>({
@@ -95,6 +135,7 @@ export function CountingWizard({ client, localRoot, containerRoot, job, onStartE
const [error, setError] = useState<string | null>(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<ArrayBuffer> {
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
<div className="wiz-section">
<h3 className="wiz-section-title">Origen de las facturas</h3>
<p className="wiz-desc">
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."}
</p>
</div>
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
{fromDownload ? (
<div className="wiz-section">
<label className="wiz-field-label">Corrida MinIO</label>
<div className="folder-picker">
<div className="folder-picker-display">
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">
downloads/{downloadJobId}
</span>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => downloadJobId && void indexFromDownload(downloadJobId)}
disabled={indexing}
>
{indexing ? <Loader2 size={14} className="spin" /> : <RotateCcw size={14} />}
{indexing ? "Indexando…" : "Recargar"}
</button>
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> PDF(s) en{" "}
<strong>{new Set(manifest.map((m) => m.nit).filter(Boolean)).size || 1}</strong>{" "}
carpeta(s) planas — listos para contar
</span>
</div>
)}
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
) : (
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
</div>
)}
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
</div>
)}
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
)}
<details className="wiz-advanced">
<summary className="wiz-advanced-trigger">Opciones avanzadas</summary>
<div className="wiz-advanced-body">
<div className="wiz-section">
<label className="wiz-field-label">Ruta en contenedor Docker</label>
<input
type="text"
value={form.containerRoot}
onChange={(e) => setForm((f) => ({ ...f, containerRoot: e.target.value }))}
placeholder="/data"
/>
<p className="wiz-hint">
Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD).
</p>
</div>
{!fromDownload && (
<div className="wiz-section">
<label className="wiz-field-label">Ruta en contenedor Docker</label>
<input
type="text"
value={form.containerRoot}
onChange={(e) => setForm((f) => ({ ...f, containerRoot: e.target.value }))}
placeholder="/data"
/>
<p className="wiz-hint">
Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD).
</p>
</div>
)}
<div className="wiz-checks-row">
<label className="wiz-check-label">
@@ -262,6 +262,21 @@ export class ApiClient {
return this.handle<DownloadJobFilesResponse>(response);
}
/** Descarga bytes de un PDF del resultado MinIO (proxy bot-descarga). */
async fetchDownloadObject(jobId: string, key: string): Promise<ArrayBuffer> {
const qs = new URLSearchParams({ key });
const response = await fetchWithTimeout(
this.url(`/bot-descarga/jobs/${encodeURIComponent(jobId)}/object?${qs.toString()}`),
{ headers: this.botHeaders() },
120_000,
);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || `Error ${response.status} al leer objeto MinIO`);
}
return response.arrayBuffer();
}
async getDownloadEvents(jobId: string, since = 0): Promise<{ events: DownloadJobEvent[] }> {
const response = await fetchWithTimeout(
this.url(`/bot-descarga/jobs/${encodeURIComponent(jobId)}/events?since=${since}`),
@@ -1,5 +1,5 @@
import { CountingWizard } from "../../components/CountingWizard";
import type { BackgroundProgress } from "../../../shared/types";
import type { BackgroundProgress, DownloadJobFile } from "../../../shared/types";
import type { ModuleWizardProps } from "../registry";
interface Props extends ModuleWizardProps {
@@ -9,21 +9,41 @@ interface Props extends ModuleWizardProps {
/**
* Wrapper del CountingWizard para el sistema de módulos.
* Pasa el job al wizard para que los resultados queden vinculados.
* Si el contexto trae downloadJobId (desde Resultados → Continuar a conteo),
* indexa PDFs desde MinIO en todas las carpetas NIT planas.
*/
export function CountingWizardModule({ client, job, onTaskCreated, onActivateModule, onProgress }: Props) {
export function CountingWizardModule({
client,
job,
context,
onTaskCreated,
onActivateModule,
onProgress,
}: Props) {
const folderPath = String(job.metadata?.folder_path ?? "");
const containerRoot = "/data";
const downloadJobId = context?.downloadJobId as string | undefined;
const downloadFiles = context?.files as DownloadJobFile[] | undefined;
return (
<CountingWizard
key={downloadJobId ? `dl-${downloadJobId}` : `local-${folderPath || "default"}`}
client={client}
job={job}
localRoot={folderPath}
localRoot={downloadJobId ? "" : folderPath}
containerRoot={containerRoot}
downloadJobId={downloadJobId}
downloadFiles={downloadFiles}
onTaskCreated={onTaskCreated}
onProgress={onProgress}
onStartExtraction={(countingTaskId, validCount, validCounts) => {
onActivateModule?.("extraction", { countingTaskId, validCount, validCounts, jobId: job.id });
onActivateModule?.("extraction", {
countingTaskId,
validCount,
validCounts,
jobId: job.id,
downloadJobId,
});
}}
/>
);
@@ -82,6 +82,9 @@ export function JobExtractionWizard({
const countingTaskId = countingTaskIdProp ?? (context?.countingTaskId as string | undefined);
const validCounts = validCountsProp ?? (context?.validCounts as number[] | undefined);
const validCount = validCountProp ?? (context?.validCount as number | undefined);
const downloadJobId =
(context?.downloadJobId as string | undefined) ??
undefined;
const [form, setForm] = useState<ExtractionFormState>(() => makeDefaultForm(job, !!countingTaskId));
const [schemaResponse, setSchemaResponse] = useState<SchemaResponse | null>(null);
@@ -100,6 +103,7 @@ export function JobExtractionWizard({
/** Rutas de muestras ya usadas en descubrimiento (para no repetir al reintentar). */
const [usedDiscoveryPaths, setUsedDiscoveryPaths] = useState<string[]>([]);
const [discoverySampleLabels, setDiscoverySampleLabels] = useState<string[]>([]);
const [resolvedDownloadJobId, setResolvedDownloadJobId] = useState<string | undefined>(downloadJobId);
const flattened = useMemo(
() => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }),
@@ -115,19 +119,69 @@ export function JobExtractionWizard({
// Paso 3: necesita al menos un campo seleccionado
(extractionStep === 3 && selection.fields.length > 0);
// Auto-indexar la carpeta del job al montar (usada como muestra para descubrir campos)
// Auto-indexar: carpeta local, o archivos del conteo desde MinIO
useEffect(() => {
if (countingTaskId) {
void (async () => {
try {
const [countFiles, countTask] = await Promise.all([
client.countJobFiles(countingTaskId),
client.countJob(countingTaskId).catch(() => null),
]);
const req = (countTask?.request ?? {}) as Record<string, unknown>;
const dlId =
downloadJobId ||
(typeof req.download_job_id === "string" ? req.download_job_id : undefined);
if (dlId) setResolvedDownloadJobId(dlId);
const fromMinio = countFiles.some((f) => f.object_key);
if (fromMinio) {
const entries: ManifestEntry[] = countFiles
.filter((f) => f.object_key)
.map((f) => ({
radicado: f.radicado || f.filename.replace(/\.pdf$/i, ""),
filename: f.filename,
localPath: f.object_key!,
size: 0,
nit: f.nit ?? null,
objectKey: f.object_key!,
}));
setManifest(entries);
setForm((cur) => ({ ...cur, mode: "general" }));
setStatusText(
`${entries.length} documento(s) desde MinIO` +
(new Set(entries.map((e) => e.nit).filter(Boolean)).size
? ` · ${new Set(entries.map((e) => e.nit).filter(Boolean)).size} carpetas`
: ""),
);
return;
}
} catch {
/* fallback a carpeta local abajo */
}
})();
}
const folder = form.localRoot || form.selectedLocalPath;
if (!folder || manifest.length > 0) return;
if (!folder || manifest.length > 0 || countingTaskId) return;
indexFolderAt(folder, form.mode, form.docTypes)
.then((entries) => {
setManifest(entries);
// Asegurar que selectedLocalPath esté sincronizado
setForm((cur) => ({ ...cur, selectedLocalPath: folder, selectedContainerPath: folder }));
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [countingTaskId]);
async function readEntryBytes(entry: ManifestEntry): Promise<{ filename: string; data: ArrayBuffer }> {
if (entry.objectKey && resolvedDownloadJobId) {
const data = await client.fetchDownloadObject(resolvedDownloadJobId, entry.objectKey);
return { filename: entry.filename, data };
}
if (!window.desktopApi) throw new Error("Requiere Electron.");
const fileData = await window.desktopApi.readFile(entry.localPath);
return { filename: fileData.filename, data: fileData.data };
}
function updateForm(patch: Partial<ExtractionFormState>) {
setForm((cur) => {
@@ -204,7 +258,8 @@ export function JobExtractionWizard({
if (candidates.length === 0) {
throw new Error("No hay documentos candidatos para la muestra.");
}
const unused = candidates.filter((e) => !exclude.has(e.localPath));
const keyOf = (e: ManifestEntry) => e.objectKey || e.localPath;
const unused = candidates.filter((e) => !exclude.has(keyOf(e)));
const pool = unused.length > 0 ? unused : candidates;
if (!preferRandom && unused.length === candidates.length) {
return candidates[0];
@@ -217,17 +272,35 @@ export function JobExtractionWizard({
await runTask(
randomOther ? "Probando con otro archivo…" : "Descubriendo campos...",
async () => {
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos.");
const exclude = new Set(randomOther ? usedDiscoveryPaths : []);
const responses: SchemaResponse[] = [];
const picked: ManifestEntry[] = [];
if (form.mode === "general") {
const fromMinio = manifest.some((m) => m.objectKey);
const nits = [
...new Set(manifest.map((m) => (m.nit || "").trim()).filter(Boolean)),
].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
// Desde descarga multi-NIT: 1 muestra por carpeta plana, luego merge.
if (fromMinio && form.mode === "general" && nits.length > 0) {
for (const nit of nits) {
const pool = manifest.filter((m) => (m.nit || "").trim() === nit);
const entry = pickSampleEntry(pool, exclude, randomOther);
picked.push(entry);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(
buildSchemaUploadForm(form, blob, fileData.filename),
),
);
}
} else if (form.mode === "general") {
const entry = pickSampleEntry(manifest, exclude, randomOther);
picked.push(entry);
const fileData = await window.desktopApi.readFile(entry.localPath);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(
@@ -242,7 +315,7 @@ export function JobExtractionWizard({
}
const entry = pickSampleEntry(typed, exclude, randomOther);
picked.push(entry);
const fileData = await window.desktopApi.readFile(entry.localPath);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(
@@ -260,11 +333,13 @@ export function JobExtractionWizard({
aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])),
});
setEstimateJson(null);
const labels = picked.map((e) => e.filename);
const labels = picked.map((e) =>
e.nit ? `${e.nit}/${e.filename}` : e.filename,
);
setDiscoverySampleLabels(labels);
setUsedDiscoveryPaths((prev) => {
const next = new Set(randomOther ? prev : []);
for (const e of picked) next.add(e.localPath);
for (const e of picked) next.add(e.objectKey || e.localPath);
return [...next];
});
setStatusText(
@@ -285,11 +360,10 @@ export function JobExtractionWizard({
async function estimate() {
await runTask("Estimando costo...", async () => {
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (!selection.fields.length) throw new Error("Selecciona al menos un campo.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta.");
const sampleEntry = manifest[0];
const fileData = await window.desktopApi.readFile(sampleEntry.localPath);
const fileData = await readEntryBytes(sampleEntry);
const blob = new Blob([fileData.data]);
const uniqueRadicados = new Set(manifest.map((e) => e.radicado)).size;
const formData = buildEstimateUploadForm(form, blob, fileData.filename, uniqueRadicados, selection.fields, form.model);
@@ -310,22 +384,32 @@ export function JobExtractionWizard({
async function runExtraction() {
await runTask("Iniciando extracción...", async () => {
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta.");
let workManifest: ManifestEntry[];
// ── Caso 1: desde conteo previo — filtrar manifest a los archivos válidos ──
// Caso 1: desde conteo previo — filtrar manifest a los archivos válidos
if (countingTaskId) {
const countFiles = await client.countJobFiles(countingTaskId);
const validKeys = new Set(
countFiles
.filter(isCountFileValid)
.map((f) => f.object_key || `${f.nit ?? ""}|${f.filename}`),
);
const validFilenames = new Set(
countFiles.filter(isCountFileValid).map((f) => f.filename),
);
workManifest = manifest.filter((e) => validFilenames.has(e.filename));
workManifest = manifest.filter((e) => {
if (e.objectKey) return validKeys.has(e.objectKey);
if (e.nit) return validKeys.has(`${e.nit}|${e.filename}`);
return validFilenames.has(e.filename);
});
if (workManifest.length === 0) {
throw new Error(
"No se encontraron archivos válidos del conteo en la carpeta. " +
"Asegúrate de que los PDFs válidos están en la carpeta seleccionada.",
"No se encontraron archivos válidos del conteo." +
(manifest.some((m) => m.objectKey)
? " Verifica el filtro de conteos válidos."
: " Asegúrate de que los PDFs válidos estén en la carpeta seleccionada."),
);
}
} else {
@@ -378,7 +462,7 @@ export function JobExtractionWizard({
running: true,
label: `Subiendo archivos (${queued + fi}/${total})…`,
});
files.push(await window.desktopApi!.readFile(batch[fi].localPath));
files.push(await readEntryBytes(batch[fi]));
}
const blobs = files.map((f) => new Blob([f.data]));
onProgress?.({
@@ -11,7 +11,7 @@ import {
Sparkles,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { BackgroundProgress, CleaningJob, CountJobFile, Job, Task } from "../../shared/types";
import type { BackgroundProgress, CleaningJob, CountJobFile, DownloadJobFile, Job, Task } from "../../shared/types";
import { CountResultsTable } from "../components/CountResultsTable";
import { ExtractionResultsTable } from "../components/ExtractionResultsTable";
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
@@ -33,6 +33,11 @@ interface Props {
onStartExtraction: (ctx?: Record<string, unknown>) => void;
onStartCleaning: (ctx?: Record<string, unknown>) => void;
onStartDownload: () => void;
onContinueToCounting?: (ctx: {
downloadJobId: string;
downloadsPrefix?: string;
files?: DownloadJobFile[];
}) => void;
}
export function JobResultsView({
@@ -47,6 +52,7 @@ export function JobResultsView({
onStartExtraction,
onStartCleaning,
onStartDownload,
onContinueToCounting,
}: Props) {
const [activeTab, setActiveTab] = useState<ResultTab>("counting");
const [refreshing, setRefreshing] = useState(false);
@@ -177,6 +183,7 @@ export function JobResultsView({
job={job}
tasks={downloadTasks}
onStartDownload={onStartDownload}
onContinueToCounting={onContinueToCounting}
/>
)}
</div>
@@ -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));
}
@@ -149,6 +149,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
<CountingWizardModule
client={client}
job={job}
context={sectionContext}
onTaskCreated={handleTaskCreated}
onProgress={(p) => 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<string, unknown>) => onActivateSection("extraction", ctx)}
onStartCleaning={(ctx?: Record<string, unknown>) => onActivateSection("cleaning", ctx)}
onStartDownload={() => onActivateSection("downloads")}
onContinueToCounting={(ctx) => onActivateSection("counting", ctx)}
/>
</div>
</div>
+10
View File
@@ -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 {
+15 -1
View File
@@ -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,
@@ -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)
+6 -2
View File
@@ -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: