← Files ClaraARCHIVED FILE

modules/funnel-analysis/scripts/funnel_core.py

28.8 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Deterministic funnel-stage table generation."""

from __future__ import annotations

import csv
import json
import re
from dataclasses import dataclass
from html import escape
from pathlib import Path
from typing import Any

__all__ = [
    "DEFAULT_STAGE_DEFINITIONS",
    "FunnelRunResult",
    "compute_funnel_rows",
    "compute_funnel_rows_from_stage_table",
    "default_recipe",
    "load_recipe",
    "run_funnel_analysis",
]

CAPABILITY_ID = "funnel.stage_table"
TABLE_KEY = "funnel_stage_table"
TABLE_SPEC_NAME = "funnel_stage_table"
EMPTY_STRINGS = {"", "-", "na", "n/a", "nan", "none", "null"}
SPANISH_DEFAULT_COPY = {
    "Baby CRM extract": "Extracto del CRM de ejemplo",
    "Lead readiness funnel": "Embudo de preparación de leads",
    "records": "registros",
    "Sequential gates": "Etapas secuenciales",
}
SPANISH_STAGE_COPY = {
    "Created records": "Registros creados",
    "Owner assigned": "Responsable asignado",
    "Source classified": "Fuente clasificada",
    "Activity logged": "Actividad registrada",
    "Country identified": "País identificado",
    "Industry identified": "Sector identificado",
    "Positive revenue captured": "Ingresos positivos registrados",
    "Sales accepted": "Lead aceptado por ventas",
}
DEFAULT_STAGE_DEFINITIONS: tuple[dict[str, Any], ...] = (
    {
        "stage": "Created records",
        "predicate": {"type": "all"},
        "note": "All source records.",
    },
    {
        "stage": "Owner assigned",
        "predicate": {"type": "nonblank", "column": "Company owner"},
    },
    {
        "stage": "Source classified",
        "predicate": {"type": "nonblank", "column": "Original Source Type"},
    },
    {
        "stage": "Activity logged",
        "predicate": {"type": "nonblank", "column": "Last Activity Date"},
    },
    {
        "stage": "Country identified",
        "predicate": {"type": "nonblank", "column": "Country/Region"},
    },
    {
        "stage": "Industry identified",
        "predicate": {"type": "nonblank", "column": "Industry"},
    },
    {
        "stage": "Positive revenue captured",
        "predicate": {"type": "positive_number", "column": "Annual Revenue"},
    },
    {
        "stage": "Sales accepted",
        "predicate": {
            "type": "equals",
            "column": "Lifecycle Stage",
            "value": "Sales Accepted Lead",
            "case_sensitive": False,
        },
    },
)


@dataclass(frozen=True)
class FunnelRunResult:
    """Paths and payloads written by one funnel table run."""

    output_dir: Path
    html_path: Path
    csv_path: Path
    context_path: Path
    manifest_path: Path
    final_artifacts_path: Path
    rows: list[dict[str, Any]]
    context: dict[str, Any]
    manifest: dict[str, Any]


def default_recipe() -> dict[str, Any]:
    """Return a generic CRM lead-readiness recipe."""

    return {
        "schema_version": "1.0",
        "title": "",
        "metric_label": "Lead readiness funnel",
        "unit": "records",
        "scope_label": "Sequential gates",
        "stage_definitions": [dict(item) for item in DEFAULT_STAGE_DEFINITIONS],
    }


def load_recipe(recipe_path: Path | None) -> dict[str, Any]:
    """Load a recipe JSON file or return the default recipe."""

    if recipe_path is None:
        return default_recipe()
    payload = json.loads(recipe_path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise ValueError(f"Recipe must be a JSON object: {recipe_path}")
    return {**default_recipe(), **payload}


def _read_rows(source_file: Path) -> list[dict[str, str]]:
    if not source_file.exists():
        raise FileNotFoundError(f"Source file does not exist: {source_file}")
    with source_file.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            raise ValueError(f"CSV file has no header row: {source_file}")
        return [dict(row) for row in reader]


def _is_nonblank(value: Any) -> bool:
    return str(value or "").strip().lower() not in EMPTY_STRINGS


def _parse_number(value: Any) -> float | None:
    text = str(value or "").strip()
    if text.lower() in EMPTY_STRINGS:
        return None
    cleaned = re.sub(r"[^0-9.+-]", "", text)
    if cleaned in {"", ".", "+", "-", "+.", "-."}:
        return None
    try:
        return float(cleaned)
    except ValueError:
        return None


def _required_columns(stage_definitions: list[dict[str, Any]]) -> set[str]:
    columns: set[str] = set()
    for stage in stage_definitions:
        predicate = stage.get("predicate") or {}
        if not isinstance(predicate, dict):
            raise ValueError(f"Stage predicate must be an object: {stage!r}")
        column = predicate.get("column")
        if isinstance(column, str) and column:
            columns.add(column)
        predicate_columns = predicate.get("columns")
        if isinstance(predicate_columns, list):
            columns.update(str(item) for item in predicate_columns if str(item))
    return columns


def _predicate_note(predicate: dict[str, Any]) -> str:
    predicate_type = str(predicate.get("type") or "")
    if predicate_type == "all":
        return "All records entering this stage."
    if predicate_type == "nonblank":
        return f"Nonblank {predicate['column']}."
    if predicate_type == "any_nonblank":
        return "Any of " + ", ".join(str(item) for item in predicate["columns"]) + "."
    if predicate_type == "positive_number":
        return f"{predicate['column']} > 0."
    if predicate_type == "equals":
        return f"{predicate['column']} = {predicate['value']}."
    if predicate_type == "in":
        values = ", ".join(str(item) for item in predicate["values"])
        return f"{predicate['column']} in {values}."
    return predicate_type or "Custom predicate."


def _spanish_predicate_note(predicate: dict[str, Any]) -> str:
    predicate_type = str(predicate.get("type") or "")
    if predicate_type == "all":
        return "Todos los registros que llegan a esta etapa."
    if predicate_type == "nonblank":
        return f"Valor presente en {predicate['column']}."
    if predicate_type == "any_nonblank":
        columns = ", ".join(str(item) for item in predicate["columns"])
        return f"Valor presente en alguna de estas columnas: {columns}."
    if predicate_type == "positive_number":
        return f"{predicate['column']} > 0."
    if predicate_type == "equals":
        return f"{predicate['column']} = {predicate['value']}."
    if predicate_type == "in":
        values = ", ".join(str(item) for item in predicate["values"])
        return f"{predicate['column']} entre {values}."
    return predicate_type or "Predicado personalizado."


def _matches_predicate(row: dict[str, str], predicate: dict[str, Any]) -> bool:
    predicate_type = str(predicate.get("type") or "")
    if predicate_type == "all":
        return True
    if predicate_type == "nonblank":
        return _is_nonblank(row.get(str(predicate["column"])))
    if predicate_type == "any_nonblank":
        return any(
            _is_nonblank(row.get(str(column))) for column in predicate["columns"]
        )
    if predicate_type == "positive_number":
        value = _parse_number(row.get(str(predicate["column"])))
        return value is not None and value > 0
    if predicate_type == "equals":
        left = str(row.get(str(predicate["column"])) or "").strip()
        right = str(predicate.get("value") or "").strip()
        if predicate.get("case_sensitive"):
            return left == right
        return left.lower() == right.lower()
    if predicate_type == "in":
        left = str(row.get(str(predicate["column"])) or "").strip()
        values = [str(item).strip() for item in predicate.get("values") or []]
        if predicate.get("case_sensitive"):
            return left in values
        return left.lower() in {value.lower() for value in values}
    raise ValueError(f"Unsupported stage predicate type: {predicate_type}")


def _stage_id(stage: str) -> str:
    compact = re.sub(r"[^a-z0-9]+", "_", stage.lower()).strip("_")
    return compact or "stage"


def _validate_stage_definitions(
    rows: list[dict[str, str]], stage_definitions: list[dict[str, Any]]
) -> None:
    if not stage_definitions:
        raise ValueError("At least one stage definition is required.")
    available_columns = set(rows[0]) if rows else set()
    missing_columns = sorted(_required_columns(stage_definitions) - available_columns)
    if missing_columns:
        raise ValueError(
            "Missing stage predicate columns: " + ", ".join(missing_columns)
        )
    for stage in stage_definitions:
        if not str(stage.get("stage") or "").strip():
            raise ValueError("Every stage definition requires a non-empty stage label.")
        predicate = stage.get("predicate")
        if not isinstance(predicate, dict):
            raise ValueError(f"Stage {stage['stage']} requires a predicate object.")


def compute_funnel_rows(
    rows: list[dict[str, str]], stage_definitions: list[dict[str, Any]]
) -> list[dict[str, Any]]:
    """Return sequential funnel rows from source rows and explicit stages."""

    _validate_stage_definitions(rows, stage_definitions)
    total_count = len(rows)
    remaining = rows
    output_rows: list[dict[str, Any]] = []
    for position, stage in enumerate(stage_definitions, start=1):
        stage_label = str(stage["stage"]).strip()
        predicate = dict(stage["predicate"])
        start_count = len(remaining)
        passed = [row for row in remaining if _matches_predicate(row, predicate)]
        pass_count = len(passed)
        drop_off = pass_count - start_count
        stage_conversion = pass_count / start_count if start_count else None
        cumulative_conversion = pass_count / total_count if total_count else None
        note = str(stage.get("note") or _predicate_note(predicate))
        output_rows.append(
            {
                "stage_id": _stage_id(stage_label),
                "stage": stage_label,
                "position": position,
                "start_count": start_count,
                "pass_count": pass_count,
                "drop_off": drop_off,
                "stage_conversion": stage_conversion,
                "cumulative_conversion": cumulative_conversion,
                "note": note,
            }
        )
        remaining = passed
    return output_rows


def compute_funnel_rows_from_stage_table(
    rows: list[dict[str, str]], mappings: dict[str, Any]
) -> list[dict[str, Any]]:
    """Return funnel rows from explicit stage/start/pass columns."""

    stage_column = str(mappings.get("stage_column") or "").strip()
    start_column = str(mappings.get("start_count_column") or "").strip()
    pass_column = str(mappings.get("pass_count_column") or "").strip()
    if not stage_column or not start_column or not pass_column:
        raise ValueError(
            "stage_table_mappings requires stage_column, start_count_column, "
            "and pass_count_column."
        )
    required = {stage_column, start_column, pass_column}
    available = set(rows[0]) if rows else set()
    missing = sorted(required - available)
    if missing:
        raise ValueError("Missing stage table columns: " + ", ".join(missing))

    stage_order: list[str] = []
    totals: dict[str, dict[str, float]] = {}
    for row_number, row in enumerate(rows, start=2):
        stage = str(row.get(stage_column) or "").strip()
        if not stage:
            raise ValueError(f"Blank stage at source row {row_number}.")
        start_count = _parse_number(row.get(start_column))
        pass_count = _parse_number(row.get(pass_column))
        if start_count is None or pass_count is None:
            raise ValueError(f"Non-numeric stage count at source row {row_number}.")
        if start_count < 0 or pass_count < 0:
            raise ValueError(f"Negative stage count at source row {row_number}.")
        if pass_count > start_count:
            raise ValueError(
                f"Pass count exceeds start count at source row {row_number}."
            )
        if stage not in totals:
            stage_order.append(stage)
            totals[stage] = {"start": 0.0, "pass": 0.0}
        totals[stage]["start"] += start_count
        totals[stage]["pass"] += pass_count

    first_start = totals[stage_order[0]]["start"] if stage_order else 0.0
    output_rows: list[dict[str, Any]] = []
    for position, stage in enumerate(stage_order, start=1):
        start_count = totals[stage]["start"]
        pass_count = totals[stage]["pass"]
        output_rows.append(
            {
                "stage_id": _stage_id(stage),
                "stage": stage,
                "position": position,
                "start_count": start_count,
                "pass_count": pass_count,
                "drop_off": pass_count - start_count,
                "stage_conversion": (pass_count / start_count if start_count else None),
                "cumulative_conversion": (
                    pass_count / first_start if first_start else None
                ),
                "note": "Counts supplied by mapped stage table columns.",
            }
        )
    return output_rows


def _json_dump(payload: dict[str, Any], path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
    )


def _write_table_csv(rows: list[dict[str, Any]], path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = [
        "stage_id",
        "stage",
        "position",
        "start_count",
        "pass_count",
        "drop_off",
        "stage_conversion",
        "cumulative_conversion",
        "note",
    ]
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            writer.writerow(row)


def _format_count(value: int | float | None) -> str:
    if value is None:
        return ""
    return f"{int(value):,}"


def _format_signed_count(value: int | float | None) -> str:
    if value is None:
        return ""
    numeric = int(value)
    if numeric > 0:
        return f"+{numeric:,}"
    return f"{numeric:,}"


def _format_percent(value: float | None) -> str:
    if value is None:
        return "n/a"
    return f"{value * 100:.0f}%"


def _bar_class(value: float | None) -> str:
    if value is None:
        return "neutral"
    if value >= 0.8:
        return "strong"
    if value >= 0.5:
        return "mid"
    return "weak"


def _render_drop_cell(row: dict[str, Any], max_drop: int) -> str:
    drop = int(row["drop_off"])
    width = 0.0 if max_drop == 0 else min(abs(drop) / max_drop * 100, 100)
    cls = "zero" if drop == 0 else "negative"
    return (
        f'<td class="num drop {cls}">'
        f'<span class="drop-track"><span class="drop-bar" style="width:{width:.1f}%"></span></span>'
        f'<span class="drop-label">{escape(_format_signed_count(drop))}</span>'
        "</td>"
    )


def _render_conversion_cell(value: float | None) -> str:
    width = 0.0 if value is None else max(0.0, min(value * 100, 100))
    cls = _bar_class(value)
    return (
        f'<td class="num conversion {cls}">'
        f'<span class="conversion-track"><span class="conversion-bar" style="width:{width:.1f}%"></span></span>'
        f'<span class="conversion-label">{escape(_format_percent(value))}</span>'
        "</td>"
    )


def _render_cumulative_cell(value: float | None) -> str:
    left = 0.0 if value is None else max(0.0, min(value * 100, 100))
    return (
        '<td class="num cumulative">'
        f'<span class="pin-track"><span class="pin-line" style="left:{left:.1f}%"></span>'
        f'<span class="pin-dot" style="left:{left:.1f}%"></span></span>'
        f'<span class="pin-label">{escape(_format_percent(value))}</span>'
        "</td>"
    )


def _language_code(recipe: dict[str, Any]) -> str:
    language = str(recipe.get("language") or "en").strip().lower().replace("_", "-")
    return language.split("-", maxsplit=1)[0]


def _localize_spanish_defaults(
    recipe: dict[str, Any], rows: list[dict[str, Any]]
) -> None:
    """Localize generated defaults without changing source or machine identifiers."""

    if _language_code(recipe) != "es":
        return

    for field in ("title", "metric_label", "unit", "scope_label"):
        value = str(recipe.get(field) or "")
        recipe[field] = SPANISH_DEFAULT_COPY.get(value, value)

    stage_definitions = recipe.get("stage_definitions")
    stage_table_mappings = recipe.get("stage_table_mappings")
    if isinstance(stage_definitions, list):
        for index, stage in enumerate(stage_definitions):
            if not isinstance(stage, dict):
                continue
            stage_label = str(stage.get("stage") or "")
            localized_stage = SPANISH_STAGE_COPY.get(stage_label, stage_label)
            stage["stage"] = localized_stage
            explicit_note = stage.get("note")
            if explicit_note:
                note = str(explicit_note)
                localized_note = (
                    "Todos los registros de origen."
                    if note == "All source records."
                    else note
                )
                stage["note"] = localized_note
            if isinstance(stage_table_mappings, dict) or index >= len(rows):
                continue
            row = rows[index]
            row["stage"] = localized_stage
            if explicit_note:
                row["note"] = stage["note"]
                continue
            predicate = stage.get("predicate")
            if isinstance(predicate, dict):
                row["note"] = _spanish_predicate_note(predicate)
    for row in rows:
        if row.get("note") == "Counts supplied by mapped stage table columns.":
            row["note"] = (
                "Recuentos obtenidos de las columnas asignadas de la tabla de etapas."
            )


def _visible_copy(recipe: dict[str, Any]) -> dict[str, str]:
    if _language_code(recipe) == "es":
        return {
            "html_lang": "es",
            "in": "en",
            "stage": "Etapa",
            "start": "Inicio",
            "pass": "Pasan",
            "drop_off": "Abandono",
            "stage_percent": "% de etapa",
            "cumulative_percent": "% acumulado",
            "note": "Nota",
            "source": "Fuente",
            "row_grain": (
                "Una fila por definición de etapa ordenada; cada fila filtra los "
                "registros que superaron la etapa anterior."
            ),
        }
    return {
        "html_lang": "en",
        "in": "in",
        "stage": "Stage",
        "start": "Start",
        "pass": "Pass",
        "drop_off": "Drop-off",
        "stage_percent": "Stage %",
        "cumulative_percent": "Cumulative %",
        "note": "Note",
        "source": "Source",
        "row_grain": (
            "One row per ordered stage definition; each row filters the rows "
            "that passed the prior stage."
        ),
    }


def _render_html(
    rows: list[dict[str, Any]], recipe: dict[str, Any], source_name: str
) -> str:
    copy = _visible_copy(recipe)
    max_drop = max((abs(int(row["drop_off"])) for row in rows), default=0)
    body_rows: list[str] = []
    for row in rows:
        body_rows.append(
            "<tr>"
            f'<td class="stage">{escape(str(row["stage"]))}</td>'
            f'<td class="num">{escape(_format_count(row["start_count"]))}</td>'
            f'<td class="num">{escape(_format_count(row["pass_count"]))}</td>'
            f"{_render_drop_cell(row, max_drop)}"
            f"{_render_conversion_cell(row['stage_conversion'])}"
            f"{_render_cumulative_cell(row['cumulative_conversion'])}"
            f'<td class="note">{escape(str(row["note"]))}</td>'
            "</tr>"
        )
    metric_line = f"{recipe['metric_label']} {copy['in']} {recipe['unit']}"
    return f"""<!doctype html>
<html lang="{copy['html_lang']}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(str(recipe['title']))} - {escape(str(recipe['metric_label']))}</title>
<style>
:root {{
  --ink: #111;
  --muted: #4b5563;
  --rule: #c9c9c9;
  --heavy: #111;
  --green: #7faa00;
  --amber: #c99700;
  --red: #ef2a1d;
  --black-pin: #222;
}}
* {{ box-sizing: border-box; }}
body {{
  margin: 0;
  background: #fff;
  color: var(--ink);
  font-family: Arial, Helvetica, sans-serif;
}}
.page {{
  width: 980px;
  padding: 34px 36px 28px;
  background: #fff;
}}
.title {{
  margin-bottom: 26px;
  line-height: 1.12;
}}
.title p {{
  margin: 0;
  font-size: 15px;
}}
.title .metric {{
  font-weight: 700;
}}
.title-rule {{
  height: 1px;
  margin-top: 16px;
  background: #888;
}}
table {{
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  font-size: 13px;
}}
thead th {{
  padding: 6px 7px 7px;
  border-bottom: 2px solid var(--heavy);
  color: var(--ink);
  font-weight: 700;
  text-align: right;
}}
thead th.stage, thead th.note {{
  text-align: left;
}}
tbody td {{
  padding: 5px 7px;
  border-bottom: 1px solid var(--rule);
  height: 30px;
  vertical-align: middle;
}}
tbody tr:first-child td,
tbody tr:last-child td {{
  border-bottom: 2px solid var(--heavy);
  font-weight: 700;
}}
.stage {{ width: 170px; text-align: left; }}
.note {{ width: 205px; text-align: left; color: var(--muted); font-size: 12px; }}
.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
.drop,
.conversion,
.cumulative {{
  position: relative;
  white-space: nowrap;
}}
.drop-track,
.conversion-track,
.pin-track {{
  position: absolute;
  left: 8px;
  right: 58px;
  top: 50%;
  height: 10px;
  transform: translateY(-50%);
}}
.drop-track {{
  border-right: 2px solid var(--black-pin);
}}
.drop-bar {{
  position: absolute;
  right: 0;
  top: 1px;
  height: 8px;
  background: var(--red);
}}
.drop.zero .drop-bar {{
  width: 0 !important;
}}
.drop-label,
.conversion-label,
.pin-label {{
  position: relative;
  z-index: 2;
}}
.drop.negative .drop-label {{ color: var(--red); }}
.conversion-track {{
  background: #f3f3f3;
  border-right: 2px solid var(--black-pin);
}}
.conversion-bar {{
  display: block;
  height: 10px;
}}
.conversion.strong .conversion-bar {{ background: var(--green); }}
.conversion.mid .conversion-bar {{ background: var(--amber); }}
.conversion.weak .conversion-bar {{ background: var(--red); }}
.conversion.neutral .conversion-bar {{ background: #aaa; }}
.pin-track {{
  height: 14px;
  border-bottom: 1px solid #9d9d9d;
}}
.pin-line {{
  position: absolute;
  bottom: -4px;
  width: 2px;
  height: 14px;
  background: var(--red);
}}
.pin-dot {{
  position: absolute;
  bottom: -5px;
  width: 7px;
  height: 7px;
  margin-left: -2px;
  background: var(--black-pin);
}}
.source {{
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-size: 12px;
}}
</style>
</head>
<body>
<main class="page" data-gallery-screenshot>
  <header class="title">
    <p>{escape(str(recipe['title']))}</p>
    <p class="metric">{escape(metric_line)}</p>
    <p>{escape(str(recipe['scope_label']))}</p>
    <div class="title-rule"></div>
  </header>
  <table>
    <thead>
      <tr>
        <th class="stage">{copy['stage']}</th>
        <th>{copy['start']}</th>
        <th>{copy['pass']}</th>
        <th>{copy['drop_off']}</th>
        <th>{copy['stage_percent']}</th>
        <th>{copy['cumulative_percent']}</th>
        <th class="note">{copy['note']}</th>
      </tr>
    </thead>
    <tbody>
      {''.join(body_rows)}
    </tbody>
  </table>
  <p class="source">{copy['source']}: {escape(source_name)}</p>
</main>
</body>
</html>
"""


def _build_context(
    rows: list[dict[str, Any]],
    recipe: dict[str, Any],
    source_file: Path,
) -> dict[str, Any]:
    copy = _visible_copy(recipe)
    chart_title_lines = [
        str(recipe["title"]),
        f"{recipe['metric_label']} {copy['in']} {recipe['unit']}",
        str(recipe["scope_label"]),
    ]
    return {
        "schema_version": "1.0",
        "analysis_type": "funnel_stage_table",
        "object_type": "table",
        "capability_id": CAPABILITY_ID,
        "table_key": TABLE_KEY,
        "table_spec_name": TABLE_SPEC_NAME,
        "metric_label": recipe["metric_label"],
        "unit": recipe["unit"],
        "title": recipe["title"],
        "scope_label": recipe["scope_label"],
        "chart_title_lines": chart_title_lines,
        "title_contract": {
            "who": chart_title_lines[0],
            "what": chart_title_lines[1],
            "when": chart_title_lines[2],
        },
        "source_file": source_file.name,
        "row_grain": copy["row_grain"],
        "stage_definitions": recipe.get("resolved_stage_definitions")
        or recipe["stage_definitions"],
        "table_rows": rows,
    }


def _build_manifest(
    output_dir: Path,
    source_file: Path,
    recipe: dict[str, Any],
) -> dict[str, Any]:
    resolved_parameters = {
        "source_file": source_file.name,
        "stage_definitions": recipe.get("resolved_stage_definitions")
        or recipe["stage_definitions"],
        "metric_label": recipe["metric_label"],
        "unit": recipe["unit"],
        "scope_label": recipe["scope_label"],
    }
    return {
        "schema_version": "1.0",
        "producer": {"plugin": "funnel-analysis", "capability_id": CAPABILITY_ID},
        "artifacts": [
            {
                "artifact_id": TABLE_KEY,
                "kind": "tables",
                "artifact_type": "table",
                "capability_id": CAPABILITY_ID,
                "table_key": TABLE_KEY,
                "table_spec_name": TABLE_SPEC_NAME,
                "path": "funnel_stage_table.html",
                "source_path": "funnel_stage_table.html",
                "data_path": "funnel_stage_table_chart_data.csv",
                "context_path": "funnel_stage_table_chart_context.json",
                "resolved_parameters": resolved_parameters,
            },
            {
                "artifact_id": "context",
                "kind": "contexts",
                "artifact_type": "context",
                "path": "funnel_stage_table_chart_context.json",
            },
        ],
        "output_dir": output_dir.name,
    }


def run_funnel_analysis(
    source_file: Path,
    output_dir: Path,
    recipe_path: Path | None = None,
    *,
    language: str = "en",
) -> FunnelRunResult:
    """Run a deterministic funnel-stage table and write artifacts."""

    recipe = load_recipe(recipe_path)
    if not str(recipe.get("title") or "").strip():
        recipe["title"] = source_file.name
    recipe["language"] = language
    rows = _read_rows(source_file)
    stage_table_mappings = recipe.get("stage_table_mappings")
    if isinstance(stage_table_mappings, dict):
        funnel_rows = compute_funnel_rows_from_stage_table(rows, stage_table_mappings)
        recipe["resolved_stage_definitions"] = [
            {
                "stage": row["stage"],
                "source": "stage_table_mappings",
            }
            for row in funnel_rows
        ]
    else:
        stage_definitions = recipe.get("stage_definitions")
        if not isinstance(stage_definitions, list):
            raise ValueError("Recipe stage_definitions must be a list.")
        funnel_rows = compute_funnel_rows(rows, stage_definitions)
    _localize_spanish_defaults(recipe, funnel_rows)
    output_dir.mkdir(parents=True, exist_ok=True)

    html_path = output_dir / "funnel_stage_table.html"
    csv_path = output_dir / "funnel_stage_table_chart_data.csv"
    context_path = output_dir / "funnel_stage_table_chart_context.json"
    manifest_path = output_dir / "artifact_manifest.json"
    final_artifacts_path = output_dir / "final_artifacts.json"
    recipe_output_path = output_dir / "used_recipe.json"

    _write_table_csv(funnel_rows, csv_path)
    html_path.write_text(
        _render_html(funnel_rows, recipe, source_file.name),
        encoding="utf-8",
    )
    _json_dump(recipe, recipe_output_path)
    context = _build_context(funnel_rows, recipe, source_file)
    _json_dump(context, context_path)
    manifest = _build_manifest(output_dir, source_file, recipe)
    _json_dump(manifest, manifest_path)
    _json_dump(
        {
            "schema_version": "1.0",
            "plugin": "funnel-analysis",
            "outputs": [
                {"path": html_path.name, "kind": "html", "status": "written"},
                {"path": csv_path.name, "kind": "csv", "status": "written"},
                {"path": context_path.name, "kind": "json", "status": "written"},
                {
                    "path": manifest_path.name,
                    "kind": "json",
                    "status": "written",
                },
            ],
        },
        final_artifacts_path,
    )
    return FunnelRunResult(
        output_dir=output_dir,
        html_path=html_path,
        csv_path=csv_path,
        context_path=context_path,
        manifest_path=manifest_path,
        final_artifacts_path=final_artifacts_path,
        rows=funnel_rows,
        context=context,
        manifest=manifest,
    )

SHA-256: 970b4664ca91d9382a314499287cf71db574c7bf226fc6dc3323cd97f32fce5d