- name
- budget-comparison
- description
- Extraer, estructurar y comparar presupuestos de obra (PEMs) de construccion generados en PDFs (CYPE Arquimedes, Presto). Comparar oferta de constructora contra presupuesto base de ejecucion material. Generar informes y herramientas HTML interactivas.
- version
- 1.1.0
- tags
- ["presupuesto","construccion","CYPE","Presto","comparacion","PDF","obra"]
# Budget Comparison — Extraccion y comparacion de presupuestos de obra
## Cuándo usarlo
- El usuario adjunta un PDF de presupuesto de obra (PEM) y quiere compararlo
- El usuario pide \"comparar presupuestos\", \"detectar diferencias entre ofertas\"
- Hay un presupuesto base (proyecto) y una/multiple ofertas de constructoras
- Se necesita extraer datos estructurados de PDFs de construccion (300+ paginas)
## No es para
- Informes institucionales/regulatorios (usar `documentos-institucionales`)
- Presupuestos de software/IT (usar `pdf-to-dashboard`)
- Analisis de facturas (usar `liteparse-rust-pdf-ocr`)
## PDF-to-Dashboard — Extracción general de datos estructurados (absorbido de `pdf-to-dashboard`)
### Cuándo usarlo
- El usuario adjunta un PDF con datos tabulares/estructurados (presupuestos, informes técnicos, documentos de construcción)
- Se necesita extraer datos numéricos y generar visualización interactiva HTML con Aurora Design System
### Pipeline completo
1. **Extracción:** pdftotext → pdfplumber (recomendado) → pdfjs-dist (fallback)
2. **Análisis de estructura:** Entender patrón de texto (capítulos, partidas, importes)
3. **Parsing estructurado:** Regex para códigos, importes españoles, tablas
4. **Generación HTML Dashboard:** Aurora Design System, Chart.js, navegación por capítulos
### Patrones específicos de presupuestos CYPE Arquímedes
- Página 1: resumen mínimo, Páginas 2-N-1: detalle, Página N: resumen final con totales
- Cabeceras: "Presupuesto parcial nº X NOMBRE"
- Formato: `Cap-1`, `Cap-2`... (no `Cap-01`)
- Normalizar claves: `offer_key = f"Cap-{cap_num.zfill(2)}"` para matching con Presto
### ⚠️ pdfjs-dist compatibility
Usar `pdfjs-dist@3.11.174` — v2.x tiene API incompatible.
Usar `execute_code` para scripts largos, no `terminal()`.
### ⚠️ Formatos numéricos españoles
`1.234,56` → `parseFloat(str.replace(/\./g, '').replace(',', '.'))`
---
## Documentos Institucionales — Análisis de informes públicos (absorbido de `documentos-institucionales`)
### Cuándo usarlo
- Informes institucionales/regulatorios públicos (CNMC, REE, MITMS, BOE, Comisión Europea)
- Se necesita extracción PDF → auditoría estructurada → multi-formato (HTML resumen, LinkedIn, nota técnica)
### Diferencia con pdf-to-dashboard
- `pdf-to-dashboard`: datos tabulares → dashboard HTML interactivo
- `documentos-institucionales`: informe texto largo → análisis estructurado → múltiples formatos de salida
---
## Flujo de trabajo
### Paso 1: Extraer presupuesto base (referencia)
```python
import pdfplumber
# PDF CYPE Arquimedes — estructura fija:
# Pagina 1: resumen minimo
# Paginas 2-N-1: detalle de partidas
# Pagina N (ultima): resumen final con totales
with pdfplumber.open(pdf_path) as pdf:
# Extraer resumen final (ultima pagina)
page = pdf.pages[-1]
text = page.extract_text()
# Parsear capitulos: "1. MOVIMIENTO DE TIERRAS .............................… 873,19"
pattern = r'(\d+)\.\s+(.+?)\.{2,}…?\s+([\d.,]+)'
# Extraer detalle (paginas intermedias)
for i in range(1, len(pdf.pages) - 1):
page = pdf.pages[i]
text = page.extract_text()
tables = page.extract_tables()
```
### Paso 2: Extraer oferta de constructora
Los PDFs de ofertas suelen tener estructura diferente:
- **Pagina 1:** Portada (datos empresa, expediente, fecha)
- **Pagina 2-3:** Resumen con capitulos + totales + IVA + total contrata
- **Pagina 4-N:** Detalle de partidas
```python
# Resumen de oferta (pagina 2-3)
page2 = pdf.pages[1]
page3 = pdf.pages[2]
text2 = page2.extract_text()
text3 = page3.extract_text()
# Pattern: "01 MOVIMIENTO DE TIERRAS 1.863,37 0,17"
pattern = r'(\d{2})\s+(.+?)\s+([\d.,]+)\s+([\d.,]+)'
# Totales
total_match = re.search(r'TOTAL EJECUCION MATERIAL\s+([\d.,]+)', text)
iva_match = re.search(r'%\s*I\.?V\.?A\.?\s+([\d.,]+)', text)
contrata_match = re.search(r'TOTAL PRESUPUESTO CONTRATA\s+([\d.,]+)', text)
```
### Paso 3: Normalizar y comparar
**⚠️ Normalizacion de claves:** CYPE usa `Cap-1`, `Cap-2`... ofertas Presto usan `Cap-01`, `Cap-02`.
```python
# Normalizar claves para matching
def normalize_key(key):
# Extraer numero de capitulo
num = re.search(r'(\d+)', key)
if num:
return f"Cap-{num.group(1).zfill(2)}"
return key
# Comparar por capitulo
for ref_key, ref_data in referencia['capitulos'].items():
offer_key = normalize_key(ref_key)
if offer_key in oferta['capitulos']:
diff = oferta['capitulos'][offer_key]['total'] - ref_data['total']
diff_pct = (diff / ref_data['total'] * 100) if ref_data['total'] > 0 else 0
else:
diff = ref_data['total'] # capitulo eliminado en oferta
diff_pct = -100
```
### Paso 4: Generar herramienta HTML
Crear un HTML autocontenido con:
- Tabla comparativa con filtros y ordenacion
- Grafico de barras (referencia vs oferta + diferencias)
- Ranking de capitulos por mayor diferencia
- Detalle de partidas
## Patrones de parsing por herramienta
### CYPE Arquimedes
- Cabeceras: "Presupuesto parcial nº X NOMBRE" o "CAPÍTULO X NOMBRE"
- Resumen final en la ÚLTIMA pagina del PDF
- Formato: `Cap-1`, `Cap-2`...
- Partidas con codigos alfanumericos largos (ej: `m23E02AM010`)
- Subpartidas con referencias de planos (VC.T-2.1 [P16-P15])
### Presto
- Cabeceras: "CAPÍTULO X NOMBRE" con ceros a la izquierda (01, 02...)
- Formato: `Cap-01`, `Cap-02`...
- Resumen en paginas 2-3
### Presto moderno (GLAM-style) — Tablas rotas + extract_words por coordenadas X
**Problema:** Algunos PDFs Presto modernos tienen tablas donde la fila del capítulo entero está en una sola celda. `extract_tables()` devuelve filas con todo el texto junto y las columnas vacías.
**Solución:** Usar `extract_words()` con coordenadas X para mapear columnas:
```python
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
words = page.extract_words()
# Agrupar palabras por linea Y
lines = {}
for w in words:
y_key = round(w['top'], 0)
if y_key not in lines:
lines[y_key] = []
lines[y_key].append(w)
for y_key in sorted(lines.keys()):
line_words = sorted(lines[y_key], key=lambda w: w['x0'])
# Detectar linea de capítulo (tiene "Capítulo")
if 'Capítulo' not in [w['text'] for w in line_words]:
continue
# Columnas conocidas:
# Código: x~52 | Nat: x~109 | Ud: x~129 | Resumen: x~258
# CanPres: x~409 | PrPres: x~453 | ImpPres: x~501
# Extraer código (primera palabra)
code = line_words[0]['text']
# Extraer nombre (entre "Capítulo" y x~400)
name_words = [w['text'] for w in line_words
if w['text'] != 'Capítulo' and w['x0'] < 400]
name = ' '.join(name_words)
# Extrair total (ImpPres: x >= 500)
imp_words = [w for w in line_words
if w['x0'] >= 500 and w['text'] != '€']
if imp_words:
imp_str = ''.join(w['text'] for w in imp_words)
total = parse_es_amount(imp_str)
```
> **Pitfall:** Los números españoles "2.041,83" se extraen como una sola palabra. Pero números con más de 4 dígitos como "2 0.316,59" se separan en palabras distintas ("2", "0.316,59"). Hay que concatenar todas las palabras en la columna ImpPres ANTES de parsear.
### ⚡ CRÍTICO: Regex para extraer precios — el bug del split numérico
**Problema:** El regex `([\d\s.,]+)\s*€` captura partes incorrectas cuando el número tiene espacios internos. Ejemplo:
```
LÍNEA: "01 Capítulo MOVIMIENTO DE TIERRAS 1,00 2.041,83 € 2.041,83 €"
price_parts = [' 1,00 2.041,83 ', ' 2.041,83 ']
# Si concatenas las dos últimas partes: "1,00 2.041,832.041,83" → parse error
```
**Solución (PROMOVER):** Usar el ÚLTIMO valor antes del último `€`, no concatenar partes:
```python
# CORRECTO: encontrar el último € y tomar todo lo que hay antes
last_euro_idx = rest.rfind('€')
before_last_euro = rest[:last_euro_idx].strip()
num_match = re.search(r'([\d][\d\s.,]*)$', before_last_euro)
num_str = num_match.group(1).strip()
clean = num_str.replace(' ', '').replace('.', '').replace(',', '.')
total = float(clean)
```
> **Por qué funciona:** En Presto, la columna `ImpPres` (importe total) siempre es el último valor antes del último `€`. No necesitas concatenar partes — solo tomar el último número.
> **Pitfall alternativo:** Si usas `extract_words()` por coordenadas X, los números se extraen como palabras individuales. En ese caso sí necesitas concatenar palabras adyacentes en la columna ImpPres.
## ⚠️ CRÍTICO: Validación del total extraído
**Problema frecuente:** La suma de TODAS las líneas de "Capítulo" extraídas da un número MUY superior al Presupuesto General del documento. Esto ocurre porque:
1. **Padres e hijos duplicados:** Presto tiene capítulos padre (ej: `SAN.07.01`) que incluyen sus hijos (`SAN.07.01.01`, `.02`, `.03`). Si sumas todo, los duplicas.
2. **Complementos de materiales incluidos:** `CA` (Micropilotes), `EA` (Acero), `EH` (Hormigón armado) están incluidos en capítulos 03, 04, 05. No se suman aparte.
3. **Instalaciones dentro de capítulos:** ELE, FON, TEL, TER, VMC están DENTRO de los capítulos numéricos (13, 15, 14, 17, 18). No son capítulos independientes.
**Procedimiento de validación (PASO OBLIGATORIO):**
1. **Siempre leer el total del documento primero:**
```python
# Última página del PDF — buscar "PRESUPUESTO_P" o "PRESUPUESTO GENERAL"
last_page = pdf.pages[-1]
text = last_page.extract_text()
# Buscar línea con "PRESUPUESTO_P" o "PRESUPUESTO GENERAL"
total_match = re.search(r'PRESUPUESTO[_\s]*(?:GENERAL)?[_\s]*P?\s+[\d\s.,]+\s*€', text)
documento_total = extract_total_from_line(last_page, text)
```
2. **Extraer TODAS las líneas de "Capítulo"** (sin filtrar).
3. **Identificar padres e hijos:**
- Un código es hijo si otro código en la lista empieza con `codigo + '.'`
- Ej: `SAN.07.01.01` es hijo de `SAN.07.01`
4. **Para cada grupo padre-hijos, decidir cuál usar:**
- Si `padre ≈ hijos` (ratio 0.9-1.1): usar padre (incluye hijos)
- Si `hijos > padre * 1.1`: usar hijos (padre es sección, no total)
- Si `padre < hijos < padre * 1.1`: usar padre (es resumen parcial)
- **⚠️ Si `padre > hijos`: el padre puede incluir MÁS cosas no listadas** → usar padre
5. **Sumar solo los capítulos seleccionados** y comparar con `documento_total`.
- Si la diferencia es < 1% → correcto.
- Si no, revisar qué capítulos sobran o faltan.
**⚠️ Pitfall avanzado:** Algunos padres tienen ratio ~0.6 (ej: SAN.07.01 = 20.316 € vs hijos = 12.373 €). Esto significa que el padre es un resumen PARCIAL que no incluye todos los hijos. En este caso, usar el padre (es el valor oficial del capítulo).
**⚠️ Pitfall avanzado 2:** Algunos padres tienen ratio > 1.5 (ej: 25 = 3.589 € vs hijos = 36.906 €). Esto significa que el padre es un NOMBRE DE SECCIÓN, no un total. Los hijos son los capítulos reales. Usar hijos.
**⚠️ Pitfall avanzado 3:** En Presto moderno, TODOS los códigos pueden ser "hijos" de algún otro código (ej: "03.04.01" no es hijo de "01" aunque contiene "01"). La función `is_child()` debe verificar `code.startswith(other + '.')` EXACTO, no solo contiene.
**Patrón común en Presto/GLAM:**
- Capítulos principales: `01` a `27` (obra civil, acabados, instalaciones incluidas)
- SAN.07.01 puede estar fuera de los capítulos numéricos (saneamiento de red)
- ELE.03.01-07 están dentro del capítulo 13
- FON.02.01.01-05 están dentro del capítulo 15
- TEL.10.01-04 están dentro del capítulo 14
- TER.01.01-05 están dentro del capítulo 17
- VMC.09.01.01-03 + VMC09.02 están dentro del capítulo 18
- CA, EA, EH son complementos de materiales (excluir)
### Herramienta recomendada
**pdfplumber** (no PyMuPDF/fitz) — funciona mejor con CYPE:
```bash
/opt/hermes/.venv/bin/python3 -c "import pdfplumber; print('ok')"
```
> **Pitfall:** pdfplumber necesita el venv de Hermes (`/opt/hermes/.venv/bin/python3`). No funciona con el python3 del sistema.
## ⚠️ CRÍTICO: GLAM puede ser igual al CYPE/Dmarche
**Problema frecuente:** El presupuesto de GLAM (constructora) tiene los mismos importes base que el CYPE/Dmarche (proyecto básico). Esto ocurre porque GLAM usa los mismos precios base del proyecto.
**Solución:** NO asumir que GLAM siempre tiene importes diferentes al CYPE. Verificar siempre con la extracción real del PDF.
**Regla:** El comparador debe mostrar SIEMPRE las 3 columnas: Referencia (Dmarche/CYPE), Constructora 1 (GLAM), Constructora 2 (Trevicon/u otra). NO eliminar ninguna columna automáticamente. El usuario decide qué significa cada una.
在 GitHub 查看