use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::Path;
use super::dto::{
PortalStorySection, PortalTemplateBranch, PortalTemplateRender, PortalTranscriptMessage,
PortalTranscriptStep,
};
use super::util::preview_text;
pub(super) fn discover_transcript_steps(
run_dir: &Path,
relative_path: &str,
) -> Option<Vec<PortalTranscriptStep>> {
let path = locate_llm_transcript(run_dir, relative_path)?;
parse_transcript_steps(&path).ok()
}
pub(super) fn discover_template_renders(
run_dir: &Path,
relative_path: &str,
) -> Option<Vec<PortalTemplateRender>> {
let path = locate_llm_transcript(run_dir, relative_path)?;
parse_template_renders(&path).ok()
}
fn locate_llm_transcript(run_dir: &Path, relative_path: &str) -> Option<std::path::PathBuf> {
let run_path = run_dir.join(relative_path);
let stem = run_path.file_stem()?.to_str()?;
let parent = run_path.parent()?;
let transcript_path = parent.join(format!("{stem}-llm/llm_transcript.jsonl"));
transcript_path.exists().then_some(transcript_path)
}
fn parse_transcript_steps(path: &Path) -> Result<Vec<PortalTranscriptStep>, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let mut steps = Vec::<PortalTranscriptStep>::new();
let mut by_call = HashMap::<String, usize>::new();
let mut call_index = 0usize;
let mut current_system_prompt: Option<String> = None;
let mut current_schema_names: Vec<String> = Vec::new();
let mut accumulated_messages: Vec<PortalTranscriptMessage> = Vec::new();
let mut previous_total: usize = 0;
let policy = harn_vm::redact::current_policy();
for line in content.lines().filter(|line| !line.trim().is_empty()) {
let mut raw: serde_json::Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(_) => continue,
};
policy.redact_json_in_place(&mut raw);
let event_type = raw
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("");
match event_type {
"system_prompt" => {
current_system_prompt = raw
.get("content")
.and_then(|value| value.as_str())
.map(str::to_string);
}
"tool_schemas" => {
current_schema_names = raw
.get("schemas")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
item.get("name")
.and_then(|value| value.as_str())
.map(str::to_string)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
}
"message" => {
accumulated_messages.push(PortalTranscriptMessage {
role: raw
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("user")
.to_string(),
content: raw
.get("content")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
});
}
"provider_call_request" => {
let call_id = raw
.get("call_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
if call_id.is_empty() {
continue;
}
call_index += 1;
let total_messages = accumulated_messages.len();
let kept_messages = previous_total.min(total_messages);
let added_context = accumulated_messages
.iter()
.skip(kept_messages)
.cloned()
.collect::<Vec<_>>();
previous_total = total_messages;
let step = PortalTranscriptStep {
call_id: call_id.clone(),
span_id: raw.get("span_id").and_then(|value| value.as_u64()),
iteration: raw
.get("iteration")
.and_then(|value| value.as_u64())
.unwrap_or_default() as usize,
call_index,
model: raw
.get("model")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
provider: raw
.get("provider")
.and_then(|value| value.as_str())
.map(str::to_string),
kept_messages,
added_messages: added_context.len(),
total_messages,
input_tokens: None,
output_tokens: None,
system_prompt: current_system_prompt.clone(),
added_context,
response_text: None,
thinking: None,
tool_calls: current_schema_names.clone(),
summary: "Waiting for model response".to_string(),
};
by_call.insert(call_id, steps.len());
steps.push(step);
}
"provider_call_response" => {
let call_id = raw
.get("call_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
if let Some(index) = by_call.get(&call_id).copied() {
let step = &mut steps[index];
step.span_id = step
.span_id
.or_else(|| raw.get("span_id").and_then(|value| value.as_u64()));
step.input_tokens = raw.get("input_tokens").and_then(|value| value.as_i64());
step.output_tokens = raw.get("output_tokens").and_then(|value| value.as_i64());
step.response_text = raw
.get("text")
.and_then(|value| value.as_str())
.map(str::to_string);
step.thinking = raw
.get("thinking")
.and_then(|value| value.as_str())
.map(str::to_string);
let response_tool_calls = raw
.get("tool_calls")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
item.get("name")
.and_then(|value| value.as_str())
.map(str::to_string)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !response_tool_calls.is_empty() {
step.tool_calls = response_tool_calls;
}
step.summary = summarize_transcript_step(step);
}
}
_ => {}
}
}
Ok(steps)
}
fn parse_template_renders(path: &Path) -> Result<Vec<PortalTemplateRender>, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let mut out = Vec::new();
let policy = harn_vm::redact::current_policy();
for line in content.lines().filter(|line| !line.trim().is_empty()) {
let mut raw: serde_json::Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(_) => continue,
};
policy.redact_json_in_place(&mut raw);
if raw.get("type").and_then(|v| v.as_str()) != Some("template.render") {
continue;
}
let Some(record) = extract_template_render(&raw) else {
continue;
};
out.push(record);
}
Ok(out)
}
fn extract_template_render(raw: &serde_json::Value) -> Option<PortalTemplateRender> {
let template_uri = raw
.get("template_uri")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let template_revision_hash = raw
.get("template_revision_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let rendered_bytes = raw
.get("rendered_bytes")
.and_then(|v| v.as_u64())
.unwrap_or_default() as usize;
let llm = raw.get("llm")?;
let provider = llm
.get("provider")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let model = llm
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let family = llm
.get("family")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let capabilities = llm
.get("capabilities")
.and_then(|v| v.as_object())
.map(|map| {
map.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
let branches = raw
.get("branches")
.and_then(|v| v.as_array())
.map(|items| items.iter().filter_map(extract_template_branch).collect())
.unwrap_or_default();
let span_id = raw.get("span_id").and_then(|v| v.as_u64());
let timestamp = raw
.get("timestamp")
.and_then(|v| v.as_str())
.map(str::to_string);
Some(PortalTemplateRender {
template_uri,
template_revision_hash,
rendered_bytes,
provider,
model,
family,
capabilities,
branches,
span_id,
timestamp,
})
}
fn extract_template_branch(raw: &serde_json::Value) -> Option<PortalTemplateBranch> {
let kind = raw.get("kind").and_then(|v| v.as_str())?.to_string();
let template_uri = raw
.get("template_uri")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let line = raw.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let col = raw.get("col").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let branch_id = raw
.get("branch_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let branch_label = raw
.get("branch_label")
.and_then(|v| v.as_str())
.map(str::to_string);
Some(PortalTemplateBranch {
kind,
template_uri,
line,
col,
branch_id,
branch_label,
})
}
fn summarize_transcript_step(step: &PortalTranscriptStep) -> String {
if let Some(last_tool) = step.tool_calls.last() {
return format!(
"kept {} messages, added {}, then asked for {}",
step.kept_messages, step.added_messages, last_tool
);
}
if step.response_text.is_some() {
return format!(
"kept {} messages, added {}, then replied in text",
step.kept_messages, step.added_messages
);
}
format!(
"kept {} messages, added {}",
step.kept_messages, step.added_messages
)
}
pub(super) fn build_story(run: &harn_vm::orchestration::RunRecord) -> Vec<PortalStorySection> {
let mut story = Vec::new();
if let Some(transcript) = &run.transcript {
collect_story_sections(transcript, "Run transcript", "run", &mut story);
}
for stage in &run.stages {
if let Some(transcript) = &stage.transcript {
collect_story_sections(
transcript,
&format!("Stage {}", stage.node_id),
"stage",
&mut story,
);
} else if let Some(text) = &stage.visible_text {
story.push(PortalStorySection {
title: format!("Stage {}", stage.node_id),
scope: "stage".to_string(),
role: "assistant".to_string(),
source: "visible_text".to_string(),
preview: preview_text(text),
text: text.clone(),
});
}
}
story
}
fn collect_story_sections(
value: &serde_json::Value,
title: &str,
scope: &str,
out: &mut Vec<PortalStorySection>,
) {
if let Some(events) = value.get("events").and_then(|events| events.as_array()) {
for event in events {
let role = event
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("assistant");
let source = event
.get("kind")
.and_then(|value| value.as_str())
.unwrap_or("message");
let text = extract_event_text(event);
if text.trim().is_empty() {
continue;
}
out.push(PortalStorySection {
title: title.to_string(),
scope: scope.to_string(),
role: role.to_string(),
source: source.to_string(),
preview: preview_text(&text),
text,
});
}
return;
}
if let Some(entries) = value.as_array() {
for entry in entries {
let role = entry
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("assistant");
let text = extract_event_text(entry);
if text.trim().is_empty() {
continue;
}
out.push(PortalStorySection {
title: title.to_string(),
scope: scope.to_string(),
role: role.to_string(),
source: "message".to_string(),
preview: preview_text(&text),
text,
});
}
}
}
fn extract_event_text(value: &serde_json::Value) -> String {
if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
return text.to_string();
}
if let Some(content) = value.get("content") {
if let Some(text) = content.as_str() {
return text.to_string();
}
if let Some(items) = content.as_array() {
return items
.iter()
.filter_map(|item| item.get("text").and_then(|text| text.as_str()))
.collect::<Vec<_>>()
.join("\n");
}
}
if let Some(blocks) = value.get("blocks").and_then(|blocks| blocks.as_array()) {
return blocks
.iter()
.filter_map(|item| item.get("text").and_then(|text| text.as_str()))
.collect::<Vec<_>>()
.join("\n");
}
String::new()
}