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
- Extracción: pdftotext → pdfplumber (recomendado) → pdfjs-dist (fallback)
- Análisis de estructura: Entender patrón de texto (capítulos, partidas, importes)
- Parsing estructurado: Regex para códigos, importes españoles, tablas
- 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... (noCap-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 interactivodocumentos-institucionales: informe texto largo → análisis estructurado → múltiples formatos de salida
Flujo de trabajo
Paso 1: Extraer presupuesto base (referencia)
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
# 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.
# 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:
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:
# 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:
- 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. - 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. - 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):
Siempre leer el total del documento primero:
# Ú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)Extraer TODAS las líneas de "Capítulo" (sin filtrar).
Identificar padres e hijos:
- Un código es hijo si otro código en la lista empieza con
codigo + '.' - Ej:
SAN.07.01.01es hijo deSAN.07.01
- Un código es hijo si otro código en la lista empieza con
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
- Si
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:
01a27(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:
/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.
Flujo actualizado — Comparador tripartito (caso Nogal 9)
Arquitectura de datos: Un solo JSON unificado con las 3 fuentes:
{
"proyecto": "NOM PROYECTO",
"localizacion": "Madrid",
"referencia": {
"nombre": "Dmarche (CYPE - Proyecto Base)",
"tipo": "PEM",
"total": 1218453.83,
"capitulos": { "01": {"nombre": "...", "total": 2041.83}, ... }
},
"ofertas": {
"glam": {
"nombre": "GLAM (Constructora)",
"total": 1218453.83,
"capitulos": { "01": {"nombre": "...", "total": 2041.83}, ... }
},
"trevicon": {
"nombre": "Trevicon (Constructora)",
"total": 1098001.21,
"capitulos": { "01": {"nombre": "...", "total": 1863.37}, ... }
}
}
}
JSON de comparaciones (resumen capítulo a capítulo):
[
{
"codigo": "01",
"nombre": "MOVIMIENTO DE TIERRAS",
"referencia": 2041.83,
"glam": 2041.83,
"trevicon": 1863.37,
"diff_glam_pct": 0.0,
"diff_trevicon_pct": -8.7
},
...
]
HTML del comparador — 4 tabs obligatorias:
- 📊 Tabla Completa — Tabla con todas las columnas + búsqueda + filtros (todos/subidas/bajadas)
- 📈 Diferencias Constructora 1 — Gráfico de barras horizontal top 10 diferencias
- 📈 Diferencias Constructora 2 — Igual para la segunda constructora
- 📋 Resumen — Stats por constructora (capítulos iguales/diferentes, totales, % diff)
KPIs en hero (4 cards):
- Referencia total
- Constructora 1 total + % vs Ref
- Constructora 2 total + % vs Ref
- Diferencia entre constructoras (€ y %)
Diseño: Aurora Design System con glass-liquid, mesh animado, orbs, responsive, fondo claro.
⚠️ CRÍTICO: El HTML del comparador — estructura de tabs
Cuando generes el HTML del comparador:
- 4 tabs: Tabla completa, diffs Constructora 1, diffs Constructora 2, Resumen
- Tabla con filtros: búsqueda por texto, filtros por tipo de diferencia (subidas/bajadas/todos)
- Gráficos de barras: Top 10 diferencias por constructora, barras horizontales con colores (verde=bajada, roja=subida)
- KPIs glass-liquid: 4 cards en hero con totales y porcentajes
- Alertas automáticas: Detectar GLAM=Referencia, diferencias >10%, constructora más barata
- Hash navigation:
#tabla,#glam,#trev,#resumenpara deep-linking - Responsive: KPIs grid 4→2→1 columnas, tabla con scroll horizontal
Multiplicador uniforme
Si todos los capitulos de instalaciones suben un ~142%, es probablemente un recargo estandar (indirectos + generales + beneficio).
Diferencias por tipo de capitulo
| Tipo | Incremento tipico | Causa |
|---|---|---|
| Instalaciones (fontaneria, electricidad) | ~142% | Multiplicador indirectos |
| Estructurales (albañileria, cimentacion) | ~109-128% | Precios de mercado |
| Acabados (revestimientos, pinturas) | ~16-38% | Diferentes calidades |
Alertas a detectar
- Capitulos completos que faltan en la oferta
- Partidas con diferencia > 50% (revisar mediciones)
- Oferta significativamente menor al PEM base (< 90%)
- Partidas que han sido eliminadas o divididas
Conversion de importes espanoles
# \"1.234,56\" → 1234.56
def parse_es_amount(s):
return float(str(s).replace('.', '').replace(',', '.'))
# 1234.56 → \"1.234,56\"
def format_es_amount(n):
return f\"{n:,.2f}\".replace(',', 'X').replace('.', ',').replace('X', '.')
⚠️ Post-extracción: Verificación y limpieza de JSON
Después de extraer los datos del PDF y generar los JSON derivados, SIEMPRE ejecutar verificación antes de dar por bueno el resultado. Los JSON derivados son propensos a errores acumulados.
Problemas comunes detectados
- Floating point artifacts:
921275.5999999999en vez de1098001.21. Solución:round(value, 2)en todos los totales al escribir. - Nombres con cantidades embebidas:
"CIMENTACION 108.327,64 €"en vez de"CIMENTACION". Solución: regex para limpiarre.sub(r'\s+[\d]{1,3}(?:\.[\d]{3})*,[\d]{2}\s*€?\s*$', '', name). - Mapeo de capítulos inconsistente: GLAM tiene
SAN.07.01pero Trevicon tiene02para el mismo saneamiento. Solución: establecer mapeo explícito y usar la referencia como canonical. - Totales que no cuadran: La suma de capítulos difiere del total declarado. Solución: SIEMPRE verificar
sum(chapters) ≈ totalcon tolerancia < 1€. - Referencia vieja: Se compara contra un presupuesto anterior (ej: 523.705€ de CYPE viejo) en vez del actual (1.218.453,83€ de GLAM). Solución: verificar que la referencia es la correcta antes de generar comparaciones.
- Capítulos duplicados: Cap.02 y SAN.07.01 cuentan el mismo saneamiento dos veces. Solución: unificar en un solo registro usando la clave de la referencia.
- Capítulos con total=0: Parsing falló para ciertos capítulos. Solución: reconstruir desde el JSON fuente (
presupuesto_referencia.json) que tiene la fuente de verdad.
Procedimiento de verificación (PASO OBLIGATORIO)
import json
# 1. Cargar fuente de verdad
with open('presupuesto_referencia.json') as f:
src = json.load(f)
ref = src['referencia']
glam = src['ofertas']['glam']
trev = src['ofertas']['trevicon']
# 2. Verificar sumas
for name, data in [('Ref', ref), ('GLAM', glam), ('Trevicon', trev)]:
chapters_sum = sum(ch['total'] for ch in data['capitulos'].values())
diff = abs(chapters_sum - data['total'])
status = "✅" if diff < 1.0 else f"❌ diff={diff:.2f}"
print(f"{name}: sum={chapters_sum:.2f} total={data['total']:.2f} {status}")
# 3. Detectar capítulos con total=0
for name, data in [('GLAM', glam), ('Trevicon', trev)]:
zeros = [k for k, v in data['capitulos'].items() if v['total'] == 0]
if zeros:
print(f"⚠️ {name} chapters with total=0: {zeros}")
# 4. Reconstruir comparaciones desde fuente de verdad
all_caps = list(ref['capitulos'].keys()) # canonical order
comparaciones = []
for cap in all_caps:
ref_val = ref['capitulos'][cap]['total']
glam_val = glam['capitulos'].get(cap, {}).get('total', 0)
# Mapeo explícito: SAN.07.01 ↔ 02
if cap == 'SAN.07.01':
trev_val = trev['capitulos'].get('02', {}).get('total', 0)
else:
trev_val = trev['capitulos'].get(cap, {}).get('total', 0)
comparaciones.append({
'codigo': cap,
'nombre': ref['capitulos'][cap]['nombre'],
'referencia': ref_val,
'glam': glam_val,
'trevicon': trev_val,
'diff_glam_pct': round((glam_val - ref_val) / ref_val * 100, 1) if ref_val else None,
'diff_trevicon_pct': round((trev_val - ref_val) / ref_val * 100, 1) if ref_val else None
})
# 5. Verificar que las comparaciones suman correctamente
ref_s = sum(c['referencia'] for c in comparaciones)
glam_s = sum(c['glam'] for c in comparaciones)
trev_s = sum(c['trevicon'] for c in comparaciones)
assert abs(ref_s - ref['total']) < 0.01, f"Ref mismatch: {ref_s} vs {ref['total']}"
assert abs(glam_s - glam['total']) < 0.01, f"GLAM mismatch: {glam_s} vs {glam['total']}"
assert abs(trev_s - trev['total']) < 0.01, f"Trev mismatch: {trev_s} vs {trev['total']}"
Regla de oro: un solo JSON fuente
Cuando existen múltiples JSON derivados (comparaciones, comparacion_final, comparacion_triptica, etc.), NUNCA arreglar cada uno por separado. En su lugar:
- Verificar y limpiar SOLO el JSON fuente (
presupuesto_referencia.json) - Reconstruir todos los derivados programáticamente desde la fuente
- Verificar sumas en todos los derivados
- Commitear todo junto
Esto garantiza consistencia y evita el "drift" entre archivos.
Referencias
references/budget-parsing-patterns.md— Patrones de parsing para presupuestos de construccion españoles (CYPE, Presto)references/budget-comparison-workflow.md— Flujo de comparacion de ofertas con caso real Nogal 9references/presto-word-extraction.md— Patrón para extraer tablas Presto rotas con coordenadas Xreferences/presto-total-validation.md— Validación del total extraído: padres/hijos, complementos materiales, instalaciones incluidas (caso GLAM Nogal9)references/github-pages-private-workaround.md— Workaround para GitHub Pages en repos privados (cuenta free)references/estructura-datos-unificada.md— Estructura JSON unificada con 3 fuentes (referencia + 2 constructoras) y comparaciones capítulo a capítuloreferences/json-verification-patterns.md— Problemas comunes en JSON derivados de presupuestos y procedimiento de verificación/limpiezapdf-to-dashboard— Extraccion general de PDFs y generacion de dashboards HTML
Comparativa de alternativas
- antoniogomezrojas/... — medición de obra lineal sobre plano por eje + PK con export BC3/FIEBDC-3 (formato estándar España); integra la medición por PK con el formato de presupuestos español.