use amberfork_model::{Payload, Step, StepKind, Warning, WarningCode};
use serde_json::{Map, Value};
const CONSUMED_KEYS: &[&str] = &[
"openinference.span.kind",
"tool.name",
"input.value",
"input.mime_type",
"output.value",
"output.mime_type",
];
const KNOWN_PREFIXES: &[&str] = &[
"openinference.",
"llm.",
"tool.",
"input.",
"output.",
"embedding.",
"reranker.",
"retrieval.",
"document.",
"message.",
"prompt.",
"metadata",
"tag.",
"session.",
"user.",
"graph.",
"gen_ai.",
"exception.",
];
#[expect(
clippy::too_many_arguments,
reason = "the envelope-independent inputs of a span-to-step mapping; grouping them into a \
struct would only move the argument list, not shorten it"
)]
pub(crate) fn map_span_to_step(
idx: usize,
parent_idx: Option<usize>,
span_name: &str,
attrs: Map<String, Value>,
provenance: Vec<(&'static str, Value)>,
t_start: Option<String>,
t_end: Option<String>,
warnings: &mut Vec<Warning>,
) -> Step {
let kind = map_kind(attrs.get("openinference.span.kind"));
let name = attrs
.get("tool.name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map_or_else(|| span_name.to_string(), str::to_string);
let inputs = decode_content(attrs.get("input.value"), attrs.get("input.mime_type"));
let outputs = decode_content(attrs.get("output.value"), attrs.get("output.mime_type"));
let mut out_attrs = Map::new();
let mut foreign = Vec::new();
for (key, value) in attrs {
if CONSUMED_KEYS.contains(&key.as_str()) {
continue;
}
if !is_known(&key) {
foreign.push(key.clone());
}
out_attrs.insert(key, value);
}
for (key, value) in provenance {
out_attrs.insert(key.to_string(), value);
}
if !foreign.is_empty() {
foreign.sort_unstable();
warnings.push(Warning {
code: WarningCode::UnmappedAttributes,
msg: format!(
"step {idx} ({name}): foreign attributes preserved to attrs: {}",
foreign.join(", ")
),
});
}
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,
name,
inputs,
outputs,
attrs: out_attrs,
t_start,
t_end,
parent_idx,
}
}
pub(crate) fn map_kind(value: Option<&Value>) -> StepKind {
match value
.and_then(|v| v.as_str())
.map(str::to_ascii_uppercase)
.as_deref()
{
Some("LLM") => StepKind::Llm,
Some("TOOL") => StepKind::Tool,
Some("AGENT") => StepKind::Agent,
_ => StepKind::Other,
}
}
pub(crate) fn decode_content(value: Option<&Value>, mime: Option<&Value>) -> Option<Payload> {
let text = match value? {
Value::String(s) => s.clone(),
other => return Some(payload_from_json(other.clone())),
};
if text.is_empty() {
return None;
}
let is_json = mime
.and_then(|m| m.as_str())
.is_some_and(|m| m.contains("json"));
if is_json {
if let Ok(parsed) = serde_json::from_str::<Value>(&text) {
return Some(payload_from_json(parsed));
}
}
Some(Payload::Text(text))
}
fn payload_from_json(value: Value) -> Payload {
match value {
Value::String(s) => Payload::Text(s),
Value::Object(map) => Payload::Object(map),
other => Payload::Other(other),
}
}
fn is_known(key: &str) -> bool {
KNOWN_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
}