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 returnsNone. narrate_result(result, ask=ask)writes archetype names, summaries and the explanations narration.attach_narrationshows 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):
- 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);
- the global-explanations narration (importance, fairness, drift or class separation) when the result carries explanations;
- 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_narrativeblock withfeature_name_warnings/numeric_claim_warningsfor 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_labelset to the label; without an annotation it is the deterministic template and the label isNone. - Interpretability lists the per-class narrations under
archetypes.narrationsand carriesnarration_label. - The feature engineering section's top features carry the field
descriptions'
label/description, withfield_descriptions_label. card.ai_writtenlists 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):
- 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; - the global-explanations narration when the result carries
explanations (
analysis["global_explanations"]); - the primary class's analysis lifted into
bundle.per_class; - for a multiclass result, :func:
narrate_per_classfor every other class and :func:narrate_comparativeacross 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 ¶
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¶
- Evaluation, trust & model cards: where annotations reach the card.
- Consuming saved results: reattach a stored narration to a loaded result.