Skip to content

AI-written narration

ParleML MLI can turn its numbers into business-language text — archetype names and descriptions, an executive summary, plain-English rule explanations, cluster names and column descriptions. That text is AI-written, and MLI treats it as an annotation: something a host application asks for after the analysis, stores next to the result with its own provenance, and shows with a label.

In short

  • mli never calls a model. The host passes in an Ask: one callable that answers a schema-constrained question, or returns None.
  • narrate_result(result, ask=ask) writes archetype names, summaries and the explanations narration. attach_narration shows it on a loaded result; save() never stores it.
  • Every answer is checked: invented feature names and numbers are quarantined, and severity stays computed.
  • Model cards take AI-written text only as labelled annotations.
  • Cluster naming sends verbatim text snippets to the model; other calls send the analysis only.

MLI never reaches a model itself

No fit, compute or report step writes text with a model. MLI holds the prompts, the answer schemas and the checks on every answer; the host passes in the one thing that talks to a model — an Ask. Without an Ask, no MLI code path can call a model, so a run is free of generative content by construction — there is no switch to set and no certificate to stamp.

The Ask contract

from parleml_mli.ask import Ask

class Ask(Protocol):
    def __call__(self, *, system: str, prompt: str, schema: dict[str, Any]) -> dict[str, Any] | None: ...

One call is one schema-constrained question: system is the call's system prompt (the *.md files under parleml_mli/reporting/prompts/), prompt the user prompt MLI assembled from the analysis, and schema a JSON Schema for the answer. The host returns the answer as a dict, or None for no answer — no model configured, a refusal, a timeout. Every caller degrades on None to its deterministic output.

Any callable with that signature works. ParleML's server passes its own gateway (parleml_agents.ask), which routes the question to the agent and model the workspace policy names and records who answered.

def ask(*, system: str, prompt: str, schema: dict) -> dict | None:
    answer = my_gateway.complete(system=system, user=prompt, json_schema=schema)
    return answer.json() if answer.ok else None

Narrate a result

from parleml_mli.reporting import attach_narration, narrate_result

narration = narrate_result(
    result,                              # a fitted ProfilingResult
    ask=ask,
    domain_brief="Card fraud, EU issuer.",   # optional business framing
    field_descriptions=fields,           # optional {column: {"label", "description"}}
    extra_context=None,                  # optional free text
)
# -> {"analysis": {...}, "bundle": {...}}  (plain JSON), or None when no call answered

narrate_result runs every narration call for the result, over the same deterministic report dict generate_report writes (built without writing files):

  1. the two-call archetype analysis — per-cluster names, descriptions, risk indicators and notes, then the cross-cluster synthesis (executive summary, groups, insights and, when the result has rules, a general rules summary);
  2. the global-explanations narration (importance, fairness, drift or class separation) when the result carries explanations;
  3. for a multiclass result, the same archetype analysis for every other class and one comparative narration across classes.

It returns {"analysis": ..., "bundle": ...}: analysis is the primary class's archetype analysis (plus "global_explanations"), bundle is a NarrationBundle as JSON (NarrationBundle.to_dict() / from_dict()). It never mutates the result; each step that raises is skipped with a warning.

To show the narration on a loaded result — archetype names in the views and plots, the landscape treemap, names in the per-case explanations — put it back in memory:

attach_narration(result, narration)   # fills result.llm_analysis_ / result.narration_
attach_narration(result, None)        # clears both

The two slots are dropped when the result is saved, so the narration always lives next to the artifact, never inside it. generate_report embeds an attached narration in archetypes_llm_report.md (labelled as AI-written) and draws the landscape treemap from it; report.json never carries it. Studio's own Landscape treemap can show computed archetypes without narration, using a neutral group for ungrouped archetypes. The Python result.plot_landscape() helper requires attached narration with groups and archetype data. See MLI in Studio.

What every answer goes through

AI text is checked before it reaches a reader:

  • Invented feature names and numbers are quarantined. A sentence that names a feature the prompt never contained, or quotes a number no prompt value could have produced, moves to an unverified_narrative block with feature_name_warnings / numeric_claim_warnings for a banner.
  • Severity is deterministic. The archetype severity comes from the cluster's measured grounding, never from the answer (severity_source: "computed").
  • Rule translations are validated against the rule they claim to restate; a drifted translation is dropped.
  • Rule narration quotes a rule's held-out (Test) numbers when the rules were measured on held-out rows and calls the Train numbers in-sample.

The other narration calls

Call Answer schema Used for
narrate_rule(rule, *, ask, ...) {"explanation": str} One rule explained on demand
describe_clusters(clusters, *, ask, ...) {cluster_id: {"name", "description", "theme"}} Unsupervised cluster names
bootstrap_fields(..., ask=ask) {column: {"label", "description"}} Column descriptions no template covers

bootstrap_fields(ask=None) — and the parleml-mli-bootstrap-fields CLI, which has no model — is the template-only path: columns the encoding registry recognises get a deterministic description, the rest a <<NEEDS REVIEW>> placeholder. Entries an answer wrote carry "source": "ai".

The forecasting narrative (parleml_mli.forecasting.narrate_forecast(result)) is a deterministic template; no model writes it.

Model cards

build_model_card reads AI-written text only from the annotations the host attaches, each with the label every reader sees:

from parleml_mli.autodoc import CardAnnotation, build_model_card

label = "AI-written · Claude Code · haiku · 8 Oct"
card = build_model_card(
    result,
    annotations={
        "explain.narration": CardAnnotation(value=narration, label=label),
        "explain.fields": CardAnnotation(value={"fields": fields}, label=label),
    },
)
  • The executive summary is the narration's primary-class summary (else the analysis's), with card.executive_summary_label set to the label; without an annotation it is the deterministic template and the label is None.
  • Interpretability lists the per-class narrations under archetypes.narrations and carries narration_label.
  • The feature engineering section's top features carry the field descriptions' label / description, with field_descriptions_label.
  • card.ai_written lists one {"section", "label"} per section that printed AI-written text (empty when there is none). The host that assembles the document states it once from this list; ParleML's card prints "This model card contains no AI-written text." under the executive summary when it is empty.

The card never reads result.narration_ / result.llm_analysis_, so a regulated reader always knows which sentences a model wrote.

What is transmitted

Each call sends its system prompt and the user prompt MLI assembled to whatever the host's Ask reaches. The prompts carry the analysis — feature names, cluster statistics, rule conditions and metrics, the domain brief and field descriptions you pass. The one that carries raw source rows is unsupervised cluster naming: per cluster, the host sends the cluster id and size, its distinctive c-TF-IDF keywords, the most separating feature names with their effect sizes, the dominant values of the passive profiling fields, and a few verbatim row snippets from the free-text columns (truncated, not redacted). Do not name clusters over text a model provider must not see; the deterministic surfaces (keywords, the computed cluster profile) stay available either way.

Generated names and descriptions are never presented as measurements: a cluster whose evidence is too thin to ground a name is described as thin, and the host labels every AI-written value with who wrote it.

See the Profiling guide for the artifacts the narration describes, and Consuming saved results for reading a saved result later.

API

Ask

Bases: Protocol

One schema-constrained question to a model the host application provides.

None means no answer (no model configured, a refusal, a timeout): every caller degrades to its deterministic output. mli never reaches a model itself.

No answer schema is an open map (an object whose values are described by additionalProperties): Claude Code's structured output cannot fill one. An answer keyed by data (a column, a cluster id, a class) is an array of items that carry their key as a field, turned back into a map with :func:by_key.

narrate_result

narrate_result(
    result: "ProfilingResult",
    *,
    ask: "Ask",
    domain_brief: str | None = None,
    field_descriptions: (
        Mapping[str, Mapping[str, Any]] | None
    ) = None,
    extra_context: str | None = None,
    top_n_features: int = 7,
    top_n_cat_values: int = 8,
    on_progress: Callable[[str], None] | None = None
) -> dict[str, Any] | None

The narration of a fitted ProfilingResult as a JSON-ready dict, or None when no call answered.

{"analysis": <the dict describe_archetypes returns, plus "global_explanations">, "bundle": <NarrationBundle as a JSON-ready dict>}

Runs, over the result's own deterministic report dict (the one :meth:~parleml_mli.ProfilingResult.generate_report writes, built without writing files):

  1. the two-call archetype analysis (:func:describe_archetypes), with the rules when the result has them — the general rules summary only; per-rule explanations stay with :func:narrate_rule;
  2. the global-explanations narration when the result carries explanations (analysis["global_explanations"]);
  3. the primary class's analysis lifted into bundle.per_class;
  4. for a multiclass result, :func:narrate_per_class for every other class and :func:narrate_comparative across classes.

Every step is isolated: a step that raises is skipped with a warning, a step that gets no answer leaves its part out. domain_brief, field_descriptions ({column: {"label", "description", ...}}) and extra_context ground the prompts. on_progress optionally reports stages to the host without exposing prompts.

attach_narration

attach_narration(
    result: "ProfilingResult",
    narration: Mapping[str, Any] | None,
) -> None

Put a stored narration on the result in memory (result.llm_analysis_ / result.narration_) so the views, plots and the case explanations name archetypes; None clears both. Never persisted.

narration is what :func:narrate_result returned. The two slots are dropped when the result is pickled — the narration lives next to the artifact, as the host's annotation.

Next