use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use futures::stream;
use serde::Deserialize;
use meerkat_client::types::LlmStream;
use meerkat_client::{LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
use meerkat_core::Provider;
use meerkat_mobkit::memory::records::{MemoryKind, RecordMeta};
use meerkat_mobkit::memory::selector::{
Coverage, FactorySelectorHandle, SelectorError, SelectorHandle, SelectorProfile, select,
};
const EXIT_SELECTOR_ERROR: i32 = 1;
const EXIT_USAGE: i32 = 2;
const EXIT_NO_AUTH: i32 = 3;
#[derive(Deserialize)]
struct FixtureRecordMeta {
id: String,
kind: String,
title: String,
#[serde(default)]
description: String,
age_days: u64,
#[serde(default)]
rank: Option<u32>,
}
impl FixtureRecordMeta {
fn into_record_meta(self) -> Result<RecordMeta, String> {
let kind = match self.kind.as_str() {
"Preference" => MemoryKind::Preference,
"Fact" => MemoryKind::Fact,
"Gotcha" => MemoryKind::Gotcha,
"Procedure" => MemoryKind::Procedure,
"Relationship" => MemoryKind::Relationship,
"OpenLoop" => MemoryKind::OpenLoop,
"Reference" => MemoryKind::Reference,
other => match MemoryKind::parse(other) {
Some(kind) => kind,
None => return Err(format!("unknown record kind '{other}'")),
},
};
Ok(RecordMeta {
id: self.id,
kind,
title: self.title,
description: self.description,
age_days: self.age_days,
rank: self.rank,
})
}
}
#[derive(Deserialize)]
struct EvalInput {
manifest: Vec<FixtureRecordMeta>,
turn_text: String,
#[serde(default)]
suppressed_ids: Vec<String>,
#[serde(default)]
profile_path: Option<PathBuf>,
}
struct MockSelectorLlm {
reply: String,
}
impl MockSelectorLlm {
fn for_input(input: &EvalInput, manifest: &[RecordMeta]) -> Self {
let suppressed: HashSet<&str> = input.suppressed_ids.iter().map(String::as_str).collect();
let turn_tokens = tokens(&input.turn_text);
let mut ids: Vec<&str> = manifest
.iter()
.filter(|meta| {
!suppressed.contains(meta.id.as_str())
&& tokens(&meta.title).iter().any(|t| turn_tokens.contains(t))
})
.map(|meta| meta.id.as_str())
.collect();
ids.sort_unstable();
let reply = serde_json::json!({
"selected_ids": ids,
"coverage": "sufficient",
});
Self {
reply: reply.to_string(),
}
}
}
fn tokens(text: &str) -> HashSet<String> {
text.to_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|token| token.len() >= 4)
.map(str::to_string)
.collect()
}
#[async_trait]
impl LlmClient for MockSelectorLlm {
fn stream<'a>(&'a self, _request: &'a LlmRequest) -> LlmStream<'a> {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: self.reply.clone(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat_core::StopReason::EndTurn,
},
}),
]))
}
fn provider(&self) -> Provider {
Provider::Other
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
}
fn fail(code: i32, message: &str) -> ! {
eprintln!("selector-eval: {message}");
std::process::exit(code);
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut mock = false;
for arg in &args {
match arg.as_str() {
"--mock" => mock = true,
"--help" | "-h" => {
eprintln!(
"usage: selector-eval [--mock] < fixture.json\n\
stdin: {{manifest, turn_text, suppressed_ids?, profile_path?}}\n\
stdout: {{selected_ids, coverage}}\n\
exit: 0 ok, 1 error, 2 usage, 3 live-without-auth (SKIP)"
);
std::process::exit(0);
}
other => fail(EXIT_USAGE, &format!("unknown argument '{other}'")),
}
}
let mut raw = String::new();
if std::io::stdin().read_to_string(&mut raw).is_err() || raw.trim().is_empty() {
fail(EXIT_USAGE, "expected a fixture JSON object on stdin");
}
let mut input: EvalInput = match serde_json::from_str(&raw) {
Ok(input) => input,
Err(err) => fail(EXIT_USAGE, &format!("invalid fixture JSON: {err}")),
};
let manifest: Vec<RecordMeta> = match std::mem::take(&mut input.manifest)
.into_iter()
.map(FixtureRecordMeta::into_record_meta)
.collect::<Result<_, _>>()
{
Ok(manifest) => manifest,
Err(err) => fail(EXIT_USAGE, &format!("invalid fixture manifest: {err}")),
};
let profile = match input.profile_path.as_deref() {
Some(path) => match SelectorProfile::load(path) {
Ok(profile) => profile,
Err(err) => fail(EXIT_USAGE, &err.to_string()),
},
None => SelectorProfile::embedded_default(),
};
let client: Arc<dyn LlmClient> = if mock {
Arc::new(MockSelectorLlm::for_input(&input, &manifest))
} else {
let state_dir = match tempfile::tempdir() {
Ok(dir) => dir.keep(),
Err(err) => fail(
EXIT_SELECTOR_ERROR,
&format!("cannot create scratch state dir: {err}"),
),
};
let handle =
FactorySelectorHandle::new(state_dir, meerkat::Config::default(), "default", &profile);
match handle.client().await {
Ok(client) => client,
Err(SelectorError::Auth(message)) => fail(
EXIT_NO_AUTH,
&format!("no resolvable auth for live selector run: {message}"),
),
Err(err) => fail(EXIT_SELECTOR_ERROR, &err.to_string()),
}
};
let suppressed: HashSet<String> = input.suppressed_ids.iter().cloned().collect();
let selection = match select(&manifest, &input.turn_text, &suppressed, &profile, &*client).await
{
Ok(selection) => selection,
Err(SelectorError::Auth(message)) if !mock => fail(
EXIT_NO_AUTH,
&format!("auth rejected during live selector run: {message}"),
),
Err(err) => fail(EXIT_SELECTOR_ERROR, &err.to_string()),
};
let coverage = match selection.coverage {
Coverage::Sufficient => "sufficient",
Coverage::NeedDeeperSweep => "need_deeper_sweep",
};
println!(
"{}",
serde_json::json!({
"selected_ids": selection.selected_ids,
"coverage": coverage,
})
);
}