| """ |
| delivery.reports.render — render a CustomerReport to Markdown / HTML. |
| |
| The findings block delegates to the Stage 4a evidence renderers, so card |
| formatting, escaping, and severity badges stay consistent between the |
| per-issue view and the full report. |
| """ |
| from __future__ import annotations |
|
|
| import html as _html |
| from typing import List |
|
|
| from ..evidence import EvidenceView |
| from ..evidence.render import ( |
| _card_html as _evidence_card_html, |
| ) |
| from ..evidence.render import ( |
| _card_markdown as _evidence_card_markdown, |
| ) |
| from ..evidence.render import ( |
| _esc, |
| _fmt_pct, |
| _fmt_score, |
| _severity_summary_line, |
| ) |
| from .customer import CustomerReport, ReportDecision |
|
|
| _REPORT_TITLE = "Operational Drift Report" |
|
|
|
|
| |
|
|
| def _entity_pill(item: dict, *, with_from: bool = False) -> str: |
| """One pill (entity id + severity badge) used in the diff lists.""" |
| eid = _esc(item.get("entity_id", "")) |
| sev = item.get("severity", "") |
| if with_from and item.get("from_severity"): |
| from_sev = _esc(item["from_severity"]) |
| return ( |
| f'<li><code>{eid}</code> ' |
| f'<span class="sev sev-{_esc(from_sev)}">{from_sev}</span>' |
| f' → <span class="sev sev-{_esc(sev)}">{_esc(sev)}</span></li>' |
| ) |
| return ( |
| f'<li><code>{eid}</code> ' |
| f'<span class="sev sev-{_esc(sev)}">{_esc(sev)}</span></li>' |
| ) |
|
|
|
|
| def _diff_section_html(diff: dict) -> str: |
| """Render the 'Since previous run' callout. Suppressed entirely when |
| every counter is zero — an empty diff is just visual noise.""" |
| n_new = diff.get("n_new", 0) |
| n_resolved = diff.get("n_resolved", 0) |
| n_severity_changed = diff.get("n_severity_changed", 0) |
| n_unchanged = diff.get("n_unchanged", 0) |
| against = diff.get("against_run_id") |
|
|
| if not any((n_new, n_resolved, n_severity_changed)): |
| |
| |
| return ( |
| '<div class="diff-callout diff-quiet">' |
| '<strong>Since previous run:</strong> no changes ' |
| f'({n_unchanged} unchanged).' |
| '</div>' |
| ) |
|
|
| sections = [] |
| if against is None: |
| sections.append( |
| '<p class="meta">This is the first run on this tenant — ' |
| 'every issue below is new.</p>' |
| ) |
|
|
| def _list_block(title, items, with_from=False): |
| if not items: |
| return "" |
| pills = "".join(_entity_pill(it, with_from=with_from) |
| for it in items[:20]) |
| more = "" if len(items) <= 20 else ( |
| f' <span class="meta">+{len(items) - 20} more</span>' |
| ) |
| return ( |
| f'<div class="diff-bucket"><strong>{_esc(title)} ' |
| f'({len(items)})</strong>' |
| f'<ul class="diff-pills">{pills}</ul>{more}</div>' |
| ) |
|
|
| sections.append(_list_block("New", diff.get("new", []))) |
| sections.append(_list_block("Severity changed", |
| diff.get("severity_changed", []), |
| with_from=True)) |
| sections.append(_list_block("Resolved", diff.get("resolved", []))) |
|
|
| return ( |
| '<div class="diff-callout">' |
| '<h2>Since previous run</h2>' |
| '<p class="counts">' |
| f'<strong>{n_new}</strong> new · ' |
| f'<strong>{n_severity_changed}</strong> severity changed · ' |
| f'<strong>{n_resolved}</strong> resolved · ' |
| f'<strong>{n_unchanged}</strong> unchanged' |
| '</p>' |
| + "".join(sections) |
| + '</div>' |
| ) |
|
|
|
|
| |
|
|
| def _summary_markdown(report: CustomerReport) -> List[str]: |
| s = report.summary |
| return [ |
| "## Executive Summary", |
| "", |
| s.headline, |
| "", |
| f"- **Entities analysed:** {s.n_entities_analyzed}", |
| f"- **Drift issues:** {s.n_issues} ({_severity_summary_line(report.evidence)})", |
| f"- **Decisions raised:** {s.n_decisions}", |
| "", |
| ] |
|
|
|
|
| def _findings_markdown(view: EvidenceView) -> List[str]: |
| if not view.cards: |
| return [ |
| "## Findings", |
| "", |
| "No entities crossed a drift threshold on this run.", |
| "", |
| ] |
| lines = ["## Findings", ""] |
| for card in view.cards: |
| lines += _evidence_card_markdown(card) |
| return lines |
|
|
|
|
| def _decisions_markdown(decisions: List[ReportDecision]) -> List[str]: |
| if not decisions: |
| return [ |
| "## Decision Queue", |
| "", |
| "No decisions raised on this run.", |
| "", |
| ] |
| lines = [ |
| "## Decision Queue", |
| "", |
| "Ranked by urgency. Each decision links back to the issue that triggered it.", |
| "", |
| "| Urgency | Entity | Recommendation | Confidence | Issue |", |
| "|---:|---|---|---:|---|", |
| ] |
| for d in decisions: |
| lines.append( |
| f"| {_fmt_score(d.urgency)} | `{d.entity_id}` | {d.recommendation} | " |
| f"{_fmt_pct(d.confidence)} | `{d.issue_id}` |" |
| ) |
| lines.append("") |
| return lines |
|
|
|
|
| def _methodology_markdown() -> List[str]: |
| return [ |
| "## Methodology", |
| "", |
| "Drift is detected by a generic, config-driven pipeline over a " |
| "per-tenant calibration derived from the tenant's own history (MAD-robust). " |
| "Each issue is scored from five signals (Δ, ψ, ξ, γ, κ) and mapped to a " |
| "severity. Decisions are recommendations grounded in the evidence of the " |
| "issue that triggered them — they are operational hypotheses to validate, " |
| "not guaranteed outcomes.", |
| "", |
| ] |
|
|
|
|
| def render_markdown(report: CustomerReport) -> str: |
| lines = [ |
| f"# {_REPORT_TITLE} — `{report.tenant_id}`", |
| "", |
| ( |
| f"- **Run:** `{report.run_id}` " |
| f"\n- **Entity type:** {report.entity_type} " |
| f"\n- **Finished at:** {report.finished_at}" |
| ), |
| "", |
| ] |
| lines += _summary_markdown(report) |
| lines += _findings_markdown(report.evidence) |
| lines += _decisions_markdown(report.decisions) |
| lines += _methodology_markdown() |
| return "\n".join(lines).rstrip() + "\n" |
|
|
|
|
| |
|
|
| _HTML_HEAD = ( |
| "<!doctype html><html><head><meta charset=\"utf-8\">" |
| "<title>Operational Drift Report</title>" |
| "<style>" |
| "body{font-family:-apple-system,Segoe UI,Arial,sans-serif;" |
| "max-width:960px;margin:32px auto;padding:0 16px;line-height:1.5;color:#222}" |
| "h1{font-size:1.7em;margin-bottom:0.1em}" |
| "h2{margin-top:1.8em;border-bottom:1px solid #ddd;padding-bottom:0.2em}" |
| "h3{margin-top:1.3em}" |
| "table{border-collapse:collapse;width:100%;margin:0.6em 0}" |
| "th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;font-size:0.95em}" |
| "th{background:#f4f4f4}" |
| ".sev{display:inline-block;padding:1px 8px;border-radius:3px;" |
| "font-size:0.85em;font-weight:600;color:#fff}" |
| ".sev-critical{background:#a00}.sev-high{background:#d35400}" |
| ".sev-medium{background:#b8860b}.sev-low{background:#777}" |
| ".meta{color:#555;font-size:0.93em}" |
| ".headline{font-size:1.05em;margin:0.4em 0 1em 0}" |
| "code{background:#f4f4f4;padding:1px 4px;border-radius:3px}" |
| ".diff-callout{padding:14px 18px;background:#eef6fb;" |
| "border:1px solid #b8d8e8;border-radius:6px;margin:1em 0 2em}" |
| ".diff-callout h2{margin-top:0;border:none;color:#1f4f6f;font-size:1.2em}" |
| ".diff-quiet{padding:8px 14px;color:#555;background:#f5f5f5;" |
| "border:1px solid #ddd;border-radius:4px;margin:1em 0 2em;font-size:0.95em}" |
| ".diff-bucket{margin-top:0.5em}" |
| ".diff-pills{list-style:none;padding:0;margin:0.4em 0;display:flex;" |
| "flex-wrap:wrap;gap:6px}" |
| ".diff-pills li{padding:2px 8px;background:#fff;border:1px solid #cdd9e1;" |
| "border-radius:12px;font-size:0.9em}" |
| ".counts{margin:0.4em 0;font-size:0.96em}" |
| "</style></head><body>" |
| ) |
| _HTML_FOOT = "</body></html>" |
|
|
|
|
| def _summary_html(report: CustomerReport) -> str: |
| s = report.summary |
| parts = [ |
| "<h2>Executive Summary</h2>", |
| f'<p class="headline">{_esc(s.headline)}</p>', |
| "<ul>", |
| f"<li><strong>Entities analysed:</strong> {s.n_entities_analyzed}</li>", |
| ( |
| "<li><strong>Drift issues:</strong> " |
| f"{s.n_issues} ({_esc(_severity_summary_line(report.evidence))})</li>" |
| ), |
| f"<li><strong>Decisions raised:</strong> {s.n_decisions}</li>", |
| "</ul>", |
| ] |
| return "".join(parts) |
|
|
|
|
| def _findings_html(view: EvidenceView) -> str: |
| if not view.cards: |
| return ( |
| "<h2>Findings</h2>" |
| "<p>No entities crossed a drift threshold on this run.</p>" |
| ) |
| parts = ["<h2>Findings</h2>"] |
| for card in view.cards: |
| parts.append(_evidence_card_html(card)) |
| return "".join(parts) |
|
|
|
|
| def _decisions_html(decisions: List[ReportDecision]) -> str: |
| if not decisions: |
| return "<h2>Decision Queue</h2><p>No decisions raised on this run.</p>" |
| parts = [ |
| "<h2>Decision Queue</h2>", |
| '<p class="meta">Ranked by urgency. Each decision links back to ' |
| "the issue that triggered it.</p>", |
| "<table><thead><tr>" |
| "<th>Urgency</th><th>Entity</th><th>Recommendation</th>" |
| "<th>Confidence</th><th>Issue</th>" |
| "</tr></thead><tbody>", |
| ] |
| for d in decisions: |
| parts.append( |
| "<tr>" |
| f"<td>{_esc(_fmt_score(d.urgency))}</td>" |
| f"<td><code>{_esc(d.entity_id)}</code></td>" |
| f"<td>{_esc(d.recommendation)}</td>" |
| f"<td>{_esc(_fmt_pct(d.confidence))}</td>" |
| f"<td><code>{_esc(d.issue_id)}</code></td>" |
| "</tr>" |
| ) |
| parts.append("</tbody></table>") |
| return "".join(parts) |
|
|
|
|
| def _methodology_html() -> str: |
| return ( |
| "<h2>Methodology</h2>" |
| "<p>Drift is detected by a generic, config-driven pipeline over a " |
| "per-tenant calibration derived from the tenant's own history " |
| "(MAD-robust). Each issue is scored from five signals (Δ, ψ, ξ, γ, κ) " |
| "and mapped to a severity. Decisions are recommendations grounded in " |
| "the evidence of the issue that triggered them — they are " |
| "operational hypotheses to validate, not guaranteed outcomes.</p>" |
| ) |
|
|
|
|
| def render_html(report: CustomerReport) -> str: |
| body = [ |
| _HTML_HEAD, |
| f"<h1>{_html.escape(_REPORT_TITLE)} — " |
| f"<code>{_esc(report.tenant_id)}</code></h1>", |
| '<p class="meta">', |
| f"Run <code>{_esc(report.run_id)}</code>", |
| f" · entity type {_esc(report.entity_type)}", |
| f" · finished {_esc(report.finished_at)}", |
| "</p>", |
| |
| |
| _diff_section_html(report.diff) if report.diff is not None else "", |
| _summary_html(report), |
| _findings_html(report.evidence), |
| _decisions_html(report.decisions), |
| _methodology_html(), |
| _HTML_FOOT, |
| ] |
| return "".join(body) |
|
|