mod answers;
mod parse;
mod questions;
mod split;
mod subtopics;
pub use answers::{answers, classify, raw_reasoning_warning};
pub use parse::string_array;
pub use questions::questions;
pub use split::{SplitClass, SplitReport, eval_size, split, split_class, stratify};
pub use subtopics::subtopics;
use crate::config::{RoleModel, Settings, Topic};
use crate::dataset::{DataFiles, DatasetError};
use crate::events::{Event, EventBus, Stage, StageStats};
use crate::llm::{
Completion, CompletionRequest, LlmClient, LlmError, RetryPolicy, Usage, with_retry,
};
use crate::pricing::Price;
use crate::prompts::{PromptError, Prompts};
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error(transparent)]
Dataset(#[from] DatasetError),
#[error(transparent)]
Prompt(#[from] PromptError),
#[error("{stage} stopped")]
Llm {
stage: Stage,
#[source]
source: LlmError,
spent: Box<StageStats>,
},
#[error("unknown topic `{0}`")]
UnknownTopic(String),
#[error("a request task failed")]
Task(#[source] tokio::task::JoinError),
}
#[derive(Debug)]
pub struct RoleClient<C> {
pub client: C,
pub model: RoleModel,
pub price: Option<Price>,
}
impl<C: LlmClient> RoleClient<C> {
#[must_use]
pub fn request(&self, system: Option<String>, prompt: String) -> CompletionRequest {
CompletionRequest::for_role(&self.model, system, prompt)
}
}
#[derive(Debug, Clone, Copy)]
pub struct Ctx<'a> {
pub settings: &'a Settings,
pub files: &'a DataFiles,
pub prompts: &'a Prompts,
pub bus: &'a EventBus,
pub topic: Option<&'a str>,
pub force: bool,
}
impl Ctx<'_> {
pub fn topics(&self) -> Result<Vec<&Topic>, PipelineError> {
let topics: Vec<&Topic> = self
.settings
.topics
.iter()
.filter(|topic| self.topic.is_none_or(|name| topic.name == name))
.collect();
match self.topic {
Some(name) if topics.is_empty() => Err(PipelineError::UnknownTopic(name.to_string())),
_ => Ok(topics),
}
}
#[must_use]
pub fn policy(&self) -> RetryPolicy {
RetryPolicy::new(self.settings.pipeline.max_retries)
}
}
#[derive(Debug, Clone)]
pub(crate) struct Item {
pub(crate) stage: Stage,
pub(crate) id: String,
}
impl Item {
pub(crate) fn failed(&self, bus: &EventBus, error: String, retryable: bool) {
bus.publish(Event::ItemFailed {
stage: self.stage,
id: self.id.clone(),
error,
retryable,
});
}
}
pub(crate) async fn complete_with_retry<C: LlmClient>(
client: &C,
policy: &RetryPolicy,
request: &CompletionRequest,
item: &Item,
bus: &EventBus,
) -> Result<Completion, LlmError> {
with_retry(
policy,
|| client.complete(request.clone()),
|error, wait| {
item.failed(
bus,
format!("{error}; retrying in {:.1}s", wait.as_secs_f64()),
true,
);
},
)
.await
}
pub(crate) async fn ask_list<C: LlmClient>(
ctx: &Ctx<'_>,
role: &RoleClient<C>,
prompt: String,
item: &Item,
stats: &mut StageStats,
) -> Result<(Vec<String>, Usage), LlmError> {
let policy = ctx.policy();
let request = role.request(None, prompt);
let mut usage = Usage::default();
for _ in 0..=policy.max_retries {
let completion =
complete_with_retry(&role.client, &policy, &request, item, ctx.bus).await?;
stats.add_usage(completion.usage, role.price.as_ref());
usage += completion.usage;
if let Some(items) = string_array(&completion.content) {
return Ok((items, usage));
}
item.failed(
ctx.bus,
"the answer is not a JSON array of strings".to_string(),
true,
);
}
Err(LlmError::InvalidResponse(
"no JSON array of strings after all retries".to_string(),
))
}
pub(crate) fn item_error(
ctx: &Ctx<'_>,
item: &Item,
error: LlmError,
stats: &mut StageStats,
) -> Result<(), PipelineError> {
if error.is_fatal_for_stage() {
return Err(PipelineError::Llm {
stage: item.stage,
source: error,
spent: Box::new(stats.clone()),
});
}
stats.failed += 1;
item.failed(ctx.bus, error.to_string(), false);
Ok(())
}
pub(crate) fn without_topics<T>(
items: Vec<T>,
topics: &[&Topic],
topic_of: impl Fn(&T) -> &str,
) -> Vec<T> {
items
.into_iter()
.filter(|item| !topics.iter().any(|topic| topic.name == topic_of(item)))
.collect()
}