CoolFace
Apppublic

CMacD/AIC_PHASE1_POC

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
1_Phase_1_Attribute_Mapping.py1123 linesDownload Raw Back to root
1#!/usr/bin/env python2# coding: utf-83"""41_Phase_1_Attribute_Mapping.py — AIC Phase 1 Attribute Mapping  (Streamlit edition)5 6Run:7    streamlit run 1_Phase_1_Attribute_Mapping.py8    streamlit run 1_Phase_1_Attribute_Mapping.py --server.port 8501 --server.address 0.0.0.09"""10 11import io12import os13import queue14import re as _re15import sys16import tempfile17import threading18import time19import traceback20import zipfile21from pathlib import Path22 23# NLTK corpora download on first run (the HF Space has internet); no bundle shipped.24import pandas as pd25import streamlit as st26from ml_package import mapping_lookup as _ml_mod27from ml_package import text_match as _tm28from ml_package import ensemble as _ens29from ml_package import ml_classifier as _rfx30from ml_package.write_results import write_results as _write_results31 32# ── Global pipeline lock — serialises sys.stdout/sys.stderr redirection ───────33# print() inside _run_pipeline is captured by redirecting the global sys.stdout/34# sys.stderr to a per-session queue stream.  Both are process-wide variables, so35# concurrent pipeline threads would clobber each other's redirection.  Removing36# this lock requires threading the log queue explicitly through every pipeline37# module call (a larger refactor).38_PIPELINE_LOCK = threading.Lock()39 40# ── Page config (must be first Streamlit call) ────────────────────────────────41st.set_page_config(42    page_title='AIC Phase 1 — Attribute Mapping',43    layout='wide',44    initial_sidebar_state='collapsed',45)46 47# ── Circana brand colours ─────────────────────────────────────────────────────48ACCENT   = '#4E106F'   # deep purple49FG_OK    = '#059669'   # green50FG_WARN  = '#D97706'   # amber51FG_ERR   = '#DC2626'   # red52FG_DIM   = '#6B7280'   # muted grey53BG_CODE  = '#F9FAFB'   # log area background54 55# Stage → progress fraction56_STAGE_PROGRESS = {57    'read inputs':   0.10,58    'mappinglookup': 0.22,59    'textmatch':     0.68,60    'ensemble':      0.78,61    'write output':  0.98,62}63_STAGE_LABELS = {64    'read inputs':   'Reading & validating input files…',65    'mappinglookup': 'Running exact & fuzzy lookup matching…',66    'textmatch':     'Predicting attributes for unresolved products (lookup + ML)…',67    'ensemble':      'Combining lookup + ML results and assigning QC priority…',68    'write output':  'Writing output Excel workbook…',69}70 71SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))72 73 74# ── Custom CSS ────────────────────────────────────────────────────────────────75st.markdown(f"""76<style>77  /* Header accent bar */78  .aic-header {{79    background: {ACCENT};80    color: white;81    padding: 18px 28px 14px 28px;82    border-radius: 8px;83    margin-bottom: 18px;84  }}85  .aic-header h1 {{86    margin: 0 0 4px 0;87    font-size: 1.55rem;88    font-weight: 700;89    letter-spacing: -0.3px;90  }}91  .aic-header p {{92    margin: 0;93    font-size: 0.88rem;94    opacity: 0.82;95  }}96  /* Log box */97  .log-box {{98    background: {BG_CODE};99    border: 1px solid #E5E7EB;100    border-radius: 6px;101    padding: 12px 14px;102    font-family: Consolas, monospace;103    font-size: 0.78rem;104    white-space: pre-wrap;105    max-height: 420px;106    overflow-y: auto;107    line-height: 1.55;108  }}109  /* Stage label */110  .stage-label {{111    color: {FG_DIM};112    font-size: 0.82rem;113    margin-top: 4px;114  }}115  /* Purple progress bar (overrides Streamlit default blue) */116  [data-testid="stProgressBar"] > div {{117    background-color: rgba(78, 16, 111, 0.15) !important;118  }}119  [data-testid="stProgressBar"] > div > div {{120    background-color: {ACCENT} !important;121  }}122  /* Green Run button (enabled only) */123  [data-testid="stBaseButton-primary"]:enabled {{124    background-color: #059669 !important;125    border-color: #059669 !important;126    color: white !important;127  }}128  [data-testid="stBaseButton-primary"]:enabled:hover {{129    background-color: #047857 !important;130    border-color: #047857 !important;131  }}132  /* Purple download button */133  [data-testid="stDownloadButton"] [data-testid="stBaseButton-primary"]:enabled {{134    background-color: {ACCENT} !important;135    border-color: {ACCENT} !important;136    color: white !important;137  }}138  [data-testid="stDownloadButton"] [data-testid="stBaseButton-primary"]:enabled:hover {{139    background-color: #3a0d54 !important;140    border-color: #3a0d54 !important;141  }}142  /* Purple Browse buttons (file uploaders) */143  [data-testid="stFileUploader"] [data-testid="stBaseButton-secondary"]:enabled {{144    background-color: {ACCENT} !important;145    border-color: {ACCENT} !important;146    color: white !important;147  }}148  [data-testid="stFileUploader"] [data-testid="stBaseButton-secondary"]:enabled:hover {{149    background-color: #3a0d54 !important;150    border-color: #3a0d54 !important;151    color: white !important;152  }}153  /* Red Stop button */154  [data-testid="stBaseButton-secondary"]:enabled {{155    border-color: #DC2626 !important;156    color: #DC2626 !important;157  }}158  [data-testid="stBaseButton-secondary"]:enabled:hover {{159    background-color: #DC2626 !important;160    color: white !important;161    border-color: #DC2626 !important;162  }}163</style>164""", unsafe_allow_html=True)165 166 167# ── Header ────────────────────────────────────────────────────────────────────168_logo_path = os.path.join(SCRIPT_DIR, 'Circana_logo.png')169if os.path.exists(_logo_path):170    import base64171    with open(_logo_path, 'rb') as _f:172        _logo_b64 = base64.b64encode(_f.read()).decode()173    st.markdown(174        f'<div style="text-align:right; margin-bottom:8px; margin-right:4px">'175        f'<img src="data:image/png;base64,{_logo_b64}" width="143"></div>',176        unsafe_allow_html=True,177    )178 179st.markdown("""180<div class="aic-header">181  <h1>AIC Phase 1 &mdash; Attribute Mapping</h1>182  <p>Upload your files, click <strong>Run</strong>, then download and QC the workbook before proceeding to Phase 2 &amp; 3.</p>183</div>184""", unsafe_allow_html=True)185 186 187# ── Instructions ──────────────────────────────────────────────────────────────188with st.expander('Input file requirements', expanded=False):189    st.markdown("""190**Upload mode — ZIP (recommended)**191 192Upload a single `.zip` of your input folder. The ZIP must contain:193 194| File | Requirement |195|------|-------------|196| **Excel (.xlsx)** | Must contain a **FINAL** sheet (historical labelled data) and a **META** sheet (attribute definitions). |197| **CSV (.csv)** | New product flat file to classify. Column headers must include `ITEM_DIM_KEY` and all *Attribute Name in MDM* values listed in META. |198 199Using ZIP mode automatically pre-loads your folder into Phase 3 — no re-upload needed after Phase 1 completes.200 201---202 203**Upload mode — Individual files**204 205Upload the Excel and CSV separately. After Phase 1 you will need to supply a ZIP containing the Phase 3 tool files (`ModelInfo.txt`, `Attributes.txt`, `AttributeValues.txt`) on the Phase 3 page.206 207---208 209**Before proceeding to Phase 3** — review `File_For_Mapping_QC.xlsx` thoroughly. QC all HIGH and MEDIUM priority rows in each attribute tab before running Phase 3.210""")211 212 213# ── Upload mode ───────────────────────────────────────────────────────────────214upload_mode = st.radio(215    'Upload mode',216    ['📁  ZIP — full pipeline (Phase 1 + 2/3)', '📄  Individual files — Phase 1 only'],217    horizontal=True,218    key='upload_mode',219    label_visibility='collapsed',220)221_zip_mode = upload_mode.startswith('📁')222 223# ── File upload ───────────────────────────────────────────────────────────────224xlsx_file = None225csv_file  = None226zip_file  = None227 228if _zip_mode:229    zip_file = st.file_uploader(230        'Upload input folder as .zip  (must contain an Excel with META+FINAL sheets and a .csv flat file)',231        type=['zip'],232        key='p1_zip',233    )234    if zip_file:235        st.caption(236            'The ZIP will supply files to **both** Phase 1 (Excel + CSV) and Phase 2/3 '237            '(ModelInfo.txt, Attributes.txt etc.). After Phase 1 completes, '238            'navigate to Phase 2/3 — your folder will already be loaded.'239        )240else:241    col_xl, col_csv = st.columns(2)242    with col_xl:243        xlsx_file = st.file_uploader('Excel file (.xlsx)', type=['xlsx', 'xls'], key='xlsx')244    with col_csv:245        csv_file = st.file_uploader('CSV flat file (.csv)', type=['csv'], key='csv')246 247 248def _extract_p1_zip(zip_bytes: bytes, dest: str):249    """250    Extract ZIP, detect single-wrapper-folder, and locate:251      - xl_path : first .xlsx that has META + FINAL sheets (not File_For_Mapping_QC)252      - csv_path: first .csv file253    Returns (effective_root, xl_path, csv_path) or raises RuntimeError.254    """255    dest_path = Path(dest)256    with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:257        zf.extractall(dest_path)258 259    # Unwrap single top-level folder260    entries = [e for e in dest_path.iterdir() if not e.name.startswith('.')]261    root = entries[0] if len(entries) == 1 and entries[0].is_dir() else dest_path262 263    # Find Excel (META + FINAL sheets, exclude File_For_Mapping_QC)264    import openpyxl265    xl_path = None266    for p in sorted(root.rglob('*.xlsx')):267        if 'file_for_mapping_qc' in p.name.lower():268            continue269        try:270            wb = openpyxl.load_workbook(str(p), read_only=True)271            names_upper = [s.upper() for s in wb.sheetnames]272            wb.close()273            if any('META' in n for n in names_upper) and any('FINAL' in n for n in names_upper):274                xl_path = str(p)275                break276        except Exception:277            pass278 279    # Find CSV280    csv_path = None281    for p in sorted(root.rglob('*.csv')):282        csv_path = str(p)283        break284 285    if not xl_path:286        raise RuntimeError(287            'No Excel file with META and FINAL sheets found in the ZIP. '288            'Check the file is included and the sheet names are correct.'289        )290    if not csv_path:291        raise RuntimeError('No .csv flat file found in the ZIP.')292 293    return str(root), xl_path, csv_path294 295 296# ── Pipeline stream (stdout → queue) ─────────────────────────────────────────297class _QueueStream:298    """Captures pipeline print() output and puts each line into a queue."""299 300    def __init__(self, q: queue.Queue):301        self._q   = q302        self._buf = ''303 304    def write(self, text: str):305        self._buf += text306        while '\n' in self._buf:307            line, self._buf = self._buf.split('\n', 1)308            if line.strip():309                self._q.put(line)310        return len(text)311 312    def flush(self):313        if self._buf.strip():314            self._q.put(self._buf)315            self._buf = ''316 317    def fileno(self):318        raise OSError('_QueueStream has no file descriptor')319 320 321# ── Sentinel raised when the user clicks Stop ─────────────────────────────────322class _PipelineStopped(Exception):323    pass324 325 326def _run_pipeline(folder: str, excel_path: str, csv_path: str,327                  stop_event: threading.Event | None = None):328    """329    Full pipeline. Runs in a worker thread; all print() output goes to _QueueStream.330    stop_event: if set between stages, raises _PipelineStopped.331    """332    def _maybe_stop():333        if stop_event and stop_event.is_set():334            raise _PipelineStopped()335    import numpy as np336    import pandas as pd337    import openpyxl338    from concurrent.futures import ThreadPoolExecutor339 340    # ── Read inputs ───────────────────────────────────────────────────────341    print('START read inputs')342    print(f'  Reading Excel: {os.path.basename(excel_path)}')343    wb          = openpyxl.load_workbook(excel_path, read_only=True)344    sheet_names = wb.sheetnames345    wb.close()346    print(f'  Excel sheets found: {sheet_names}')347 348    meta_sheet  = next((s for s in sheet_names if 'META'  in s.upper()), None)349    final_sheet = next((s for s in sheet_names if 'FINAL' in s.upper()), None)350    if not meta_sheet:351        raise RuntimeError(f'No META sheet in {excel_path}. Found: {sheet_names}')352    if not final_sheet:353        raise RuntimeError(f'No FINAL sheet in {excel_path}. Found: {sheet_names}')354 355    print(f'  Loading META sheet ({meta_sheet}) and FINAL sheet ({final_sheet})…')356    metaGridPDF    = pd.read_excel(excel_path, sheet_name=meta_sheet)357    combinedAMPPDF = pd.read_excel(excel_path, sheet_name=final_sheet)358    print(f'  Reading CSV: {os.path.basename(csv_path)}')359    attrGridPDF    = pd.read_csv(csv_path, low_memory=False)360    print(f'  Loaded — META {metaGridPDF.shape} | FINAL {combinedAMPPDF.shape} | CSV {attrGridPDF.shape}')361 362    # Strip '|' (pipe) from DESCRIPTION — some client submission systems treat it363    # as a delimiter and reject the file. Replace with a space so adjacent words364    # don't merge.365    for _df in (combinedAMPPDF, attrGridPDF):366        if 'DESCRIPTION' in _df.columns:367            _df['DESCRIPTION'] = _df['DESCRIPTION'].astype(str).str.replace('|', ' ', regex=False).str.strip()368 369    FINAL   = combinedAMPPDF.copy()370    SAL_COL = 'RAW_TOTAL_DOLLARS'371    if SAL_COL not in combinedAMPPDF.columns:372        SAL_COL = next((c for c in combinedAMPPDF.columns if 'dollar' in c.lower()), None)373        print(f'  WARNING: using {SAL_COL!r} as sales column')374    print('DONE read inputs')375    _maybe_stop()376 377    # ── Validate + prep META ──────────────────────────────────────────────378    for col in ('Attribute Name in MDM', 'Attribute Group name', 'Attribute_Type', 'Type'):379        if col not in metaGridPDF.columns:380            raise RuntimeError(f"META sheet missing column: '{col}'")381 382    metaGridPDF_old = metaGridPDF.copy()383    # Case-insensitive match: META sheets export Attribute_Type as "Modeling"/384    # "Reporting" (title case), not "MODELING". Strip + upper before comparing385    # (mirrors the V2 api/pipeline.py fix) — otherwise 0 attributes match.386    metaGridPDF     = metaGridPDF[387        metaGridPDF['Attribute_Type'].astype(str).str.strip().str.upper() == 'MODELING'388    ]389    metaFields      = list(set(390        list(metaGridPDF['Attribute Name in MDM'].unique()) +391        list(metaGridPDF['Attribute Group name'].unique())392    ))393 394    missing_flat = list(set(metaGridPDF['Attribute Name in MDM']) - set(attrGridPDF.columns))395    missing_amp  = list(set(metaFields) - set(combinedAMPPDF.columns))396    if missing_flat:397        print(f'  WARNING - columns missing from CSV: {missing_flat}')398    if missing_amp:399        print(f'  WARNING - columns missing from FINAL: {missing_amp}')400 401    attrGridPDF    = attrGridPDF.astype(object).fillna('nan').astype(str)402    combinedAMPPDF = combinedAMPPDF.astype(object).fillna('nan').astype(str)403    metaGridPDF    = metaGridPDF.astype(object).fillna('nan').astype(str)404    combinedAMPPDF['TOTAL_UNIT_SALES'] = pd.to_numeric(405        combinedAMPPDF[SAL_COL], errors='coerce'406    ).fillna(0)407 408    dictRecom = {}409 410    # ── Lookup ────────────────────────────────────────────────────────────411    print('START mappinglookup')412    print('  Exact & fuzzy lookup — finds direct matches for new products using values already seen in the historical labelled data.')413    n_attrs = len(metaGridPDF['Attribute Name in MDM'].unique())414    n_new   = len(attrGridPDF)415    print(f'  Matching {n_new} new products against {n_attrs} attributes…')416    dictRecom, _, FLAT_FILE_OUT, dict_split_parent = _ml_mod.runLookup(417        attrGridPDF, metaGridPDF, combinedAMPPDF, dictRecom418    )419    print('DONE mappinglookup')420    _maybe_stop()421 422    # ── BM25 + XGBoost in parallel ────────────────────────────────────────423    print('START textmatch')424    print('  For products not resolved by lookup, predicting attributes from patterns in your historical data (lookup + ML)…')425    _dictRecom_tm = {}426    _dictRecom_ml = [None]427 428    def _run_tm():429        if stop_event and stop_event.is_set():430            return431        _tm.runTextMatch(metaGridPDF, combinedAMPPDF, attrGridPDF, _dictRecom_tm)432 433    def _run_ml():434        if stop_event and stop_event.is_set():435            return436        _dictRecom_ml[0] = _rfx.runML(attrGridPDF, metaGridPDF, combinedAMPPDF)437 438    with ThreadPoolExecutor(max_workers=4) as pool:439        f_tm = pool.submit(_run_tm)440        f_ml = pool.submit(_run_ml)441        f_tm.result()442        f_ml.result()443 444    dictRecom.update(_dictRecom_tm)445    if _dictRecom_ml[0]:446        dictRecom.update(_dictRecom_ml[0])447    print('DONE textmatch')448    _maybe_stop()449 450    # ── Ensemble ──────────────────────────────────────────────────────────451    print('START ensemble')452    print('  Combining ML predictions with lookup results and assigning QC priority…')453    dictEnsemble = _ens.runEnsemble(dictRecom, metaGridPDF, dict_split_parent)454    print('DONE ensemble')455    _maybe_stop()456 457    # ── Hand off to QC wizard (Excel written after analyst review) ────────458    sheets = ['FINAL', 'FLAT_FILE', 'META'] + list(dictEnsemble.keys())459    print(f'DONE pipeline — {len(dictEnsemble)} lookup sheet(s) ready for QC review: {", ".join(dictEnsemble.keys())}')460    return FINAL, FLAT_FILE_OUT, metaGridPDF_old, dictEnsemble461 462 463# ── Disk-usage helpers ───────────────────────────────────────────────────────464def _dir_size(path: str) -> int:465    """Return total bytes of all files under path (recursive)."""466    total = 0467    try:468        for dirpath, _, filenames in os.walk(path):469            for fn in filenames:470                try:471                    total += os.path.getsize(os.path.join(dirpath, fn))472                except OSError:473                    pass474    except OSError:475        pass476    return total477 478 479def _fmt_bytes(n: int) -> str:480    """Format byte count as human-readable string."""481    for unit in ('B', 'KB', 'MB', 'GB'):482        if n < 1024:483            return f'{n:.1f} {unit}'484        n /= 1024485    return f'{n:.1f} GB'486 487 488# ── Log filtering ─────────────────────────────────────────────────────────────489# Per-attribute detail lines (e.g. "  BM25     BRAND: 100 products") are490# kept in the full download log but hidden from the UI — analysts see only491# high-level stage progress and any error/warning lines.492_DETAIL_LOG_RE = _re.compile(493    r'^\s{2,}(Lookup|BM25|ML|XGB|VOCAB|Done|Ensemble|AO\s)\s{2,}',494)495 496 497def _is_bridge_line(line: str) -> bool:498    """Return True for lines that should be visible in the UI log box."""499    low = line.lower()500    if any(w in low for w in ('error', 'exception', 'traceback', 'runtimeerror', 'warning')):501        return True502    return not _DETAIL_LOG_RE.match(line)503 504 505def _sort_lkp_df(df: pd.DataFrame) -> pd.DataFrame:506    """Pre-sort a lookup DataFrame identically to _write_lkp_sheet:507    HIGH → MEDIUM → LOW priority, No → Yes ML agreement, score ascending."""508    df = df.copy()509    _pri = {'HIGH': 0, 'MEDIUM': 1, 'LOW': 2}510    _ml  = {'No': 0, 'Yes': 1}511    sort_cols, sort_asc = [], []512    if 'QC Priority' in df.columns:513        df['_s_pri'] = df['QC Priority'].map(_pri).fillna(3)514        sort_cols.append('_s_pri'); sort_asc.append(True)515    if 'ML Matches Lookup' in df.columns:516        df['_s_ml'] = df['ML Matches Lookup'].map(_ml).fillna(2)517        sort_cols.append('_s_ml'); sort_asc.append(True)518    if 'score' in df.columns:519        sort_cols.append('score'); sort_asc.append(True)520    if sort_cols:521        df = df.sort_values(sort_cols, ascending=sort_asc)522        df = df.drop(columns=[c for c in ('_s_pri', '_s_ml') if c in df.columns])523    return df.reset_index(drop=True)524 525 526def _write_qc_excel(payload: dict, qc_edits: dict, lkp_keys: list) -> None:527    """Write File_For_Mapping_QC.xlsx using analyst-edited lookup DataFrames.528 529    Sheets already reviewed are taken from qc_edits; any sheets skipped use530    the original DataFrame from the pipeline.  Called on the main thread after531    the wizard completes (or is skipped), so xlsxwriter runs without GIL532    contention from the pipeline thread.533    """534    final_dict = {535        key: qc_edits[key] if key in qc_edits else payload['dictEnsemble'][key]536        for key in lkp_keys537    }538    out_path = os.path.join(st.session_state.tmpdir, 'File_For_Mapping_QC.xlsx')539    _write_results(out_path, payload['FINAL'], payload['FLAT_FILE_OUT'], payload['meta'], final_dict)540    with open(out_path, 'rb') as fh:541        _out_data = fh.read()542    st.session_state.output_bytes = _out_data543    # Update disk metrics with the final output file size544    if st.session_state.disk_metrics is not None:545        st.session_state.disk_metrics['output_bytes'] = len(_out_data)546        # Re-measure tmpdir now the output xlsx has been written547        st.session_state.disk_metrics['tmpdir_bytes'] = _dir_size(548            os.path.join(st.session_state.tmpdir)549        )550    st.session_state.progress_value = 1.0551    st.session_state.stage_label    = '✓  Complete'552 553 554# ── Log line colouring ────────────────────────────────────────────────────────555def _colour_line(line: str) -> str:556    """Wrap a log line in an HTML span with the appropriate colour."""557    low = line.lower()558    if any(w in low for w in ('error', 'exception', 'traceback', 'runtimeerror')):559        colour = FG_ERR560    elif any(w in low for w in ('warn', 'warning', 'missing')):561        colour = FG_WARN562    elif any(w in low for w in ('done', 'complete', 'written', 'filled')):563        colour = FG_OK564    elif any(w in low for w in ('running', 'reading', 'building', 'writing', 'applying', 'start')):565        colour = ACCENT566    else:567        colour = '#374151'568    return f'<span style="color:{colour}">{line}</span>'569 570 571def _stage_from_line(line: str):572    """Return (stage_key, is_done) if line announces a stage transition, else None."""573    low = line.lower()574    if 'done' not in low and 'start' not in low:575        return None576    for key in _STAGE_PROGRESS:577        if key in low:578            return key, 'done' in low579    return None580 581 582# ── Session state ─────────────────────────────────────────────────────────────583for _k, _v in [584    ('running',         False),585    ('stop_event',      None),586    ('done_event',      None),587    ('log_q',           None),588    ('log_lines',       []),589    ('err_holder',      [None]),590    ('stopped_by_user', False),591    ('t_start',         None),592    ('tmpdir',          None),593    ('output_bytes',    None),594    ('run_elapsed',     ''),595    ('progress_value',  0.0),596    ('stage_label',     ''),597    ('error_count',     0),598    ('run_log',         ''),599    ('disk_metrics',    None),600    # QC wizard state601    ('result_holder',   None),602    ('qc_phase',        None),   # None | 'wizard'603    ('qc_payload',      None),604    ('qc_step',         0),605    ('qc_lkp_keys',     []),606    ('qc_edits',        {}),607]:608    if _k not in st.session_state:609        st.session_state[_k] = _v610 611# ── Controls row ──────────────────────────────────────────────────────────────612# The stop button must be rendered at the TOP of each rerun so Streamlit613# registers clicks before the polling/rerun logic runs below.614pipeline_busy = _PIPELINE_LOCK.locked() and not st.session_state.running615run_clicked   = False616stop_clicked  = False617 618if st.session_state.running:619    col_stop, col_status = st.columns([2, 10])620    with col_stop:621        stop_clicked = st.button('⏹  Stop', type='secondary')622    with col_status:623        if st.session_state.t_start:624            # JS timer runs entirely in the browser — unaffected by server625            # rerun frequency or GIL contention from the pipeline threads.626            _start_ms = int(st.session_state.t_start * 1000)627            st.components.v1.html(f"""628<span id="aic-t" style="font-size:0.85rem;color:#6B7280;font-family:sans-serif">629  Pipeline running&hellip; 0:00630</span>631<script>632(function(){{633  var s0 = {_start_ms};634  function tick(){{635    var el = Math.floor((Date.now() - s0) / 1000);636    var m  = Math.floor(el / 60);637    var s  = el % 60;638    var el2 = document.getElementById('aic-t');639    if (el2) el2.textContent = 'Pipeline running\u2026 ' + m + ':' + (s < 10 ? '0' : '') + s;640  }}641  tick();642  setInterval(tick, 1000);643}})();644</script>645""", height=28, scrolling=False)646else:647    if _zip_mode:648        ready = zip_file is not None and not pipeline_busy649    else:650        ready = (xlsx_file is not None and csv_file is not None) and not pipeline_busy651    run_clicked = st.button('▶  Run', disabled=not ready, type='primary')652    if pipeline_busy:653        st.warning('Pipeline is currently running for another user — please wait.')654    elif not ready:655        st.caption('Upload both files to enable Run.')656 657# ── Handle stop click ─────────────────────────────────────────────────────────658if stop_clicked and st.session_state.stop_event and not st.session_state.stop_event.is_set():659    st.session_state.stop_event.set()660    st.session_state.stopped_by_user = True661    st.session_state.stage_label = '⏹  Stopping — waiting for current stage to finish…'662 663# ── Start pipeline on Run click ───────────────────────────────────────────────664if run_clicked and not st.session_state.running:665    stop_ev  = threading.Event()666    done_ev  = threading.Event()667    log_q    = queue.Queue()668    err_hold = [None]669 670    result_holder = [None]671    st.session_state.update({672        'running':         True,673        'stop_event':      stop_ev,674        'done_event':      done_ev,675        'log_q':           log_q,676        'log_lines':       [],677        'err_holder':      err_hold,678        'stopped_by_user': False,679        't_start':         time.time(),680        'output_bytes':    None,681        'run_elapsed':     '',682        'progress_value':  0.0,683        'stage_label':     '',684        'error_count':     0,685        'run_log':         '',686        'disk_metrics':    None,687        # reset wizard state for fresh run688        'result_holder':   result_holder,689        'qc_phase':        None,690        'qc_payload':      None,691        'qc_step':         0,692        'qc_lkp_keys':     [],693        'qc_edits':        {},694    })695 696    tmpdir = tempfile.mkdtemp(prefix='aic_')697    st.session_state.tmpdir = tmpdir698 699    if _zip_mode:700        # Extract ZIP and locate the Phase 1 inputs701        try:702            p1_root, xl_p, csv_p = _extract_p1_zip(zip_file.getvalue(), tmpdir)703        except RuntimeError as _e:704            st.error(str(_e))705            st.session_state.running = False706            st.stop()707 708        # Pre-populate Phase 2/3 session state so the folder is ready on page 2709        st.session_state['p3_extracted_dir'] = p1_root710        # Reset cols so Phase 2/3 detects them fresh on first render711        st.session_state['p3_raw_cols'] = []712        st.session_state['p3_all_cols'] = []713        st.session_state['p3_default_upc_col'] = ''714        st.session_state['p3_default_mfr_col'] = ''715        st.session_state['p3_mfr_values']       = []716        st.session_state['p3_brand_values']     = []717        st.session_state['p3_tool_brand_values'] = []718    else:719        xl_p  = os.path.join(tmpdir, xlsx_file.name)720        csv_p = os.path.join(tmpdir, csv_file.name)721        with open(xl_p,  'wb') as fh: fh.write(xlsx_file.getvalue())722        with open(csv_p, 'wb') as fh: fh.write(csv_file.getvalue())723 724    # Capture references for the worker thread — do NOT access st.session_state725    # from inside the thread (it has no Streamlit context).726    def _worker(727        _tmpdir=tmpdir, _xl=xl_p, _csv=csv_p,728        _log_q=log_q, _stop_ev=stop_ev, _done_ev=done_ev, _err=err_hold,729        _result=result_holder,730    ):731        _stream  = _QueueStream(_log_q)732        _old_out = sys.stdout733        _old_err = sys.stderr734        with _PIPELINE_LOCK:735            sys.stdout = _stream736            sys.stderr = _stream737            try:738                _result[0] = _run_pipeline(_tmpdir, _xl, _csv, stop_event=_stop_ev)739            except _PipelineStopped:740                _log_q.put('⏹  Run cancelled by user.')741            except Exception:742                for _ln in traceback.format_exc().splitlines():743                    _log_q.put(_ln)744                _err[0] = traceback.format_exc()745            finally:746                _stream.flush()747                sys.stdout = _old_out748                sys.stderr = _old_err749        _done_ev.set()750 751    # Capture input file sizes before the thread starts so we can report them later752    if _zip_mode and zip_file is not None:753        _input_bytes = len(zip_file.getvalue())754    else:755        _input_bytes = (756            (len(xlsx_file.getvalue()) if xlsx_file else 0) +757            (len(csv_file.getvalue())  if csv_file  else 0)758        )759    st.session_state['_input_bytes'] = _input_bytes760 761    threading.Thread(target=_worker, daemon=True).start()762    st.rerun()763 764# ── Live progress + log (runs on every rerun while pipeline is active) ─────────765if st.session_state.running or st.session_state.log_lines:766 767    # Drain any queued log lines into session state768    if st.session_state.running and st.session_state.log_q:769        while True:770            try:771                line = st.session_state.log_q.get_nowait()772                st.session_state.log_lines.append(line)773                hit = _stage_from_line(line)774                if hit:775                    key, is_done = hit776                    if is_done:777                        st.session_state.progress_value = _STAGE_PROGRESS[key]778                        st.session_state.stage_label    = f'✓ {_STAGE_LABELS[key]}'779                    else:780                        st.session_state.stage_label = f'⟳ {_STAGE_LABELS.get(key, "")}'781                low = line.lower()782                if any(w in low for w in ('error', 'exception', 'traceback', 'runtimeerror')):783                    st.session_state.error_count += 1784            except queue.Empty:785                break786 787    # Progress bar — deterministic fill based on pipeline stage reached788    if st.session_state.running or st.session_state.stage_label.startswith('✓'):789        st.progress(st.session_state.progress_value)790    elif st.session_state.stage_label.startswith('✗'):791        st.markdown(792            f'<div style="height:4px;background:{FG_ERR};border-radius:2px;margin:8px 0 2px 0;"></div>',793            unsafe_allow_html=True,794        )795    if st.session_state.stage_label:796        st.markdown(797            f'<p class="stage-label">{st.session_state.stage_label}</p>',798            unsafe_allow_html=True,799        )800 801    # Live error banner802    _nerr = st.session_state.error_count803    if _nerr > 0:804        st.warning(f'⚠  {_nerr} error{"s" if _nerr > 1 else ""} logged — see highlighted lines below')805 806    # Log box — bridge lines only in UI; full detail goes to downloadable log807    bridge     = [l for l in st.session_state.log_lines if _is_bridge_line(l)]808    visible    = bridge[-60:]809    html_lines = '<br>'.join(_colour_line(l) for l in visible)810    st.markdown(f'<div class="log-box">{html_lines}</div>', unsafe_allow_html=True)811 812    # ── Check for completion ───────────────────────────────────────────────813    if st.session_state.running and st.session_state.done_event and st.session_state.done_event.is_set():814        _el = time.time() - st.session_state.t_start815        _m, _s = divmod(int(_el), 60)816        st.session_state.run_elapsed = f'{_m}:{_s:02d}'817        st.session_state.run_log     = '\n'.join(st.session_state.log_lines)818 819        # Measure disk usage now that the pipeline has finished writing to tmpdir820        _tmpdir_now  = st.session_state.get('tmpdir', '')821        _tmpdir_size = _dir_size(_tmpdir_now) if _tmpdir_now else 0822        _input_size  = st.session_state.get('_input_bytes', 0)823        _log_size    = len(st.session_state.run_log.encode('utf-8', errors='replace'))824        st.session_state.disk_metrics = {825            'input_bytes':  _input_size,826            'tmpdir_bytes': _tmpdir_size,827            'log_bytes':    _log_size,828        }829 830        if st.session_state.stopped_by_user:831            st.session_state.progress_value = 0.0832            st.session_state.stage_label    = '⏹  Stopped'833        elif (st.session_state.err_holder[0] is None834              and st.session_state.result_holder is not None835              and st.session_state.result_holder[0] is not None):836            FINAL, FLAT_FILE_OUT, metaGridPDF_old, dictEnsemble = st.session_state.result_holder[0]837            st.session_state.qc_payload  = {838                'FINAL':         FINAL,839                'FLAT_FILE_OUT': FLAT_FILE_OUT,840                'meta':          metaGridPDF_old,841                'dictEnsemble':  dictEnsemble,842            }843            st.session_state.qc_phase    = 'wizard'844            st.session_state.qc_step     = 0845            st.session_state.qc_lkp_keys = list(dictEnsemble.keys())846            st.session_state.qc_edits    = {}847            st.session_state.progress_value = 1.0848            st.session_state.stage_label    = '✓  Pipeline complete — QC review ready'849            # Persist the QC payload to disk so a browser/Spaces session drop after850            # a successful run can RELOAD the sheets (see the wizard guard below)851            # instead of forcing a full pipeline re-run.852            try:853                import os as _os, pickle as _pkl, tempfile as _tf854                _qc_dir = _os.path.join(_tf.gettempdir(), 'aic_qc_cache')855                _os.makedirs(_qc_dir, exist_ok=True)856                with open(_os.path.join(_qc_dir, 'last_payload.pkl'), 'wb') as _fh:857                    _pkl.dump({'payload':  st.session_state.qc_payload,858                               'lkp_keys': st.session_state.qc_lkp_keys}, _fh)859            except Exception:860                pass861        else:862            st.session_state.progress_value = 0.0863            st.session_state.stage_label    = '✗  Failed'864 865        st.session_state.running = False866        st.rerun()867 868    elif st.session_state.running:869        # Still running — poll again shortly.870        # 0.5 s keeps the UI responsive without hammering the server with871        # 12 reruns/second (which caused apparent freezes on HF Spaces).872        time.sleep(0.5)873        st.rerun()874 875# ── QC Wizard ─────────────────────────────────────────────────────────────────876if st.session_state.qc_phase == 'wizard':877    from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode, DataReturnMode878 879    payload   = st.session_state.qc_payload880    lkp_keys  = st.session_state.qc_lkp_keys881    step      = st.session_state.qc_step882    total     = len(lkp_keys)883 884    # Guard: distinguish a genuinely-empty result from a lost session — these885    # have completely different causes and the old code conflated them, so an886    # empty pipeline result (0 attributes matched) wrongly read as a timeout.887    _guard_msg = None888    if payload is None:889        # Session state gone — e.g. HF Spaces browser/WebSocket timeout mid-run.890        # First try to recover the last successful run from disk (the completion891        # handler persisted it); only warn if there is nothing to recover.892        try:893            import os as _os, pickle as _pkl, tempfile as _tf894            _cache_f = _os.path.join(_tf.gettempdir(), 'aic_qc_cache', 'last_payload.pkl')895            if _os.path.exists(_cache_f):896                with open(_cache_f, 'rb') as _fh:897                    _cached = _pkl.load(_fh)898                st.session_state.qc_payload  = _cached['payload']899                st.session_state.qc_lkp_keys = _cached['lkp_keys']900                st.session_state.qc_phase    = 'wizard'901                st.rerun()902        except Exception:903            pass904        _guard_msg = (905            'QC session data was lost — this can happen after a browser timeout on '906            'HuggingFace Spaces. Please re-run the pipeline to regenerate the lookup sheets.'907        )908    elif not lkp_keys:909        # Pipeline DID complete (payload present) but produced zero lookup sheets:910        # it matched 0 attributes.  Almost always a META/column mismatch, NOT a911        # timeout.  Give the analyst the real cause instead of the misleading one.912        _guard_msg = (913            'The pipeline finished but produced **0 lookup sheets** (0 attributes matched). '914            'This is almost always a META/column mismatch — not a session timeout. '915            'Check that each META "Attribute Name in MDM" value exists as a column in '916            'BOTH the FINAL sheet and the flat-file CSV (watch for renamed or missing '917            'RAW_ columns), then re-run.'918        )919    if _guard_msg is not None:920        (st.warning if payload is None else st.error)(_guard_msg)921        if st.button('↩  Re-run pipeline', key='btn_rerun_after_timeout'):922            st.session_state.qc_phase   = None923            st.session_state.qc_payload = None924            st.session_state.qc_step    = 0925            st.session_state.qc_lkp_keys = []926            st.session_state.run_elapsed  = ''927            st.rerun()928        st.stop()929 930    if step >= total:931        # State drift — step advanced past last sheet without completing wizard932        st.info('All lookup sheets reviewed. Writing Excel workbook…')933        with st.spinner('Writing Excel workbook…'):934            _write_qc_excel(payload, st.session_state.qc_edits, lkp_keys)935        st.session_state.qc_phase = None936        st.rerun()937 938    st.markdown('---')939    st.markdown(940        f'<div class="aic-header" style="margin-top:12px">'941        f'<h1 style="font-size:1.2rem">QC Lookup Review</h1>'942        f'<p>Review and correct each lookup sheet before downloading the final workbook.</p>'943        f'</div>',944        unsafe_allow_html=True,945    )946 947    if step < total:948        sheet_key = lkp_keys[step]949        attr      = sheet_key.replace('Final_', '').replace('_lkp', '')950 951        # Progress indicator952        st.markdown(953            f'<p class="stage-label">Sheet <strong>{step + 1} of {total}</strong>: '954            f'<code>{attr}</code></p>',955            unsafe_allow_html=True,956        )957        st.progress(step / total)958 959        # Build display DataFrame — sort, drop internal columns, clean nulls960        orig_df    = payload['dictEnsemble'][sheet_key]961        display_df = _sort_lkp_df(orig_df)962        # ML Method carries pipeline-internal labels not useful to analysts963        if 'ML Method' in display_df.columns:964            display_df = display_df.drop(columns=['ML Method'])965        ml_col_raw = f'ML{attr}'966        if ml_col_raw in display_df.columns:967            display_df = display_df.rename(columns={ml_col_raw: 'ML Suggestion'})968        # Replace float NaN and the pipeline's 'nan' sentinel with blank so the969        # grid never shows "nan" — mirrors _clean() in _write_results.py970        display_df = display_df.fillna('')971        obj_cols = display_df.select_dtypes(include='object').columns972        display_df[obj_cols] = display_df[obj_cols].replace('nan', '')973 974        # Dropdown values: union of existing attribute values + ML Suggestion values975        attr_vals = (976            sorted(display_df[attr].dropna().astype(str).unique().tolist())977            if attr in display_df.columns else []978        )979        ml_vals = (980            sorted(display_df['ML Suggestion'].dropna().astype(str).unique().tolist())981            if 'ML Suggestion' in display_df.columns else []982        )983        dropdown_vals = [''] + sorted({v for v in set(attr_vals + ml_vals) if v and v != 'nan'})984 985        # ── AgGrid configuration ───────────────────────────────────────────986        gb = GridOptionsBuilder.from_dataframe(display_df)987        gb.configure_default_column(editable=False, sortable=False, filter=True, resizable=True)988        gb.configure_pagination(enabled=True, paginationAutoPageSize=False, paginationPageSize=50)989 990        if attr in display_df.columns:991            gb.configure_column(992                attr,993                editable=True,994                cellEditor='agSelectCellEditor',995                cellEditorParams={'values': dropdown_vals},996            )997 998        grid_options = gb.build()999 1000        response = AgGrid(1001            display_df,1002            gridOptions=grid_options,1003            height=480,1004            update_mode=GridUpdateMode.VALUE_CHANGED,1005            data_return_mode=DataReturnMode.AS_INPUT,1006            fit_columns_on_grid_load=False,1007            theme='streamlit',1008            reload_data=False,1009            key=f'qc_grid_{sheet_key}',1010        )1011 1012        # ── Navigation buttons ─────────────────────────────────────────────1013        btn_label = ('Save & Next →' if step < total - 11014                     else 'Save & Generate Excel ✓')1015        col_next, col_skip = st.columns([3, 2])1016 1017        with col_next:1018            if st.button(btn_label, type='primary', key=f'btn_next_{step}'):1019                edited_df = pd.DataFrame(response['data'])1020                # Restore numeric dtypes that AgGrid may have stringified1021                for _nc in ('score', 'Rank'):1022                    if _nc in edited_df.columns:1023                        edited_df[_nc] = pd.to_numeric(edited_df[_nc], errors='coerce')1024                # Rename ML Suggestion back to original column name for write_results1025                if ml_col_raw in orig_df.columns and 'ML Suggestion' in edited_df.columns:1026                    edited_df = edited_df.rename(columns={'ML Suggestion': ml_col_raw})1027                st.session_state.qc_edits[sheet_key] = edited_df1028                # Keep dictEnsemble in sync so any in-memory reads see the edits1029                payload['dictEnsemble'][sheet_key] = edited_df.copy()1030                st.session_state.qc_step += 11031                if st.session_state.qc_step >= total:1032                    with st.spinner('Writing Excel workbook…'):1033                        _write_qc_excel(payload, st.session_state.qc_edits, lkp_keys)1034                    st.session_state.qc_phase = None1035                st.rerun()1036 1037        with col_skip:1038            if st.button('Skip remaining sheets & Download', key='btn_skip'):1039                with st.spinner('Writing Excel workbook…'):1040                    _write_qc_excel(payload, st.session_state.qc_edits, lkp_keys)1041                st.session_state.qc_phase = None1042                st.rerun()1043 1044 1045# ── Post-run status + download ────────────────────────────────────────────────1046if (not st.session_state.running1047        and st.session_state.run_elapsed1048        and st.session_state.qc_phase != 'wizard'):1049    if st.session_state.output_bytes is not None:1050        st.success(f'Done in {st.session_state.run_elapsed}. Download your QC workbook below.')1051        st.download_button(1052            '⬇  Download File_For_Mapping_QC.xlsx',1053            data=st.session_state.output_bytes,1054            file_name='File_For_Mapping_QC.xlsx',1055            mime='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',1056            type='primary',1057            use_container_width=True,1058            key='p1_xlsx',1059        )1060        st.markdown('')1061        if st.button('▶▶  Save & proceed to Phase 2/3', type='primary', key='p1_to_p2'):1062            st.switch_page('pages/2_Phase_3_Pipeline_and_QC.py')1063 1064        # ── Disk usage estimate ────────────────────────────────────────────1065        _dm = st.session_state.get('disk_metrics')1066        if _dm:1067            with st.expander('Disk usage estimate (Azure sizing)', expanded=False):1068                _inp  = _dm.get('input_bytes',  0)1069                _tmp  = _dm.get('tmpdir_bytes', 0)1070                _out  = _dm.get('output_bytes', 0)1071                _log  = _dm.get('log_bytes',    0)1072 1073                # Peak per-user = everything in tmpdir at its fullest point1074                # (includes extracted inputs, working files, output xlsx)1075                _peak = max(_tmp, _inp + _out)1076 1077                # 3× covers persistent logs, Streamlit session cache,1078                # Python/model artefacts, and a safety buffer.1079                _MULTIPLIER = 31080 1081                st.markdown(1082                    '**Per-user breakdown for this run**  '1083                    '*(actual disk written to the container temp directory)*'1084                )1085                _rows = [1086                    ('Uploaded input files',             _fmt_bytes(_inp)),1087                    ('Peak temp directory (all files)',   _fmt_bytes(_tmp)),1088                    ('Output workbook (QC xlsx)',         _fmt_bytes(_out) if _out else '—'),1089                    ('Run log',                          _fmt_bytes(_log)),1090                    ('**Peak per-user total**',          f'**{_fmt_bytes(_peak)}**'),1091                ]1092                st.table(pd.DataFrame(_rows, columns=['Component', 'Size']))1093 1094                st.markdown('---')1095                st.markdown(1096                    '**Azure storage sizing table** — peak per user × concurrent users × overhead multiplier.'1097                )1098                _az_rows = []1099                for _u in [1, 5, 10, 25, 50]:1100                    _raw   = _peak * _u1101                    _total = _raw  * _MULTIPLIER1102                    _az_rows.append((str(_u), _fmt_bytes(_raw), _fmt_bytes(_total)))1103                st.table(pd.DataFrame(1104                    _az_rows,1105                    columns=[1106                        'Concurrent users',1107                        'Raw peak (users × per-user peak)',1108                        f'Recommended ({_MULTIPLIER}× buffer for logs + OS overhead)',1109                    ],1110                ))1111                st.caption(1112                    f'Per-user peak: **{_fmt_bytes(_peak)}**.  '1113                    f'The {_MULTIPLIER}× multiplier covers persistent run logs, '1114                    'Streamlit session cache, Python/model artefacts, and a safety buffer.  '1115                    'If you need logs to survive redeploys, add an Azure File Share '1116                    'separately (≥ 1 GB per 1 000 runs is a reasonable starting point).'1117                )1118 1119    elif st.session_state.stopped_by_user:1120        st.warning(f'Run stopped after {st.session_state.run_elapsed}.')1121    else:1122        st.error(f'Pipeline failed after {st.session_state.run_elapsed} — check the log above for details.')1123