File size: 40,041 Bytes
c226a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
"""Construction Intelligence AI | local portfolio dashboard.
Results derive only from bundled publicly sourced experiments. No company data.
Run: python -m streamlit run app.py
"""

from __future__ import annotations

import io
import json
import html
import tempfile
from pathlib import Path

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from plotly.subplots import make_subplots

ROOT = Path(__file__).resolve().parent
F = ROOT / "data" / "forecasting"
D = ROOT / "data" / "document_ai"
O = ROOT / "data" / "ocr"
B = ROOT / "data" / "bim"
M = ROOT / "models"
A = ROOT / "assets"
COLORS = {"real":"#0f766e", "neural":"#6857e8", "base":"#f59e0b", "pale":"#94a3b8"}

st.set_page_config(page_title="Construction Intelligence AI", page_icon="🏗️", layout="wide", initial_sidebar_state="expanded")
st.markdown("""
<style>
:root {color-scheme: light;}
.block-container {max-width:1440px; padding-top:1.4rem; padding-bottom:4rem;}
[data-testid="stSidebar"] {background:#f5f7fb; border-right:1px solid #e4e9f1;}
[data-testid="stSidebar"] h2 {letter-spacing:-0.02em;}
h1,h2,h3 {letter-spacing:-0.035em; color:#0f172a;}
h1 {font-size:2.25rem !important;}
small,.small {color:#64748b;}
.hero {padding:1.55rem 1.75rem;background:linear-gradient(115deg,#0b1732 0%,#18295d 65%,#0f756f 155%);
  color:white;border-radius:20px;margin-bottom:1.4rem;}
.hero h1 {color:white !important; margin:.2rem 0 .35rem 0;font-size:2.0rem!important;}
.hero p {color:#cbd5e1;margin:0;max-width:850px;line-height:1.55;}
.eyebrow {text-transform:uppercase;font-size:.70rem;letter-spacing:.14em;font-weight:800;color:#8fdcd1;}
.metric-card {background:white;border:1px solid #e2e8f0;border-radius:16px;padding:18px 18px 14px;
box-shadow:0 4px 18px rgba(15,23,42,.035);margin:0 0 10px;min-height:124px;}
.metric-card .label {color:#64748b;font-size:.76rem;font-weight:700;}
.metric-card .value {color:#0f172a;font-size:2.0rem;font-weight:780;letter-spacing:-.035em;margin:.13rem 0;}
.metric-card .sub {color:#64748b;font-size:.71rem;line-height:1.25;}
.section-note {background:#f3f6fa;border-left:3px solid #64748b;padding:12px 16px;color:#475569;
border-radius:0 9px 9px 0;font-size:.88rem;margin:12px 0 20px;}
[data-testid="stFileUploader"] {background:#fbfdff;border:1px dashed #b6c3d3;border-radius:12px;padding:8px;}
[data-testid="stTabs"] [data-baseweb="tab-list"] {gap:8px;}
[data-testid="stTabs"] button[role="tab"] {background:#f1f5f9;border-radius:8px 8px 0 0;padding:8px 15px;}
.architecture {display:flex; gap:8px; align-items:stretch; flex-wrap:wrap; margin:10px 0 22px;}
.architecture > div:not(.arch-arrow){flex:1 1 170px; background:linear-gradient(140deg,#f8fafc,#eff6ff);border:1px solid #dce6f3;border-radius:14px;padding:15px 12px;display:flex;flex-direction:column;gap:6px;min-width:130px;}
.architecture strong{color:#0f172a;font-size:0.92rem;}.architecture small{color:#64748b;font-size:.75rem;}
.arch-icon{color:#4f46e5;font-size:1.35rem;}.arch-arrow{display:flex;align-items:center;color:#94a3b8;font-size:1.4rem;font-weight:700;}
.evidence {border:1px solid #c7d2fe;background:#eef2ff;border-radius:14px;padding:16px 18px;}
.evidence h4{margin:0 0 8px;color:#1e1b4b;}.evidence p{margin:0;color:#312e81;font-size:.87rem;}
</style>
""", unsafe_allow_html=True)

@st.cache_data(show_spinner=False)
def csv(path: str, dates: tuple[str, ...] = ()) -> pd.DataFrame:
    p = ROOT / path
    df = pd.read_csv(p)
    for c in dates:
        if c in df:
            df[c] = pd.to_datetime(df[c], errors="coerce")
    return df

@st.cache_data(show_spinner=False)
def js(path: str) -> dict:
    return json.loads((ROOT / path).read_text(encoding="utf-8"))


def metric(label: str, value: str, sub: str) -> None:
    st.markdown(f'<div class="metric-card"><div class="label">{label}</div><div class="value">{value}</div><div class="sub">{sub}</div></div>', unsafe_allow_html=True)


def note(text: str) -> None:
    st.markdown(f'<div class="section-note">{text}</div>', unsafe_allow_html=True)


def fig_layout(fig, height=400):
    fig.update_layout(
        height=height, margin=dict(l=5,r=12,t=35,b=10),
        paper_bgcolor="rgba(0,0,0,0)",plot_bgcolor="rgba(0,0,0,0)",
        font=dict(family="Arial, sans-serif", color="#334155", size=12),
        hovermode="x unified",legend=dict(orientation="h",y=1.13,x=0),
        xaxis=dict(showgrid=False),yaxis=dict(gridcolor="#e8edf3")
    )
    return fig


def as_csv(df):
    return df.to_csv(index=False).encode("utf-8-sig")


forecast_sum = csv("data/forecasting/resumen_modelos.csv")
forecast_pred = csv("data/forecasting/predicciones_auditadas.csv", ("fecha",))
forecast_future = csv("data/forecasting/forecast_experimental.csv", ("fecha",))
forecast_year = csv("data/forecasting/metricas_por_anio.csv")
doc_scores = csv("data/document_ai/metricas_document_ai.csv")
ocr_comp = csv("data/ocr/comparacion_ocr_neuronal.csv")
bim_budget = csv("data/bim/prepresupuesto_bim.csv")
bim_alerts = csv("data/bim/alertas_bim.csv")
bim_info = js("data/bim/resumen.json")

with st.sidebar:
    st.markdown("## 🏗️ Construction Intelligence")
    st.caption("AI PORTFOLIO · 2026 · EDICIÓN PRO")
    st.divider()
    page = st.radio("Explorar módulos", [
        "Resumen ejecutivo", "Centro de métricas IA", "Forecasting neuronal", "Document AI",
        "OCR neuronal", "Automatización BIM", "Cotizaciones estructuradas", "Laboratorio interactivo"
    ], label_visibility="collapsed")
    st.divider()
    st.caption("FUENTES ABIERTAS")
    st.markdown("FRED WPU101 / WPU08  \\\nCORD v1 · IFC público")
    st.caption("Proyecto independiente de demostración. Sin conexión activa a Revit, Dynamo, Rhino o Grasshopper.")

st.markdown('<div class="hero"><div class="eyebrow">Open-data AI portfolio</div><h1>Construction Intelligence AI</h1><p>Forecasting de materiales · Automatización documental · OCR neuronal · Auditoría de modelos BIM. Resultados reproducibles con datos públicos y controles de calidad.</p></div>', unsafe_allow_html=True)

if page == "Resumen ejecutivo":
    st.subheader("Indicadores principales")
    cols = st.columns(4)
    acero = forecast_sum[forecast_sum.material=="Acero"].iloc[0]
    doc_mlp = doc_scores[(doc_scores.modelo=="MLP") & (doc_scores.periodo=="prueba")].iloc[0]
    rapid = ocr_comp[ocr_comp.modo=="RapidOCR_neuronal"].iloc[0]
    with cols[0]: metric("Mejora observada · acero", f"{acero.mejora_porcentaje:.1f}%", "LSTM vs persistencia · 48 meses de prueba")
    with cols[1]: metric("Document AI · MLP", f"{doc_mlp.accuracy*100:.1f}%", "Accuracy en transcripciones CORD · 1,336 líneas")
    with cols[2]: metric("RapidOCR", f"{rapid.cobertura_textual_aproximada*100:.1f}%", "Cobertura textual aproximada · cinco recibos")
    with cols[3]: metric("Alertas BIM", str(len(bim_alerts)), "Revisión de cantidades · IFC público")
    note("<b>Interpretación:</b> no son métricas de un mismo sistema de extremo a extremo. Cada módulo tiene un dataset, una evaluación y limitaciones propias. Los índices PPI estadounidenses no representan precios en México.")
    c1, c2 = st.columns([1.2,1])
    with c1:
        st.markdown("#### Predicción mensual de acero")
        tmp = forecast_pred[forecast_pred.material=="Acero"].copy()
        fig = go.Figure()
        for col, label, color in [("real","Real",COLORS["real"]),("persistencia","Persistencia",COLORS["base"]),("prediccion","LSTM",COLORS["neural"])]:
            fig.add_scatter(x=tmp.fecha,y=tmp[col],mode="lines",name=label,line=dict(color=color,width=2.4 if col=="real" else 2))
        fig.update_yaxes(title="Índice PPI")
        st.plotly_chart(fig_layout(fig),use_container_width=True)
    with c2:
        st.markdown("#### Cobertura de OCR")
        tmp = ocr_comp.copy()
        tmp["cobertura_%"] = tmp["cobertura_textual_aproximada"] * 100
        fig = px.bar(tmp, x="cobertura_%", y="modo", orientation="h", text="cobertura_%", color="modo", color_discrete_sequence=["#94a3b8","#cbd5e1","#64748b",COLORS["real"]])
        fig.update_traces(texttemplate="%{text:.1f}%",textposition="outside")
        fig.update_xaxes(title="% de líneas emparejadas",range=[0,100]); fig.update_yaxes(title=None)
        fig.update_layout(showlegend=False)
        st.plotly_chart(fig_layout(fig),use_container_width=True)
    st.markdown("#### Arquitectura de la solución")
    st.markdown("""
    <div class="architecture">
      <div><span class="arch-icon">▤</span><strong>Datos públicos</strong><small>FRED · CORD · IFC</small></div>
      <div class="arch-arrow">→</div>
      <div><span class="arch-icon">✦</span><strong>Modelos de IA</strong><small>LSTM · MLP · OCR</small></div>
      <div class="arch-arrow">→</div>
      <div><span class="arch-icon">◈</span><strong>Validación</strong><small>MAE · F1 · Alertas</small></div>
      <div class="arch-arrow">→</div>
      <div><span class="arch-icon">▧</span><strong>Decisiones auditables</strong><small>Revisión humana</small></div>
    </div>""", unsafe_allow_html=True)
    st.markdown("#### Qué es demostración y qué está validado")
    st.dataframe(pd.DataFrame([
        {"Componente":"Forecasting (acero)","Evidencia":"MAE 5.965 vs 7.825 · LSTM","Alcance":"Mejora exploratoria; prueba reutilizada"},
        {"Componente":"Document AI","Evidencia":"F1 macro 0.825 · logística en prueba","Alcance":"Líneas con transcripción CORD; no facturas mexicanas"},
        {"Componente":"OCR neuronal","Evidencia":"40/51 líneas emparejadas","Alcance":"5 recibos; coincidencia aproximada, no CER/WER"},
        {"Componente":"BIM / IFC","Evidencia":"123 elementos · 16 alertas","Alcance":"Archivo público, cantidades sin precios aprobados"},
    ]), hide_index=True, use_container_width=True)

elif page == "Centro de métricas IA":
    st.subheader("Centro de métricas y evaluación de modelos")
    st.caption("Cuatro módulos, cuatro metodologías distintas. Cada indicador conserva su fuente, denominador y limitación.")
    acero = forecast_sum[forecast_sum.material=="Acero"].iloc[0]
    madera = forecast_sum[forecast_sum.material=="Madera"].iloc[0]
    doc_log = doc_scores[(doc_scores.modelo=="LOGISTICA") & (doc_scores.periodo=="prueba")].iloc[0]
    doc_mlp = doc_scores[(doc_scores.modelo=="MLP") & (doc_scores.periodo=="prueba")].iloc[0]
    rapid = ocr_comp[ocr_comp.modo=="RapidOCR_neuronal"].iloc[0]
    num_elements = int(bim_info.get("elementos", 123))
    n_no_qto = int(bim_info.get("elementos_sin_qto", 28))

    st.markdown("#### Resultados destacados de tus experimentos")
    c1,c2,c3,c4=st.columns(4)
    with c1: metric("Acero · mejora LSTM", f"{acero.mejora_porcentaje:.2f}%", "MAE 5.965 frente a 7.825 · prueba histórica")
    with c2: metric("Document AI · MLP", f"{doc_mlp.accuracy*100:.2f}%", "Accuracy, líneas ya transcritas")
    with c3: metric("RapidOCR · cobertura", f"{rapid.cobertura_textual_aproximada*100:.2f}%", "40/51 coincidencias textuales aproximadas")
    with c4: metric("IFC · registros de cantidades", str(bim_info.get("registros_cantidades",357)), "123 elementos · 16 alertas de coherencia")

    st.markdown("""<div class="evidence"><h4>🔬 Evidencia antes que promesas</h4>
    <p>Los modelos se comparan contra referencias sencillas y muestran también los errores. El forecasting se evaluó con un periodo histórico examinado durante el desarrollo; OCR se probó en cinco recibos ya vistos. Ningún indicador permite afirmar exactitud comercial ni emitir presupuestos sin revisión.</p></div>""",unsafe_allow_html=True)

    t1,t2,t3,t4 = st.tabs(["🧠 Forecasting", "📄 Document AI", "👁️ OCR neuronal", "🏗️ BIM / IFC"])

    with t1:
        st.markdown("#### Comparación justa con persistencia")
        c1,c2,c3,c4=st.columns(4)
        with c1: metric("Acero · LSTM MAE", f"{acero.MAE_modelo:.3f}", "Puntos de índice PPI")
        with c2: metric("Acero · línea base MAE", f"{acero.MAE_persistencia:.3f}", "Persistencia · menor es mejor")
        with c3: metric("Acierto de dirección", f"{acero.acierto_direccion_porcentaje:.2f}%", "35 de 48 meses · acero")
        with c4: metric("Madera · línea base MAE", f"{madera.MAE_persistencia:.3f}", "Persistencia seleccionada")
        left,right=st.columns([1,1.15])
        with left:
            mae = pd.DataFrame({"Material":["Acero","Acero","Madera","Madera"],"Modelo":["Persistencia","LSTM","Persistencia","Seleccionado"],"MAE":[float(acero.MAE_persistencia),float(acero.MAE_modelo),float(madera.MAE_persistencia),float(madera.MAE_modelo)]})
            fig=px.bar(mae,x="Material",y="MAE",color="Modelo",barmode="group",text="MAE",color_discrete_map={"Persistencia":"#f59e0b","LSTM":"#6857e8","Seleccionado":"#0f766e"})
            fig.update_traces(texttemplate="%{text:.2f}",textposition="outside")
            st.plotly_chart(fig_layout(fig,350),use_container_width=True)
            st.caption("MAE: error absoluto medio en puntos de índice, no MXN. La madera no mejoró a la referencia.")
        with right:
            material = st.selectbox("Analizar errores mensuales", ["Acero", "Madera"], key="metro_material")
            datos = forecast_pred[forecast_pred.material==material].copy()
            datos["Error L1 · seleccionado"] = (datos.real-datos.prediccion).abs()
            datos["Error L1 · referencia"] = (datos.real-datos.persistencia).abs()
            fig=go.Figure()
            fig.add_scatter(x=datos.fecha,y=datos["Error L1 · seleccionado"],name="Seleccionado",mode="lines+markers",line_color=COLORS["neural"])
            fig.add_scatter(x=datos.fecha,y=datos["Error L1 · referencia"],name="Persistencia",mode="lines",line_color=COLORS["base"])
            fig.update_yaxes(title="Error absoluto |real - predicción|")
            st.plotly_chart(fig_layout(fig,350),use_container_width=True)
        st.info(f"Intervalo bootstrap exploratorio de mejora en acero: {acero.IC95_aprox_inferior:.2f}% a {acero.IC95_aprox_superior:.2f}%. Incluye 0: todavía hay incertidumbre de generalización.")

    with t2:
        st.markdown("#### Calidad de clasificación por categoría")
        import sklearn.metrics as skm
        real = csv("data/document_ai/predicciones_documentales.csv")
        clases = ["ARTICULO","PRECIO","CANTIDAD","SUBTOTAL","TOTAL","OTRO"]
        informe = skm.classification_report(real.categoria,real.prediccion,labels=clases,output_dict=True,zero_division=0)
        byclass = pd.DataFrame([{"Categoria":n,"Precision":informe[n]["precision"],"Recall":informe[n]["recall"],"F1":informe[n]["f1-score"],"Lineas":int(informe[n]["support"])} for n in clases])
        c1,c2,c3=st.columns(3)
        with c1: metric("Selección por validación","Logística", "F1 macro validación 81.44%")
        with c2: metric("F1 prueba · logística",f"{doc_log.macro_F1*100:.2f}%","Modelo elegido sin mirar la prueba")
        with c3: metric("F1 prueba · MLP",f"{doc_mlp.macro_F1*100:.2f}%","Red conservada como comparativa")
        left,right=st.columns([1.1,1])
        with left:
            byclass["F1 (%)"] = byclass.F1*100
            fig=px.bar(byclass,x="F1 (%)",y="Categoria",orientation="h",text="F1 (%)",color="F1 (%)",color_continuous_scale=[[0,"#db2777"],[0.5,"#7c3aed"],[1,"#0f766e"]])
            fig.update_traces(texttemplate="%{text:.1f}%",textposition="outside")
            fig.update_xaxes(range=[0,110]);fig.update_layout(coloraxis_showscale=False)
            st.plotly_chart(fig_layout(fig,380),use_container_width=True)
        with right:
            cm=skm.confusion_matrix(real.categoria,real.prediccion,labels=clases)
            fig=px.imshow(cm,x=clases,y=clases,text_auto=True,color_continuous_scale="Blues",aspect="auto")
            fig.update_xaxes(title="Predicho");fig.update_yaxes(title="Verdadero")
            fig.update_layout(coloraxis_showscale=False)
            st.plotly_chart(fig_layout(fig,380),use_container_width=True)
        st.dataframe(byclass[["Categoria","Precision","Recall","F1","Lineas"]].round(3),hide_index=True,use_container_width=True)
        st.warning("La categoría OTRO tiene el F1 más bajo. El clasificador trabaja con transcripciones del dataset CORD v1, no con facturas CFDI ni precios de obras en México.")

    with t3:
        st.markdown("#### Resultados comparables de reconocimiento textual")
        d=ocr_comp.copy(); d["Cobertura (%)"]=100*d.cobertura_textual_aproximada
        d["Clasificación correcta / 51 (%)"]=100*d.clases_correctas/d.lineas_referencia
        left,right=st.columns(2)
        with left:
            fig=go.Figure()
            fig.add_bar(x=d.modo,y=d["Cobertura (%)"],name="Cobertura aproximada",marker_color="#0f766e",text=[f"{v:.1f}%" for v in d["Cobertura (%)"]],textposition="outside")
            fig.add_scatter(x=d.modo,y=d["Clasificación correcta / 51 (%)"],name="Correctamente clasificadas / 51",mode="lines+markers",line=dict(color="#6857e8",width=3))
            fig.update_yaxes(title="Porcentaje de 51 líneas referencia",range=[0,100])
            st.plotly_chart(fig_layout(fig,380),use_container_width=True)
        with right:
            nr=d[d.modo=="RapidOCR_neuronal"].iloc[0]
            metric("RapidOCR · líneas reconocidas",f"{int(nr.lineas_ocr)}", "Segmentos OCR: pueden dividir una sola línea")
            metric("RapidOCR · emparejamientos",f"{int(nr.coincidencias)}/51", "Coincidencia aproximada con la transcripción")
            metric("RapidOCR · categorías correctas",f"{int(nr.clases_correctas)}/51", "Entre emparejamientos: 32/40 correctos")
        st.dataframe(d[["modo","lineas_ocr","coincidencias","clases_correctas","Cobertura (%)"]].round(2),hide_index=True,use_container_width=True)
        st.caption("Comparación exploratoria en cinco imágenes. Las puntuaciones son aproximaciones basadas en emparejamientos, no CER, WER ni precisión financiera.")

    with t4:
        st.markdown("#### Trazabilidad y control de calidad del IFC")
        c1,c2,c3,c4=st.columns(4)
        with c1: metric("Elementos IFC",f"{num_elements}","Modelo público")
        with c2: metric("Con QTO",f"{num_elements-n_no_qto}",f"{(num_elements-n_no_qto)/num_elements*100:.1f}% de elementos")
        with c3: metric("Sin QTO",f"{n_no_qto}","No inventar cantidades")
        with c4: metric("Alertas",str(len(bim_alerts)),"Revisión obligatoria")
        left,right=st.columns([1,1.05])
        with left:
            counts=bim_alerts.regla.value_counts().rename_axis("Regla").reset_index(name="Alertas")
            fig=px.bar(counts,x="Alertas",y="Regla",orientation="h",text="Alertas",color_discrete_sequence=["#e11d48"])
            st.plotly_chart(fig_layout(fig,350),use_container_width=True)
        with right:
            st.markdown("**Partidas y restricciones de uso**")
            st.dataframe(bim_budget[["codigo","concepto","cantidad","unidad","estado_calidad"]],hide_index=True,use_container_width=True)
        st.warning("Sin precios unitarios aprobados; no hay presupuesto monetario. Las partidas con alertas permanecen bloqueadas, aun cuando se simulen cantidades.")

    st.divider()
    st.markdown("#### Reporte ejecutivo descargable")
    st.caption("Un resumen offline de métricas y límites metodológicos, útil como anexo técnico de portafolio o entrevista.")
    reporte_html = f"""<!doctype html><html lang='es'><head><meta charset='utf-8'><title>Construction Intelligence AI · Informe</title>
    <style>body{{font:15px Arial,sans-serif;background:#f4f7fb;color:#192b44;padding:40px;max-width:1000px;margin:auto}}
    h1{{color:#172f6b}}h2{{margin-top:35px}}table{{border-collapse:collapse;width:100%;background:white}}
    th,td{{padding:12px;border-bottom:1px solid #e0e6ef;text-align:left}}th{{background:#e8eefb}}
    .warning{{background:#fff7e6;padding:15px;border-left:4px solid #f59e0b}}
    .hero{{background:#172f6b;color:white;padding:27px;border-radius:12px}}</style></head><body>
    <div class='hero'><h1 style='color:white'>Construction Intelligence AI</h1>AI portfolio · informe de resultados experimentales</div>
    <h2>1. Forecasting de índices industriales</h2>
    {forecast_sum[['material','modelo_elegido','MAE_persistencia','MAE_modelo','mejora_porcentaje','meses_prueba']].round(3).to_html(index=False,escape=True)}
    <h2>2. Clasificación de recibos</h2>{doc_scores.round(4).to_html(index=False,escape=True)}
    <h2>3. Comparación de motores OCR</h2>{ocr_comp[['modo','lineas_referencia','coincidencias','clases_correctas','cobertura_textual_aproximada']].round(3).to_html(index=False,escape=True)}
    <h2>4. Calidad BIM</h2><p>Elementos: {num_elements} · sin QTO: {n_no_qto} · alertas: {len(bim_alerts)} · cantidades QTO: {bim_info.get('registros_cantidades',357)}</p>
    <div class='warning'><strong>Limitaciones:</strong> Índices de EE. UU. no equivalen a costos en MXN; evaluación exploratoria reutilizada; OCR comparado en cinco recibos; clasificación sobre textos transcritos; IFC público sin precios aprobados. Ninguna cifra es precisión global del sistema.</div>
    <p style='color:#64748b'>Proyecto personal de demostración. No está afiliado a ninguna empresa.</p></body></html>"""
    st.download_button("📥 Descargar informe ejecutivo (HTML)", reporte_html.encode("utf-8"), file_name="construction_ai_reporte_ejecutivo.html", mime="text/html", type="primary")

elif page == "Forecasting neuronal":
    st.subheader("Forecasting neuronal de materiales")
    note("Series FRED WPU101 (hierro/acero) y WPU08 (madera), índices de precios industriales en EE. UU. Evaluación a un mes; el pronóstico a seis meses es exploratorio. La prueba histórica ya fue examinada durante el desarrollo.")
    material = st.radio("Material", options=["Acero","Madera"],index=0,horizontal=True)
    material = material or "Acero"
    s = forecast_sum[forecast_sum.material==material].iloc[0]
    c1,c2,c3,c4=st.columns(4)
    with c1: metric("Modelo seleccionado",str(s.modelo_elegido),"Elegido por validación temporal")
    with c2: metric("MAE seleccionado",f"{s.MAE_modelo:.3f}","Puntos de índice PPI")
    with c3: metric("MAE persistencia",f"{s.MAE_persistencia:.3f}","Referencia sin IA")
    with c4: metric("Cambio observado",f"{s.mejora_porcentaje:+.1f}%","Menor MAE es mejor")
    tmp=forecast_pred[forecast_pred.material==material]
    fig=go.Figure()
    for col,name,color,dash in [("real","Real",COLORS["real"],None),("persistencia","Persistencia",COLORS["base"],"dash"),("prediccion",str(s.modelo_elegido),COLORS["neural"],None)]:
        fig.add_scatter(x=tmp.fecha,y=tmp[col],name=name,mode="lines",line=dict(color=color,width=2.5 if col=="real" else 2,dash=dash))
    fig.update_yaxes(title="Índice PPI")
    st.plotly_chart(fig_layout(fig,460),use_container_width=True)
    c1,c2=st.columns(2)
    with c1:
        st.markdown("#### MAE por año de prueba")
        y=forecast_year[forecast_year.material==material]
        fig=go.Figure()
        for col,name,color in [("MAE_modelo",str(s.modelo_elegido),COLORS["neural"]),("MAE_baseline","Persistencia",COLORS["base"])]:
            fig.add_bar(x=y.anio,y=y[col],name=name,marker_color=color)
        fig.update_layout(barmode="group")
        st.plotly_chart(fig_layout(fig,300),use_container_width=True)
    with c2:
        st.markdown("#### Próximos seis meses (experimental)")
        col=f"{material}_indice"
        fy=forecast_future[["fecha",col]].copy(); fy[col]=fy[col].round(3)
        st.dataframe(fy.rename(columns={"fecha":"Mes",col:"Índice previsto"}),hide_index=True,use_container_width=True)
        if material=="Acero":
            st.caption(f"Intervalo bootstrap exploratorio de mejora: {s.IC95_aprox_inferior:.1f}% a {s.IC95_aprox_superior:.1f}%. Incluye cero; no demuestra mejora universal.")
        else:
            st.caption("Persistencia fue seleccionada: los pronósticos se mantienen constantes, no hay evidencia de mejora de la red en madera.")
    st.download_button("Descargar predicciones de prueba (CSV)",as_csv(tmp),file_name=f"pronosticos_{material.lower()}.csv",mime="text/csv")

elif page == "Document AI":
    st.subheader("Clasificación inteligente de documentos")
    note("Entrenamiento con CORD v1: 800 recibos para entrenamiento, 100 para validación y 100 para prueba. Entrada: <b>texto ya reconocido</b>. F1 y accuracy no miden lectura de fotos ni de facturas mexicanas.")
    c1,c2,c3,c4=st.columns(4)
    for i,(modelo,periodo,col,ttl) in enumerate([("LOGISTICA","prueba","macro_F1","F1 · modelo seleccionado"),("MLP","prueba","macro_F1","F1 · MLP"),("LOGISTICA","prueba","accuracy","Accuracy · logística"),("MLP","prueba","accuracy","Accuracy · MLP")]):
        value=doc_scores[(doc_scores.modelo==modelo)&(doc_scores.periodo==periodo)].iloc[0][col]
        with [c1,c2,c3,c4][i]: metric(ttl, f"{value*100:.1f}%","Métricas sobre 1,336 líneas")
    c1,c2=st.columns([1,1])
    with c1:
        plot = doc_scores.copy();plot["F1 (%)"] = plot.macro_F1*100
        fig=px.bar(plot,x="modelo",y="F1 (%)",color="periodo",barmode="group",text="F1 (%)",color_discrete_map={"validacion":"#94a3b8","prueba":COLORS["neural"]})
        fig.update_traces(texttemplate="%{text:.1f}%",textposition="outside")
        fig.update_yaxes(range=[0,100])
        st.plotly_chart(fig_layout(fig,350),use_container_width=True)
    with c2:
        st.image(str(A/"matriz_confusion.png"),caption="Matriz de confusión · regresión logística (prueba)",use_container_width=True)
    st.markdown("#### Errores del clasificador seleccionado")
    pred=csv("data/document_ai/predicciones_documentales.csv")
    filt=st.checkbox("Mostrar solo clasificaciones incorrectas",value=True)
    if filt: pred=pred[pred.categoria != pred.prediccion]
    st.dataframe(pred[["documento","texto","categoria","prediccion"]].head(250),hide_index=True,use_container_width=True)
    st.caption("MLP logró un F1 ligeramente superior en prueba, pero la regresión logística quedó seleccionada usando validación, sin usar prueba para elegir el modelo.")

elif page == "OCR neuronal":
    st.subheader("Comparativa OCR: Tesseract vs RapidOCR")
    note("Experimento diagnóstico de solo cinco recibos CORD ya examinados. Se cuentan líneas emparejadas mediante similitud textual ≥ 65%. Esto <b>no</b> es la tasa oficial de acierto de OCR ni una evaluación independiente.")
    tmp=ocr_comp.copy()
    tmp["Cobertura %"]=tmp.cobertura_textual_aproximada*100
    tmp["Clasificación condicional %"]=tmp.clasificacion_condicional_aproximada*100
    rapid=tmp[tmp.modo=="RapidOCR_neuronal"].iloc[0]
    c1,c2,c3=st.columns(3)
    with c1:metric("RapidOCR · cobertura",f"{rapid['Cobertura %']:.1f}%","40 de 51 líneas emparejadas")
    with c2:metric("Líneas detectadas",str(int(rapid.lineas_ocr)),"RapidOCR detectó 79 fragmentos")
    with c3:metric("Clasificaciones emparejadas",f"{rapid.clases_correctas:.0f}/{rapid.coincidencias:.0f}","No representa exactitud end-to-end")
    fig=go.Figure()
    fig.add_bar(x=tmp.modo,y=tmp["Cobertura %"],marker_color=["#a0aec0","#a0aec0","#a0aec0",COLORS["real"]],text=[f"{x:.1f}%" for x in tmp["Cobertura %"]],textposition="outside")
    fig.update_yaxes(title="Cobertura aproximada (%)",range=[0,100])
    st.plotly_chart(fig_layout(fig,355),use_container_width=True)
    st.dataframe(tmp[["modo","lineas_referencia","lineas_ocr","coincidencias","clases_correctas","Cobertura %","Clasificación condicional %"]].round(2),hide_index=True,use_container_width=True)
    st.markdown("#### Inspeccionar una fotografía pública y la lectura neuronal")
    num=st.selectbox("Recibo de prueba",options=list(range(5)),format_func=lambda n:f"CORD test {n:04d}")
    a,b=st.columns([0.65,1.35])
    with a:st.image(str(A/f"cord-test-{num:04d}.png"),caption="Vista pública CORD (redimensionada)",use_container_width=True)
    with b:
        lines=csv("data/ocr/lineas_ocr_neuronal.csv")
        l=lines[lines.documento==f"cord-test-{num:04d}"].copy()
        st.dataframe(l[["texto_ocr","categoria_predicha","score_ocr_no_calibrado","requiere_revision"]],hide_index=True,use_container_width=True)
        st.caption("Las puntuaciones del OCR y del clasificador no equivalen a probabilidades calibradas de que un importe sea correcto.")

elif page == "Automatización BIM":
    st.subheader("Auditoría de modelos IFC / BIM")
    note("Resultados extraídos de un modelo IFC público, independiente de los recibos CORD. No existen precios aprobados y no hay integración en vivo con Dynamo o Grasshopper.")
    c1,c2,c3,c4=st.columns(4)
    for col,lab,v,sub in [(c1,"Elementos BIM",bim_info.get("elementos",123),"IfcElement + IfcSpace"),(c2,"Sin cantidades",bim_info.get("elementos_sin_qto",28),"QTO no disponible"),(c3,"Alertas detectadas",len(bim_alerts),"Revisión de coherencia"),(c4,"Partidas preliminares",len(bim_budget),"Ningún precio autorizado")]:
        with col: metric(lab,str(v),sub)
    c1,c2=st.columns([1,1])
    with c1:
        st.markdown("#### Catálogo preliminar")
        st.dataframe(bim_budget[["codigo","concepto","cantidad","unidad","estado_calidad"]],hide_index=True,use_container_width=True)
        st.caption("No se suma m², m³ y piezas entre sí. Las cantidades son únicamente del archivo IFC de ejemplo.")
    with c2:
        st.markdown("#### Alertas por regla")
        counts=bim_alerts.regla.value_counts().rename_axis("regla").reset_index(name="alertas")
        fig=px.bar(counts,x="alertas",y="regla",orientation="h",color_discrete_sequence=[COLORS["base"]],text="alertas")
        st.plotly_chart(fig_layout(fig,320),use_container_width=True)
    st.markdown("#### Simulación de sensibilidad (no es diseño geométrico)")
    pct=st.slider("Variación hipotética de cantidades",min_value=-20,max_value=20,value=0,step=5,format="%d%%")
    scenario=bim_budget[["codigo","concepto","unidad","cantidad","estado_calidad"]].copy()
    scenario["Cantidad escenario"] = np.where(scenario.estado_calidad.ne("PRELIMINAR"),np.nan,scenario.cantidad*(1+pct/100))
    st.dataframe(scenario.round(3),hide_index=True,use_container_width=True)
    st.info("Las partidas bloqueadas no se utilizan para cálculos. Los importes quedan vacíos hasta contar con precios verificados y proyecto/alcance coincidentes.")
    with st.expander("Ver alertas completas"):
        st.dataframe(bim_alerts,hide_index=True,use_container_width=True)
    st.download_button("Descargar escenario (CSV)",as_csv(scenario),file_name="escenario_bim.csv")

elif page == "Cotizaciones estructuradas":
    from quotation_ui import render_quotation_ui
    render_quotation_ui()

elif page == "Laboratorio interactivo":
    st.subheader("Laboratorio: probar tus propios archivos")
    note("Estas herramientas ejecutan inferencia y auditoría localmente, sin reentrenar modelos. No sustituyen la revisión humana de importes, facturas o cantidades BIM. Las pruebas no se mezclan con las métricas oficiales de los datasets públicos.")
    tabs=st.tabs(["Clasificar texto", "Leer imagen o PDF", "Inspeccionar IFC"])
    with tabs[0]:
        st.markdown("#### Clasificador documental entrenado")
        user_text=st.text_area("Pega una línea de recibo o cotización por renglón",value="CONCRETO PREMEZCLADO\n2.00\nPRECIO 3000\nSUBTOTAL 6000\nTOTAL 6000",height=145)
        if st.button("Clasificar líneas",type="primary",key="clasificar_texto"):
            try:
                import joblib
                @st.cache_resource(show_spinner=False)
                def get_classifier():return joblib.load(M/"document_ai_logistica.joblib")
                pack=get_classifier();vect=pack["vectorizador"];model=pack["modelo"];cats=pack["categorias"]
                texts=[x.strip() for x in user_text.splitlines() if x.strip()]
                if not texts:st.warning("Escribe al menos una línea.")
                else:
                    X=vect.transform(texts);proba=model.predict_proba(X);idx=proba.argmax(axis=1)
                    res=pd.DataFrame({"texto":texts,"categoria":[cats[int(model.classes_[i])] for i in idx],"puntuación_no_calibrada":proba.max(axis=1)})
                    res["revisar"]=(res["puntuación_no_calibrada"]<.65)|(res["categoria"]=="OTRO")
                    st.dataframe(res.round(3),hide_index=True,use_container_width=True)
                    st.download_button("Descargar resultado CSV",as_csv(res),file_name="clasificacion_documental.csv")
                    if hasattr(model, "coef_"):
                        with st.expander("🔍 IA interpretable: caracteres que influyeron en las predicciones"):
                            st.caption("Explicación estadística del clasificador lineal mediante aportes TF-IDF × coeficiente. No representa una justificación causal ni valida el contenido del documento.")
                            nombres=vect.get_feature_names_out()
                            for ri, clasepos in enumerate(idx[:3]):
                                cidx=int(model.classes_[int(clasepos)])
                                st.markdown(f"**Línea {ri+1}:** `{texts[ri][:80]}` → **{cats[cidx]}**")
                                vector=X[ri].toarray().ravel()
                                coef=model.coef_[int(clasepos)]
                                contrib=vector*coef
                                mejores=np.argsort(contrib)[-8:][::-1]
                                datos_aportes=pd.DataFrame([{"Fragmento":str(nombres[k]),"Aporte":float(contrib[k])} for k in mejores if contrib[k]>0])
                                if not datos_aportes.empty:
                                    fig=px.bar(datos_aportes,x="Aporte",y="Fragmento",orientation="h",color_discrete_sequence=[COLORS["neural"]])
                                    st.plotly_chart(fig_layout(fig,245),use_container_width=True)
                                else:st.caption("No hay fragmentos positivos significativos para esta predicción.")
                    st.caption("Entrenado en recibos CORD en inglés/indonesio; los conceptos de construcción en español están fuera de su dominio de entrenamiento y requieren revisión.")
            except Exception as e:st.error(f"No se pudo cargar el modelo local: {e}")
    with tabs[1]:
        st.markdown("#### OCR neuronal + clasificación de líneas")
        st.caption("RapidOCR puede descargar pesos ONNX en el primer uso. Ningún archivo se guarda persistentemente en este dashboard.")
        up=st.file_uploader("Sube una imagen JPG/PNG o PDF (máximo 20 MB)",type=["jpg","jpeg","png","pdf"],key="ocr_upload")
        if up is not None and st.button("Procesar con RapidOCR",type="primary"):
            try:
                raw=up.getvalue()
                if len(raw)>20*1024*1024:st.error("El archivo supera 20 MB.")
                else:
                    from PIL import Image
                    from rapidocr import RapidOCR
                    import joblib
                    imlist=[]
                    if up.name.lower().endswith(".pdf"):
                        import fitz
                        pdf=fitz.open(stream=raw,filetype="pdf")
                        for k in range(min(len(pdf),3)):
                            pm=pdf[k].get_pixmap(matrix=fitz.Matrix(1.5,1.5),alpha=False)
                            imlist.append((k+1,Image.open(io.BytesIO(pm.tobytes("png"))).convert("RGB")))
                    else:
                        imlist=[(1,Image.open(io.BytesIO(raw)).convert("RGB"))]
                    @st.cache_resource(show_spinner=False)
                    def get_ocr():return RapidOCR()
                    @st.cache_resource(show_spinner=False)
                    def get_clf():return joblib.load(M/"document_ai_logistica.joblib")
                    engine=get_ocr();pkg=get_clf();result=[]
                    with st.spinner("Reconociendo imágenes y clasificando líneas..."):
                        for p,img in imlist:
                            if img.width*img.height>16_000_000:
                                st.warning(f"Página {p} demasiado grande; omitida.")
                                continue
                            out=engine(np.asarray(img));texts=list(out.txts) if out.txts is not None else []
                            scores=list(out.scores) if out.scores is not None else [np.nan]*len(texts)
                            if not texts:continue
                            probs=pkg["modelo"].predict_proba(pkg["vectorizador"].transform(texts))
                            ids=probs.argmax(axis=1)
                            for k,(t,sc) in enumerate(zip(texts,scores)):
                                cat=pkg["categorias"][int(pkg["modelo"].classes_[ids[k]])]
                                clf=float(probs[k,ids[k]])
                                result.append({"pagina":p,"texto":t,"clase":cat,"score_ocr":float(sc),"score_clasificador":clf,"revisar":cat=="OTRO" or clf<.65 or float(sc)<.65})
                    if result:
                        rdf=pd.DataFrame(result);st.success(f"{len(rdf)} líneas reconocidas. Pendientes de revisión: {rdf.revisar.sum()}")
                        st.dataframe(rdf.round(3),hide_index=True,use_container_width=True)
                        st.download_button("Descargar OCR + clasificación",as_csv(rdf),file_name="ocr_documento.csv")
                    else:st.warning("No se detectaron líneas. Intenta otra foto con mayor resolución.")
            except ImportError as e:
                st.error(f"Falta un módulo opcional: {e}. Ejecuta pip install -r requirements.txt")
            except Exception as e: st.error(f"No se pudo procesar el documento: {e}")
    with tabs[2]:
        st.markdown("#### Extracción básica de cantidades IFC")
        st.caption("Permite cargar un IFC sin Revit ni Rhino. No estima cantidades geométricas que no estén declaradas como QTO.")
        upload=st.file_uploader("Selecciona un IFC (máximo 30 MB)",type=["ifc"],key="bim_upload")
        if upload and st.button("Analizar IFC",type="primary"):
            try:
                import ifcopenshell
                import ifcopenshell.util.element
                if len(upload.getvalue())>30*1024*1024:st.error("El archivo excede 30 MB.")
                else:
                    with tempfile.NamedTemporaryFile(suffix=".ifc",delete=False) as tmpfile:
                        tmpfile.write(upload.getvalue());tmpname=tmpfile.name
                    try:mod=ifcopenshell.open(tmpname)
                    finally:Path(tmpname).unlink(missing_ok=True)
                    elems=mod.by_type("IfcElement")
                    rows=[]
                    for e in elems[:15000]:
                        try: qs=ifcopenshell.util.element.get_psets(e,qtos_only=True)
                        except Exception:qs={}
                        for qname,qvals in qs.items():
                            for name,val in qvals.items():
                                if name=="id" or isinstance(val,bool) or not isinstance(val,(int,float)):continue
                                rows.append({"Guid":e.GlobalId,"Clase":e.is_a(),"QTO":qname,"Medida":name,"Valor_original":val})
                    st.success(f"{len(elems):,} elementos; {len(rows):,} cantidades QTO documentadas")
                    if rows:
                        q=pd.DataFrame(rows);st.dataframe(q.head(1000),hide_index=True,use_container_width=True)
                        st.download_button("Descargar QTO IFC",as_csv(q),file_name="cantidades_ifc.csv")
                    else:st.warning("No hay QTO asociados. No se calcularán áreas o volúmenes por suposición.")
            except ImportError:st.error("IfcOpenShell no instalado. Ejecuta pip install ifcopenshell")
            except Exception as e:st.error(f"Error leyendo IFC: {e}")

st.divider()
st.caption("Construction Intelligence AI · Demo independiente sobre datos públicos · Evaluaciones exploratorias no aptas para presupuestos ni compras reales sin validación adicional · GitHub/FRED/CORD/buildingSMART")