use crate::cache::Cache;
use crate::config::Config;
use crate::models::{MetricValue, ModelScore, SourceResult, SourceStatus};
use crate::sources::Source;
use anyhow::{Context, Result};
use chrono::Utc;
use std::collections::HashMap;
use std::time::Duration;
pub struct Arena;
impl Source for Arena {
fn name(&self) -> &str {
"arena"
}
fn fetch(&self, _config: &Config, cache: &Cache) -> Result<SourceResult> {
if let Some((fetched_at, cached_data)) = cache.get("arena") {
return Ok(self.parse_response(&cached_data, Some(fetched_at), SourceStatus::Cached));
}
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let response = client
.get("https://raw.githubusercontent.com/nakasyou/lmarena-history/main/output/scores.json")
.send()
.context("Failed to fetch from Arena GitHub")?;
if !response.status().is_success() {
return Ok(SourceResult {
source: self.name().into(),
fetched_at: None,
status: SourceStatus::Error(format!("HTTP {}", response.status())),
scores: vec![],
});
}
let data = response.json::<serde_json::Value>()?;
cache.set("arena", &data)?;
Ok(self.parse_response(&data, Some(Utc::now()), SourceStatus::Ok))
}
}
impl Arena {
fn parse_response(
&self,
data: &serde_json::Value,
fetched_at: Option<chrono::DateTime<Utc>>,
status: SourceStatus,
) -> SourceResult {
let mut scores = Vec::new();
let obj = match data.as_object() {
Some(o) => o,
None => {
return SourceResult {
source: self.name().into(),
fetched_at,
status,
scores,
};
}
};
let text_data = match obj
.keys()
.max()
.and_then(|k| obj.get(k))
.and_then(|d| d.get("text"))
{
Some(t) => t,
None => {
return SourceResult {
source: self.name().into(),
fetched_at,
status,
scores,
};
}
};
let category = if text_data.get("overall").is_some() {
"overall"
} else if text_data.get("full_old").is_some() {
"full_old"
} else if let Some(first_category) = text_data.as_object().and_then(|o| o.keys().next()) {
first_category.as_str()
} else {
return SourceResult {
source: self.name().into(),
fetched_at,
status: SourceStatus::Error("No valid categories found".into()),
scores: vec![],
};
};
if let Some(models_obj) = text_data.get(category).and_then(|c| c.as_object()) {
let mut ranked_models: Vec<(String, f64)> = models_obj
.iter()
.filter_map(|(model_name, score_value)| {
score_value
.as_f64()
.map(|score| (model_name.clone(), score))
})
.collect();
ranked_models
.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
for (rank, (source_model_name, elo_score)) in ranked_models.iter().enumerate() {
let rank_u32 = (rank + 1) as u32;
let canonical_name = source_model_name.to_lowercase().replace([' ', '_'], "-");
let mut metrics = HashMap::new();
metrics.insert("elo_score".into(), MetricValue::Float(*elo_score));
metrics.insert("rank".into(), MetricValue::Int(rank_u32 as i64));
scores.push(ModelScore {
model: canonical_name,
source_model_name: source_model_name.clone(),
metrics,
rank: Some(rank_u32),
});
}
}
if scores.is_empty() && matches!(status, SourceStatus::Ok | SourceStatus::Cached) {
return SourceResult {
source: self.name().into(),
fetched_at,
status: SourceStatus::Error("Failed to parse Arena data structure".into()),
scores: vec![],
};
}
SourceResult {
source: self.name().into(),
fetched_at,
status,
scores,
}
}
}