Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/data-analytics-demo/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ ml:
$(PYTHON) -m data_analytics_demo.ml.upsell

narrative:
@echo "[narrative] TODO T-08: Ollama narrative not yet implemented"
@exit 1
$(PYTHON) -m data_analytics_demo.narrative.generate

dashboard:
@echo "[dashboard] TODO T-09: Evidence dashboard not yet implemented"
Expand Down
10 changes: 5 additions & 5 deletions packages/data-analytics-demo/src/data_analytics_demo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

from __future__ import annotations

import sys

import typer

app = typer.Typer(
Expand Down Expand Up @@ -49,9 +47,11 @@ def ml() -> None:

@app.command()
def narrative() -> None:
"""Generate LLM narrative via local Ollama (T-08, not yet implemented)."""
typer.echo("[narrative] TODO T-08: Ollama narrative not yet implemented", err=True)
sys.exit(1)
"""Generate an executive narrative from SHAP via local Ollama."""
from data_analytics_demo.narrative import generate as narrative_gen

out = narrative_gen.main()
typer.echo(f"wrote {out}")


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""LLM narrative layer for the customer-analytics demo.

Public surface:
generate.main() Orchestrates SHAP -> Ollama -> markdown.
ollama_client.generate_narrative() Thin Ollama wrapper.
prompts.build_prompt() SHAP-summary -> prompt string.

All inference runs locally via Ollama (no external LLM API). The module
asserts the absence of cloud-LLM credentials in `os.environ` at invocation
time (AC-4.3).
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Narrative generator entry point — reads SHAP summary, calls Ollama, writes markdown.

Glue between the ML layer (SHAP JSON output) and the executive-facing
output. Implements AC-4.1 through AC-4.5 in a single linear flow.
"""

from __future__ import annotations

import json
import sys
from datetime import UTC, datetime
from pathlib import Path

from .. import __version__
from ..ml import _io
from . import ollama_client, prompts


def _emit(msg: str) -> None:
print(f"[narrative] {msg}", file=sys.stderr, flush=True) # noqa: T201


def _default_shap_path() -> Path:
return _io.default_artifacts_dir() / "shap_summary.json"


def _default_output_path() -> Path:
return _io.package_root() / "narrative" / "output.md"


def _render(
*,
body: str,
model_id: str,
host: str,
shap_path: Path,
feature_count: int,
) -> str:
"""Wrap the LLM body with provenance + reproducibility metadata.

The metadata block satisfies AC-4.4 (citation back to SHAP JSON) and
AC-4.5 (model identifier present in the artifact).
"""
timestamp = datetime.now(UTC).isoformat(timespec="seconds")
return (
"# Churn-Risk Narrative (Auto-generated)\n"
"\n"
"> **Model**: local Ollama — `"
f"{model_id}` via `{host}`.\n"
"> "
f"**Source**: SHAP feature importances at `{shap_path.as_posix()}` "
f"({feature_count} features ranked).\n"
f"> **Generated**: {timestamp} by `data-analytics-demo` v{__version__}.\n"
"> **External LLM calls**: 0 (assertion-enforced; see "
"`narrative/ollama_client.py::assert_no_external_api_envs`).\n"
"\n"
f"{body.strip()}\n"
"\n"
"---\n"
"*Auto-generated. Do not edit by hand — re-run `make narrative` to refresh.*\n"
)


def main(
*,
shap_path: Path | None = None,
output_path: Path | None = None,
) -> Path:
"""Run the full narrative pipeline and return the output file path."""
# AC-4.3 — assert clean env before any LLM call.
ollama_client.assert_no_external_api_envs()

shap_in = shap_path or _default_shap_path()
if not shap_in.exists():
raise FileNotFoundError(
f"shap_summary.json not found at {shap_in}. "
"Run `make ml` to produce it before `make narrative`."
)

out_path = output_path or _default_output_path()
out_path.parent.mkdir(parents=True, exist_ok=True)

_emit(f"loading SHAP summary from {shap_in.name}")
summary = json.loads(shap_in.read_text(encoding="utf-8"))
feature_count = len(summary.get("top_features", []))

prompt = prompts.build_prompt(summary)
_emit(
f"calling Ollama at {ollama_client.resolved_host()} "
f"(model={ollama_client.resolved_model()})"
)
body = ollama_client.generate_narrative(prompt)

rendered = _render(
body=body,
model_id=ollama_client.resolved_model(),
host=ollama_client.resolved_host(),
shap_path=shap_in,
feature_count=feature_count,
)
out_path.write_text(rendered, encoding="utf-8")
_emit(f"wrote {out_path}")
return out_path


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Thin wrapper around the local Ollama HTTP API.

Two responsibilities:
1. `assert_no_external_api_envs` — enforce AC-4.3 at runtime; raises if any
cloud-LLM credential is present in the environment.
2. `generate_narrative` — call the local Ollama daemon and return the text.

The wrapper is intentionally small: the heavy lifting (transport, JSON
shape) is delegated to the `ollama` Python client, which is the official
maintainer's SDK.
"""

from __future__ import annotations

import os
from typing import Final

import ollama

DEFAULT_HOST: Final[str] = "http://localhost:11434"
DEFAULT_MODEL: Final[str] = "llama3.1:8b-instruct-q4_K_M"

# Env vars that, if set, indicate the caller has set up a cloud LLM
# credential. Their mere presence triggers an AC-4.3 fail-stop.
EXTERNAL_API_ENV_VARS: Final[tuple[str, ...]] = (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"AZURE_OPENAI_API_KEY",
"COHERE_API_KEY",
)


def resolved_host() -> str:
return os.environ.get("OLLAMA_HOST", DEFAULT_HOST)


def resolved_model() -> str:
return os.environ.get("OLLAMA_MODEL", DEFAULT_MODEL)


def assert_no_external_api_envs() -> None:
"""AC-4.3 — fail fast if any cloud-LLM credential leaked into env."""
leaked = [k for k in EXTERNAL_API_ENV_VARS if os.environ.get(k)]
if leaked:
msg = (
"External LLM API credentials detected in environment: "
f"{leaked}. This package routes all inference through local "
"Ollama. Unset these variables or use a clean shell before "
"running `make narrative`."
)
raise RuntimeError(msg)


def generate_narrative(
prompt: str,
*,
model: str | None = None,
host: str | None = None,
temperature: float = 0.4,
) -> str:
"""Send `prompt` to local Ollama and return the response text.

Raises a clear RuntimeError (with remediation hint) if Ollama is not
reachable, satisfying AC-4.2.
"""
use_host = host or resolved_host()
use_model = model or resolved_model()

try:
client = ollama.Client(host=use_host)
response = client.chat(
model=use_model,
messages=[{"role": "user", "content": prompt}],
options={"temperature": temperature},
)
except ConnectionError as exc:
raise RuntimeError(
f"Cannot reach Ollama at {use_host}. Run `ollama serve` and "
f"ensure model `{use_model}` is pulled (`ollama pull {use_model}`)."
) from exc
except Exception as exc: # noqa: BLE001
# ollama-py wraps transport errors in its own exception classes;
# surface them with the same remediation hint.
msg = str(exc).lower()
if "connect" in msg or "refused" in msg or "timeout" in msg:
raise RuntimeError(
f"Cannot reach Ollama at {use_host}. Run `ollama serve` and "
f"ensure model `{use_model}` is pulled (`ollama pull {use_model}`)."
) from exc
raise

return str(response["message"]["content"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Prompt templates for the narrative layer.

Kept here (not inline in `generate.py`) so the prompt is reviewable as a
single artifact and can be A/B-tested without touching the orchestration.
"""

from __future__ import annotations

from typing import Any

NARRATIVE_TEMPLATE = """You are an analyst writing a brief for a SaaS executive team. \
A machine-learning model has surfaced the following top drivers behind \
customer-churn predictions (ranked by mean absolute SHAP value):

{features}

Write a concise 3-paragraph narrative for the executive audience:

1. **What the model is telling us.** Summarise the dominant signal in plain \
business language, without restating the SHAP numbers.
2. **Why it matters now.** Tie the signal to revenue impact and customer \
experience; keep it actionable.
3. **Recommended next steps.** Propose 2-3 concrete experiments or playbooks \
the customer-success team could run this quarter.

Constraints:
- Avoid jargon (no "SHAP", "feature importance", "ROC-AUC" in the output).
- Do not invent metrics that the model did not surface.
- Keep total length under 350 words.
"""


def build_prompt(shap_summary: dict[str, Any]) -> str:
"""Render the SHAP summary into the executive-narrative prompt."""
rows = []
for feat in shap_summary.get("top_features", []):
direction_label = (
"raises the churn likelihood"
if feat.get("direction") == "increases_prediction"
else "lowers the churn likelihood"
)
rows.append(f"- `{feat['name']}` ({direction_label}; magnitude {feat['mean_abs_shap']:.3f})")
features_block = "\n".join(rows) if rows else "- (no features available)"
return NARRATIVE_TEMPLATE.format(features=features_block)
Loading
Loading