"""Output formatters for InterpretationEnvelope (spec v0.4 §4.6, P-M). Pure functions: each takes an envelope and returns a string. `cli.py` uses them via the `Format` dispatch table; consumers wanting the rendered string without printing (Tauri, tests, debug tooling) call them directly. Four formats per spec: - text: ANSI-colored CLI output, integrates `output.py` severity badges - json: spec §4.5 envelope serialization (machine-readable; Tauri consumes this) - markdown: GitHub-flavored, suitable for PR comments / issue bodies - html: standalone fragment suitable for embedding in reports The text formatter is the only one that mutates output via `output.py`'s ANSI helpers — those auto-disable when `--no-color` is set or `NO_COLOR` is in the env, so the formatter is safe in non-TTY contexts too. """ from __future__ import annotations import html import json as _json from typing import Literal from uofa_cli.interpretation.envelope import InterpretationEnvelope from uofa_cli.output import color, severity_badge Format = Literal["text", "json", "markdown", "html"] # ── Public dispatch ──────────────────────────────────────── def render_envelope(env: InterpretationEnvelope, *, format: Format = "text") -> str: """Render `env` to the requested format. Pure function — no I/O.""" if format == "json": return render_json(env) if format == "markdown": return render_markdown(env) if format == "html": return render_html(env) return render_text(env) # ── JSON ─────────────────────────────────────────────────── def render_json(env: InterpretationEnvelope) -> str: """Spec §4.5 envelope, pretty-printed. Stable contract for Tauri.""" return _json.dumps(env.to_dict(), indent=2) # ── Text (ANSI-colored CLI) ──────────────────────────────── def render_text(env: InterpretationEnvelope) -> str: """ANSI-colored CLI rendering — `output.py` color helpers honor NO_COLOR.""" if env.interpretation is None: return "" interp = env.interpretation lines: list[str] = [] lines.append("") lines.append(color("══ Interpretation ══", "bold")) lines.append(f" Backend: {color(interp.interpretation_backend, 'cyan')}" f" / {color(interp.interpretation_model, 'cyan')}") lines.append(f" Functions run: {', '.join(interp.functions_run) or color('(none)', 'dim')}") lines.append("") if interp.explanations: lines.append(color("Explanations:", "bold")) for e in interp.explanations: pid = e.get("patternId", "?") sev = e.get("severity", "?") err = color(" [unavailable]", "red") if e.get("error") else "" lines.append(f" {severity_badge(sev)} {color(pid, 'bold')}{err}") # v0.4.0 schema: three prose fields. Render each on a labeled # line so the structure is scannable. Skip empty fields. for label, key in ( ("Affected evidence", "affected_evidence_summary"), ("Gap", "gap_description"), ("Relevance to COU", "relevance_to_cou"), ): value = e.get(key, "") or "" if not value: continue lines.append(f" {color(label + ':', 'cyan')}") for body_line in value.splitlines(): lines.append(f" {body_line}") lines.append("") if interp.groupings: lines.append(color(f"Groupings ({len(interp.groupings)}):", "bold")) for theme, info in interp.groupings.items(): # P-F: each value is now a dict {kind, members, rationale}. # Older callers may have stored a bare list; handle both. if isinstance(info, dict): kind = info.get("kind", "") members = info.get("members", []) rationale = info.get("rationale", "") kind_str = f" [{kind}]" if kind else "" members_str = ", ".join(members) if isinstance(members, list) else str(members) lines.append(f" • {color(theme, 'cyan')}{color(kind_str, 'dim')}") if members_str: lines.append(f" members: {members_str}") if rationale: for body_line in rationale.splitlines(): lines.append(f" {body_line}") else: # Back-compat: bare list of members members_str = ", ".join(info) if isinstance(info, list) else str(info) lines.append(f" • {color(theme, 'cyan')}: {members_str}") lines.append("") if interp.contextual_severity: lines.append(color("Contextual severity (1 = most consequential for this COU):", "bold")) # Sort by rank ascending so the most-important firing prints first sorted_items = sorted( interp.contextual_severity.items(), key=lambda kv: kv[1].get("rank", 99) if isinstance(kv[1], dict) else 99, ) for pid, info in sorted_items: if isinstance(info, dict): rank = info.get("rank", "?") rationale = info.get("rationale", "") lines.append(f" {color(f'#{rank}', 'bold')} {color(pid, 'cyan')}") if rationale: for body_line in rationale.splitlines(): lines.append(f" {body_line}") else: lines.append(f" • {color(pid, 'bold')}: rank {info}") lines.append("") if interp.cross_patterns: lines.append(color(f"Cross-item patterns ({len(interp.cross_patterns)}):", "bold")) for pat in interp.cross_patterns: if isinstance(pat, dict): name = pat.get("name", "(unnamed)") desc = pat.get("description", "") involved = pat.get("involved_firings", []) lines.append(f" • {color(name, 'cyan')}") if involved: lines.append(f" involves: {', '.join(involved)}") if desc: for body_line in desc.splitlines(): lines.append(f" {body_line}") else: lines.append(f" • {color(str(pat), 'cyan')}") lines.append("") if interp.narratives: lines.append(color(f"Surviving-set narratives ({len(interp.narratives)}):", "bold")) for n in interp.narratives: cou = n.get("cou", "?") if isinstance(n, dict) else "?" text = n.get("text", "") if isinstance(n, dict) else str(n) lines.append(f" • {color(cou, 'cyan')}") for body_line in text.splitlines(): lines.append(f" {body_line}") lines.append("") return "\n".join(lines).rstrip() + "\n" # ── Markdown ────────────────────────────────────────────── def render_markdown(env: InterpretationEnvelope) -> str: """GitHub-flavored markdown — suitable for PR comments, issue bodies, docs.""" if env.interpretation is None: return "" interp = env.interpretation lines: list[str] = [] lines.append("") lines.append("## Interpretation") lines.append("") lines.append(f"_Backend: `{interp.interpretation_backend}` / `{interp.interpretation_model}`_ ") lines.append(f"_Functions run: {', '.join(f'`{n}`' for n in interp.functions_run) or '_none_'}_") lines.append("") if interp.explanations: lines.append("### Explanations") lines.append("") for e in interp.explanations: pid = e.get("patternId", "?") sev = e.get("severity", "?") err_marker = " ⚠️ unavailable" if e.get("error") else "" lines.append(f"#### `{pid}` ({sev}){err_marker}") lines.append("") # Round 1 schema: four prose fields. Render each as a labeled # subsection with bold label + body paragraph. Skip empty fields. any_field = False for label, key in ( ("Affected evidence", "affected_evidence_summary"), ("Gap", "gap_description"), ("Relevance to COU", "relevance_to_cou"), ): value = e.get(key, "") or "" if not value: continue lines.append(f"**{label}:** {value}") lines.append("") any_field = True if not any_field: lines.append("_No explanation produced._") lines.append("") if interp.groupings: lines.append(f"### Groupings ({len(interp.groupings)})") lines.append("") for theme, info in interp.groupings.items(): if isinstance(info, dict): kind = info.get("kind", "") members = info.get("members", []) rationale = info.get("rationale", "") kind_suffix = f" _({kind})_" if kind else "" members_str = ", ".join(f"`{m}`" for m in members) if isinstance(members, list) else f"`{members}`" lines.append(f"#### {theme}{kind_suffix}") lines.append("") if members_str: lines.append(f"**Members:** {members_str}") lines.append("") if rationale: lines.append(rationale) lines.append("") else: members_str = ", ".join(f"`{m}`" for m in info) if isinstance(info, list) else f"`{info}`" lines.append(f"- **{theme}**: {members_str}") lines.append("") if interp.contextual_severity: lines.append("### Contextual severity") lines.append("") lines.append("_Ranked 1..N where 1 is most consequential for this COU._") lines.append("") sorted_items = sorted( interp.contextual_severity.items(), key=lambda kv: kv[1].get("rank", 99) if isinstance(kv[1], dict) else 99, ) for pid, info in sorted_items: if isinstance(info, dict): rank = info.get("rank", "?") rationale = info.get("rationale", "") lines.append(f"#### #{rank} — `{pid}`") lines.append("") if rationale: lines.append(rationale) lines.append("") else: lines.append(f"- `{pid}` — rank {info}") lines.append("") if interp.cross_patterns: lines.append(f"### Cross-item patterns ({len(interp.cross_patterns)})") lines.append("") for pat in interp.cross_patterns: if isinstance(pat, dict): name = pat.get("name", "(unnamed)") desc = pat.get("description", "") involved = pat.get("involved_firings", []) lines.append(f"#### {name}") lines.append("") if involved: lines.append(f"**Involves:** {', '.join(f'`{p}`' for p in involved)}") lines.append("") if desc: lines.append(desc) lines.append("") else: lines.append(f"- {pat}") lines.append("") if interp.narratives: lines.append(f"### Surviving-set narratives ({len(interp.narratives)})") lines.append("") for n in interp.narratives: cou = n.get("cou", "?") if isinstance(n, dict) else "?" text = n.get("text", "") if isinstance(n, dict) else str(n) lines.append(f"#### {cou}") lines.append("") lines.append(text) lines.append("") return "\n".join(lines).rstrip() + "\n" # ── HTML ────────────────────────────────────────────────── def render_html(env: InterpretationEnvelope) -> str: """Standalone HTML fragment. Suitable for embedding in `
` containers or copying into a report builder. Not a full document — caller wraps if needed (in a `` shell). All user-visible text is escaped. The CSS class names follow `uofa-explain-*` for safe namespacing. """ if env.interpretation is None: return "" interp = env.interpretation parts: list[str] = [] parts.append('
') parts.append('

Interpretation

') parts.append('

') parts.append(f'Backend: {html.escape(interp.interpretation_backend)} / ' f'{html.escape(interp.interpretation_model)}
') parts.append('Functions run: ') if interp.functions_run: parts.append(", ".join(f'{html.escape(n)}' for n in interp.functions_run)) else: parts.append('none') parts.append('

') if interp.explanations: parts.append('
') parts.append('

Explanations

') for e in interp.explanations: pid = html.escape(str(e.get("patternId", "?"))) sev = html.escape(str(e.get("severity", "?"))) err_attr = ' data-error="true"' if e.get("error") else "" parts.append(f'
') parts.append(f'

{pid} ' f'{sev}' '

') # Round 1 schema: four prose fields rendered as labeled
# entries (definition list — semantic + good for screen readers). any_field = False dl_parts: list[str] = [] for label, key, css_key in ( ("Affected evidence", "affected_evidence_summary", "evidence"), ("Gap", "gap_description", "gap"), ("Relevance to COU", "relevance_to_cou", "relevance"), ): value = e.get(key, "") or "" if not value: continue any_field = True dl_parts.append( f'
{html.escape(label)}
' f'
{html.escape(value)}
' ) if any_field: parts.append('
') parts.extend(dl_parts) parts.append('
') parts.append('
') parts.append('
') if interp.groupings: parts.append('
') parts.append(f'

Groupings ({len(interp.groupings)})

') for theme, info in interp.groupings.items(): theme_safe = html.escape(str(theme)) if isinstance(info, dict): kind = html.escape(str(info.get("kind", ""))) members = info.get("members", []) rationale = html.escape(str(info.get("rationale", ""))) members_html = ( ", ".join(f'{html.escape(str(m))}' for m in members) if isinstance(members, list) else f'{html.escape(str(members))}' ) # Hoist the kind-span out of the f-string: nested f-strings # with escaped quotes are a Python 3.12+ feature, and we # support 3.11 per pyproject.toml requires-python. kind_class = kind.replace(" ", "-") kind_span = f' ({kind})' if kind else "" parts.append(f'
') parts.append(f'

{theme_safe}{kind_span}

') if members_html: parts.append(f'

Members: {members_html}

') if rationale: parts.append(f'

{rationale}

') parts.append('
') else: members_html = ( ", ".join(f'{html.escape(str(m))}' for m in info) if isinstance(info, list) else f'{html.escape(str(info))}' ) parts.append(f'

{theme_safe}: {members_html}

') parts.append('
') if interp.contextual_severity: parts.append('
') parts.append('

Contextual severity

') parts.append('

Ranked 1..N where 1 is most consequential for this COU.

') sorted_items = sorted( interp.contextual_severity.items(), key=lambda kv: kv[1].get("rank", 99) if isinstance(kv[1], dict) else 99, ) parts.append('
    ') for pid, info in sorted_items: pid_safe = html.escape(str(pid)) if isinstance(info, dict): rationale = html.escape(str(info.get("rationale", ""))) parts.append(f'
  1. {pid_safe}') if rationale: parts.append(f' — {rationale}') parts.append('
  2. ') else: parts.append(f'
  3. {pid_safe} — rank {html.escape(str(info))}
  4. ') parts.append('
') parts.append('
') if interp.cross_patterns: parts.append('
') parts.append(f'

Cross-item patterns ({len(interp.cross_patterns)})

') for pat in interp.cross_patterns: if isinstance(pat, dict): name = html.escape(str(pat.get("name", "(unnamed)"))) desc = html.escape(str(pat.get("description", ""))) involved = pat.get("involved_firings", []) parts.append(f'

{name}

') if involved: involved_html = ", ".join(f'{html.escape(str(p))}' for p in involved) parts.append(f'

Involves: {involved_html}

') if desc: parts.append(f'

{desc}

') parts.append('
') else: parts.append(f'

{html.escape(str(pat))}

') parts.append('
') if interp.narratives: parts.append('
') parts.append(f'

Surviving-set narratives ({len(interp.narratives)})

') for n in interp.narratives: cou = html.escape(str(n.get("cou", "?") if isinstance(n, dict) else "?")) text = html.escape(str(n.get("text", "") if isinstance(n, dict) else n)) parts.append(f'

{cou}

') parts.append(f'

{text}

') parts.append('
') parts.append('
') parts.append('
') return "\n".join(parts) + "\n"