use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::Metadata;
use crate::content::Part;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Sample {
pub id: String,
pub input: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Part>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: Metadata,
}
impl Sample {
pub fn new(id: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
id: id.into(),
input: vec![prompt.into()],
..Default::default()
}
}
pub fn turns(
id: impl Into<String>,
turns: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
id: id.into(),
input: turns.into_iter().map(Into::into).collect(),
..Default::default()
}
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn attach(mut self, part: Part) -> Self {
self.attachments.push(part);
self
}
pub fn image(self, media_type: impl Into<String>, uri: impl Into<String>) -> Self {
self.attach(Part::image_uri(media_type, uri))
}
pub fn prompt_parts(&self) -> Vec<Part> {
let mut parts: Vec<Part> = self.input.iter().map(|s| Part::text(s.as_str())).collect();
parts.extend(self.attachments.iter().cloned());
parts
}
pub fn modalities(&self) -> Vec<&'static str> {
let mut seen: Vec<&'static str> = Vec::new();
if !self.input.is_empty() {
seen.push("text");
}
for part in &self.attachments {
let k = part.kind();
if !seen.contains(&k) {
seen.push(k);
}
}
seen
}
pub fn expected(mut self, expected: impl Into<serde_json::Value>) -> Self {
self.expected = Some(expected.into());
self
}
pub fn file(mut self, path: impl Into<String>, contents: impl Into<String>) -> Self {
self.files.insert(path.into(), contents.into());
self
}
pub fn meta(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn expected_str(&self) -> Option<&str> {
self.expected.as_ref().and_then(|v| v.as_str())
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Dataset {
pub samples: Vec<Sample>,
}
impl Dataset {
pub fn new(samples: Vec<Sample>) -> Self {
Self { samples }
}
pub fn len(&self) -> usize {
self.samples.len()
}
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
pub fn jsonl(path: impl AsRef<Path>) -> std::io::Result<Self> {
let text = std::fs::read_to_string(path)?;
Self::from_jsonl_str(&text)
}
pub fn from_jsonl_str(text: &str) -> std::io::Result<Self> {
let mut samples = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let sample: Sample = serde_json::from_str(line).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("line {}: {e}", i + 1),
)
})?;
samples.push(sample);
}
Ok(Self { samples })
}
pub fn json(path: impl AsRef<Path>) -> std::io::Result<Self> {
let text = std::fs::read_to_string(path)?;
let samples: Vec<Sample> = serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
Ok(Self { samples })
}
}
impl From<Vec<Sample>> for Dataset {
fn from(samples: Vec<Sample>) -> Self {
Self { samples }
}
}
impl FromIterator<Sample> for Dataset {
fn from_iter<T: IntoIterator<Item = Sample>>(iter: T) -> Self {
Self {
samples: iter.into_iter().collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_builder() {
let s = Sample::new("a", "hi")
.tag("smoke")
.expected("42")
.file("main.rs", "fn main() {}")
.meta("trace", "https://obs/123");
assert_eq!(s.input, vec!["hi"]);
assert_eq!(s.tags, vec!["smoke"]);
assert_eq!(s.expected_str(), Some("42"));
assert_eq!(s.files.get("main.rs").unwrap(), "fn main() {}");
assert_eq!(s.metadata.get("trace").unwrap(), "https://obs/123");
}
#[test]
fn jsonl_roundtrip_and_blank_lines() {
let text = r#"
{"id":"a","input":["hello"]}
{"id":"b","input":["world"],"tags":["smoke"]}
"#;
let ds = Dataset::from_jsonl_str(text).unwrap();
assert_eq!(ds.len(), 2);
assert_eq!(ds.samples[1].tags, vec!["smoke"]);
}
#[test]
fn jsonl_reports_bad_line() {
let err = Dataset::from_jsonl_str("{not json}").unwrap_err();
assert!(err.to_string().contains("line 1"));
}
#[test]
fn multi_turn() {
let s = Sample::turns("c", ["a", "b", "c"]);
assert_eq!(s.input.len(), 3);
}
#[test]
fn multimodal_input_fuses_text_and_attachments() {
let s = Sample::new("a", "what is in this image?")
.image("image/png", "https://x/cat.png")
.attach(Part::audio_uri("audio/wav", "https://x/clip.wav"));
let parts = s.prompt_parts();
assert_eq!(parts.len(), 3); assert_eq!(parts[0].as_text(), Some("what is in this image?"));
assert_eq!(parts[1].kind(), "image");
assert_eq!(s.modalities(), vec!["text", "image", "audio"]);
}
#[test]
fn text_only_sample_has_no_attachments_on_the_wire() {
let s = Sample::new("a", "hi");
assert!(s.attachments.is_empty());
let line = serde_json::to_string(&s).unwrap();
assert!(!line.contains("attachments"));
let m = Sample::new("b", "describe").image("image/png", "u");
let back: Sample = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back, m);
}
}