mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-01 04:16:17 +08:00
1751 lines
70 KiB
Python
1751 lines
70 KiB
Python
|
|
"""Render dc._session_summary.md from dc._session_tree.json + dc._session_manifest.json.
|
|||
|
|
|
|||
|
|
Given `DATA_ROOT/<sid>/dc._session_tree.json` (produced by `scripts/assemble_dc.py`)
|
|||
|
|
this module emits a human-readable `dc._session_summary.md`. Pure tree reader —
|
|||
|
|
no raw DMO fetches, no joins. The `session.identity` sub-object on the tree
|
|||
|
|
(added by the assembler) supplies all identity fields; everything else
|
|||
|
|
(counts, trees, catalog) comes from the tree itself.
|
|||
|
|
|
|||
|
|
Output has 8 `##` sections:
|
|||
|
|
|
|||
|
|
1. Session identity — org/agent/bot/planner/session-start/end/duration
|
|||
|
|
2. ID reference — full UUIDs for every ellipsized id in the tree
|
|||
|
|
3. Transcript — narrative USER/AGENT text per TURN
|
|||
|
|
4. Complete hierarchical trace — tree with `+start + dur = +end` math
|
|||
|
|
5. Per-turn summary — one row per interaction
|
|||
|
|
6. Session counts — engineer-facing totals
|
|||
|
|
7. Empties diagnostics — DMOs that returned 0 rows + reason
|
|||
|
|
8. Catalog (session-filtered)
|
|||
|
|
|
|||
|
|
Contract:
|
|||
|
|
- Pure in-memory compute over already-produced artifacts.
|
|||
|
|
- Reads DATA_ROOT/<sid>/dc._session_tree.json and dc._session_manifest.json.
|
|||
|
|
- UUIDs in the tree are truncated to first 8 chars + `…`; full forms
|
|||
|
|
live in the ID reference block.
|
|||
|
|
- Session-not-found trees render only Session identity + a note.
|
|||
|
|
- Tree schema version check: refuses incompatible versions, warns on
|
|||
|
|
missing version (backward compat).
|
|||
|
|
|
|||
|
|
Invocation:
|
|||
|
|
python3 scripts/render_dc.py --session <sid>
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import html
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Dict, List, Optional
|
|||
|
|
|
|||
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|||
|
|
|
|||
|
|
from config import DATA_ROOT, paths
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- schema ---------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_SUPPORTED_SCHEMA_VERSION = 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- timestamp / string helpers -------------------------------------------
|
|||
|
|
|
|||
|
|
def _parse_iso(s: Optional[str]) -> Optional[datetime]:
|
|||
|
|
if not s:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|||
|
|
except (ValueError, AttributeError):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_offset(ts_iso: Optional[str], start_dt: Optional[datetime]) -> str:
|
|||
|
|
"""Return '+12.345s' or '—' if timestamp is missing."""
|
|||
|
|
ts = _parse_iso(ts_iso)
|
|||
|
|
if ts is None or start_dt is None:
|
|||
|
|
return "—"
|
|||
|
|
return f"+{(ts - start_dt).total_seconds():.3f}s"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_duration_ms(start_iso: Optional[str], end_iso: Optional[str]) -> str:
|
|||
|
|
s = _parse_iso(start_iso)
|
|||
|
|
e = _parse_iso(end_iso)
|
|||
|
|
if s is None or e is None:
|
|||
|
|
return "—"
|
|||
|
|
return f"{int((e - s).total_seconds() * 1000)}ms"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _decode(s: Optional[str]) -> str:
|
|||
|
|
if not s:
|
|||
|
|
return ""
|
|||
|
|
return html.unescape(s).replace("\n", " ").strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _truncate(s: Optional[str], n: int = 80) -> str:
|
|||
|
|
if not s:
|
|||
|
|
return "—"
|
|||
|
|
s = _decode(s)
|
|||
|
|
return s if len(s) <= n else s[: n - 1] + "…"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _short(uid: Optional[str], keep: int = 8) -> str:
|
|||
|
|
"""Truncate a UUID to `keep` chars + ellipsis. Full form lives in ID reference."""
|
|||
|
|
if not uid:
|
|||
|
|
return "—"
|
|||
|
|
return uid[:keep] + "…" if len(uid) > keep else uid
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_total_duration(start_iso: Optional[str], end_iso: Optional[str]) -> Optional[str]:
|
|||
|
|
s = _parse_iso(start_iso)
|
|||
|
|
e = _parse_iso(end_iso)
|
|||
|
|
if s is None or e is None:
|
|||
|
|
return None
|
|||
|
|
total_secs = (e - s).total_seconds()
|
|||
|
|
if total_secs >= 3600:
|
|||
|
|
h = int(total_secs // 3600)
|
|||
|
|
m = int((total_secs % 3600) // 60)
|
|||
|
|
return f"{h}h {m}m {total_secs % 60:.3f}s"
|
|||
|
|
if total_secs >= 60:
|
|||
|
|
m = int(total_secs // 60)
|
|||
|
|
return f"{m}m {total_secs % 60:.3f}s"
|
|||
|
|
return f"{total_secs:.3f}s"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- session-end derivation ----------------------------------------------
|
|||
|
|
|
|||
|
|
def _derive_session_end(sess: dict) -> tuple[Optional[str], Optional[str]]:
|
|||
|
|
"""Return (effective_end_iso, source_label).
|
|||
|
|
|
|||
|
|
Contract §3.5 derivation:
|
|||
|
|
1. If `session.end_ts` is non-null → return it as-is (caller adds
|
|||
|
|
"✓ materialized" suffix).
|
|||
|
|
2. Else prefer a SESSION_END interaction's `start_ts`
|
|||
|
|
(label: "from SESSION_END interaction").
|
|||
|
|
3. Else fall back to the last TURN interaction's `end_ts`
|
|||
|
|
(or `start_ts` if end is null) (label: "session still open (last TURN)").
|
|||
|
|
4. Else `(None, None)`.
|
|||
|
|
"""
|
|||
|
|
end_iso = sess.get("end_ts")
|
|||
|
|
if end_iso:
|
|||
|
|
return end_iso, None # no derivation needed
|
|||
|
|
interactions = sess.get("interactions") or []
|
|||
|
|
# Prefer SESSION_END start_ts.
|
|||
|
|
for iv in interactions:
|
|||
|
|
if iv.get("type") == "SESSION_END" and iv.get("start_ts"):
|
|||
|
|
return iv["start_ts"], "from SESSION_END interaction"
|
|||
|
|
# Fall back to the last TURN (by interaction order; tree is already sorted).
|
|||
|
|
last_turn = None
|
|||
|
|
for iv in interactions:
|
|||
|
|
if iv.get("type") == "TURN":
|
|||
|
|
last_turn = iv
|
|||
|
|
if last_turn is not None:
|
|||
|
|
fallback = last_turn.get("end_ts") or last_turn.get("start_ts")
|
|||
|
|
if fallback:
|
|||
|
|
return fallback, "session still open (last TURN)"
|
|||
|
|
return None, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- section builders -----------------------------------------------------
|
|||
|
|
|
|||
|
|
# Channel-mode value → human cell. Annotated so the reader can tell at
|
|||
|
|
# a glance why we believe the mode is
|
|||
|
|
# what it is — `ssot__AiAgentChannelType__c` is identical for MIAW
|
|||
|
|
# production and Builder Previewer, which makes it useless without the
|
|||
|
|
# annotation.
|
|||
|
|
_MODE_HINTS: dict[str, str] = {
|
|||
|
|
"production_messaging": "production_messaging ← inferred from RelatedMessagingSessionId",
|
|||
|
|
"builder_previewer": "builder_previewer ← inferred from VariableText__c bootstrap keys",
|
|||
|
|
"voice": "voice ← inferred from RelatedVoiceCallId",
|
|||
|
|
"unknown": "unknown ← no signals (headless API, etc.)",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_mode_cell(mode_value: str) -> str:
|
|||
|
|
"""Format the `mode` field with a short why-this-value annotation."""
|
|||
|
|
return _MODE_HINTS.get(mode_value, mode_value)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Channel values that flag a production messaging session. `VariableText__c`
|
|||
|
|
# is expected to be NOT_SET on these — the bootstrap variables ride the
|
|||
|
|
# messaging session record, not the AI session. Any other channel
|
|||
|
|
# (E & O headless API, Builder Previewer, voice, unknown/integration)
|
|||
|
|
# can also legitimately produce NOT_SET, but for a different reason
|
|||
|
|
# (no bootstrap variables were attached at session start) — and we
|
|||
|
|
# must not mislabel those as "production messaging path".
|
|||
|
|
_MESSAGING_CHANNELS = frozenset({
|
|||
|
|
"SCRT2 - EmbeddedMessaging",
|
|||
|
|
"Messaging",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_session_bootstrap(identity: dict, channel: Optional[str] = None) -> List[str]:
|
|||
|
|
"""Render the `bootstrap_variables` block parsed from VariableText__c.
|
|||
|
|
|
|||
|
|
Three states (all rendered as a small subtable so they don't clutter
|
|||
|
|
the main identity table):
|
|||
|
|
- None / NOT_SET → "no bootstrap variables for this session"
|
|||
|
|
(with a messaging-path addendum only when the
|
|||
|
|
session's `channel` is actually a messaging
|
|||
|
|
channel — see `_MESSAGING_CHANNELS`).
|
|||
|
|
- parse error → "_parse_error" with the raw prefix
|
|||
|
|
- populated → key/value pairs sorted, plus a "Builder Previewer
|
|||
|
|
indicators" tally derived from the same key set
|
|||
|
|
used in `_derive_mode`.
|
|||
|
|
|
|||
|
|
Empty section returned when bootstrap_variables is missing entirely
|
|||
|
|
(older artifacts predate the bootstrap-variables harvest).
|
|||
|
|
"""
|
|||
|
|
if "bootstrap_variables" not in identity:
|
|||
|
|
return [] # older artifact: no bootstrap_variables harvested
|
|||
|
|
|
|||
|
|
bootstrap = identity.get("bootstrap_variables")
|
|||
|
|
lines: List[str] = ["## Session bootstrap", ""]
|
|||
|
|
|
|||
|
|
if bootstrap is None:
|
|||
|
|
if channel in _MESSAGING_CHANNELS:
|
|||
|
|
lines.append(
|
|||
|
|
"`ssot__VariableText__c` is `NOT_SET` — no bootstrap variables "
|
|||
|
|
"(production messaging path; messaging sessions don't carry VariableText)."
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
lines.append(
|
|||
|
|
"`ssot__VariableText__c` is `NOT_SET` — no bootstrap variables for this session."
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
if isinstance(bootstrap, dict) and bootstrap.get("_parse_error"):
|
|||
|
|
raw = bootstrap.get("_raw") or ""
|
|||
|
|
lines.append("`ssot__VariableText__c` failed to parse as JSON:")
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append(raw)
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
# Populated case.
|
|||
|
|
indicator_keys = {
|
|||
|
|
"__resolved_locale__",
|
|||
|
|
"__locale_instruction__",
|
|||
|
|
"__supports_result_display__",
|
|||
|
|
"__show_tool_results_invoked__",
|
|||
|
|
}
|
|||
|
|
present_indicators = sorted(set(bootstrap) & indicator_keys)
|
|||
|
|
indicator_cell = (
|
|||
|
|
"yes · " + ", ".join(present_indicators)
|
|||
|
|
if present_indicators else "no"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines.append("| Key | Value |")
|
|||
|
|
lines.append("|---|---|")
|
|||
|
|
for key in sorted(bootstrap):
|
|||
|
|
value = bootstrap[key]
|
|||
|
|
# Render lists / dicts compactly; keep strings/numbers/bools as-is.
|
|||
|
|
if isinstance(value, (list, dict)):
|
|||
|
|
value_repr = json.dumps(value)
|
|||
|
|
else:
|
|||
|
|
value_repr = str(value)
|
|||
|
|
# Pipe character would break the markdown table — escape.
|
|||
|
|
value_repr = value_repr.replace("|", "\\|")
|
|||
|
|
lines.append(f"| {key} | {value_repr} |")
|
|||
|
|
lines.append(f"| **Builder Previewer indicators** | {indicator_cell} |")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _compose_agent_cell(identity: dict) -> Optional[str]:
|
|||
|
|
"""Build the human Agent cell from identity fields; drop None components."""
|
|||
|
|
parts: List[str] = []
|
|||
|
|
api_name = identity.get("agent_api_name")
|
|||
|
|
version = identity.get("agent_version")
|
|||
|
|
if api_name and version:
|
|||
|
|
parts.append(f"{api_name} {version}")
|
|||
|
|
elif api_name:
|
|||
|
|
parts.append(api_name)
|
|||
|
|
elif version:
|
|||
|
|
parts.append(version)
|
|||
|
|
label = identity.get("agent_label")
|
|||
|
|
if label:
|
|||
|
|
parts.append(f"— {label}" if parts else label)
|
|||
|
|
atype = identity.get("agent_type")
|
|||
|
|
if atype:
|
|||
|
|
parts.append(f"({atype})")
|
|||
|
|
return " ".join(parts) if parts else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_session_end_cell(effective_end_iso: Optional[str],
|
|||
|
|
end_source: Optional[str],
|
|||
|
|
raw_end_iso: Optional[str]) -> Optional[str]:
|
|||
|
|
"""Compose the identity-table value for `Session end`.
|
|||
|
|
|
|||
|
|
- Materialized end (`raw_end_iso` truthy) → "<iso> ✓ materialized"
|
|||
|
|
- Derived end (end_source truthy) → "<iso> (<source label>)"
|
|||
|
|
- Neither → None (row is dropped)
|
|||
|
|
"""
|
|||
|
|
if effective_end_iso is None:
|
|||
|
|
return None
|
|||
|
|
if raw_end_iso:
|
|||
|
|
return f"{effective_end_iso} ✓ materialized"
|
|||
|
|
if end_source:
|
|||
|
|
return f"{effective_end_iso} ({end_source})"
|
|||
|
|
return effective_end_iso
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_session_identity(sess: dict, effective_end_iso: Optional[str],
|
|||
|
|
end_source: Optional[str]) -> List[str]:
|
|||
|
|
identity = sess.get("identity") or {}
|
|||
|
|
org = sess.get("org") or {}
|
|||
|
|
|
|||
|
|
# `mode` is "production_messaging" / "builder_previewer" / "voice" /
|
|||
|
|
# "unknown" — derived in assemble_dc from RelatedMessagingSessionId +
|
|||
|
|
# RelatedVoiceCallId + bootstrap_variables because
|
|||
|
|
# ssot__AiAgentChannelType__c is identical for MIAW production and
|
|||
|
|
# Builder Previewer.
|
|||
|
|
mode_value = identity.get("mode")
|
|||
|
|
mode_cell = _fmt_mode_cell(mode_value) if mode_value else None
|
|||
|
|
|
|||
|
|
rows_out = [
|
|||
|
|
("Session id", sess.get("id")),
|
|||
|
|
("Org id", identity.get("org_id")),
|
|||
|
|
("Org alias", org.get("alias")),
|
|||
|
|
("Instance URL", org.get("instance_url")),
|
|||
|
|
("Channel", sess.get("channel")),
|
|||
|
|
("Mode", mode_cell),
|
|||
|
|
("App type", identity.get("app_type")),
|
|||
|
|
("Agent", _compose_agent_cell(identity)),
|
|||
|
|
("Bot id", identity.get("bot_id")),
|
|||
|
|
("Bot name", identity.get("bot_name")),
|
|||
|
|
("Bot version id", identity.get("bot_version_id")),
|
|||
|
|
("Planner id", identity.get("planner_id")),
|
|||
|
|
("Planner name", identity.get("planner_name")),
|
|||
|
|
("Planner type", identity.get("planner_type")),
|
|||
|
|
("Configured model", identity.get("configured_model")),
|
|||
|
|
("Platform user id", identity.get("platform_user_id")),
|
|||
|
|
("Messaging session id", identity.get("messaging_session_id")),
|
|||
|
|
("Messaging end-user id", identity.get("messaging_end_user_id")),
|
|||
|
|
("Voice call id", identity.get("voice_call_id")),
|
|||
|
|
("Individual id", identity.get("individual_id")),
|
|||
|
|
("Session start", sess.get("start_ts")),
|
|||
|
|
(
|
|||
|
|
"Session end",
|
|||
|
|
_fmt_session_end_cell(effective_end_iso, end_source, sess.get("end_ts")),
|
|||
|
|
),
|
|||
|
|
("End type", sess.get("end_type")),
|
|||
|
|
("Total duration", _fmt_total_duration(sess.get("start_ts"), effective_end_iso)),
|
|||
|
|
]
|
|||
|
|
lines: List[str] = ["## Session identity", "", "| Field | Value |", "|---|---|"]
|
|||
|
|
for label, value in rows_out:
|
|||
|
|
if value is None or value == "" or value == "—":
|
|||
|
|
continue
|
|||
|
|
lines.append(f"| {label} | {value} |")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_id_reference(sess: dict) -> List[str]:
|
|||
|
|
"""Full-UUID lookup for every id the tree truncates."""
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## ID reference",
|
|||
|
|
"",
|
|||
|
|
"The tree truncates UUIDs to the first 8 chars + `…`. Look up the full "
|
|||
|
|
"form here. Ordered by type → first occurrence.",
|
|||
|
|
"",
|
|||
|
|
"```",
|
|||
|
|
f"session = {sess.get('id')}",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
# Interactions
|
|||
|
|
lines.append("interactions:")
|
|||
|
|
for iv in sess.get("interactions", []):
|
|||
|
|
lines.append(
|
|||
|
|
f" {iv.get('id')} type={iv.get('type') or '?'} "
|
|||
|
|
f"trace={iv.get('trace_id') or '—'}"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
# Participants
|
|||
|
|
lines.append("participants:")
|
|||
|
|
for p in sess.get("participants", []):
|
|||
|
|
lines.append(
|
|||
|
|
f" {p.get('participant_id') or '—'} role={p.get('role') or '—'} "
|
|||
|
|
f"agent={p.get('agent_api_name') or '—'} version={p.get('agent_version') or '—'} "
|
|||
|
|
f"type={p.get('agent_type') or '—'}"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
|
|||
|
|
# Steps, generations, gateway_requests, messages harvested from the tree.
|
|||
|
|
all_steps: List[tuple] = []
|
|||
|
|
all_gens: List[tuple] = []
|
|||
|
|
all_gws: List[tuple] = []
|
|||
|
|
all_msgs: List[tuple] = []
|
|||
|
|
for iv in sess.get("interactions", []):
|
|||
|
|
for m in iv.get("messages", []):
|
|||
|
|
if m.get("message_id"):
|
|||
|
|
all_msgs.append((m["message_id"], m.get("role") or m.get("type") or "?"))
|
|||
|
|
for st in iv.get("steps", []):
|
|||
|
|
if st.get("id"):
|
|||
|
|
all_steps.append((st["id"], st.get("type") or "?", st.get("name") or "—"))
|
|||
|
|
gen = st.get("generation")
|
|||
|
|
if gen and gen.get("generation_id"):
|
|||
|
|
all_gens.append((
|
|||
|
|
gen["generation_id"],
|
|||
|
|
gen.get("response_id") or "—",
|
|||
|
|
gen.get("feature") or "—",
|
|||
|
|
))
|
|||
|
|
gw = st.get("gateway_request")
|
|||
|
|
if gw and gw.get("gateway_request_id"):
|
|||
|
|
all_gws.append((
|
|||
|
|
gw["gateway_request_id"], "declared",
|
|||
|
|
gw.get("feature") or "—", gw.get("model") or "—",
|
|||
|
|
))
|
|||
|
|
for tb in iv.get("timestamp_bound_gateway_calls", []):
|
|||
|
|
if tb.get("gateway_request_id"):
|
|||
|
|
all_gws.append((
|
|||
|
|
tb["gateway_request_id"], "timestamp_window",
|
|||
|
|
tb.get("feature") or "—", tb.get("model") or "—",
|
|||
|
|
))
|
|||
|
|
for g in sess.get("unbound_gateway_calls", []):
|
|||
|
|
if g.get("gateway_request_id"):
|
|||
|
|
all_gws.append((
|
|||
|
|
g["gateway_request_id"], "unbound",
|
|||
|
|
g.get("feature") or "—", g.get("model") or "—",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
if all_steps:
|
|||
|
|
lines.append("steps:")
|
|||
|
|
for sid_, stype, sname in all_steps:
|
|||
|
|
lines.append(f" {sid_} type={stype} name={sname}")
|
|||
|
|
lines.append("")
|
|||
|
|
if all_gens:
|
|||
|
|
lines.append("generations:")
|
|||
|
|
for gid, rid, feat in all_gens:
|
|||
|
|
lines.append(f" {gid} response_id={rid} feature={feat}")
|
|||
|
|
lines.append("")
|
|||
|
|
if all_gws:
|
|||
|
|
lines.append("gateway_requests:")
|
|||
|
|
for gwid, bm, feat, model in all_gws:
|
|||
|
|
lines.append(f" {gwid} binding={bm} feature={feat} model={model}")
|
|||
|
|
lines.append("")
|
|||
|
|
if all_msgs:
|
|||
|
|
lines.append("messages:")
|
|||
|
|
for mid, role in all_msgs:
|
|||
|
|
lines.append(f" {mid} role={role}")
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_transcript(sess: dict, start_dt: Optional[datetime]) -> List[str]:
|
|||
|
|
turns = [iv for iv in sess.get("interactions", []) if iv.get("type") == "TURN"]
|
|||
|
|
if not turns:
|
|||
|
|
return []
|
|||
|
|
lines: List[str] = ["## Transcript", ""]
|
|||
|
|
for iv in turns:
|
|||
|
|
start_off = _fmt_offset(iv.get("start_ts"), start_dt)
|
|||
|
|
dur = _fmt_duration_ms(iv.get("start_ts"), iv.get("end_ts"))
|
|||
|
|
lines.append(f"**Interaction {_short(iv.get('id'))}** · {start_off} · {dur}")
|
|||
|
|
for m in iv.get("messages", []):
|
|||
|
|
role = m.get("role") or m.get("type") or "?"
|
|||
|
|
lines.append(f"> **{role}:** {_decode(m.get('text') or '')}")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_hierarchical_trace(sess: dict, start_dt: Optional[datetime],
|
|||
|
|
effective_end_iso: Optional[str],
|
|||
|
|
end_source: Optional[str]) -> List[str]:
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## Complete hierarchical trace",
|
|||
|
|
"",
|
|||
|
|
"Notation: `+Xs` = offset in seconds from session start. "
|
|||
|
|
"UUIDs are truncated to 8 chars + `…` for readability; "
|
|||
|
|
"full forms are in the **ID reference** block above.",
|
|||
|
|
"",
|
|||
|
|
"```",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Session header
|
|||
|
|
lines.append(f"SESSION {_short(sess.get('id'))}")
|
|||
|
|
start_iso = sess.get("start_ts")
|
|||
|
|
if start_iso:
|
|||
|
|
lines.append(f"│ Start +0.000s ({start_iso})")
|
|||
|
|
|
|||
|
|
end_iso_raw = sess.get("end_ts")
|
|||
|
|
end_type = sess.get("end_type") or None
|
|||
|
|
outcome_s = end_type or "—"
|
|||
|
|
if not end_type and not end_iso_raw:
|
|||
|
|
outcome_s = "— (session end not yet materialized in STDM)"
|
|||
|
|
display_end = end_iso_raw or effective_end_iso
|
|||
|
|
if display_end:
|
|||
|
|
label_suffix = f" [{end_source}]" if end_source else ""
|
|||
|
|
lines.append(
|
|||
|
|
f"│ End {_fmt_offset(display_end, start_dt)} ({display_end}){label_suffix} "
|
|||
|
|
f"outcome={outcome_s}"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
lines.append(f"│ End — (session still open) outcome={outcome_s}")
|
|||
|
|
lines.append("│")
|
|||
|
|
|
|||
|
|
interactions = sess.get("interactions", [])
|
|||
|
|
for iv_idx, iv in enumerate(interactions):
|
|||
|
|
is_last_iv = iv_idx == len(interactions) - 1
|
|||
|
|
iv_branch = "└──" if is_last_iv else "├──"
|
|||
|
|
iv_cont = " " if is_last_iv else "│ "
|
|||
|
|
|
|||
|
|
iv_start_off = _fmt_offset(iv.get("start_ts"), start_dt)
|
|||
|
|
iv_end_off = _fmt_offset(iv.get("end_ts"), start_dt)
|
|||
|
|
iv_dur = _fmt_duration_ms(iv.get("start_ts"), iv.get("end_ts"))
|
|||
|
|
iv_type = iv.get("type") or "?"
|
|||
|
|
lines.append(
|
|||
|
|
f"{iv_branch} {iv_type} {_short(iv.get('id'))} "
|
|||
|
|
f"{iv_start_off} + {iv_dur} = {iv_end_off}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
topic = iv.get("topic")
|
|||
|
|
if topic:
|
|||
|
|
lines.append(f"{iv_cont}├── TOPIC: {topic}")
|
|||
|
|
|
|||
|
|
for m in iv.get("messages", []):
|
|||
|
|
role = m.get("role") or m.get("type") or "?"
|
|||
|
|
m_off = _fmt_offset(m.get("ts"), start_dt) if m.get("ts") else "—"
|
|||
|
|
lines.append(f"{iv_cont}├── {role} message {_short(m.get('message_id'))} ts={m_off}")
|
|||
|
|
lines.append(f"{iv_cont}│ └── text: \"{_truncate(m.get('text'), 100)}\"")
|
|||
|
|
|
|||
|
|
steps = iv.get("steps", [])
|
|||
|
|
tsbound = iv.get("timestamp_bound_gateway_calls", [])
|
|||
|
|
tsb_by_step: Dict[str, List[dict]] = {}
|
|||
|
|
tsb_interaction_level: List[dict] = []
|
|||
|
|
for tb in tsbound:
|
|||
|
|
bsid = tb.get("bound_to_step_id")
|
|||
|
|
if bsid:
|
|||
|
|
tsb_by_step.setdefault(bsid, []).append(tb)
|
|||
|
|
else:
|
|||
|
|
tsb_interaction_level.append(tb)
|
|||
|
|
|
|||
|
|
remaining_groups: List[str] = []
|
|||
|
|
if steps:
|
|||
|
|
remaining_groups.append("steps")
|
|||
|
|
if tsb_interaction_level:
|
|||
|
|
remaining_groups.append("ts_il")
|
|||
|
|
|
|||
|
|
for grp_idx, grp in enumerate(remaining_groups):
|
|||
|
|
grp_is_last = grp_idx == len(remaining_groups) - 1
|
|||
|
|
grp_branch = "└──" if grp_is_last else "├──"
|
|||
|
|
grp_cont = " " if grp_is_last else "│ "
|
|||
|
|
if grp == "steps":
|
|||
|
|
lines.append(f"{iv_cont}{grp_branch} STEPS:")
|
|||
|
|
for st_idx, st in enumerate(steps):
|
|||
|
|
lines.extend(_render_step(
|
|||
|
|
st, st_idx, len(steps), iv_cont, grp_cont,
|
|||
|
|
start_dt, tsb_by_step,
|
|||
|
|
))
|
|||
|
|
else: # grp == "ts_il"
|
|||
|
|
lines.append(f"{iv_cont}{grp_branch} INTERACTION-LEVEL TIMESTAMP-BOUND GATEWAY CALLS:")
|
|||
|
|
for tb_idx, tb in enumerate(tsb_interaction_level):
|
|||
|
|
tb_is_last = tb_idx == len(tsb_interaction_level) - 1
|
|||
|
|
tb_branch = "└──" if tb_is_last else "├──"
|
|||
|
|
lines.append(
|
|||
|
|
f"{iv_cont}{grp_cont}{tb_branch} "
|
|||
|
|
f"gateway_request [timestamp_window, interaction-level] "
|
|||
|
|
f"{_short(tb.get('gateway_request_id'))} "
|
|||
|
|
f"feature={tb.get('feature') or '—'} "
|
|||
|
|
f"model={tb.get('model') or '—'}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not is_last_iv:
|
|||
|
|
lines.append("│")
|
|||
|
|
|
|||
|
|
# Unbound gateway calls (session root level)
|
|||
|
|
ub = sess.get("unbound_gateway_calls", [])
|
|||
|
|
if ub:
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append(
|
|||
|
|
f"UNBOUND GATEWAY CALLS ({len(ub)}) — neither declared chain "
|
|||
|
|
"nor timestamp window matched"
|
|||
|
|
)
|
|||
|
|
for i, g in enumerate(ub):
|
|||
|
|
branch = "└──" if i == len(ub) - 1 else "├──"
|
|||
|
|
lines.append(
|
|||
|
|
f"{branch} {_short(g.get('gateway_request_id'))} "
|
|||
|
|
f"feature={g.get('feature') or '—'} model={g.get('model') or '—'}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_step(st: dict, st_idx: int, n_steps: int,
|
|||
|
|
iv_cont: str, grp_cont: str,
|
|||
|
|
start_dt: Optional[datetime],
|
|||
|
|
tsb_by_step: Dict[str, List[dict]]) -> List[str]:
|
|||
|
|
"""Render one step + its nested generation / gateway_request / ts-bound GWs."""
|
|||
|
|
st_is_last = st_idx == n_steps - 1
|
|||
|
|
st_branch = "└──" if st_is_last else "├──"
|
|||
|
|
st_cont = " " if st_is_last else "│ "
|
|||
|
|
st_start_off = _fmt_offset(st.get("start_ts"), start_dt)
|
|||
|
|
st_end_off = _fmt_offset(st.get("end_ts"), start_dt)
|
|||
|
|
st_dur = _fmt_duration_ms(st.get("start_ts"), st.get("end_ts"))
|
|||
|
|
|
|||
|
|
# Show the bound LLM model alongside the step name when known.
|
|||
|
|
# `model_name` is mirrored from the bound gateway_request by
|
|||
|
|
# assemble_dc; None when the declared chain didn't reach or when STDM
|
|||
|
|
# dropped writes (the `gateway_requests_dropped_by_stdm` shape).
|
|||
|
|
name_cell = st.get("name") or "—"
|
|||
|
|
model_name = st.get("model_name")
|
|||
|
|
if model_name:
|
|||
|
|
name_cell = f"{name_cell} · {model_name}"
|
|||
|
|
|
|||
|
|
lines = [
|
|||
|
|
f"{iv_cont}{grp_cont}{st_branch} {st.get('type') or '?'} {_short(st.get('id'))} "
|
|||
|
|
f"name={name_cell} {st_start_off} + {st_dur} = {st_end_off}"
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Step children: error, generation (+ trust signals), gateway_request, collision, bound GWs.
|
|||
|
|
step_kids: List[tuple] = []
|
|||
|
|
if st.get("error_text"):
|
|||
|
|
step_kids.append(("error", None))
|
|||
|
|
gen = st.get("generation")
|
|||
|
|
if gen:
|
|||
|
|
step_kids.append(("gen", gen))
|
|||
|
|
gw = st.get("gateway_request")
|
|||
|
|
if gw:
|
|||
|
|
step_kids.append(("gw", gw))
|
|||
|
|
if st.get("gateway_request_collision"):
|
|||
|
|
step_kids.append(("collision", None))
|
|||
|
|
for tb in tsb_by_step.get(st.get("id", ""), []):
|
|||
|
|
step_kids.append(("tsb", tb))
|
|||
|
|
|
|||
|
|
prefix = f"{iv_cont}{grp_cont}{st_cont}"
|
|||
|
|
for k_idx, (ktype, kval) in enumerate(step_kids):
|
|||
|
|
k_is_last = k_idx == len(step_kids) - 1
|
|||
|
|
k_branch = "└──" if k_is_last else "├──"
|
|||
|
|
k_cont = " " if k_is_last else "│ "
|
|||
|
|
|
|||
|
|
if ktype == "error":
|
|||
|
|
lines.append(f"{prefix}{k_branch} ERROR: {st['error_text']}")
|
|||
|
|
elif ktype == "collision":
|
|||
|
|
lines.append(
|
|||
|
|
f"{prefix}{k_branch} ⚠ gateway_request_collision: "
|
|||
|
|
"earlier step claimed the declared GW"
|
|||
|
|
)
|
|||
|
|
elif ktype == "gen":
|
|||
|
|
lines.extend(_render_generation(kval, prefix, k_branch, k_cont,
|
|||
|
|
step_name=st.get("name")))
|
|||
|
|
elif ktype == "gw":
|
|||
|
|
lines.extend(_render_gw_declared(kval, prefix, k_branch, k_cont))
|
|||
|
|
elif ktype == "tsb":
|
|||
|
|
tb = kval
|
|||
|
|
lines.append(
|
|||
|
|
f"{prefix}{k_branch} gateway_request [timestamp_window] "
|
|||
|
|
f"{_short(tb.get('gateway_request_id'))} "
|
|||
|
|
f"feature={tb.get('feature') or '—'} "
|
|||
|
|
f"model={tb.get('model') or '—'}"
|
|||
|
|
)
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
_ROLE_LABELS = {
|
|||
|
|
"ReactTopicPrompt": "topic-classification output",
|
|||
|
|
"ReactInitialPrompt": "ReAct planner step",
|
|||
|
|
"ReactValidationPrompt": "ReAct validator",
|
|||
|
|
"ReactFormatSurfaceResponsePrompt": "response formatter",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _role_label_for(step_name: Optional[str]) -> Optional[str]:
|
|||
|
|
if not step_name:
|
|||
|
|
return None
|
|||
|
|
for key, label in _ROLE_LABELS.items():
|
|||
|
|
if key in step_name:
|
|||
|
|
return label
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_finish_reason(response_parameters: Optional[str]) -> Optional[str]:
|
|||
|
|
"""Pull finish_reason out of the HTML-escaped JSON in responseParameters__c.
|
|||
|
|
Shape: `{"finish_reason":"\"stop\"",...}`."""
|
|||
|
|
if not response_parameters:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
decoded = html.unescape(response_parameters)
|
|||
|
|
parsed = json.loads(decoded)
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
return None
|
|||
|
|
raw = parsed.get("finish_reason") if isinstance(parsed, dict) else None
|
|||
|
|
if not isinstance(raw, str):
|
|||
|
|
return None
|
|||
|
|
# value often comes wrapped in escaped quotes: `\"stop\"` → `stop`
|
|||
|
|
return raw.replace("\\", "").strip('"').strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _decoded_line(response_text: Optional[str]) -> str:
|
|||
|
|
"""Render `decoded:` content. Detect tool-call JSON and summarize.
|
|||
|
|
responseText__c is HTML-escaped (" etc.) in the wire format — unescape
|
|||
|
|
before trying to parse as JSON."""
|
|||
|
|
if not response_text:
|
|||
|
|
return "—"
|
|||
|
|
candidate = html.unescape(response_text).strip()
|
|||
|
|
if candidate.startswith("{") and '"toolInvocations"' in candidate:
|
|||
|
|
try:
|
|||
|
|
obj = json.loads(candidate)
|
|||
|
|
tools = obj.get("toolInvocations") or []
|
|||
|
|
content = (obj.get("content") or "").strip()
|
|||
|
|
if tools:
|
|||
|
|
first = tools[0].get("function") or {}
|
|||
|
|
name = first.get("name") or "?"
|
|||
|
|
args_raw = first.get("arguments") or ""
|
|||
|
|
arg_summary = ""
|
|||
|
|
try:
|
|||
|
|
args_obj = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
|
|||
|
|
if isinstance(args_obj, dict) and args_obj:
|
|||
|
|
k, v = next(iter(args_obj.items()))
|
|||
|
|
v_str = str(v)
|
|||
|
|
if len(v_str) > 60:
|
|||
|
|
v_str = v_str[:57] + "…"
|
|||
|
|
arg_summary = f'{k}="{v_str}"'
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
pass
|
|||
|
|
prefix = "no user text" if not content else f'"{_truncate(content, 60)}"'
|
|||
|
|
n = len(tools)
|
|||
|
|
call_word = "tool call" if n == 1 else "tool calls"
|
|||
|
|
return f"{prefix}; {n} {call_word} → {name}({arg_summary})"
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
pass
|
|||
|
|
return f'"{_truncate(response_text, 140)}"'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _float_or_none(v) -> Optional[float]:
|
|||
|
|
try:
|
|||
|
|
return float(v)
|
|||
|
|
except (ValueError, TypeError):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _trust_line(g: dict) -> str:
|
|||
|
|
"""Summarize trust signals. TOXICITY sub-categories (parented on the
|
|||
|
|
quality row) carry a dedicated `safety_score` category in the 0-1 range
|
|||
|
|
(1.0 = clean). Non-`safety_score` sub-categories are the 8 hazard axes
|
|||
|
|
(profanity, hate, violence, …); report the max of those when a detection
|
|||
|
|
fires so the reader sees which axis tripped.
|
|||
|
|
Non-TOXICITY detectors (InstructionAdherence, etc.) are parented directly
|
|||
|
|
on the generation and render as textual verdicts."""
|
|||
|
|
quality = g.get("quality") or []
|
|||
|
|
cats = g.get("categories") or []
|
|||
|
|
if not quality and not cats:
|
|||
|
|
return "— (no quality/category rows)"
|
|||
|
|
parts: List[str] = []
|
|||
|
|
for q in quality:
|
|||
|
|
subs = q.get("_toxicity_subcategories") or []
|
|||
|
|
detected = str(q.get("isToxicityDetected__c", "")).lower() == "true"
|
|||
|
|
safety: Optional[float] = None
|
|||
|
|
hazard_subs: List[tuple] = []
|
|||
|
|
for s in subs:
|
|||
|
|
if (s.get("detectorType__c") or "").upper() != "TOXICITY":
|
|||
|
|
continue
|
|||
|
|
name = s.get("category__c") or ""
|
|||
|
|
val = _float_or_none(s.get("value__c"))
|
|||
|
|
if val is None:
|
|||
|
|
continue
|
|||
|
|
if name == "safety_score":
|
|||
|
|
safety = val
|
|||
|
|
else:
|
|||
|
|
hazard_subs.append((name, val))
|
|||
|
|
status = "detected" if detected else "clean"
|
|||
|
|
safety_str = f"{safety:.2f}" if safety is not None else "—"
|
|||
|
|
if not detected and hazard_subs and all(v == 0 for _, v in hazard_subs):
|
|||
|
|
detail = f" — all {len(hazard_subs)} sub-categories 0.00"
|
|||
|
|
elif hazard_subs:
|
|||
|
|
top_name, top_val = max(hazard_subs, key=lambda kv: kv[1])
|
|||
|
|
detail = f" — max {top_name}={top_val:.2f}"
|
|||
|
|
else:
|
|||
|
|
detail = ""
|
|||
|
|
parts.append(f"TOXICITY safety_score={safety_str} ({status}{detail})")
|
|||
|
|
# Non-TOXICITY detectors (generation-direct).
|
|||
|
|
for c in cats:
|
|||
|
|
dtype = (c.get("detectorType__c") or "?").strip()
|
|||
|
|
if dtype.upper() == "TOXICITY":
|
|||
|
|
continue
|
|||
|
|
val = c.get("value__c")
|
|||
|
|
parts.append(f"{dtype}: {_truncate(val, 80)}")
|
|||
|
|
return "; ".join(parts) if parts else "—"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_generation(g: dict, prefix: str, k_branch: str, k_cont: str,
|
|||
|
|
*, step_name: Optional[str] = None) -> List[str]:
|
|||
|
|
"""Generation node: 3 fixed children (decoded / finish_reason+masked / trust).
|
|||
|
|
Header is naked (id only); `response_id` and `feature` live in the ID reference
|
|||
|
|
block / on the sibling gateway_request line."""
|
|||
|
|
lines = [
|
|||
|
|
f"{prefix}{k_branch} generation {_short(g.get('generation_id'))}"
|
|||
|
|
]
|
|||
|
|
role = _role_label_for(step_name)
|
|||
|
|
decoded = _decoded_line(g.get("response_text"))
|
|||
|
|
if role:
|
|||
|
|
decoded_line = f"decoded: {decoded} ({role})"
|
|||
|
|
else:
|
|||
|
|
decoded_line = f"decoded: {decoded}"
|
|||
|
|
masked = g.get("masked_response_text")
|
|||
|
|
masked_disp = _truncate(masked, 60) if masked else "—"
|
|||
|
|
finish_reason = _parse_finish_reason(g.get("response_parameters")) or "—"
|
|||
|
|
gprefix = f"{prefix}{k_cont}"
|
|||
|
|
lines.append(f"{gprefix}├── {decoded_line}")
|
|||
|
|
lines.append(f"{gprefix}├── finish_reason={finish_reason} masked={masked_disp}")
|
|||
|
|
lines.append(f"{gprefix}└── trust: {_trust_line(g)}")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_gw_declared(g: dict, prefix: str, k_branch: str, k_cont: str) -> List[str]:
|
|||
|
|
tokens = (
|
|||
|
|
f"prompt={int(g.get('prompt_tokens') or 0)} "
|
|||
|
|
f"completion={int(g.get('completion_tokens') or 0)} "
|
|||
|
|
f"total={int(g.get('total_tokens') or 0)}"
|
|||
|
|
)
|
|||
|
|
tags = g.get("tags") or []
|
|||
|
|
md = g.get("metadata") or []
|
|||
|
|
recs = g.get("records") or []
|
|||
|
|
llm = g.get("llm") or []
|
|||
|
|
audit_line = (
|
|||
|
|
f"audit: tags={len(tags)} metadata={len(md)} records={len(recs)} llm={len(llm)}"
|
|||
|
|
if (tags or md or recs or llm)
|
|||
|
|
else "audit: (none)"
|
|||
|
|
)
|
|||
|
|
return [
|
|||
|
|
f"{prefix}{k_branch} gateway_request [declared] "
|
|||
|
|
f"{_short(g.get('gateway_request_id'))} "
|
|||
|
|
f"feature={g.get('feature') or '—'} "
|
|||
|
|
f"model={g.get('model') or '—'} "
|
|||
|
|
f"provider={g.get('provider') or '—'}",
|
|||
|
|
f"{prefix}{k_cont}├── tokens: {tokens}",
|
|||
|
|
f"{prefix}{k_cont}└── {audit_line}",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_per_turn_summary(sess: dict, start_dt: Optional[datetime]) -> List[str]:
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## Per-turn summary",
|
|||
|
|
"",
|
|||
|
|
"| Interaction | Type | Start offset | Duration | Steps | "
|
|||
|
|
"GW declared | GW ts_window | USER → AGENT |",
|
|||
|
|
"|---|---|---|---|---|---|---|---|",
|
|||
|
|
]
|
|||
|
|
for iv in sess.get("interactions", []):
|
|||
|
|
iv_type = iv.get("type") or "?"
|
|||
|
|
start_off = _fmt_offset(iv.get("start_ts"), start_dt)
|
|||
|
|
dur = _fmt_duration_ms(iv.get("start_ts"), iv.get("end_ts"))
|
|||
|
|
step_count = len(iv.get("steps", []))
|
|||
|
|
declared_gws = sum(
|
|||
|
|
1 for st in iv.get("steps", [])
|
|||
|
|
if st.get("gateway_request")
|
|||
|
|
and st["gateway_request"].get("binding_method") == "declared"
|
|||
|
|
)
|
|||
|
|
tsw_gws = len(iv.get("timestamp_bound_gateway_calls", []))
|
|||
|
|
user_msg = next((m for m in iv.get("messages", []) if m.get("role") == "USER"), None)
|
|||
|
|
agent_msg = next((m for m in iv.get("messages", []) if m.get("role") == "AGENT"), None)
|
|||
|
|
ut = _truncate(user_msg.get("text") if user_msg else None, 40)
|
|||
|
|
at = _truncate(agent_msg.get("text") if agent_msg else None, 40)
|
|||
|
|
lines.append(
|
|||
|
|
f"| `{_short(iv.get('id'))}` | {iv_type} | {start_off} | {dur} | "
|
|||
|
|
f"{step_count} | {declared_gws} | {tsw_gws} | {ut} → {at} |"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- visual analysis (mermaid) -------------------------------------------
|
|||
|
|
|
|||
|
|
# Chars that break mermaid label parsing when bare; quote-wrap the string
|
|||
|
|
# when any of these show up. Underscore+digit tokens in topic strings are
|
|||
|
|
# legal inside mermaid node labels — only syntax-significant chars need
|
|||
|
|
# escaping here.
|
|||
|
|
_MERMAID_LABEL_SPECIALS = set(',()<>:"#')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _escape_mermaid_label(s: str) -> str:
|
|||
|
|
if not s:
|
|||
|
|
return '""'
|
|||
|
|
if any(ch in _MERMAID_LABEL_SPECIALS for ch in s):
|
|||
|
|
return '"' + s.replace('"', '\\"') + '"'
|
|||
|
|
return s
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _sequence_msg(s: str) -> str:
|
|||
|
|
"""Sanitize free-form text for the right-hand side of a sequenceDiagram
|
|||
|
|
arrow. Mermaid parses `A->>B: <text>` up to newline — the text itself is
|
|||
|
|
unquoted, so wrapping in `"..."` (as node-label escape does) renders the
|
|||
|
|
quotes literally. We just strip newlines and escape the one char that
|
|||
|
|
ends the message field (semicolon is the statement terminator)."""
|
|||
|
|
if not s:
|
|||
|
|
return "—"
|
|||
|
|
return s.replace("\n", " ").replace(";", ",").strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _flowchart_edge_label(s: str) -> str:
|
|||
|
|
"""Sanitize free-form text for a flowchart edge label `A -->|label| B`.
|
|||
|
|
The delimiter is the pipe itself, so a literal `|` in the label shatters
|
|||
|
|
the parser. Square brackets also clash with node-shape syntax. Strip/
|
|||
|
|
substitute these; other specials (commas, colons, parens) are fine
|
|||
|
|
inside quoted labels, which `_escape_mermaid_label` handles."""
|
|||
|
|
if not s:
|
|||
|
|
return ""
|
|||
|
|
cleaned = s.replace("|", "/").replace("[", "(").replace("]", ")")
|
|||
|
|
return _escape_mermaid_label(cleaned)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _step_display_name(st: dict) -> str:
|
|||
|
|
"""Short, mermaid-safe row label for a step.
|
|||
|
|
|
|||
|
|
- Strip `AiCopilot__` prefix on LLM prompts.
|
|||
|
|
- For ACTION_STEP names shaped `Topic_xxx.AGNT_Action_yyy`, keep the
|
|||
|
|
action-name portion after the last `.` so the row reads as the
|
|||
|
|
invoked action, not the topic.
|
|||
|
|
"""
|
|||
|
|
name = st.get("name") or st.get("type") or "?"
|
|||
|
|
if name.startswith("AiCopilot__"):
|
|||
|
|
name = name[len("AiCopilot__"):]
|
|||
|
|
if st.get("type") == "ACTION_STEP" and "." in name:
|
|||
|
|
name = name.rsplit(".", 1)[-1]
|
|||
|
|
# Gantt task names cannot contain colons (task syntax is `label : id, start, dur`).
|
|||
|
|
return name.replace(":", "·")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _iter_turns(sess: dict) -> List[dict]:
|
|||
|
|
return [iv for iv in sess.get("interactions", []) if iv.get("type") == "TURN"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mermaid_gantt(
|
|||
|
|
sess: dict,
|
|||
|
|
start_dt: Optional[datetime],
|
|||
|
|
llm_calls: Optional[List[dict]] = None,
|
|||
|
|
) -> List[str]:
|
|||
|
|
"""Gantt chart — wall-clock timeline.
|
|||
|
|
|
|||
|
|
Rows per turn (ACTION steps tagged :crit). When `llm_calls` is
|
|||
|
|
non-empty, a trailing "LLM calls" section renders per-call rows
|
|||
|
|
tagged by region-class (:active = cross_region, :done = same-
|
|||
|
|
region, plain = unknown). Mermaid's built-in 3-class palette is
|
|||
|
|
enough signal for v1; full per-region color would need a classDef
|
|||
|
|
block (deferred — see plan OOS-2.A).
|
|||
|
|
|
|||
|
|
Kill-criterion: <3 total rows after dropping zero-duration steps
|
|||
|
|
AND no LLM-call rows → return []. If the step set is thin but the
|
|||
|
|
call set is non-empty, we still render (the call rows are the
|
|||
|
|
whole point of the overlay).
|
|||
|
|
"""
|
|||
|
|
if start_dt is None:
|
|||
|
|
return []
|
|||
|
|
rows: List[tuple] = [] # (section_label, row_label, start_ms, end_ms, is_action)
|
|||
|
|
turns = _iter_turns(sess)
|
|||
|
|
for iv in sess.get("interactions", []):
|
|||
|
|
if iv.get("type") != "TURN":
|
|||
|
|
continue
|
|||
|
|
topic = iv.get("topic") or "(no topic)"
|
|||
|
|
section = f"Turn {turns.index(iv) + 1} ({topic})"
|
|||
|
|
for st in iv.get("steps", []):
|
|||
|
|
if st.get("type") == "SESSION_END":
|
|||
|
|
continue
|
|||
|
|
s_dt = _parse_iso(st.get("start_ts"))
|
|||
|
|
e_dt = _parse_iso(st.get("end_ts"))
|
|||
|
|
if s_dt is None or e_dt is None:
|
|||
|
|
continue
|
|||
|
|
s_ms = int((s_dt - start_dt).total_seconds() * 1000)
|
|||
|
|
e_ms = int((e_dt - start_dt).total_seconds() * 1000)
|
|||
|
|
if e_ms <= s_ms:
|
|||
|
|
continue # skip zero-duration (e.g. TOPIC_STEP markers)
|
|||
|
|
rows.append((section, _step_display_name(st), s_ms, e_ms,
|
|||
|
|
st.get("type") == "ACTION_STEP"))
|
|||
|
|
|
|||
|
|
# LLM-call rows — one per gateway call, derived from _llm_calls.json.
|
|||
|
|
# Rendered in a trailing section so they don't interleave with step
|
|||
|
|
# rows (mermaid sections don't sort across boundaries).
|
|||
|
|
call_rows: List[tuple] = [] # (row_label, start_ms, end_ms, class_tag)
|
|||
|
|
for call in (llm_calls or []):
|
|||
|
|
t_iso = call.get("_time")
|
|||
|
|
dur_ms = call.get("duration_ms")
|
|||
|
|
if not t_iso or dur_ms is None:
|
|||
|
|
continue
|
|||
|
|
t_dt = _parse_iso(t_iso)
|
|||
|
|
if t_dt is None:
|
|||
|
|
continue
|
|||
|
|
s_ms = int((t_dt - start_dt).total_seconds() * 1000)
|
|||
|
|
e_ms = s_ms + int(dur_ms)
|
|||
|
|
if e_ms <= s_ms:
|
|||
|
|
continue
|
|||
|
|
# class_tag: cross_region=True → :active (mermaid's yellow),
|
|||
|
|
# cross_region=False → :done (grey), unknown/None → no tag
|
|||
|
|
# (default blue). Operators can scan for the yellow bars as
|
|||
|
|
# the cross-region outliers.
|
|||
|
|
cr = call.get("cross_region")
|
|||
|
|
if cr is True:
|
|||
|
|
class_tag = ":active, "
|
|||
|
|
elif cr is False:
|
|||
|
|
class_tag = ":done, "
|
|||
|
|
else:
|
|||
|
|
class_tag = ":"
|
|||
|
|
model = call.get("model_name") or "call"
|
|||
|
|
region = call.get("routing_decision") or "—"
|
|||
|
|
label = _escape_mermaid_label(f"{model} [{region}]")
|
|||
|
|
call_rows.append((label, s_ms, e_ms, class_tag))
|
|||
|
|
|
|||
|
|
if len(rows) < 3 and not call_rows:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
# axisFormat: `%M:%S` renders as minutes:seconds via d3. Our offsets
|
|||
|
|
# are ms-from-start passed through `dateFormat x` (unix epoch); d3
|
|||
|
|
# treats them as Jan 1 1970 timestamps, so for any session under
|
|||
|
|
# 1 hour this reads cleanly as m:ss elapsed. `%L` (ms-of-second)
|
|||
|
|
# always rendered 000 because ticks land at second boundaries.
|
|||
|
|
# The leading `section anchor` + 1ms milestone forces the x-axis to
|
|||
|
|
# start at 00:00 — mermaid derives axis min from the smallest task
|
|||
|
|
# timestamp, and without the anchor that's the first step's offset
|
|||
|
|
# (~5s into a typical session), making the axis misleadingly slide.
|
|||
|
|
lines = [
|
|||
|
|
"```mermaid",
|
|||
|
|
"gantt",
|
|||
|
|
" title Session timeline (m:ss from start)",
|
|||
|
|
" dateFormat x",
|
|||
|
|
" axisFormat %M:%S",
|
|||
|
|
" section Session start",
|
|||
|
|
" t0 :milestone, 0, 0",
|
|||
|
|
]
|
|||
|
|
current_section: Optional[str] = None
|
|||
|
|
for section, label, s_ms, e_ms, is_action in rows:
|
|||
|
|
if section != current_section:
|
|||
|
|
lines.append(f" section {section}")
|
|||
|
|
current_section = section
|
|||
|
|
# Mermaid gantt task: `<name> :[tag,] <start>, <end>` — single colon,
|
|||
|
|
# tag is the first comma-separated field after it. Two colons (the
|
|||
|
|
# `:crit:` shape) is a parse error.
|
|||
|
|
prefix = ":crit, " if is_action else ":"
|
|||
|
|
lines.append(f" {label} {prefix}{s_ms}, {e_ms}")
|
|||
|
|
if call_rows:
|
|||
|
|
lines.append(" section LLM calls")
|
|||
|
|
for label, s_ms, e_ms, class_tag in call_rows:
|
|||
|
|
lines.append(f" {label} {class_tag}{s_ms}, {e_ms}")
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _action_short_name(st: dict) -> str:
|
|||
|
|
name = st.get("name") or ""
|
|||
|
|
if "." in name:
|
|||
|
|
name = name.rsplit(".", 1)[-1]
|
|||
|
|
return name or "action"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mermaid_sequence_per_turn(iv: dict, start_dt: Optional[datetime],
|
|||
|
|
turn_no: int) -> List[str]:
|
|||
|
|
steps = iv.get("steps", [])
|
|||
|
|
has_error = any(st.get("error_text") for st in steps)
|
|||
|
|
action_steps = [st for st in steps if st.get("type") == "ACTION_STEP"]
|
|||
|
|
if not has_error and len(action_steps) < 2:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
topic = iv.get("topic") or "(no topic)"
|
|||
|
|
heading = f"### Turn {turn_no} ({topic}) — control flow"
|
|||
|
|
lines: List[str] = [heading, "", "```mermaid", "sequenceDiagram"]
|
|||
|
|
|
|||
|
|
# Participants: U, P, L are always present; actions get A1, A2, …
|
|||
|
|
lines.append(" participant U as USER")
|
|||
|
|
lines.append(" participant P as Planner")
|
|||
|
|
lines.append(" participant L as LLM Gateway")
|
|||
|
|
action_alias: Dict[str, str] = {} # step id → alias
|
|||
|
|
for i, st in enumerate(action_steps, start=1):
|
|||
|
|
alias = f"A{i}"
|
|||
|
|
action_alias[st.get("id") or f"_a{i}"] = alias
|
|||
|
|
label = _escape_mermaid_label(_action_short_name(st))
|
|||
|
|
lines.append(f" participant {alias} as {label}")
|
|||
|
|
|
|||
|
|
# User → Planner (utterance).
|
|||
|
|
user_msg = next((m for m in iv.get("messages", []) if m.get("role") == "USER"), None)
|
|||
|
|
user_text = _truncate(user_msg.get("text") if user_msg else None, 40)
|
|||
|
|
if user_msg:
|
|||
|
|
lines.append(f" U->>P: {_sequence_msg(user_text)}")
|
|||
|
|
|
|||
|
|
# Walk steps in order. LLM → L, ACTION → A<n>, errors → Note over.
|
|||
|
|
for st in steps:
|
|||
|
|
stype = st.get("type")
|
|||
|
|
if stype == "LLM_STEP":
|
|||
|
|
label = _step_display_name(st)
|
|||
|
|
gw = st.get("gateway_request") or {}
|
|||
|
|
model = gw.get("model")
|
|||
|
|
suffix = f" ({model})" if model else ""
|
|||
|
|
lines.append(f" P->>L: {_sequence_msg(label + suffix)}")
|
|||
|
|
dur = _fmt_duration_ms(st.get("start_ts"), st.get("end_ts"))
|
|||
|
|
if st.get("error_text"):
|
|||
|
|
lines.append(f" Note over L: ERROR in {dur}")
|
|||
|
|
else:
|
|||
|
|
lines.append(f" L-->>P: ok ({dur})")
|
|||
|
|
elif stype == "ACTION_STEP":
|
|||
|
|
alias = action_alias.get(st.get("id") or "", "A?")
|
|||
|
|
dur = _fmt_duration_ms(st.get("start_ts"), st.get("end_ts"))
|
|||
|
|
lines.append(f" P->>{alias}: invoke")
|
|||
|
|
lines.append(f" {alias}-->>P: result ({dur})")
|
|||
|
|
|
|||
|
|
# Agent reply (if any).
|
|||
|
|
agent_msg = next((m for m in iv.get("messages", []) if m.get("role") == "AGENT"), None)
|
|||
|
|
if agent_msg:
|
|||
|
|
agent_text = _truncate(agent_msg.get("text"), 40)
|
|||
|
|
lines.append(f" P-->>U: {_sequence_msg(agent_text)}")
|
|||
|
|
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mermaid_topic_flowchart(sess: dict) -> List[str]:
|
|||
|
|
turns = _iter_turns(sess)
|
|||
|
|
if not turns:
|
|||
|
|
return []
|
|||
|
|
# Order-preserving unique topics.
|
|||
|
|
topic_ids: Dict[str, str] = {}
|
|||
|
|
for iv in turns:
|
|||
|
|
t = iv.get("topic") or "(no topic)"
|
|||
|
|
if t not in topic_ids:
|
|||
|
|
topic_ids[t] = f"T{len(topic_ids) + 1}"
|
|||
|
|
if len(topic_ids) == len(turns):
|
|||
|
|
return [] # linear — no repeats, skip
|
|||
|
|
|
|||
|
|
lines: List[str] = ["```mermaid", "flowchart LR"]
|
|||
|
|
# Node declarations.
|
|||
|
|
for topic, node_id in topic_ids.items():
|
|||
|
|
lines.append(f" {node_id}[{_escape_mermaid_label(topic)}]")
|
|||
|
|
|
|||
|
|
# Edges: one per turn transition, labelled by the user utterance that
|
|||
|
|
# drove into that turn. The first turn has no predecessor so we just
|
|||
|
|
# declare the node; edges start from turn 2.
|
|||
|
|
prev_topic = turns[0].get("topic") or "(no topic)"
|
|||
|
|
for iv in turns[1:]:
|
|||
|
|
cur_topic = iv.get("topic") or "(no topic)"
|
|||
|
|
user_msg = next((m for m in iv.get("messages", []) if m.get("role") == "USER"), None)
|
|||
|
|
utter = _truncate(user_msg.get("text") if user_msg else None, 30)
|
|||
|
|
src = topic_ids[prev_topic]
|
|||
|
|
dst = topic_ids[cur_topic]
|
|||
|
|
label = _flowchart_edge_label(utter) if utter and utter != "—" else ""
|
|||
|
|
arrow = f" {src} -->|{label}| {dst}" if label else f" {src} --> {dst}"
|
|||
|
|
lines.append(arrow)
|
|||
|
|
prev_topic = cur_topic
|
|||
|
|
|
|||
|
|
# Error class for topics whose turn ended with an error_text.
|
|||
|
|
errored_topic_ids: List[str] = []
|
|||
|
|
for iv in turns:
|
|||
|
|
if any(st.get("error_text") for st in iv.get("steps", [])):
|
|||
|
|
tid = topic_ids[iv.get("topic") or "(no topic)"]
|
|||
|
|
if tid not in errored_topic_ids:
|
|||
|
|
errored_topic_ids.append(tid)
|
|||
|
|
if errored_topic_ids:
|
|||
|
|
lines.append(f" classDef err fill:#fee,stroke:#c00")
|
|||
|
|
for tid in errored_topic_ids:
|
|||
|
|
lines.append(f" class {tid} err")
|
|||
|
|
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _mermaid_token_pie(sess: dict) -> List[str]:
|
|||
|
|
buckets: Dict[str, int] = {}
|
|||
|
|
total = 0
|
|||
|
|
for iv in sess.get("interactions", []):
|
|||
|
|
for st in iv.get("steps", []):
|
|||
|
|
gw = st.get("gateway_request") or {}
|
|||
|
|
p = int(gw.get("prompt_tokens") or 0)
|
|||
|
|
c = int(gw.get("completion_tokens") or 0)
|
|||
|
|
tok = p + c
|
|||
|
|
if tok <= 0:
|
|||
|
|
continue
|
|||
|
|
role = _role_label_for(st.get("name")) or "other"
|
|||
|
|
buckets[role] = buckets.get(role, 0) + tok
|
|||
|
|
total += tok
|
|||
|
|
if total < 1000 or not buckets:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
lines = [
|
|||
|
|
"```mermaid",
|
|||
|
|
"pie showData",
|
|||
|
|
f" title Prompt-role token attribution (total = {total:,})",
|
|||
|
|
]
|
|||
|
|
# Largest slice first reads better in most renderers.
|
|||
|
|
for role, n in sorted(buckets.items(), key=lambda kv: -kv[1]):
|
|||
|
|
lines.append(f' "{role}" : {n}')
|
|||
|
|
lines.append("```")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_visual_analysis(
|
|||
|
|
sess: dict,
|
|||
|
|
start_dt: Optional[datetime],
|
|||
|
|
llm_calls: Optional[List[dict]] = None,
|
|||
|
|
) -> List[str]:
|
|||
|
|
blocks: List[List[str]] = []
|
|||
|
|
gantt = _mermaid_gantt(sess, start_dt, llm_calls=llm_calls)
|
|||
|
|
if gantt:
|
|||
|
|
blocks.append(gantt)
|
|||
|
|
# Per-turn sequence diagrams (at most one per turn; most turns skip).
|
|||
|
|
turns = _iter_turns(sess)
|
|||
|
|
for idx, iv in enumerate(turns, start=1):
|
|||
|
|
seq = _mermaid_sequence_per_turn(iv, start_dt, idx)
|
|||
|
|
if seq:
|
|||
|
|
blocks.append(seq)
|
|||
|
|
flow = _mermaid_topic_flowchart(sess)
|
|||
|
|
if flow:
|
|||
|
|
blocks.append(flow)
|
|||
|
|
pie = _mermaid_token_pie(sess)
|
|||
|
|
if pie:
|
|||
|
|
blocks.append(pie)
|
|||
|
|
|
|||
|
|
if not blocks:
|
|||
|
|
return []
|
|||
|
|
lines: List[str] = ["## Visual analysis", ""]
|
|||
|
|
for b in blocks:
|
|||
|
|
lines.extend(b)
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_session_counts(sess: dict) -> List[str]:
|
|||
|
|
c = sess.get("counts", {})
|
|||
|
|
st_by_type = c.get("steps_by_type", {})
|
|||
|
|
gwb = c.get("gw_binding", {})
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## Session counts",
|
|||
|
|
"",
|
|||
|
|
"| metric | value |",
|
|||
|
|
"|---|---|",
|
|||
|
|
f"| interactions | {c.get('interactions_total', 0)} |",
|
|||
|
|
f"| steps | {c.get('steps_total', 0)} |",
|
|||
|
|
f"| llm_steps | {st_by_type.get('LLM_STEP', 0)} |",
|
|||
|
|
f"| action_steps | {st_by_type.get('ACTION_STEP', 0)} |",
|
|||
|
|
f"| gateway_requests | {c.get('gateway_requests', 0)} |",
|
|||
|
|
f"| gateway_responses | {c.get('gateway_responses', 0)} |",
|
|||
|
|
f"| 1:1 invariant | {'✓' if c.get('audit_chain_1to1_ok') else '✗'} |",
|
|||
|
|
f"| gw declared | {gwb.get('declared', 0)} |",
|
|||
|
|
f"| gw timestamp_window | {gwb.get('timestamp_window', 0)} |",
|
|||
|
|
f"| gw unbound | {gwb.get('unbound', 0)} |",
|
|||
|
|
]
|
|||
|
|
if gwb.get("declared_collisions"):
|
|||
|
|
lines.append(f"| ⚠ declared_collisions | {gwb['declared_collisions']} |")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_empties_diagnostics(manifest: dict) -> List[str]:
|
|||
|
|
"""Operator-actionable: lift `_unavailable_reason` verbatim from the manifest."""
|
|||
|
|
queries = manifest.get("queries", []) if manifest else []
|
|||
|
|
empties = [
|
|||
|
|
q for q in queries
|
|||
|
|
if q.get("rows") == 0 and q.get("_unavailable_reason")
|
|||
|
|
]
|
|||
|
|
if not empties:
|
|||
|
|
return []
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## Empties diagnostics",
|
|||
|
|
"",
|
|||
|
|
"DMOs that returned zero rows, with the reason lifted verbatim from the manifest:",
|
|||
|
|
"",
|
|||
|
|
"| DMO | Reason |",
|
|||
|
|
"|---|---|",
|
|||
|
|
]
|
|||
|
|
for q in empties:
|
|||
|
|
name = q.get("name") or "?"
|
|||
|
|
# Pipe characters in reason text would break markdown tables.
|
|||
|
|
reason = str(q.get("_unavailable_reason") or "").replace("|", "\\|")
|
|||
|
|
lines.append(f"| {name} | {reason} |")
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_catalog(tree: dict) -> List[str]:
|
|||
|
|
catalog = tree.get("catalog", {}) or {}
|
|||
|
|
agents = ", ".join(catalog.get("agents_observed", []) or []) or "—"
|
|||
|
|
return [
|
|||
|
|
"## Catalog (session-filtered)",
|
|||
|
|
"",
|
|||
|
|
f"- TagDefinitions: {len(catalog.get('tag_definitions', []) or [])}",
|
|||
|
|
f"- TagDefinitionAssociations: "
|
|||
|
|
f"{len(catalog.get('tag_definition_associations', []) or [])} (agents: {agents})",
|
|||
|
|
f"- Tags: {len(catalog.get('tags', []) or [])}",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- top-level render + entry points -------------------------------------
|
|||
|
|
|
|||
|
|
def render(
|
|||
|
|
tree: dict,
|
|||
|
|
manifest: Optional[dict] = None,
|
|||
|
|
session_dir: Optional[Path] = None,
|
|||
|
|
*,
|
|||
|
|
show_prompts: bool = False,
|
|||
|
|
) -> str:
|
|||
|
|
"""Produce the summary markdown for a single session.
|
|||
|
|
|
|||
|
|
Branches on tree shape:
|
|||
|
|
- gateway-direct tree (``_source == "gateway_direct"``) → identity +
|
|||
|
|
lag banner + gateway-chain table + per-call detail; skips the
|
|||
|
|
Interaction-dependent sections.
|
|||
|
|
- minimal tree (session-not-found) → short markdown with just identity.
|
|||
|
|
- full tree → multi-section summary (see SKILL.md for the full list).
|
|||
|
|
|
|||
|
|
``session_dir`` is reserved for callers that want the renderer to look
|
|||
|
|
up artifacts beside the tree on disk. The standalone d360 skill produces
|
|||
|
|
no runtime-telemetry rollups — DC alone doesn't expose per-turn LLM
|
|||
|
|
latency in a useful form — so when ``None`` (the test-friendly default),
|
|||
|
|
the gantt simply draws without the LLM-call overlay.
|
|||
|
|
|
|||
|
|
``show_prompts`` (opt-in): when True, the full-tree branch appends
|
|||
|
|
a "Planner LLM calls" section with full prompt + response text per
|
|||
|
|
LLM call. Default False — the section can add hundreds of KB to the
|
|||
|
|
summary on multi-turn sessions.
|
|||
|
|
|
|||
|
|
Refuses incompatible tree schema versions (see `_assert_schema_version`).
|
|||
|
|
"""
|
|||
|
|
_assert_schema_version(tree)
|
|||
|
|
|
|||
|
|
# Gateway-direct branch — session resolved but STDM hierarchy hasn't
|
|||
|
|
# materialized yet. Handled before the has_interactions check because
|
|||
|
|
# the gateway-direct tree does set `session.interactions = []`.
|
|||
|
|
if tree.get("_source") == "gateway_direct":
|
|||
|
|
return _render_gateway_direct(tree, manifest, show_prompts=show_prompts)
|
|||
|
|
|
|||
|
|
sess = tree.get("session") or {}
|
|||
|
|
sid = sess.get("id") or "<unknown>"
|
|||
|
|
has_interactions = "interactions" in sess
|
|||
|
|
|
|||
|
|
# Minimal-tree early return.
|
|||
|
|
if not has_interactions:
|
|||
|
|
return _render_minimal(sid, sess)
|
|||
|
|
|
|||
|
|
start_iso = sess.get("start_ts")
|
|||
|
|
start_dt = _parse_iso(start_iso)
|
|||
|
|
effective_end_iso, end_source = _derive_session_end(sess)
|
|||
|
|
|
|||
|
|
# The d360 skill produces no runtime-telemetry rollups — DC alone
|
|||
|
|
# doesn't expose per-turn LLM latency in a useful form. Visual
|
|||
|
|
# analysis falls back to its pre-rollup output.
|
|||
|
|
llm_calls: List[dict] = []
|
|||
|
|
|
|||
|
|
lines: List[str] = [f"# Session {sid}", ""]
|
|||
|
|
lines.extend(_section_session_identity(sess, effective_end_iso, end_source))
|
|||
|
|
# VariableText__c bootstrap (channel-mode diagnostic).
|
|||
|
|
lines.extend(_section_session_bootstrap(
|
|||
|
|
sess.get("identity") or {}, channel=sess.get("channel"),
|
|||
|
|
))
|
|||
|
|
lines.extend(_section_id_reference(sess))
|
|||
|
|
lines.extend(_section_transcript(sess, start_dt))
|
|||
|
|
lines.extend(_section_hierarchical_trace(sess, start_dt, effective_end_iso, end_source))
|
|||
|
|
lines.extend(_section_per_turn_summary(sess, start_dt))
|
|||
|
|
# Opt-in full prompt + response per LLM call. Off by
|
|||
|
|
# default — multi-turn sessions can produce hundreds of KB here.
|
|||
|
|
lines.extend(_section_planner_llm_calls(sess, show_prompts=show_prompts))
|
|||
|
|
lines.extend(_section_visual_analysis(sess, start_dt, llm_calls=llm_calls))
|
|||
|
|
lines.extend(_section_session_counts(sess))
|
|||
|
|
lines.extend(_section_empties_diagnostics(manifest or {}))
|
|||
|
|
lines.extend(_section_catalog(tree))
|
|||
|
|
return "\n".join(lines) + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_minimal(sid: str, sess: dict) -> str:
|
|||
|
|
"""Short markdown for session-not-found minimal trees."""
|
|||
|
|
shape = (sess.get("counts") or {}).get("session_shape", "session_not_found")
|
|||
|
|
lines = [
|
|||
|
|
f"# Session {sid}",
|
|||
|
|
"",
|
|||
|
|
"## Session identity",
|
|||
|
|
"",
|
|||
|
|
"| Field | Value |",
|
|||
|
|
"|---|---|",
|
|||
|
|
f"| Session id | {sid} |",
|
|||
|
|
f"| Session shape | {shape} |",
|
|||
|
|
"",
|
|||
|
|
"_No interactions resolved in Data Cloud. Check the session id, or wait for "
|
|||
|
|
"STDM materialization._",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
return "\n".join(lines) + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Display-only cap on `prompt_text` inside per-call detail. The raw JSON on
|
|||
|
|
# disk (dc.gateway_requests.json) is authoritative and never truncated — the
|
|||
|
|
# assembler stores the full prompt, and this slice only applies to markdown.
|
|||
|
|
_PROMPT_DISPLAY_CAP_BYTES = 65536
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_token_count(value) -> str:
|
|||
|
|
"""Tolerate ints, stringified ints, and None/''/NOT_SET."""
|
|||
|
|
if value in (None, "", "NOT_SET"):
|
|||
|
|
return "—"
|
|||
|
|
return str(value)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_gateway_direct(tree: dict, manifest: Optional[dict],
|
|||
|
|
*, show_prompts: bool = False) -> str:
|
|||
|
|
"""Render the STDM-hasn't-materialized-yet view.
|
|||
|
|
|
|||
|
|
Sections:
|
|||
|
|
1. Session identity (reused)
|
|||
|
|
2. ID reference (reused; gracefully handles the empty
|
|||
|
|
interactions/participants on this path)
|
|||
|
|
3. STDM lag banner (gateway-direct specific)
|
|||
|
|
4. Gateway chain table (gateway-direct specific)
|
|||
|
|
5. Per-call detail (gateway-direct specific)
|
|||
|
|
6. Empties diagnostics (reused; reads manifest, not tree)
|
|||
|
|
7. Catalog (reused; catalog may be empty on a
|
|||
|
|
fresh-session gateway-direct tree)
|
|||
|
|
|
|||
|
|
Skipped: Transcript, Hierarchical trace, Per-turn summary, Session
|
|||
|
|
counts — all require Interaction rows that don't exist yet.
|
|||
|
|
|
|||
|
|
``show_prompts`` (default False): forwarded to per-call detail so the
|
|||
|
|
full prompt block only renders under ``--show-prompts``. The prior
|
|||
|
|
behavior unconditionally leaked prompts on this branch, contradicting
|
|||
|
|
the documented contract for ``dc._session_summary.md``.
|
|||
|
|
"""
|
|||
|
|
sess = tree.get("session") or {}
|
|||
|
|
sid = sess.get("id") or "<unknown>"
|
|||
|
|
start_iso = sess.get("start_ts")
|
|||
|
|
start_dt = _parse_iso(start_iso)
|
|||
|
|
# No derivation needed — sessions.end_ts is the only source on this path.
|
|||
|
|
effective_end_iso = sess.get("end_ts")
|
|||
|
|
|
|||
|
|
lines: List[str] = [f"# Session {sid}", ""]
|
|||
|
|
lines.extend(_section_session_identity(sess, effective_end_iso, None))
|
|||
|
|
# VariableText__c bootstrap (channel-mode diagnostic).
|
|||
|
|
lines.extend(_section_session_bootstrap(
|
|||
|
|
sess.get("identity") or {}, channel=sess.get("channel"),
|
|||
|
|
))
|
|||
|
|
lines.extend(_section_id_reference(sess))
|
|||
|
|
lines.extend(_section_stdm_lag_banner())
|
|||
|
|
lines.extend(_section_gateway_chain_table(sess, start_dt))
|
|||
|
|
lines.extend(_section_gateway_per_call_detail(sess, show_prompts=show_prompts))
|
|||
|
|
lines.extend(_section_empties_diagnostics(manifest or {}))
|
|||
|
|
lines.extend(_section_catalog(tree))
|
|||
|
|
return "\n".join(lines) + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_stdm_lag_banner() -> List[str]:
|
|||
|
|
return [
|
|||
|
|
"## STDM materialization lag",
|
|||
|
|
"",
|
|||
|
|
"> **Note** STDM Interaction/Step/Message DMOs have not yet "
|
|||
|
|
"materialized for this session. The view below is the gateway chain "
|
|||
|
|
"harvested directly from Gateway DMOs (materialize in minutes). "
|
|||
|
|
"Re-run in 24–72h for the full hierarchical trace.",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_gateway_chain_table(sess: dict,
|
|||
|
|
start_dt: Optional[datetime]) -> List[str]:
|
|||
|
|
chain = sess.get("gateway_chain") or []
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## Gateway chain",
|
|||
|
|
"",
|
|||
|
|
"| # | Request ts | Model | Provider | Prompt template | "
|
|||
|
|
"Prompt tok | Completion tok | Total tok | Response ts |",
|
|||
|
|
"|---|---|---|---|---|---|---|---|---|",
|
|||
|
|
]
|
|||
|
|
for i, call in enumerate(chain, start=1):
|
|||
|
|
req_offset = _fmt_offset(call.get("timestamp"), start_dt)
|
|||
|
|
resp_offset = _fmt_offset(
|
|||
|
|
(call.get("response") or {}).get("timestamp"), start_dt)
|
|||
|
|
model = call.get("model") or "—"
|
|||
|
|
provider = call.get("provider") or "—"
|
|||
|
|
template = call.get("prompt_template_dev_name") or "—"
|
|||
|
|
prompt_tok = _fmt_token_count(call.get("prompt_tokens"))
|
|||
|
|
completion_tok = _fmt_token_count(call.get("completion_tokens"))
|
|||
|
|
total_tok = _fmt_token_count(call.get("total_tokens"))
|
|||
|
|
lines.append(
|
|||
|
|
f"| {i} | {req_offset} | {model} | {provider} | {template} | "
|
|||
|
|
f"{prompt_tok} | {completion_tok} | {total_tok} | {resp_offset} |"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _pick_fence(text: str) -> str:
|
|||
|
|
"""Pick a backtick fence long enough to wrap `text` safely.
|
|||
|
|
|
|||
|
|
CommonMark lets a fenced code block use any run of 3+ backticks; the
|
|||
|
|
closing fence must be at least as long as the opening. LLM prompts
|
|||
|
|
routinely contain triple-backticks inside tool-use examples, so a
|
|||
|
|
hardcoded ``` fence closes early and corrupts the rest of the doc.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(text, str) or not text:
|
|||
|
|
return "```"
|
|||
|
|
longest = 0
|
|||
|
|
run = 0
|
|||
|
|
for ch in text:
|
|||
|
|
if ch == "`":
|
|||
|
|
run += 1
|
|||
|
|
if run > longest:
|
|||
|
|
longest = run
|
|||
|
|
else:
|
|||
|
|
run = 0
|
|||
|
|
return "`" * max(3, longest + 1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _capped_payload(text: Optional[str], note_source: str) -> tuple[str, bool, str]:
|
|||
|
|
"""Cap a payload string at `_PROMPT_DISPLAY_CAP_BYTES` for display.
|
|||
|
|
|
|||
|
|
Returns ``(body, truncated, source_note)``. Byte-length check so
|
|||
|
|
multi-byte chars don't blow through the limit when the renderer emits
|
|||
|
|
UTF-8 text. Slicing happens on the encoded form, then decodes with
|
|||
|
|
``errors="ignore"`` so we never split a multi-byte char mid-sequence.
|
|||
|
|
`source_note` names the on-disk file with the authoritative full text.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(text, str) or not text:
|
|||
|
|
return ("(empty)", False, note_source)
|
|||
|
|
encoded = text.encode("utf-8")
|
|||
|
|
if len(encoded) <= _PROMPT_DISPLAY_CAP_BYTES:
|
|||
|
|
return (text, False, note_source)
|
|||
|
|
body = encoded[:_PROMPT_DISPLAY_CAP_BYTES].decode("utf-8", errors="ignore")
|
|||
|
|
return (body, True, note_source)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_call_detail_block(call: dict, idx: int, *,
|
|||
|
|
show_prompts: bool = False,
|
|||
|
|
show_response_text: bool = False) -> List[str]:
|
|||
|
|
"""Render one ``#### LLM call N — <short-id>`` block.
|
|||
|
|
|
|||
|
|
Used by both:
|
|||
|
|
- the gateway-direct branch (``_section_gateway_per_call_detail``)
|
|||
|
|
- the full-tree opt-in section (``_section_planner_llm_calls``)
|
|||
|
|
|
|||
|
|
``call`` shape (subset of fields used here):
|
|||
|
|
gateway_request_id, model, provider, prompt_template_dev_name,
|
|||
|
|
prompt_tokens, completion_tokens, total_tokens, prompt_text,
|
|||
|
|
response (-> finish_reason), response_text (only on full-tree path).
|
|||
|
|
|
|||
|
|
The prompt and response blocks are independently gated:
|
|||
|
|
- ``show_prompts`` controls the **Prompt** block.
|
|||
|
|
- ``show_response_text`` controls the **Response** block (full-tree
|
|||
|
|
only — gateway-direct chain entries don't carry response_text).
|
|||
|
|
Both default off so callers must opt in explicitly. The summary line
|
|||
|
|
(model/provider/template/tokens/finish_reason) always renders.
|
|||
|
|
"""
|
|||
|
|
gw_id = call.get("gateway_request_id") or "—"
|
|||
|
|
short_id = _short(gw_id)
|
|||
|
|
lines = [f"#### LLM call {idx} — {short_id}", ""]
|
|||
|
|
finish_reason = (call.get("response") or {}).get("finish_reason") or "—"
|
|||
|
|
summary = (
|
|||
|
|
f"- model={call.get('model') or '—'}"
|
|||
|
|
f" provider={call.get('provider') or '—'}"
|
|||
|
|
f" template={call.get('prompt_template_dev_name') or '—'}"
|
|||
|
|
f" prompt_tok={_fmt_token_count(call.get('prompt_tokens'))}"
|
|||
|
|
f" completion_tok={_fmt_token_count(call.get('completion_tokens'))}"
|
|||
|
|
f" total_tok={_fmt_token_count(call.get('total_tokens'))}"
|
|||
|
|
f" finish_reason={finish_reason}"
|
|||
|
|
)
|
|||
|
|
lines.append(summary)
|
|||
|
|
lines.append("")
|
|||
|
|
|
|||
|
|
# Prompt block — gated by show_prompts. Default-off everywhere; the
|
|||
|
|
# summary file should never leak full prompt text without an explicit
|
|||
|
|
# --show-prompts opt-in (matches the doc contract in SKILL.md).
|
|||
|
|
if show_prompts:
|
|||
|
|
body, truncated, src = _capped_payload(
|
|||
|
|
call.get("prompt_text"), "dc.gateway_requests.json")
|
|||
|
|
fence = _pick_fence(body)
|
|||
|
|
lines.append("**Prompt** (full input sent to the model):")
|
|||
|
|
lines.append(fence)
|
|||
|
|
lines.append(body)
|
|||
|
|
if truncated:
|
|||
|
|
lines.append(f"…[truncated; full prompt in {src}]")
|
|||
|
|
lines.append(fence)
|
|||
|
|
lines.append("")
|
|||
|
|
|
|||
|
|
# Response block — only the full-tree path carries response_text;
|
|||
|
|
# gateway-direct rows get finish_reason in the header line above and
|
|||
|
|
# nothing else (the response DMO doesn't carry text on that path).
|
|||
|
|
if show_response_text:
|
|||
|
|
body, truncated, src = _capped_payload(
|
|||
|
|
call.get("response_text"), "dc.generations.json")
|
|||
|
|
# html.unescape so the rendered block reads as plain JSON instead of
|
|||
|
|
# "-laden text — matches the existing _decoded_line treatment.
|
|||
|
|
if body and body != "(empty)":
|
|||
|
|
body = html.unescape(body)
|
|||
|
|
fence = _pick_fence(body)
|
|||
|
|
lines.append("**Response** (model output, including tool invocations):")
|
|||
|
|
lines.append(fence)
|
|||
|
|
lines.append(body)
|
|||
|
|
if truncated:
|
|||
|
|
lines.append(f"…[truncated; full response in {src}]")
|
|||
|
|
lines.append(fence)
|
|||
|
|
lines.append("")
|
|||
|
|
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_gateway_per_call_detail(sess: dict, *,
|
|||
|
|
show_prompts: bool = False) -> List[str]:
|
|||
|
|
"""Per-call detail for the gateway-direct branch.
|
|||
|
|
|
|||
|
|
``gateway_chain`` does NOT carry `response_text` (the responses DMO
|
|||
|
|
doesn't include it on that path), so the response block is always
|
|||
|
|
suppressed. The prompt block is gated by ``show_prompts`` so the
|
|||
|
|
default summary doesn't leak full prompt text — matches the
|
|||
|
|
documented contract for ``dc._session_summary.md``.
|
|||
|
|
"""
|
|||
|
|
chain = sess.get("gateway_chain") or []
|
|||
|
|
lines: List[str] = ["## Per-call detail", ""]
|
|||
|
|
for i, call in enumerate(chain, start=1):
|
|||
|
|
lines.extend(_render_call_detail_block(
|
|||
|
|
call, i, show_prompts=show_prompts, show_response_text=False))
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _collect_planner_llm_calls(sess: dict) -> List[dict]:
|
|||
|
|
"""Walk ``interactions[].steps[]`` and collect call-view dicts.
|
|||
|
|
|
|||
|
|
Each step that has a ``gateway_request`` (regardless of binding method)
|
|||
|
|
contributes one call view. ``response_text`` is sourced from the step's
|
|||
|
|
sibling ``generation.response_text`` when present; otherwise the call is
|
|||
|
|
still emitted with the prompt + token summary.
|
|||
|
|
|
|||
|
|
Returned dicts use the same field names as ``gateway_chain`` entries so
|
|||
|
|
``_render_call_detail_block`` can consume both shapes uniformly.
|
|||
|
|
"""
|
|||
|
|
calls: List[dict] = []
|
|||
|
|
for iv in sess.get("interactions") or []:
|
|||
|
|
for st in iv.get("steps") or []:
|
|||
|
|
gw = st.get("gateway_request")
|
|||
|
|
if not gw:
|
|||
|
|
continue
|
|||
|
|
gen = st.get("generation") or {}
|
|||
|
|
calls.append({
|
|||
|
|
"gateway_request_id": gw.get("gateway_request_id"),
|
|||
|
|
"model": gw.get("model"),
|
|||
|
|
"provider": gw.get("provider"),
|
|||
|
|
"prompt_template_dev_name": gw.get("prompt_template_dev_name"),
|
|||
|
|
"prompt_tokens": gw.get("prompt_tokens"),
|
|||
|
|
"completion_tokens": gw.get("completion_tokens"),
|
|||
|
|
"total_tokens": gw.get("total_tokens"),
|
|||
|
|
"prompt_text": gw.get("prompt_text"),
|
|||
|
|
"response": gw.get("response"),
|
|||
|
|
"response_text": gen.get("response_text"),
|
|||
|
|
})
|
|||
|
|
return calls
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _section_planner_llm_calls(sess: dict, *, show_prompts: bool) -> List[str]:
|
|||
|
|
"""Opt-in full-tree section showing the input prompt + response
|
|||
|
|
for every LLM call in the session's hierarchical trace.
|
|||
|
|
|
|||
|
|
Off by default — prompts can be 30 KB+ each on multi-turn sessions
|
|||
|
|
and would otherwise dominate the summary. Enable with
|
|||
|
|
``render_dc.py --show-prompts``.
|
|||
|
|
"""
|
|||
|
|
if not show_prompts:
|
|||
|
|
return []
|
|||
|
|
calls = _collect_planner_llm_calls(sess)
|
|||
|
|
if not calls:
|
|||
|
|
return []
|
|||
|
|
lines: List[str] = [
|
|||
|
|
"## Planner LLM calls (full prompts + responses)",
|
|||
|
|
"",
|
|||
|
|
f"_Found {len(calls)} LLM call(s) across the session's hierarchical "
|
|||
|
|
f"trace. Prompts are capped at "
|
|||
|
|
f"{_PROMPT_DISPLAY_CAP_BYTES // 1024} KB for display; full payloads "
|
|||
|
|
f"are on disk in `dc.gateway_requests.json` and "
|
|||
|
|
f"`dc.generations.json`._",
|
|||
|
|
"",
|
|||
|
|
]
|
|||
|
|
for i, call in enumerate(calls, start=1):
|
|||
|
|
# show_prompts is True here by section-guard above (early return when
|
|||
|
|
# show_prompts is False); pass through explicitly so the helper's new
|
|||
|
|
# prompt-gate stays aligned with the section's intent.
|
|||
|
|
lines.extend(_render_call_detail_block(
|
|||
|
|
call, i, show_prompts=True, show_response_text=True))
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _assert_schema_version(tree: dict) -> None:
|
|||
|
|
"""Refuse unsupported versions; warn on missing version (older assembler)."""
|
|||
|
|
version = (tree.get("session") or {}).get("_schema_version")
|
|||
|
|
if version is None:
|
|||
|
|
print(
|
|||
|
|
"render_dc: WARN tree has no _schema_version "
|
|||
|
|
"(produced by an older assembler?); rendering anyway",
|
|||
|
|
file=sys.stderr,
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
if version != _SUPPORTED_SCHEMA_VERSION:
|
|||
|
|
raise SystemExit(
|
|||
|
|
f"render_dc: unsupported tree _schema_version={version}; "
|
|||
|
|
f"expected {_SUPPORTED_SCHEMA_VERSION}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main_for_session(sid: str, *, show_prompts: bool = False) -> int:
|
|||
|
|
"""Read session tree + manifest from the nested session dir; emit summary.md.
|
|||
|
|
|
|||
|
|
Uses ``assemble_dc._find_session_dir`` to locate the session under
|
|||
|
|
``DATA_ROOT/<org>/<agent>__<ver>/<sid>/`` — follows the ``_sessions/*.link``
|
|||
|
|
breadcrumb when present, globs otherwise. No callers need to know the
|
|||
|
|
full identity triple upfront.
|
|||
|
|
|
|||
|
|
``show_prompts``: pass through to ``render`` to include the
|
|||
|
|
opt-in "Planner LLM calls" section.
|
|||
|
|
"""
|
|||
|
|
from assemble_dc import _find_session_dir
|
|||
|
|
session_dir = _find_session_dir(sid)
|
|||
|
|
tree_path = session_dir / "dc._session_tree.json"
|
|||
|
|
if not tree_path.is_file():
|
|||
|
|
raise SystemExit(
|
|||
|
|
f"render_dc: tree not found at {tree_path}; "
|
|||
|
|
f"run `python3 scripts/assemble_dc.py --session {sid}` first"
|
|||
|
|
)
|
|||
|
|
manifest_path = session_dir / "dc._session_manifest.json"
|
|||
|
|
manifest: Optional[dict] = None
|
|||
|
|
if manifest_path.is_file():
|
|||
|
|
try:
|
|||
|
|
manifest = json.loads(manifest_path.read_text())
|
|||
|
|
except (json.JSONDecodeError, OSError) as e:
|
|||
|
|
print(
|
|||
|
|
f"render_dc: WARN could not read manifest: {str(e).splitlines()[0]}",
|
|||
|
|
file=sys.stderr,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
tree = json.loads(tree_path.read_text())
|
|||
|
|
_assert_schema_version(tree)
|
|||
|
|
|
|||
|
|
md_path = session_dir / "dc._session_summary.md"
|
|||
|
|
md_path.write_text(render(tree, manifest, session_dir=session_dir,
|
|||
|
|
show_prompts=show_prompts))
|
|||
|
|
print(f"render_dc: wrote {md_path}", file=sys.stderr)
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
ap = argparse.ArgumentParser(
|
|||
|
|
prog="render_dc.py",
|
|||
|
|
description="Render dc._session_summary.md from dc._session_tree.json for one session.",
|
|||
|
|
)
|
|||
|
|
ap.add_argument("--session", required=True,
|
|||
|
|
help="AI-agent session UUID or MessagingSession id (0Mw...). "
|
|||
|
|
"Messaging ids are resolved from disk "
|
|||
|
|
"(DATA_ROOT/*/dc.sessions.json); run fetch_dc.py first "
|
|||
|
|
"if the session hasn't been fetched yet.")
|
|||
|
|
ap.add_argument("--show-prompts", action="store_true",
|
|||
|
|
help="Include the opt-in 'Planner LLM calls' section "
|
|||
|
|
"with the full input prompt + response per LLM call. "
|
|||
|
|
"Off by default — multi-turn sessions can produce "
|
|||
|
|
"hundreds of KB here. Per-prompt display is capped "
|
|||
|
|
"at 64 KB; full payloads remain on disk in "
|
|||
|
|
"dc.gateway_requests.json + dc.generations.json.")
|
|||
|
|
# Runtime-agnostic path overrides; default to ~/.vibe/...
|
|||
|
|
from _shared.cli_override import add_cli_flags, apply_overrides
|
|||
|
|
add_cli_flags(ap)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
apply_overrides(args, caller_globals=globals())
|
|||
|
|
from resolve_session import resolve_disk_or_live
|
|||
|
|
sid = resolve_disk_or_live(args.session)
|
|||
|
|
return main_for_session(sid, show_prompts=args.show_prompts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|