use std::sync::Arc;
use arrow::array::{ArrayRef, BooleanBuilder, StringBuilder, UInt64Builder};
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use arrow::error::ArrowError;
use arrow::record_batch::RecordBatch;
use serde::Serialize;
use crate::dashboard::DashboardRow;
#[derive(Serialize)]
struct SettlementJson {
persona: String,
spend_base_units: String,
charged_base_units: String,
}
#[must_use]
pub(crate) fn schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("conversation_id", DataType::Utf8, false),
Field::new("total_events", DataType::UInt64, false),
Field::new("committed_turns", DataType::UInt64, false),
Field::new("input_tokens", DataType::UInt64, false),
Field::new("output_tokens", DataType::UInt64, false),
Field::new("summary_active", DataType::Boolean, false),
Field::new("summary_text", DataType::Utf8, true),
Field::new("last_turn_id", DataType::Utf8, true),
Field::new("edges_json", DataType::Utf8, false),
Field::new("settlements_json", DataType::Utf8, false),
Field::new("created_at_ms", DataType::UInt64, true),
Field::new("last_activity_ms", DataType::UInt64, true),
Field::new("persona_id", DataType::Utf8, true),
Field::new("first_message_preview", DataType::Utf8, true),
]))
}
pub(crate) fn decode_dashboard_batch(rows: &[DashboardRow]) -> Result<RecordBatch, ArrowError> {
let mut conversation_id_b = StringBuilder::with_capacity(rows.len(), rows.len() * 36);
let mut total_events_b = UInt64Builder::with_capacity(rows.len());
let mut committed_turns_b = UInt64Builder::with_capacity(rows.len());
let mut input_tokens_b = UInt64Builder::with_capacity(rows.len());
let mut output_tokens_b = UInt64Builder::with_capacity(rows.len());
let mut summary_active_b = BooleanBuilder::with_capacity(rows.len());
let mut summary_text_b = StringBuilder::with_capacity(rows.len(), rows.len() * 16);
let mut last_turn_id_b = StringBuilder::with_capacity(rows.len(), rows.len() * 32);
let mut edges_json_b = StringBuilder::with_capacity(rows.len(), rows.len() * 16);
let mut settlements_json_b = StringBuilder::with_capacity(rows.len(), rows.len() * 16);
let mut created_at_ms_b = UInt64Builder::with_capacity(rows.len());
let mut last_activity_ms_b = UInt64Builder::with_capacity(rows.len());
let mut persona_id_b = StringBuilder::with_capacity(rows.len(), rows.len() * 36);
let mut first_message_preview_b = StringBuilder::with_capacity(rows.len(), rows.len() * 32);
for row in rows {
conversation_id_b.append_value(&row.conversation_id);
#[allow(clippy::cast_possible_truncation)] total_events_b.append_value(row.total_events as u64);
#[allow(clippy::cast_possible_truncation)]
committed_turns_b.append_value(row.committed_turns as u64);
input_tokens_b.append_value(row.input_tokens);
output_tokens_b.append_value(row.output_tokens);
summary_active_b.append_value(row.summary_active());
match &row.summary_text {
Some(v) => summary_text_b.append_value(v),
None => summary_text_b.append_null(),
}
match &row.last_turn_id {
Some(v) => last_turn_id_b.append_value(v),
None => last_turn_id_b.append_null(),
}
edges_json_b
.append_value(serde_json::to_string(&row.edges).unwrap_or_else(|_| "[]".to_owned()));
let settlements: Vec<SettlementJson> = row
.settlements
.iter()
.map(|s| SettlementJson {
persona: s.persona.clone(),
spend_base_units: s.spend_base_units.to_string(),
charged_base_units: s.charged_base_units.to_string(),
})
.collect();
settlements_json_b
.append_value(serde_json::to_string(&settlements).unwrap_or_else(|_| "[]".to_owned()));
match row.created_at_ms {
Some(v) => created_at_ms_b.append_value(v),
None => created_at_ms_b.append_null(),
}
match row.last_activity_ms {
Some(v) => last_activity_ms_b.append_value(v),
None => last_activity_ms_b.append_null(),
}
match &row.persona_id {
Some(v) => persona_id_b.append_value(v),
None => persona_id_b.append_null(),
}
match &row.first_message_preview {
Some(v) => first_message_preview_b.append_value(v),
None => first_message_preview_b.append_null(),
}
}
let columns: Vec<ArrayRef> = vec![
Arc::new(conversation_id_b.finish()),
Arc::new(total_events_b.finish()),
Arc::new(committed_turns_b.finish()),
Arc::new(input_tokens_b.finish()),
Arc::new(output_tokens_b.finish()),
Arc::new(summary_active_b.finish()),
Arc::new(summary_text_b.finish()),
Arc::new(last_turn_id_b.finish()),
Arc::new(edges_json_b.finish()),
Arc::new(settlements_json_b.finish()),
Arc::new(created_at_ms_b.finish()),
Arc::new(last_activity_ms_b.finish()),
Arc::new(persona_id_b.finish()),
Arc::new(first_message_preview_b.finish()),
];
RecordBatch::try_new(schema(), columns)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use arrow::array::{Array as _, BooleanArray, StringArray, UInt64Array};
use super::*;
use crate::dashboard::DashboardSettlement;
fn sample_row() -> DashboardRow {
DashboardRow {
conversation_id: "conv-a".to_owned(),
total_events: 9,
committed_turns: 1,
input_tokens: 10,
output_tokens: 5,
summary_text: Some("condensed".to_owned()),
last_turn_id: Some("abc123".to_owned()),
edges: vec!["web".to_owned(), "slack".to_owned()],
settlements: vec![DashboardSettlement {
persona: "persona-a".to_owned(),
spend_base_units: 1_500,
charged_base_units: 250,
}],
created_at_ms: Some(1_700_000_000_000),
last_activity_ms: Some(1_700_000_001_000),
persona_id: Some("persona-a".to_owned()),
first_message_preview: Some("hello there".to_owned()),
}
}
#[test]
fn decode_dashboard_batch_round_trips_every_column() {
let batch = decode_dashboard_batch(&[sample_row()]).expect("batch build");
assert_eq!(batch.num_rows(), 1);
assert_eq!(batch.schema(), schema());
let col = |i: usize| batch.column(i).clone();
let conversation_id = col(0);
let conversation_id = conversation_id
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(conversation_id.value(0), "conv-a");
let total_events = col(1);
let total_events = total_events.as_any().downcast_ref::<UInt64Array>().unwrap();
assert_eq!(total_events.value(0), 9);
let summary_active = col(5);
let summary_active = summary_active
.as_any()
.downcast_ref::<BooleanArray>()
.unwrap();
assert!(summary_active.value(0));
let edges_json = col(8);
let edges_json = edges_json.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(edges_json.value(0), r#"["web","slack"]"#);
let settlements_json = col(9);
let settlements_json = settlements_json
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
let decoded: serde_json::Value = serde_json::from_str(settlements_json.value(0)).unwrap();
assert_eq!(decoded[0]["persona"], "persona-a");
assert_eq!(decoded[0]["spend_base_units"], "1500");
assert_eq!(decoded[0]["charged_base_units"], "250");
let persona_id = col(12);
let persona_id = persona_id.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(persona_id.value(0), "persona-a");
}
#[test]
fn decode_dashboard_batch_renders_absent_fields_as_null_and_empty_lists_as_empty_json_arrays() {
let row = DashboardRow {
conversation_id: "conv-bare".to_owned(),
..Default::default()
};
let batch = decode_dashboard_batch(&[row]).expect("batch build");
let summary_text = batch.column(6);
let summary_text = summary_text.as_any().downcast_ref::<StringArray>().unwrap();
assert!(summary_text.is_null(0));
let created_at_ms = batch.column(10);
let created_at_ms = created_at_ms
.as_any()
.downcast_ref::<UInt64Array>()
.unwrap();
assert!(created_at_ms.is_null(0));
let edges_json = batch.column(8);
let edges_json = edges_json.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(edges_json.value(0), "[]");
let settlements_json = batch.column(9);
let settlements_json = settlements_json
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(settlements_json.value(0), "[]");
}
#[test]
fn decode_dashboard_batch_empty_input_has_zero_rows_and_the_full_schema() {
let batch = decode_dashboard_batch(&[]).expect("batch build");
assert_eq!(batch.num_rows(), 0);
assert_eq!(batch.schema(), schema());
}
}