"""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(""" """, 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'
Forecasting de materiales · Automatización documental · OCR neuronal · Auditoría de modelos BIM. Resultados reproducibles con datos públicos y controles de calidad.
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.
Elementos: {num_elements} · sin QTO: {n_no_qto} · alertas: {len(bim_alerts)} · cantidades QTO: {bim_info.get('registros_cantidades',357)}
Proyecto personal de demostración. No está afiliado a ninguna empresa.
""" 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: texto ya reconocido. 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 no 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")