use crate::IngestError;
use amberfork_model::{Payload, Run, SchemaVersion, Step, StepKind, Warning, WarningCode};
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::path::Path;
const OPENAI_LEAF: &str = "openai.chat.completions.create";
const INPUT_KEYS: &[&str] = &["model", "messages"];
const OUTPUT_KEYS: &[&str] = &["choices", "usage"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HalMeta {
pub gaia_task_id: String,
pub model: Option<String>,
pub passed: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConvertedHal {
pub run: Run,
pub meta: HalMeta,
pub warnings: Vec<Warning>,
}
pub fn convert_str(s: &str) -> Result<Vec<ConvertedHal>, IngestError> {
let dump: RawHalDump =
serde_json::from_str(s).map_err(|source| IngestError::Parse { path: None, source })?;
Ok(convert_dump(dump))
}
pub fn convert_file(path: impl AsRef<Path>) -> Result<Vec<ConvertedHal>, IngestError> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|source| IngestError::Io {
path: path.to_path_buf(),
source,
})?;
convert_str(&text).map_err(|err| err.with_path(path))
}
fn convert_dump(dump: RawHalDump) -> Vec<ConvertedHal> {
let passed: HashSet<&str> = dump
.results
.successful_tasks
.iter()
.map(String::as_str)
.collect();
let model = dump.config.agent_args.model_name;
let run_prefix = dump.config.run_id;
let mut by_task: BTreeMap<String, Vec<RawHalRecord>> = BTreeMap::new();
for record in dump.raw_logging_results {
if record.summary.weave.trace_name.as_deref() != Some(OPENAI_LEAF) {
continue;
}
let Some(task_id) = record.attributes.weave_task_id.clone() else {
continue;
};
by_task.entry(task_id).or_default().push(record);
}
by_task
.into_iter()
.map(|(task_id, mut records)| {
records.sort_by(|a, b| a.started_at.cmp(&b.started_at));
let mut warnings = Vec::new();
let mut steps: Vec<Step> = Vec::new();
let mut prev_messages_len = 0usize;
for (turn_idx, record) in records.into_iter().enumerate() {
let messages = message_array(&record.inputs);
if turn_idx > 0 {
for msg in messages.iter().skip(prev_messages_len) {
if msg.get("role").and_then(Value::as_str) == Some("assistant") {
continue;
}
if let Some(text) = message_text(msg) {
steps.push(Step {
idx: steps.len(),
kind: StepKind::Tool,
name: "tool_result".to_string(),
inputs: None,
outputs: Some(Payload::Text(text)),
attrs: Map::new(),
t_start: None,
t_end: None,
parent_idx: None,
});
}
}
}
prev_messages_len = messages.len();
let idx = steps.len();
steps.push(record.into_step(idx, &mut warnings));
}
let id = if run_prefix.is_empty() {
task_id.clone()
} else {
format!("{run_prefix}/{task_id}")
};
let passed = passed.contains(task_id.as_str());
ConvertedHal {
run: Run {
schema_version: SchemaVersion::current(),
id,
task: None,
outcome: None,
steps,
edges: None,
},
meta: HalMeta {
gaia_task_id: task_id,
model: model.clone(),
passed,
},
warnings,
}
})
.collect()
}
fn message_array(inputs: &Value) -> Vec<Value> {
inputs
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
fn message_text(msg: &Value) -> Option<String> {
match msg.get("content") {
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
Some(Value::Array(items)) => {
let mut buf = String::new();
for item in items {
if let Some(text) = item.get("text").and_then(Value::as_str) {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(text);
}
}
(!buf.is_empty()).then_some(buf)
}
_ => None,
}
}
fn project(value: &Value, keys: &[&str]) -> Option<Payload> {
let object = value.as_object()?;
let mut kept = Map::new();
for key in keys {
if let Some(found) = object.get(*key) {
kept.insert((*key).to_string(), found.clone());
}
}
(!kept.is_empty()).then_some(Payload::Object(kept))
}
#[derive(Deserialize, Default)]
struct RawHalDump {
#[serde(default)]
config: RawHalConfig,
#[serde(default)]
results: RawHalResults,
#[serde(default)]
raw_logging_results: Vec<RawHalRecord>,
}
#[derive(Deserialize, Default)]
struct RawHalConfig {
#[serde(default)]
run_id: String,
#[serde(default)]
agent_args: RawHalAgentArgs,
}
#[derive(Deserialize, Default)]
struct RawHalAgentArgs {
#[serde(default)]
model_name: Option<String>,
}
#[derive(Deserialize, Default)]
struct RawHalResults {
#[serde(default)]
successful_tasks: Vec<String>,
#[serde(default)]
#[allow(dead_code)]
failed_tasks: Vec<String>,
}
#[derive(Deserialize)]
struct RawHalRecord {
#[serde(default)]
id: String,
#[serde(default)]
trace_id: Option<String>,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
ended_at: Option<String>,
#[serde(default)]
attributes: RawHalAttrs,
#[serde(default)]
summary: RawHalSummary,
#[serde(default)]
inputs: Value,
#[serde(default)]
output: Value,
}
impl RawHalRecord {
fn into_step(self, idx: usize, warnings: &mut Vec<Warning>) -> Step {
let name = self
.summary
.weave
.trace_name
.unwrap_or_else(|| OPENAI_LEAF.to_string());
let inputs = project(&self.inputs, INPUT_KEYS);
let outputs = project(&self.output, OUTPUT_KEYS);
let mut attrs = Map::new();
if !self.id.is_empty() {
attrs.insert("weave.call_id".to_string(), Value::from(self.id));
}
if let Some(trace_id) = self.trace_id.filter(|t| !t.is_empty()) {
attrs.insert("weave.trace_id".to_string(), Value::from(trace_id));
}
if inputs.is_none() && outputs.is_none() {
warnings.push(Warning {
code: WarningCode::ContentAbsent,
msg: format!("step {idx} ({name}): no input or output content captured"),
});
}
Step {
idx,
kind: StepKind::Llm,
name,
inputs,
outputs,
attrs,
t_start: self.started_at,
t_end: self.ended_at,
parent_idx: None,
}
}
}
#[derive(Deserialize, Default)]
struct RawHalAttrs {
#[serde(default)]
weave_task_id: Option<String>,
}
#[derive(Deserialize, Default)]
struct RawHalSummary {
#[serde(default)]
weave: RawHalWeave,
}
#[derive(Deserialize, Default)]
struct RawHalWeave {
#[serde(default)]
trace_name: Option<String>,
}