{
  "name": "Statistical Factor Models - User Workflow (sanitized template)",
  "nodes": [
    {
      "parameters": {
        "content": "### Statistical Factor Models — User Workflow\n\nThis workflow fits one of three MCP **stat-factor-models** extractors on a numeric\npanel `X` with shape `(N, T)`:\n\n| algorithm | MCP tool | When to use |\n|---|---|---|\n| `complete-panel` | CompletePanelFactorExtractor | Balanced panel, **no missing values** |\n| `stock-watson` | SWFactorExtractor | Missing values allowed; static EM model |\n| `banbura-modugno` | BMFactorExtractor | Missing values allowed; dynamic / state-space model |\n\nRows are series, columns are time points. Encode missing observations as `null`\nin JSON or empty/`NA` cells in CSV.\n\n### 1. Configure the **Configuration** node\n\n* `algorithm` — `complete-panel`, `stock-watson`, or `banbura-modugno`\n* `dataSource` — `json` or `csv`\n* `panelJson` — 2D JSON array when `dataSource=json`\n* `panelCsv` — wide CSV (optional header; first column may be series name)\n* `extractorInitKwargs` — JSON object passed to `*-extractor-create`\n  (`n_factors` **or** `n_factors_max`, plus optional `flow_series`,\n  `flow_series_diff`, `max_iter`, `tol`, `voting_method`, `seed`, …)\n\nFor **stock-watson**, `extractorInitKwargs` must include exactly one rank mode and explicit\n`flow_series` / `flow_series_diff` (use `{}` when not needed).\n\n### 2. Output routing\n\n| Trigger | Where to read the result |\n|---|---|\n| **Manual Trigger** | n8n execution log → **Log Report to n8n Logs** (or **Log Error to n8n Logs**) |\n| **Webhook** | HTTP response from **Respond to Webhook** / **Respond to Webhook (Error)** |\n\nThere is no `delivery`, Telegram, or email configuration.\n\n### 3. Run manually\n\nClick **Execute workflow** on **Manual Trigger**. The report is printed to the\nn8n execution log by **Log Report to n8n Logs**.\n\n### 4. Webhook\n\n`POST /webhook/stat-factor-model-fit` with JSON or multipart CSV upload.\nThe JSON response is returned automatically by **Respond to Webhook**.\n\nSee `stat_factor_models/WEBHOOK_API.md` in the repository for full API details.\n",
        "height": 680,
        "width": 720
      },
      "id": "9e7c94a2-eabf-4db8-8f89-fad1c30a3c4f",
      "name": "Instructions",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -96,
        -288
      ]
    },
    {
      "parameters": {
        "content": "### Rank and stock-watson mapping preflight\n\n* Choose **exactly one** of `n_factors` (fixed rank) or `n_factors_max` (automatic).\n* Fixed rank: `n_factors <= min(N, T)`.\n* Automatic rank: `n_factors_max < N` and `n_factors_max < T`.\n* `voting_method` / `nfactors_method`: `voting`, `mean`, or `median`.\n* **stock-watson** `flow_series` / `flow_series_diff`: disjoint `{row_index: M}` maps.\n* **complete-panel**: no missing values in `X`.\n* **banbura-modugno**: at least `T >= 3` time periods.\n\n### MCP endpoint\n\n`<mcp-endpoint-url>`\n\nInstall [n8n-nodes-mcp](https://www.npmjs.com/package/n8n-nodes-mcp) and link an\n*MCP Client (HTTP Streamable)* credential (no auth required for the public endpoint).\n\n### Data examples\n\n**panelJson** (4 series × 10 periods):\n\n```json\n[[1.2,0.8,-0.3,0.5,0.1,-0.2,0.4,0.3,-0.1,0.2],\n [0.5,-0.1,0.3,0.2,-0.4,0.6,0.1,-0.2,0.3,0.0]]\n```\n\n**panelCsv** (wide format):\n\n```csv\nseries,t1,t2,t3\ns1,1.2,0.8,-0.3\ns2,0.5,-0.1,0.3\n```\n",
        "height": 620,
        "width": 720
      },
      "id": "7480c691-729b-4e3e-bb45-33e30698fd3a",
      "name": "Algorithm Reference",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -96,
        384
      ]
    },
    {
      "parameters": {},
      "id": "6ac6e035-82fc-4b1d-ad02-966171795fc5",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        1072,
        864
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "stat-factor-model-fit",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "46d61d95-21d8-4e01-8222-a9dd6755dbcc",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        400,
        624
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "triggerSource",
              "value": "manual",
              "type": "string"
            },
            {
              "name": "algorithm",
              "value": "banbura-modugno",
              "type": "string"
            },
            {
              "name": "dataSource",
              "value": "csv",
              "type": "string"
            },
            {
              "name": "panelJson",
              "value": "[[1.2, 0.8, -0.3, 0.5, 0.1, -0.2, 0.4, 0.3, -0.1, 0.2], [0.5, -0.1, 0.3, 0.2, -0.4, 0.6, 0.1, -0.2, 0.3, 0.0], [-0.2, 0.4, 0.1, -0.3, 0.2, 0.5, -0.1, 0.3, 0.2, -0.4], [0.3, 0.2, -0.5, 0.4, 0.1, -0.1, 0.2, 0.4, -0.2, 0.1]]",
              "type": "string"
            },
            {
              "name": "panelCsv",
              "value": "series,t1,t2,t3,t4,t5,t6,t7,t8,t9,t10\ns1,1.2,0.8,-0.3,0.5,0.1,-0.2,0.4,0.3,-0.1,NaN\ns2,0.5,-0.1,0.3,0.2,-0.4,0.6,0.1,-0.2,0.3,0.0\ns3,-0.2,0.4,0.1,-0.3,0.2,0.5,-0.1,0.3,0.2,-0.4\ns4,0.3,0.2,-0.5,0.4,0.1,-0.1,0.2,0.4,-0.2,0.1\n",
              "type": "string"
            },
            {
              "name": "extractorInitKwargs",
              "value": "{\"n_factors\": 2}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "921e2851-10a2-40d8-a0ee-0b2b9299d6cd",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1296,
        864
      ]
    },
    {
      "parameters": {
        "jsCode": "// Copy uploaded panel CSV binary to portfolioUpload for Extract From File.\nconst item = $input.first();\nconst json = { ...(item.json || {}) };\nconst binary = { ...(item.binary || {}) };\n\nif (json.body && typeof json.body === 'object' && !Array.isArray(json.body)) {\n  Object.assign(json, json.body);\n  delete json.body;\n} else if (json.body && typeof json.body === 'string') {\n  try {\n    Object.assign(json, JSON.parse(json.body));\n  } catch (e) {\n    // keep raw body for optional text/csv handling downstream\n  }\n}\n\nconst uploadKeys = ['panelFile', 'panelCsv', 'file', 'data'];\nlet hasCsvBinary = false;\n\nfor (const key of uploadKeys) {\n  if (item.binary?.[key]) {\n    binary.portfolioUpload = item.binary[key];\n    hasCsvBinary = true;\n    break;\n  }\n}\n\njson._csvBinaryReady = hasCsvBinary;\n\nreturn [{ json, binary }];\n"
      },
      "id": "482f6512-9907-4b10-9d79-1fdd0aa18717",
      "name": "Consolidate CSV Binary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        624,
        624
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "a3327f7c-3818-497b-8ce4-89d6e600e808",
              "leftValue": "={{ $json._csvBinaryReady == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "58c0ec2c-2f80-4394-a89b-157f8dc2054a",
      "name": "CSV File Uploaded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        848,
        624
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "portfolioUpload",
        "destinationKey": "panelCsvExtracted",
        "options": {}
      },
      "id": "2e51195a-ab5f-4eb8-b6cb-e2bab53a2005",
      "name": "Extract CSV File",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        1072,
        560
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Map webhook POST body (JSON or multipart) to the Configuration shape.\n#\n# Panel CSV priority:\n#   1. panelCsv in JSON / multipart form fields\n#   2. panelCsvExtracted from Extract From File (uploaded CSV file)\n#   3. raw request body when Content-Type is text/csv (Raw Body on)\nimport csv\nimport io\nimport json\n\nitem = _items[0]\nbody = dict(item.get(\"json\") or {})\n\n\ndef _looks_like_csv_text(text):\n    if not text or not isinstance(text, str):\n        return False\n    lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]\n    if not lines:\n        return False\n    comma_lines = sum(1 for ln in lines if \",\" in ln or \";\" in ln)\n    return comma_lines >= max(1, len(lines) // 2)\n\n\ndef _panel_csv_has_data(text):\n    if not _looks_like_csv_text(text):\n        return False\n    first_line = text.strip().splitlines()[0]\n    delimiter = \";\" if first_line.count(\";\") > first_line.count(\",\") else \",\"\n    rows = []\n    for row in csv.reader(io.StringIO(text.strip()), delimiter=delimiter):\n        if not row or not any(str(cell).strip() for cell in row):\n            continue\n        rows.append(row)\n    if not rows:\n        return False\n    start = 0\n    numeric_second = False\n    if len(rows[0]) >= 2:\n        try:\n            float(str(rows[0][1]).strip().replace(\",\", \".\"))\n            numeric_second = True\n        except (ValueError, TypeError):\n            start = 1\n    for row in rows[start:]:\n        nums = 0\n        for cell in row[1:] if (start == 1 and len(row) > 1) else row:\n            raw = str(cell).strip()\n            if raw.lower() in (\"\", \"na\", \"nan\", \"null\", \"none\"):\n                nums += 1\n                continue\n            try:\n                float(raw.replace(\",\", \".\"))\n                nums += 1\n            except ValueError:\n                continue\n        if nums > 0:\n            return True\n    return numeric_second and len(rows) >= 1\n\n\ndef _get_content_type(headers):\n    if not isinstance(headers, dict):\n        return \"\"\n    for key, value in headers.items():\n        if str(key).lower() == \"content-type\" and value not in (None, \"\"):\n            return str(value).split(\";\", 1)[0].strip().lower()\n    return \"\"\n\n\ndef _parse_webhook_body(data):\n    raw_body = data.get(\"body\") if isinstance(data.get(\"body\"), str) else None\n    json_parsed = False\n\n    if isinstance(data.get(\"body\"), dict):\n        nested = data.pop(\"body\")\n        if isinstance(nested, dict):\n            data.update(nested)\n\n    if isinstance(data.get(\"body\"), str):\n        raw_body = data[\"body\"]\n        try:\n            parsed = json.loads(raw_body)\n        except Exception:\n            parsed = None\n        if isinstance(parsed, dict):\n            data.update(parsed)\n            json_parsed = True\n\n    return raw_body, json_parsed\n\n\ndef _collect_csv_candidates(data, raw_body, json_parsed):\n    candidates = []\n    explicit = (data.get(\"panelCsv\") or \"\").strip()\n    if explicit and _panel_csv_has_data(explicit):\n        candidates.append((\"fields\", explicit))\n    extracted = (data.get(\"panelCsvExtracted\") or \"\").strip()\n    if extracted and _panel_csv_has_data(extracted):\n        candidates.append((\"extracted\", extracted))\n    content_type = _get_content_type(data.get(\"headers\") or {})\n    if content_type == \"text/csv\" and raw_body and not json_parsed:\n        raw_csv = raw_body.strip()\n        if _panel_csv_has_data(raw_csv):\n            candidates.append((\"raw_body\", raw_csv))\n    elif raw_body and not json_parsed and _panel_csv_has_data(raw_body):\n        candidates.append((\"raw_body\", raw_body.strip()))\n    return candidates\n\n\ndef _pick_panel_csv(candidates):\n    priority = (\"fields\", \"extracted\", \"raw_body\")\n    by_source = {source: text for source, text in candidates}\n    for source in priority:\n        if source in by_source:\n            return by_source[source]\n    return \"\"\n\n\nraw_body, json_parsed = _parse_webhook_body(body)\npanel_csv = _pick_panel_csv(_collect_csv_candidates(body, raw_body, json_parsed))\nif panel_csv:\n    body[\"panelCsv\"] = panel_csv\n    body.setdefault(\"dataSource\", \"csv\")\n\nbody.pop(\"_csvBinaryReady\", None)\nbody.pop(\"panelCsvExtracted\", None)\n\nif body.get(\"panelJson\") is not None and not isinstance(body.get(\"panelJson\"), str):\n    body[\"panelJson\"] = json.dumps(body[\"panelJson\"])\n\nif body.get(\"extractorInitKwargs\") is not None and not isinstance(body.get(\"extractorInitKwargs\"), str):\n    body[\"extractorInitKwargs\"] = json.dumps(body[\"extractorInitKwargs\"])\n\nbody.setdefault(\"algorithm\", \"complete-panel\")\nbody.setdefault(\"dataSource\", \"json\")\nbody[\"triggerSource\"] = \"webhook\"\n\nreturn [{\"json\": body}]\n"
      },
      "id": "ef96025f-b402-41cd-adcb-ebda3330f42f",
      "name": "Normalize Webhook Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1296,
        624
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Validate configuration for the Statistical Factor Models workflow.\nimport json\n\nitem = _items[0][\"json\"]\nerrors = []\n\nalgorithm = (item.get(\"algorithm\") or \"\").strip().lower()\nif algorithm not in (\"complete-panel\", \"stock-watson\", \"banbura-modugno\"):\n    errors.append(\"algorithm must be 'complete-panel', 'stock-watson' or 'banbura-modugno'.\")\n\ndata_source = (item.get(\"dataSource\") or \"\").strip().lower()\nif data_source not in (\"json\", \"csv\"):\n    errors.append(\"dataSource must be 'json' or 'csv'.\")\n\nif data_source == \"json\":\n    raw = item.get(\"panelJson\")\n    if raw in (None, \"\"):\n        errors.append(\"panelJson is required when dataSource is 'json'.\")\n    else:\n        try:\n            parsed = json.loads(raw) if isinstance(raw, str) else raw\n            if not isinstance(parsed, list) or not parsed:\n                errors.append(\"panelJson must be a non-empty 2D JSON array.\")\n        except Exception as exc:\n            errors.append(f\"panelJson is not valid JSON: {exc}\")\n\nif data_source == \"csv\":\n    if not str(item.get(\"panelCsv\") or \"\").strip():\n        errors.append(\"panelCsv is required when dataSource is 'csv'.\")\n\nraw_init = item.get(\"extractorInitKwargs\") or \"{}\"\ntry:\n    init_kwargs = json.loads(raw_init) if isinstance(raw_init, str) else dict(raw_init or {})\n    if not isinstance(init_kwargs, dict):\n        raise ValueError(\"must be a JSON object\")\nexcept Exception as exc:\n    errors.append(f\"extractorInitKwargs is not valid JSON: {exc}\")\n    init_kwargs = {}\n\nif not errors:\n    n_factors = init_kwargs.get(\"n_factors\")\n    n_factors_max = init_kwargs.get(\"n_factors_max\")\n    has_fixed = n_factors is not None and n_factors != \"\"\n    has_auto = n_factors_max is not None and n_factors_max != \"\"\n    if algorithm == \"stock-watson\" and not has_fixed and not has_auto:\n        errors.append(\n            \"SWFactorExtractor requires extractorInitKwargs with exactly one of \"\n            \"n_factors or n_factors_max.\"\n        )\n    if has_fixed and has_auto:\n        errors.append(\n            \"extractorInitKwargs must set exactly one of n_factors or n_factors_max, not both.\"\n        )\n    if has_fixed:\n        try:\n            nf = int(n_factors)\n            if nf <= 0:\n                errors.append(\"n_factors must be a positive integer.\")\n        except (TypeError, ValueError):\n            errors.append(\"n_factors must be a positive integer.\")\n    if has_auto:\n        try:\n            nfm = int(n_factors_max)\n            if nfm <= 0:\n                errors.append(\"n_factors_max must be a positive integer.\")\n        except (TypeError, ValueError):\n            errors.append(\"n_factors_max must be a positive integer.\")\n\n    for key in (\"flow_series\", \"flow_series_diff\"):\n        if key in init_kwargs and init_kwargs[key] not in (None, \"\"):\n            val = init_kwargs[key]\n            if not isinstance(val, dict):\n                errors.append(f\"{key} must be a JSON object mapping row index to block size M.\")\n            else:\n                overlap = set((init_kwargs.get(\"flow_series\") or {}).keys()) & set(\n                    (init_kwargs.get(\"flow_series_diff\") or {}).keys()\n                )\n                if overlap:\n                    errors.append(\n                        f\"flow_series and flow_series_diff must be disjoint; overlap: {sorted(overlap)}\"\n                    )\n\nif errors:\n    return [{\n        \"json\": {\n            **item,\n            \"configError\": True,\n            \"configErrorMessage\": \"Configuration error(s): \" + \" | \".join(errors),\n        }\n    }]\n\nreturn [{\"json\": {**item, \"configError\": False, \"configErrorMessage\": \"\"}}]\n"
      },
      "id": "61fcba07-bf51-45a1-bcda-507594a494e7",
      "name": "Validate Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1520,
        720
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "8bd79f40-f832-4d99-9d8d-ac9b4a3c5b41",
              "leftValue": "={{ $json.configError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a77b2f64-b48a-4b2e-a39a-bf2b10b0148e",
      "name": "Config Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1744,
        720
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Build the numeric panel matrix X with shape (N, T) for MCP factor extractors.\n# Rows are series, columns are time points. Missing values are encoded as null.\nimport csv\nimport io\nimport json\nimport math\n\nitem = _items[0][\"json\"]\nsource = (item.get(\"dataSource\") or \"json\").strip().lower()\nalgorithm = (item.get(\"algorithm\") or \"complete-panel\").strip().lower()\n\nMISSING_TOKENS = {\"\", \"na\", \"nan\", \"null\", \"none\", \"n/a\"}\n\n\ndef _parse_cell(raw):\n    text = str(raw).strip()\n    if text.lower() in MISSING_TOKENS:\n        return None\n    try:\n        val = float(text.replace(\",\", \".\"))\n    except (TypeError, ValueError):\n        raise ValueError(f\"Non-numeric cell value: {raw!r}\")\n    if not math.isfinite(val):\n        raise ValueError(f\"Non-finite cell value: {raw!r}\")\n    return val\n\n\ndef _matrix_from_json(raw):\n    data = json.loads(raw) if isinstance(raw, str) else raw\n    if not isinstance(data, list) or not data:\n        raise ValueError(\"panelJson must be a non-empty 2D array.\")\n    matrix = []\n    width = None\n    for i, row in enumerate(data):\n        if not isinstance(row, list) or not row:\n            raise ValueError(f\"panelJson row {i} must be a non-empty array.\")\n        parsed_row = []\n        for cell in row:\n            if cell is None:\n                parsed_row.append(None)\n            else:\n                parsed_row.append(_parse_cell(cell))\n        if width is None:\n            width = len(parsed_row)\n        elif len(parsed_row) != width:\n            raise ValueError(\"panelJson rows must have equal length.\")\n        matrix.append(parsed_row)\n    return matrix\n\n\ndef _matrix_from_csv(text):\n    text = (text or \"\").strip()\n    if not text:\n        raise ValueError(\"panelCsv is empty\")\n    first_line = text.splitlines()[0]\n    delimiter = \";\" if first_line.count(\";\") > first_line.count(\",\") else \",\"\n    rows = []\n    for row in csv.reader(io.StringIO(text), delimiter=delimiter):\n        if not row or not any(str(c).strip() for c in row):\n            continue\n        rows.append(row)\n    if not rows:\n        raise ValueError(\"panelCsv has no data rows\")\n\n    start = 0\n    series_labels = []\n    if len(rows[0]) >= 2:\n        try:\n            float(str(rows[0][1]).strip().replace(\",\", \".\"))\n        except (ValueError, TypeError):\n            start = 1\n\n    matrix = []\n    for row in rows[start:]:\n        label = str(row[0]).strip() if start == 1 else f\"series_{len(matrix)}\"\n        values = row[1:] if start == 1 else row\n        parsed = [_parse_cell(cell) for cell in values]\n        if not parsed:\n            continue\n        series_labels.append(label)\n        matrix.append(parsed)\n\n    if not matrix:\n        raise ValueError(\"panelCsv has no valid numeric rows\")\n    width = len(matrix[0])\n    for i, row in enumerate(matrix):\n        if len(row) != width:\n            raise ValueError(f\"panelCsv row {i} has inconsistent width\")\n    return matrix, series_labels\n\n\ndef _summarize_matrix(matrix):\n    n_series = len(matrix)\n    n_obs = len(matrix[0]) if matrix else 0\n    missing = sum(1 for row in matrix for cell in row if cell is None)\n    observed = sum(1 for row in matrix for cell in row if cell is not None)\n    return n_series, n_obs, missing, observed\n\n\ntry:\n    series_labels = []\n    if source == \"json\":\n        matrix = _matrix_from_json(item.get(\"panelJson\") or \"[]\")\n        series_labels = [f\"series_{i}\" for i in range(len(matrix))]\n    else:\n        matrix, series_labels = _matrix_from_csv(item.get(\"panelCsv\") or \"\")\nexcept Exception as exc:\n    return [{\n        \"json\": {\n            **item,\n            \"buildError\": True,\n            \"buildErrorMessage\": f\"Invalid panel data: {exc}\",\n        }\n    }]\n\nn_series, n_obs, missing_count, observed_count = _summarize_matrix(matrix)\n\nif n_obs < 1 or n_series < 1:\n    return [{\n        \"json\": {\n            **item,\n            \"buildError\": True,\n            \"buildErrorMessage\": \"Panel must have at least one series and one time point.\",\n        }\n    }]\n\nif algorithm == \"banbura-modugno\" and n_obs < 3:\n    return [{\n        \"json\": {\n            **item,\n            \"buildError\": True,\n            \"buildErrorMessage\": \"BMFactorExtractor requires T >= 3 time periods.\",\n        }\n    }]\n\nif algorithm == \"complete-panel\" and missing_count > 0:\n    return [{\n        \"json\": {\n            **item,\n            \"buildError\": True,\n            \"buildErrorMessage\": (\n                \"CompletePanelFactorExtractor does not allow missing values. \"\n                \"Use algorithm 'stock-watson' or 'banbura-modugno', or impute missing cells.\"\n            ),\n        }\n    }]\n\nfor r_idx, row in enumerate(matrix):\n    if all(cell is None for cell in row):\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": f\"Row {r_idx} is all missing; remove it before fitting.\",\n            }\n        }]\nfor c_idx in range(n_obs):\n    if all(matrix[r][c_idx] is None for r in range(n_series)):\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": f\"Column {c_idx} is all missing; remove it before fitting.\",\n            }\n        }]\n\nreturn [{\n    \"json\": {\n        **item,\n        \"buildError\": False,\n        \"buildErrorMessage\": \"\",\n        \"X\": matrix,\n        \"n_series\": n_series,\n        \"n_obs\": n_obs,\n        \"missing_count\": missing_count,\n        \"observed_count\": observed_count,\n        \"series_labels\": json.dumps(series_labels),\n    }\n}]\n"
      },
      "id": "a97aeeb5-530f-465d-a8fe-4e0c0779ba08",
      "name": "Build Panel",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1968,
        656
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "854ff6d2-332d-4b7f-af01-138291bd6724",
              "leftValue": "={{ $json.buildError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "48090415-5019-4a4e-a1c3-e3b0ac27a7a6",
      "name": "Panel Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2192,
        656
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Prepare MCP create/fit context from validated configuration and built panel.\nimport json\n\nitem = _items[0][\"json\"]\nalgorithm = (item.get(\"algorithm\") or \"complete-panel\").strip().lower()\n\nraw_init = item.get(\"extractorInitKwargs\") or \"{}\"\ntry:\n    init_kwargs = json.loads(raw_init) if isinstance(raw_init, str) else dict(raw_init or {})\nexcept Exception:\n    init_kwargs = {}\n\nif algorithm == \"stock-watson\":\n    init_kwargs.setdefault(\"flow_series\", {})\n    init_kwargs.setdefault(\"flow_series_diff\", {})\n\ncreate_tool = {\n    \"complete-panel\": \"complete-panel-factor-extractor-create\",\n    \"stock-watson\": \"swfactor-extractor-create\",\n    \"banbura-modugno\": \"bmfactor-extractor-create\",\n}[algorithm]\n\nfit_tool = {\n    \"complete-panel\": \"complete-panel-factor-extractor-fit\",\n    \"stock-watson\": \"swfactor-extractor-fit\",\n    \"banbura-modugno\": \"bmfactor-extractor-fit\",\n}[algorithm]\n\nneed_create = algorithm == \"stock-watson\" or bool(init_kwargs)\n\nreturn [{\n    \"json\": {\n        **item,\n        \"mcpCreateTool\": create_tool,\n        \"mcpFitTool\": fit_tool,\n        \"mcpNeedCreate\": need_create,\n        \"mcpInitKwargs\": init_kwargs,\n    }\n}]\n"
      },
      "id": "a601ecad-a89d-40be-be57-b0c1e55d398d",
      "name": "Prepare MCP Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2416,
        384
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b2237e02-f8b9-4f0b-919a-5e415ee8b043",
                    "leftValue": "={{ $json.algorithm }}",
                    "rightValue": "complete-panel",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "complete-panel"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "a14d29ed-e187-44ba-b32b-3ab8c4db9bf2",
                    "leftValue": "={{ $json.algorithm }}",
                    "rightValue": "stock-watson",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "stock-watson"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "e3d713ce-2bf3-4acb-bfd3-6c471c76a8a4",
                    "leftValue": "={{ $json.algorithm }}",
                    "rightValue": "banbura-modugno",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "banbura-modugno"
            }
          ]
        },
        "options": {}
      },
      "id": "745ce01b-28b9-4973-9ee6-f5e7f3c83ba0",
      "name": "Route Algorithm",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2640,
        368
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "6bd609ab-5a2f-42e4-9d18-d0c2432896c1",
              "leftValue": "={{ $json.mcpNeedCreate == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5ff18343-c64c-4b81-9fe9-78f517493d88",
      "name": "CP Need Create?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2864,
        96
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "complete-panel-factor-extractor-create",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', init_kwargs: $json.mcpInitKwargs } }) }}"
      },
      "id": "9cbeb3cf-ecf6-4b34-90cc-d4f8e59134b4",
      "name": "MCP - CP Create",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3088,
        0
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "complete-panel-factor-extractor-fit",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', X: $('Prepare MCP Context').first().json.X, handle: $json.result.structuredContent.result.handle } }) }}"
      },
      "id": "be57bb69-d879-415c-8e62-37ccc182bbe8",
      "name": "MCP - CP Fit (handle)",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3312,
        0
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "complete-panel-factor-extractor-fit",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', X: $json.X } }) }}"
      },
      "id": "2801e890-2f83-44d9-84fb-c174c51356ff",
      "name": "MCP - CP Fit (direct)",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3312,
        192
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "swfactor-extractor-create",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', init_kwargs: $json.mcpInitKwargs } }) }}"
      },
      "id": "e2e43035-8452-4df1-92dd-84cedaf1d0b3",
      "name": "MCP - SW Create",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3088,
        384
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "swfactor-extractor-fit",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', X: $('Prepare MCP Context').first().json.X, handle: $json.result.structuredContent.result.handle } }) }}"
      },
      "id": "461e807c-2e53-4041-9e60-f11192740c01",
      "name": "MCP - SW Fit",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3312,
        384
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "527afe19-af2c-49d3-aec1-2ad4ee3da80d",
              "leftValue": "={{ $json.mcpNeedCreate == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "aac3098a-29ad-4d94-95c4-b733e729b81a",
      "name": "BM Need Create?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2864,
        672
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "bmfactor-extractor-create",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', init_kwargs: $json.mcpInitKwargs } }) }}"
      },
      "id": "7e78c39c-0c7a-412f-a25f-e20f20f6f198",
      "name": "MCP - BM Create",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3088,
        576
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "bmfactor-extractor-fit",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', X: $('Prepare MCP Context').first().json.X, handle: $json.result.structuredContent.result.handle } }) }}"
      },
      "id": "ab8d4802-d2f2-4883-86a4-b86be27cf9af",
      "name": "MCP - BM Fit (handle)",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3312,
        576
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "bmfactor-extractor-fit",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', X: $json.X } }) }}"
      },
      "id": "7b286ae8-8b21-42b6-bc4c-11089cf10712",
      "name": "MCP - BM Fit (direct)",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3312,
        768
      ]
    },
    {
      "parameters": {
        "jsCode": "// Merge MCP fit output with the prepared context item.\nconst ctx = $('Prepare MCP Context').first().json;\nconst fitItem = $input.first().json;\nconst result = fitItem.result || fitItem;\nconst sc = result.structuredContent || {};\nconst resultId = sc.result_id || (sc.meta || {}).result_id || '';\n\nreturn [{\n  json: {\n    ...ctx,\n    fitResult: result,\n    result_id: resultId,\n  },\n}];\n"
      },
      "id": "9b281a5f-5b2c-4792-a380-7a07b7cbcda8",
      "name": "Merge Fit Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3536,
        384
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "ec86ac99-bc8b-487b-a339-6fac706a875b",
              "leftValue": "={{ !!$json.result_id && !$json.fitResult?.structuredContent?.result?.loadings_ && !$json.fitResult?.structuredContent?.result?.factors_ && !$json.fitResult?.structuredContent?.result?.payload?.loadings_ }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "73491c00-9d3f-4234-9c75-c66271e23fdc",
      "name": "Has result_id?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        3760,
        384
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "result_get",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', result_id: $json.result_id } }) }}"
      },
      "id": "d9143dc3-ddb2-4b0e-8474-0fde81580176",
      "name": "MCP - Get Fit Result",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3984,
        304
      ]
    },
    {
      "parameters": {
        "jsCode": "// Attach full stored payload from result_get to the merged context item.\nconst ctx = $('Merge Fit Output').first().json;\nconst stored = $input.first().json.result;\nconst out = stored ? { ...ctx, fitResult: stored } : { ...ctx };\nreturn [{ json: out }];\n"
      },
      "id": "d47766b0-4c83-4651-bded-e4463a6c649e",
      "name": "Apply Stored Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4208,
        304
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Helpers for unwrapping MCP stat-factor-models fit payloads.\n\n\ndef _normalize_fit_dict(candidate):\n    if not isinstance(candidate, dict):\n        return None\n    if candidate.get(\"loadings_\") is not None or candidate.get(\"factors_\") is not None:\n        return candidate\n    payload = candidate.get(\"payload\")\n    if isinstance(payload, dict) and (\n        payload.get(\"loadings_\") is not None or payload.get(\"factors_\") is not None\n    ):\n        return payload\n    return None\n\n\ndef unwrap_fit_result(payload):\n    if not isinstance(payload, dict):\n        return None\n\n    direct = _normalize_fit_dict(payload)\n    if direct is not None:\n        return direct\n\n    def _from_structured_content(sc):\n        if not isinstance(sc, dict):\n            return None\n        candidate = sc.get(\"result\")\n        if isinstance(candidate, dict):\n            fit = _normalize_fit_dict(candidate)\n            if fit is not None:\n                return fit\n        if isinstance(candidate, list) and candidate:\n            first = candidate[0]\n            if isinstance(first, dict):\n                fit = _normalize_fit_dict(first)\n                if fit is not None:\n                    return fit\n        return None\n\n    fit = _from_structured_content(payload.get(\"structuredContent\"))\n    if fit is not None:\n        return fit\n\n    inner = payload.get(\"result\")\n    if isinstance(inner, dict):\n        fit = _normalize_fit_dict(inner)\n        if fit is not None:\n            return fit\n        fit = _from_structured_content(inner.get(\"structuredContent\"))\n        if fit is not None:\n            return fit\n        fit = _from_structured_content(inner)\n        if fit is not None:\n            return fit\n    return None\n\n\ndef extract_result_id(payload, fallback=\"\"):\n    if not isinstance(payload, dict):\n        return fallback or \"\"\n    sc = payload.get(\"structuredContent\") or {}\n    rid = sc.get(\"result_id\") or (sc.get(\"meta\") or {}).get(\"result_id\")\n    if rid:\n        return rid\n    inner = payload.get(\"result\")\n    if isinstance(inner, dict):\n        sc = inner.get(\"structuredContent\") or {}\n        rid = sc.get(\"result_id\") or (sc.get(\"meta\") or {}).get(\"result_id\")\n        if rid:\n            return rid\n    return payload.get(\"result_id\") or fallback or \"\"\n\n\n# Merge MCP fit output (and optional result_get payload) into a delivery-ready report.\nimport json\nfrom datetime import datetime\n\nitem = _items[0][\"json\"]\n\nfit_raw = item.get(\"fitResult\") or item.get(\"result\")\nfit = unwrap_fit_result(fit_raw if isinstance(fit_raw, dict) else {})\nif fit is None and isinstance(item.get(\"result\"), dict):\n    fit = unwrap_fit_result(item[\"result\"])\n\nif fit is None or (fit.get(\"loadings_\") is None and fit.get(\"factors_\") is None):\n    return [{\n        \"json\": {\n            **item,\n            \"calcError\": True,\n            \"calcErrorMessage\": \"MCP fit result is missing or unreadable.\",\n        }\n    }]\n\nn_factors = fit.get(\"n_factors\")\nloadings = fit.get(\"loadings_\")\nfactors = fit.get(\"factors_\")\nmse = fit.get(\"mse_by_n_factors\")\nconverged = fit.get(\"converged_\")\nn_iter = fit.get(\"n_iter_\")\nwarning = fit.get(\"convergence_warning_\")\n\nalgorithm = (item.get(\"algorithm\") or \"\").strip().lower()\nn_series = item.get(\"n_series\")\nn_obs = item.get(\"n_obs\")\nmissing_count = item.get(\"missing_count\", 0)\n\nsummary_lines = [\n    f\"Algorithm: {algorithm}\",\n    f\"Panel shape: N={n_series}, T={n_obs}\",\n    f\"Estimated factors: {n_factors}\",\n]\nif missing_count:\n    summary_lines.append(f\"Missing cells in input: {missing_count}\")\nif converged is not None:\n    summary_lines.append(f\"EM converged: {converged} (iterations={n_iter})\")\nif warning:\n    summary_lines.append(f\"Warning: {warning}\")\nif mse:\n    summary_lines.append(f\"MSE by rank: {json.dumps(mse)}\")\n\nsummary_text = \"\\n\".join(summary_lines)\nloadings_text = json.dumps(loadings, indent=2) if loadings is not None else \"\"\nfactors_preview = \"\"\nif isinstance(factors, list) and factors:\n    factors_preview = json.dumps(factors[: min(3, len(factors))], indent=2)\n\ntoday = datetime.utcnow().date().strftime(\"%Y-%m-%d\")\nreport_ts = datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\")\n\nreturn [{\n    \"json\": {\n        **{k: v for k, v in item.items() if k not in (\"result\", \"fitResult\")},\n        \"calcError\": False,\n        \"calcErrorMessage\": \"\",\n        \"report_date\": today,\n        \"report_timestamp\": report_ts,\n        \"result_id\": extract_result_id(fit_raw if isinstance(fit_raw, dict) else {}, item.get(\"result_id\", \"\")),\n        \"n_factors\": n_factors,\n        \"loadings\": loadings,\n        \"factors\": factors,\n        \"mse_by_n_factors\": mse,\n        \"converged\": converged,\n        \"n_iter\": n_iter,\n        \"convergence_warning\": warning,\n        \"summary_text\": summary_text,\n        \"loadings_text\": loadings_text,\n        \"factors_preview\": factors_preview,\n        \"caption\": f\"Statistical Factor Model Report\\n{summary_text}\",\n        \"subject\": f\"Stat Factor Model Report - {today}\",\n    }\n}]\n"
      },
      "id": "df0eb5a7-ff89-42cf-b995-88aa9d9a1874",
      "name": "Assemble Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4432,
        384
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "c586697e-78e2-4716-a80d-d78bb3d1ba9e",
              "leftValue": "={{ $json.calcError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "0710c1e0-7cbe-4dc8-abe6-8a957d2a4453",
      "name": "Calc Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        4656,
        384
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "172a6d01-f51e-4db5-a738-7767b5b035f4",
              "leftValue": "={{ $json.triggerSource == 'webhook' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "51c2ea27-6945-4ae7-8188-276927783bd7",
      "name": "Webhook Response?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        4880,
        336
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "item = _items[0][\"json\"]\nprint(\"=== Statistical Factor Model Report ===\")\nprint(item.get(\"caption\") or item.get(\"summary_text\") or \"\")\nprint(\"\\n--- Loadings ---\")\nprint(item.get(\"loadings_text\") or item.get(\"loadings\"))\nreturn [{\"json\": item}]\n"
      },
      "id": "c423ae90-f671-4a8e-a722-1d578f3019a2",
      "name": "Log Report to n8n Logs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5104,
        448
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Shape JSON for Respond to Webhook (success path).\n\nitem = _items[0][\"json\"]\n\nout = {\n    \"ok\": True,\n    \"report_date\": item.get(\"report_date\"),\n    \"algorithm\": item.get(\"algorithm\"),\n    \"dataSource\": item.get(\"dataSource\"),\n    \"n_series\": item.get(\"n_series\"),\n    \"n_obs\": item.get(\"n_obs\"),\n    \"missing_count\": item.get(\"missing_count\"),\n    \"n_factors\": item.get(\"n_factors\"),\n    \"converged\": item.get(\"converged\"),\n    \"n_iter\": item.get(\"n_iter\"),\n    \"convergence_warning\": item.get(\"convergence_warning\"),\n    \"mse_by_n_factors\": item.get(\"mse_by_n_factors\"),\n    \"loadings\": item.get(\"loadings\"),\n    \"factors_preview\": item.get(\"factors_preview\"),\n    \"summary_text\": item.get(\"summary_text\"),\n    \"loadings_text\": item.get(\"loadings_text\"),\n    \"result_id\": item.get(\"result_id\"),\n    \"caption\": item.get(\"caption\"),\n}\n\nreturn [{\"json\": out}]\n"
      },
      "id": "d64b700b-fed2-40b1-8cd1-110e6c73c4af",
      "name": "Prepare Webhook Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5104,
        240
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "682e9b70-9c81-4d9f-928b-9dc3164b62aa",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5328,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst parts = [];\nfor (const key of ['configErrorMessage', 'buildErrorMessage', 'calcErrorMessage']) {\n  if (item[key]) parts.push(String(item[key]));\n}\nreturn [{ json: { ...item, errorText: parts.join('\\n') || 'Workflow failed.' } }];\n"
      },
      "id": "4ff5bf15-f7ef-41a8-a8ec-f74b8b9e4be0",
      "name": "Format Error Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4880,
        912
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "d3ac6da5-3c79-4ad6-aa6f-b65035568be5",
              "leftValue": "={{ $json.triggerSource == 'webhook' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "55f02d6c-c27e-4f0c-b9c3-01e6a30314ad",
      "name": "Webhook Error Response?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        5104,
        912
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "item = _items[0][\"json\"]\nprint(\"=== Statistical Factor Model ERROR ===\")\nprint(item.get(\"errorText\") or item.get(\"configErrorMessage\") or item.get(\"buildErrorMessage\") or item.get(\"calcErrorMessage\"))\nreturn [{\"json\": item}]\n"
      },
      "id": "87c4ce8e-aac4-44fa-82f8-a8aed545a5e5",
      "name": "Log Error to n8n Logs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5328,
        528
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Shape JSON for Respond to Webhook (error path).\n\nitem = _items[0][\"json\"]\nparts = []\nfor key in (\"configErrorMessage\", \"buildErrorMessage\", \"calcErrorMessage\", \"errorText\"):\n    val = item.get(key)\n    if val:\n        parts.append(str(val))\n\nerror = \"\\n\".join(parts) if parts else \"Statistical factor model workflow failed.\"\n\nreturn [{\"json\": {\"ok\": False, \"error\": error}}]\n"
      },
      "id": "6e4a94c9-7a1b-4315-9410-d7e469cb757c",
      "name": "Prepare Webhook Error Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5328,
        960
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "968c08b4-3907-49c3-bfa0-76c3f122a60a",
      "name": "Respond to Webhook (Error)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5552,
        960
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration": {
      "main": [
        [
          {
            "node": "Validate Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Consolidate CSV Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Consolidate CSV Binary": {
      "main": [
        [
          {
            "node": "CSV File Uploaded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CSV File Uploaded?": {
      "main": [
        [
          {
            "node": "Extract CSV File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Normalize Webhook Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract CSV File": {
      "main": [
        [
          {
            "node": "Normalize Webhook Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Webhook Input": {
      "main": [
        [
          {
            "node": "Validate Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Configuration": {
      "main": [
        [
          {
            "node": "Config Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Panel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Panel": {
      "main": [
        [
          {
            "node": "Panel Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Panel Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare MCP Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare MCP Context": {
      "main": [
        [
          {
            "node": "Route Algorithm",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Algorithm": {
      "main": [
        [
          {
            "node": "CP Need Create?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MCP - SW Create",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "BM Need Create?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CP Need Create?": {
      "main": [
        [
          {
            "node": "MCP - CP Create",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MCP - CP Fit (direct)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - CP Create": {
      "main": [
        [
          {
            "node": "MCP - CP Fit (handle)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - SW Create": {
      "main": [
        [
          {
            "node": "MCP - SW Fit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "BM Need Create?": {
      "main": [
        [
          {
            "node": "MCP - BM Create",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MCP - BM Fit (direct)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - BM Create": {
      "main": [
        [
          {
            "node": "MCP - BM Fit (handle)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - CP Fit (handle)": {
      "main": [
        [
          {
            "node": "Merge Fit Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - CP Fit (direct)": {
      "main": [
        [
          {
            "node": "Merge Fit Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - SW Fit": {
      "main": [
        [
          {
            "node": "Merge Fit Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - BM Fit (handle)": {
      "main": [
        [
          {
            "node": "Merge Fit Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - BM Fit (direct)": {
      "main": [
        [
          {
            "node": "Merge Fit Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Fit Output": {
      "main": [
        [
          {
            "node": "Has result_id?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has result_id?": {
      "main": [
        [
          {
            "node": "MCP - Get Fit Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Assemble Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - Get Fit Result": {
      "main": [
        [
          {
            "node": "Apply Stored Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Stored Result": {
      "main": [
        [
          {
            "node": "Assemble Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Result": {
      "main": [
        [
          {
            "node": "Calc Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calc Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Webhook Response?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Response?": {
      "main": [
        [
          {
            "node": "Prepare Webhook Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Report to n8n Logs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Webhook Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Error Message": {
      "main": [
        [
          {
            "node": "Webhook Error Response?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Error Response?": {
      "main": [
        [
          {
            "node": "Prepare Webhook Error Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to n8n Logs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Webhook Error Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook (Error)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "timeSavedMode": "fixed",
    "callerPolicy": "workflowsFromSameOwner",
    "executionTimeout": 600,
    "availableInMCP": false
  },
  "nodeGroups": [],
  "__quantx_template_note": "Sanitized public template. Credentials, private recipients, private webhook identifiers, and literal secrets were removed. Configure MCP and delivery credentials inside your own n8n instance."
}
