CoolFace
Apppublic

CMacD/AIC_PHASE1_POC

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
pipeline.py692 linesDownload Raw Back to phase3_package
1"""2Pipeline orchestrator for AIC Phase 2 + Phase 3.3 4Runs the full processing pipeline in sequence:5  Phase 2  – Attribute assembly from workbook sheets (aic_phase2).6  Phase 3  – Quality checks, transformations, SKU collapse, and7             category splitting (quality, transforms, sku_collapse).8 9Called by ``PipelineWorker`` in the GUI; all ``print()`` output is10captured and streamed to the GUI log widget via a stdout proxy.11 12Returns13-------14(collapsed_df, duplicate_dimkeys_df)15"""16 17from __future__ import annotations18 19import json20import os21import re22from pathlib import Path23from typing import Dict, Any, List, Optional24 25import pandas as pd26pd.set_option('display.max_colwidth', None)27 28# --- Phase 2 imports (attribute assembly + MDM QC) -------------------------29from phase3_package.aic_phase2 import aic_code, run_tool_vs_mdm_qc30 31# --- Phase 3 transforms (private label, brand overrides, restricted) -------32from phase3_package.transforms import (33    overwrite_upc10_for_private_label,34    apply_private_label_rules,35    normalize_upc10,36    strip_legacy_brand_overrides,37    apply_brand_overrides,38    strip_legacy_restricted_suffix,39    raw_multi_restricted_overrides,40    _print_step_header,41)42 43# --- Phase 3 quality checks -----------------------------------------------44from phase3_package.quality import (45    update_req_check,46    verify_ao_cat_def,47    demand_group_check,48    check_identifier_numeric_format,49    flag_invalid_headers,50    check_special_chars,51    check_duplicate_dimkeys,52    split_by_raw_assortment_category,53    check_brand_tool_brand_mismatch,54    check_null_modeling_reporting_cols,55)56 57# --- SKU collapse ----------------------------------------------------------58from phase3_package.sku_collapse import prepare_and_collapse59 60 61# ═══════════════════════════════════════════════════════════════════════════62# Formatting Constants63# ═══════════════════════════════════════════════════════════════════════════64MAJOR_SEP = "=" * 7065MINOR_SEP = "-" * 6066INDENT = "   "67 68 69# ═══════════════════════════════════════════════════════════════════════════70# Main Pipeline71# ═══════════════════════════════════════════════════════════════════════════72 73def run_through_step_12(74    directory_path: str,75    raw_upc_pl_brand_col: str,76    private_label_config: Dict[str, Any],77    brand_override_config: Dict[str, Any],78    is_custom_collapse: bool,79    file_manifest: Dict[str, Any] = None,80    skip_rmrr: bool = False,81    pl_base_name: str = "",82) -> tuple:83    """84    Run Steps 1-12 plus mismatch detection (Phase A).85 86    Returns87    -------88    (df, duplicate_dimkeys_df, mismatch_groups, pipeline_context)89        - df: DataFrame after all transformations through Step 12.90        - duplicate_dimkeys_df: QC DataFrame of duplicate ITEM_DIM_KEYs found.91        - mismatch_groups: List of per-model mismatch dicts (empty list if none).92        - pipeline_context: Dict carrying forward state needed by Phase B.93    """94    input_dir = Path(directory_path)95 96    # ------------------------------------------------------------------97    # Build file manifest (scan directory ONCE if not pre-provided)98    # ------------------------------------------------------------------99    if file_manifest is None:100        file_manifest = _scan_directory(input_dir)101 102    # ------------------------------------------------------------------103    # Read and concatenate all ModelInfo files from manifest104    # ------------------------------------------------------------------105    model_info_paths = file_manifest["model_info_paths"]106    model_info_frames: List[pd.DataFrame] = []107    for file_path in model_info_paths:108        try:109            model_info_frame = pd.read_csv(file_path, sep="|")110            model_info_frames.append(model_info_frame)111        except PermissionError:112            raise PermissionError(113                f"\n{'=' * 60}\n"114                f"FILE LOCKED: Cannot access '{Path(file_path).name}'\n"115                f"{'=' * 60}\n"116                f"The file appears to be open in another application.\n\n"117                f"Please close the file and try again.\n"118                f"{'=' * 60}"119            )120        except Exception as exc:121            raise RuntimeError(f"Error reading ModelInfo file at {file_path}: {exc}")122 123    model_info_df = pd.concat(model_info_frames, ignore_index=True)124 125    # Log which ModelInfo files were used126    if len(model_info_paths) == 1:127        relative_path = Path(model_info_paths[0]).relative_to(input_dir)128        print(f"{INDENT}Using ModelInfo.txt from: {relative_path}")129    else:130        relative_paths = ", ".join(131            str(Path(p).relative_to(input_dir)) for p in model_info_paths132        )133        print(f"{INDENT}Detected multiple ModelInfo.txt files in subdirectories: {relative_paths}")134        print(f"{INDENT}Combined all ModelInfo files into a single frame for QC checks.")135 136    # ==================================================================137    # Phase 2: AIC Attribute Assembly138    # ==================================================================139    print(f"\n{MAJOR_SEP}")140    print("PHASE 2 AIC PROCESSING")141    print(MAJOR_SEP)142 143    # skip_qc=True because QC runs later on fully transformed data144    # Pass the file manifest so aic_code() skips its own directory scans145    df, _, meta_df, combined_attributes_df, combined_attr_values_df, demand_group_fallback = aic_code(146        str(input_dir), skip_qc=True, file_manifest=file_manifest147    )148 149    # ==================================================================150    # Phase 3: Quality Checks and Transformations (Steps 1-12)151    # ==================================================================152    print(f"\n{MAJOR_SEP}")153    print("PHASE 3 QUALITY CHECKS AND TRANSFORMATIONS")154    print(MAJOR_SEP)155 156    # --- Derive valid model suffixes from subdirectories ------------------157    tool_sources = file_manifest.get("tool_sources", [])158    subdir_names = [159        s.split(":", 1)[1] for s in tool_sources if s.startswith("subdir:")160    ]161 162    valid_model_suffixes: Optional[set] = None163    if subdir_names:164        # Discover known column suffixes from any TOOL_X / X paired columns165        # (e.g. TOOL_BRAND/BRAND, TOOL_SUBBRAND/SUBBRAND) — two-pass approach:166        # pass 1 finds unambiguous base pairs, pass 2 finds their suffixed variants.167        col_upper_set = {str(c).upper() for c in df.columns}168        known_suffixes: set = set()169        for col_key in col_upper_set:170            if col_key.startswith("TOOL_"):171                base = col_key[len("TOOL_"):]172                if base and "_" not in base and base in col_upper_set:173                    # Confirmed base pair — scan for X_SUFFIX / TOOL_X_SUFFIX variants174                    base_prefix = f"{base}_"175                    for other_col in col_upper_set:176                        if other_col.startswith(base_prefix):177                            suffix = other_col[len(base_prefix):]178                            if suffix and f"TOOL_{base}_{suffix}" in col_upper_set:179                                known_suffixes.add(suffix)180 181        # Additional pass: directly validate directory names as column suffixes.182        # Handles projects where only suffixed columns exist (no base TOOL_X/X pair),183        # e.g. SUBBRAND_MULO / TOOL_SUBBRAND_MULO with no base SUBBRAND / TOOL_SUBBRAND.184        for dir_name in subdir_names:185            dir_upper = dir_name.upper()186            if dir_upper not in known_suffixes:187                suffix_tag = f"_{dir_upper}"188                for col_key in col_upper_set:189                    if col_key.startswith("TOOL_") and col_key.endswith(suffix_tag):190                        non_tool = col_key[len("TOOL_"):]191                        if non_tool in col_upper_set:192                            known_suffixes.add(dir_upper)193                            break194 195        valid_model_suffixes = set()196        print(f"{INDENT}Multi-model subdirectories detected:")197 198        for dir_name in sorted(subdir_names):199            dir_upper = dir_name.upper()200 201            # Exact match (e.g. directory called "CONV")202            if dir_upper in known_suffixes:203                valid_model_suffixes.add(dir_upper)204                print(f"{INDENT}  {dir_name} → {dir_upper} (confirmed column suffix _{dir_upper})")205                continue206 207            # Segment match — check each underscore-delimited part208            segments = dir_upper.split("_")209            matched = [seg for seg in segments if seg in known_suffixes]210 211            if len(matched) == 1:212                valid_model_suffixes.add(matched[0])213                print(f"{INDENT}  {dir_name} → {matched[0]} (confirmed column suffix _{matched[0]})")214            elif len(matched) > 1:215                # Ambiguous — use last matching segment216                chosen = matched[-1]217                valid_model_suffixes.add(chosen)218                print(f"{INDENT}  ⚠ {dir_name} matches multiple suffixes {matched} — using {chosen}")219            else:220                # No column match — fall back to last segment and warn221                fallback = segments[-1]222                valid_model_suffixes.add(fallback)223                print(224                    f"{INDENT}  ⚠ {dir_name} → {fallback} (no matching column pair with suffix _{fallback} found — "225                    f"expected e.g. BRAND_{fallback} / TOOL_BRAND_{fallback} or similar TOOL_*/base pair)"226                )227 228        valid_model_suffixes = valid_model_suffixes or None229 230    if valid_model_suffixes:231        print(f"{INDENT}Active model suffixes: {', '.join(sorted(valid_model_suffixes))}")232        print(f"{INDENT}Only column pairs matching these suffixes will be processed.\n")233 234    # Step 1: Ensure UPDATE_REQUIRED is set to 1 for all rows235    df = update_req_check(df)236 237    # Step 2: Align ASSORTMENT_CATEGORY_DEFINITION to ModelInfo238    df = verify_ao_cat_def(df, model_info_df)239 240    # Step 3: Audit DEMAND_GROUP and interaction columns241    df = demand_group_check(df, demand_group_fallback=demand_group_fallback)242 243    # Step 4: Overwrite UPC10 with ITEM_DIM_KEY for private label rows244    df = overwrite_upc10_for_private_label(df, raw_upc_pl_brand_col)245 246    # Step 5: Apply retailer-specific private label tagging247    df = apply_private_label_rules(df, private_label_config, show_examples=True, valid_model_suffixes=valid_model_suffixes, pl_base_name=pl_base_name)248 249    # Step 6: Check UPC10/SKU/ITEM_DIM_KEY for scientific notation / decimals250    check_identifier_numeric_format(df, cols=("UPC10", "SKU", "ITEM_DIM_KEY"))251 252    # Step 7: Left-pad UPC10 to 10 characters and mirror to UPC10_ATTR253    df = normalize_upc10(df, upc_col="UPC10")254 255    # Step 8: Drop unnamed columns, flag non-standard column names256    df = flag_invalid_headers(df)257 258    # Step 9: Replace '/' with ' OR ' in reporting columns, flag special chars259    df = check_special_chars(df, suffix=None)260 261    # Step 10: Deduplicate ITEM_DIM_KEYs (keep highest-dollar row)262    df, duplicate_dimkeys_df = check_duplicate_dimkeys(df)263 264    # Step 10.5: Strip legacy RESTRICTED suffix before brand override cleanup265    df = strip_legacy_restricted_suffix(df, valid_model_suffixes=valid_model_suffixes)266 267    # Step 10.6: Strip stale brand overrides for non-configured manufacturers268    df = strip_legacy_brand_overrides(df, brand_override_config, valid_model_suffixes=valid_model_suffixes)269 270    # Step 11: Apply client brand mapping overrides271    df = apply_brand_overrides(df, brand_override_config, valid_model_suffixes=valid_model_suffixes)272 273    # Step 12: Canonicalize TOOL_BRAND with _RESTRICTED where RAW_MULTI signals it274    # Skipped for non-MULO+ geo groupings where RMRR tagging does not apply275    if not skip_rmrr:276        df = raw_multi_restricted_overrides(df, valid_model_suffixes=valid_model_suffixes)277 278    # Step 12.5: QC — flag BRAND vs TOOL_BRAND mismatches (potential DB logic issues)279    mismatch_groups = check_brand_tool_brand_mismatch(280        df,281        raw_manufacturer_col=brand_override_config.get("raw_manufacturer_col", ""),282        valid_model_suffixes=valid_model_suffixes,283    )284 285    # Build pipeline context for Phase B286    pipeline_context = {287        "meta_df": meta_df,288        "combined_attributes_df": combined_attributes_df,289        "combined_attr_values_df": combined_attr_values_df,290        "duplicate_dimkeys_df": duplicate_dimkeys_df,291        "input_dir": str(input_dir),292        "is_custom_collapse": is_custom_collapse,293        "raw_manufacturer_col": brand_override_config.get("raw_manufacturer_col", ""),294        "valid_model_suffixes": valid_model_suffixes,295    }296 297    return df, duplicate_dimkeys_df, mismatch_groups, pipeline_context298 299 300def run_from_step_14(301    df: pd.DataFrame,302    pipeline_context: Dict[str, Any],303    corrections: Optional[list] = None,304) -> tuple:305    """306    Apply BRAND / TOOL_BRAND corrections (if any), then run Steps 14-17 (Phase B).307 308    The category split is **not** performed here — the collapsed output is309    written as a single sheet so analysts can review and edit before the310    post-QC stage re-collapses and exports CSVs.311 312    Parameters313    ----------314    df : DataFrame315        The DataFrame from Phase A (after Step 12).316    pipeline_context : dict317        State carried forward from ``run_through_step_12()``.318    corrections : list of dict, optional319        Each dict has ``type`` ("brand" or "tool_brand") plus the320        original values, new value, and actual column names in df.321 322    Returns323    -------324    (collapsed_df, duplicate_dimkeys_df)325    """326    meta_df = pipeline_context["meta_df"]327    combined_attributes_df = pipeline_context["combined_attributes_df"]328    combined_attr_values_df = pipeline_context["combined_attr_values_df"]329    duplicate_dimkeys_df = pipeline_context["duplicate_dimkeys_df"]330    input_dir = pipeline_context["input_dir"]331    is_custom_collapse = pipeline_context["is_custom_collapse"]332 333    # --- Apply user corrections to BRAND / TOOL_BRAND --------------------334    raw_manufacturer_col = pipeline_context.get("raw_manufacturer_col", "")335    col_upper_map = {str(c).upper(): c for c in df.columns}336    mfr_col_actual = col_upper_map.get(raw_manufacturer_col.upper()) if raw_manufacturer_col else None337 338    if corrections:339        brand_count = 0340        tool_count = 0341        rows_updated = 0342        corrected_pairs: set = set()343        for fix in corrections:344            brand_col_name = fix.get("brand_col", "BRAND")345            tool_col_name = fix.get("tool_brand_col", "TOOL_BRAND")346 347            # Row mask: match on original BRAND + TOOL_BRAND values348            mask = (349                (df[brand_col_name].astype(str).str.upper() == fix["brand"].upper())350                & (df[tool_col_name].astype(str).str.upper() == fix["tool_brand_old"].upper())351            )352 353            # Narrow by parent manufacturer when available (AO brand rows)354            parent_val = fix.get("parent", "")355            if parent_val and mfr_col_actual:356                mask = mask & (df[mfr_col_actual].astype(str).str.upper() == parent_val.upper())357 358            n_rows = int(mask.sum())359            rows_updated += n_rows360            corrected_pairs.add((fix["brand"], fix["tool_brand_old"]))361 362            if fix.get("type") == "brand":363                df.loc[mask, brand_col_name] = fix["brand_new"]364                brand_count += 1365            else:366                df.loc[mask, tool_col_name] = fix["tool_brand_new"]367                tool_count += 1368 369        parts = []370        if brand_count:371            parts.append(f"{brand_count} BRAND")372        if tool_count:373            parts.append(f"{tool_count} TOOL_BRAND")374        n_pairs = len(corrected_pairs)375        print(f"{INDENT}Mismatch review: {' + '.join(parts)} correction(s) manually applied "376              f"— {n_pairs} distinct pair(s), {rows_updated} row(s) updated")377 378 379    # Step 14: SKU collapse (top-dollar or custom parent dim-key)380    collapsed_df = prepare_and_collapse(381        df,382        verbose=True,383        is_custom_collapse=is_custom_collapse,384    )385 386    # Step 15: QC — flag null values in modeling/reporting columns.387    # Run on collapsed_df (the data that will be written to output.xlsx) so that388    # any auto-fills are applied to the output, not to the pre-collapse df which389    # is already discarded.  Previously this ran on df, so auto-fills never390    # reached the output and Post-QC would re-find the same nulls.391    check_null_modeling_reporting_cols(collapsed_df, meta_df=meta_df)392 393    # Step 16: Tool vs MDM attribute comparison QC (on final transformed data)394    _print_step_header("16", "ATTRIBUTE QC (TOOL VS MDM COMPARISON)")395    valid_model_suffixes = pipeline_context.get("valid_model_suffixes")396    run_tool_vs_mdm_qc(397        input_dir,398        collapsed_df,399        combined_attributes_df=combined_attributes_df,400        combined_attr_values_df=combined_attr_values_df,401        valid_model_suffixes=valid_model_suffixes,402    )403 404    return collapsed_df, duplicate_dimkeys_df405 406 407def run_post_qc(408    excel_path: str,409    is_custom_collapse: bool,410    meta_df: Optional[pd.DataFrame] = None,411) -> tuple:412    """413    Post-QC pipeline: re-validate and re-collapse analyst-edited output.414 415    Reads the single-sheet Excel file that the analyst has reviewed and416    edited, checks for null values in modeling/reporting columns, re-runs417    SKU collapse (to ensure edits haven't violated collapse rules), and418    splits by category for CSV export.419 420    Parameters421    ----------422    excel_path : str423        Path to the analyst-edited Excel workbook (single "Cleaned Output" sheet).424    is_custom_collapse : bool425        If True, use analyst-selected parent dim-key for SKU collapse.426    meta_df : DataFrame, optional427        META sheet used for null-check column classification.428 429    Returns430    -------431    (collapsed_df, category_splits)432        - collapsed_df: Re-collapsed DataFrame.433        - category_splits: Dict mapping category name → DataFrame subset.434    """435    print(f"\n{MAJOR_SEP}")436    print("POST-QC PIPELINE  (Finalize & Export)")437    print(MAJOR_SEP)438 439    # --- Read the edited Excel file back ---------------------------------440    _print_step_header("Post-Step 1", "Read Edited Output File")441    print(f"{INDENT}Reading: {excel_path}")442 443    df = pd.read_excel(excel_path, sheet_name="Cleaned Output", engine="openpyxl")444    cleaned_output_rows = len(df)445    print(f"{INDENT}Loaded {cleaned_output_rows} row(s), {len(df.columns)} column(s)")446 447    # --- Pre-collapse validation -----------------------------------------448    _print_step_header("Post-Step 2", "Pre-Collapse Validation")449    print(f"{INDENT}Running null checks on modeling/reporting columns before SKU re-collapse...")450    check_null_modeling_reporting_cols(df, meta_df=meta_df, show_step_header=False)451 452    # --- Re-run SKU collapse ---------------------------------------------453    _print_step_header("Post-Step 3", "SKU Re-Collapse")454    print(f"{INDENT}Re-collapsing SKUs to reflect any analyst edits made to the Cleaned Output sheet...")455    collapsed_df = prepare_and_collapse(456        df,457        verbose=True,458        is_custom_collapse=is_custom_collapse,459        show_step_header=False,460    )461 462    # --- Split by category for CSV export --------------------------------463    _print_step_header("Post-Step 4", "Category Split & Re-Export")464    category_splits = split_by_raw_assortment_category(collapsed_df)465 466    # --- Multi-category sanity check -------------------------------------467    if len(category_splits) > 1:468        summed_rows = sum(len(cat_df) for cat_df in category_splits.values())469        collapsed_rows = len(collapsed_df)470        match_str = "PASS" if summed_rows == collapsed_rows else "FAIL"471        print(f"\n{INDENT}Sanity check: sum of category sheet rows ({summed_rows}) "472              f"vs Cleaned Output rows ({collapsed_rows}) — {match_str}")473        if summed_rows != collapsed_rows:474            print(f"{INDENT}⚠ Row count mismatch — {abs(summed_rows - collapsed_rows)} row(s) difference. "475                  f"Check for blank or unmapped RAW_ASSORTMENT_CATEGORY values.")476 477    print(f"\n{INDENT}Post-QC pipeline complete — ready for CSV export.")478    return collapsed_df, category_splits479 480 481def main(482    directory_path: str,483    raw_upc_pl_brand_col: str,484    private_label_config: Dict[str, Any],485    brand_override_config: Dict[str, Any],486    is_custom_collapse: bool,487    file_manifest: Dict[str, Any] = None,488):489    """490    Run the full Phase 2 → Phase 3 pipeline and return results.491 492    Convenience wrapper that calls ``run_through_step_12()`` followed by493    ``run_from_step_14()``.  Preserved for backwards compatibility with494    non-GUI callers.495 496    Parameters497    ----------498    directory_path : str499        Root folder containing File_For_Mapping_QC.xlsx, ModelInfo.txt,500        and tool/lookup files.501    raw_upc_pl_brand_col : str502        RAW column used for private-label UPC10 overwrite (e.g. "RAW_BRAND").503    private_label_config : dict504        Retailer-specific private label rules (walmart, cvs, heb, etc.).505    brand_override_config : dict506        Manufacturer → brand override mapping rules.507    is_custom_collapse : bool508        If True, use analyst-selected parent dim-key for SKU collapse509        instead of top-dollar row.510    file_manifest : dict, optional511        Pre-scanned directory manifest. When provided, skips all directory512        scanning and redundant file reads. Built by ``_scan_directory()``513        or passed from the GUI layer.514 515    Returns516    -------517    (collapsed_df, duplicate_dimkeys_df)518        - collapsed_df: Final cleaned DataFrame after all steps.519        - duplicate_dimkeys_df: QC DataFrame of duplicate ITEM_DIM_KEYs found.520    """521    df, dup_df, mismatch_groups, ctx = run_through_step_12(522        directory_path,523        raw_upc_pl_brand_col,524        private_label_config,525        brand_override_config,526        is_custom_collapse,527        file_manifest=file_manifest,528    )529    return run_from_step_14(df, ctx)530 531 532# ═══════════════════════════════════════════════════════════════════════════533# Directory Scanner (single scan for entire pipeline)534# ═══════════════════════════════════════════════════════════════════════════535 536def _scan_directory(input_dir: Path) -> Dict[str, Any]:537    """538    Scan the input directory ONCE and build a manifest of all files539    needed by the pipeline.540 541    Returns a dict with:542    - ``workbook_path``            : Path to File_For_Mapping_QC.xlsx (or None)543    - ``model_info_paths``         : List of Path to ModelInfo.txt files544    - ``combined_attributes_df``   : Combined Attributes.txt DataFrame (or None)545    - ``combined_attr_values_df``  : Combined AttributeValues.txt DataFrame (or None)546    - ``tool_sources``             : List of source labels (e.g. "root", "subdir:MULO")547    - ``json_config``              : Parsed JSON config dict (or None)548    - ``skipped_files``            : List of skipped file names549    """550    workbook_path: Optional[Path] = None551    model_info_paths: List[Path] = []552    root_model_info: Optional[Path] = None553    all_attributes_dfs: List[pd.DataFrame] = []554    all_attr_values_dfs: List[pd.DataFrame] = []555    tool_sources: List[str] = []556    json_config = None557    skipped_files: List[str] = []558    subdirs: List[Path] = []559 560    expected_txt_patterns = [561        r"^Attributes\.txt$",562        r"^AttributeValues\.txt$",563        r"(?i)^ModelInfo.*\.txt$",564    ]565    expected_xlsx_pattern = r"(?i).*file_for_mapping_qc.*\.xlsx$"566 567    def _is_expected_txt(name: str) -> bool:568        return any(re.match(p, name) for p in expected_txt_patterns)569 570    # --- Scan root directory ------------------------------------------------571    root_has_attributes = False572    root_has_attr_values = False573    root_attributes_df = None574    root_attr_values_df = None575 576    for entry in input_dir.iterdir():577        if entry.is_dir():578            subdirs.append(entry)579            continue580 581        if not entry.is_file():582            continue583 584        name = entry.name585        name_lower = name.lower()586 587        # Workbook588        if name_lower.endswith(".xlsx") and re.match(expected_xlsx_pattern, name):589            workbook_path = entry590            continue591 592        # ModelInfo593        if name_lower == "modelinfo.txt":594            root_model_info = entry595            continue596 597        # Attributes.txt598        if name == "Attributes.txt":599            try:600                root_attributes_df = pd.read_csv(str(entry), delimiter="|")601                root_has_attributes = True602            except Exception as exc:603                print(f"{INDENT}  ⚠ Skipped {name}: could not parse ({type(exc).__name__})")604                skipped_files.append(name)605            continue606 607        # AttributeValues.txt608        if name == "AttributeValues.txt":609            try:610                root_attr_values_df = pd.read_csv(str(entry), delimiter="|")611                root_has_attr_values = True612            except Exception as exc:613                print(f"{INDENT}  ⚠ Skipped {name}: could not parse ({type(exc).__name__})")614                skipped_files.append(name)615            continue616 617        # JSON config618        if name_lower.endswith(".json"):619            try:620                with open(str(entry), "r") as f:621                    json_config = json.load(f)622            except (json.JSONDecodeError, Exception):623                skipped_files.append(name)624            continue625 626        # Other expected txt files (e.g. other ModelInfo variants)627        if name_lower.endswith(".txt") and _is_expected_txt(name):628            continue629 630        # Non-essential files631        if not name_lower.endswith(".csv"):632            skipped_files.append(name)633 634    # Collect root-level tool files635    if root_has_attributes and root_has_attr_values:636        all_attributes_dfs.append(root_attributes_df)637        all_attr_values_dfs.append(root_attr_values_df)638        tool_sources.append("root")639 640    # Use root ModelInfo if found; otherwise check subdirectories641    if root_model_info:642        model_info_paths = [root_model_info]643 644    # --- Scan subdirectories -----------------------------------------------645    for subdir in subdirs:646        try:647            sub_entries = {e.name: e for e in subdir.iterdir() if e.is_file()}648        except PermissionError:649            continue650 651        # ModelInfo in subdirectory (only if not found at root)652        if not root_model_info:653            for sub_name, sub_path in sub_entries.items():654                if sub_name.lower() == "modelinfo.txt":655                    model_info_paths.append(sub_path)656 657        # Tool files in subdirectory658        attr_entry = sub_entries.get("Attributes.txt")659        attr_val_entry = sub_entries.get("AttributeValues.txt")660 661        if attr_entry and attr_val_entry:662            try:663                all_attributes_dfs.append(pd.read_csv(str(attr_entry), delimiter="|"))664                all_attr_values_dfs.append(pd.read_csv(str(attr_val_entry), delimiter="|"))665                tool_sources.append(f"subdir:{subdir.name}")666            except Exception as exc:667                print(f"{INDENT}Error reading tool files in subdirectory '{subdir.name}': {exc}")668 669    # --- Validate required files -------------------------------------------670    if not model_info_paths:671        raise FileNotFoundError(672            f"ModelInfo.txt not found in {input_dir} "673            f"or any immediate subdirectory."674        )675 676    # --- Combine tool DataFrames -------------------------------------------677    combined_attributes_df = None678    combined_attr_values_df = None679    if all_attributes_dfs and all_attr_values_dfs:680        combined_attributes_df = pd.concat(all_attributes_dfs, ignore_index=True).drop_duplicates()681        combined_attr_values_df = pd.concat(all_attr_values_dfs, ignore_index=True).drop_duplicates()682 683    return {684        "workbook_path": workbook_path,685        "model_info_paths": model_info_paths,686        "combined_attributes_df": combined_attributes_df,687        "combined_attr_values_df": combined_attr_values_df,688        "tool_sources": tool_sources,689        "json_config": json_config,690        "skipped_files": skipped_files,691    }692