use std::future::Future;
use std::pin::Pin;
use futures::stream::{self, StreamExt, TryStreamExt};
use serde::Serialize;
use tokio::sync::Mutex;
use super::{Ctx, Item, PipelineError, RoleClient, item_error, without_topics};
use crate::config::Topic;
use crate::dataset::{Appender, Id, Question, Rejected, Subtopic, read, rewrite};
use crate::dedup::Deduplicator;
use crate::events::{Event, Stage, StageStats};
use crate::llm::{LlmClient, LlmError, Usage};
use crate::prompts;
type Fill<'a> = Pin<Box<dyn Future<Output = Result<(), PipelineError>> + Send + 'a>>;
#[derive(Serialize)]
struct Context<'a> {
topic: &'a str,
description: Option<&'a str>,
subtopic: &'a str,
count: usize,
accepted: &'a [String],
}
pub async fn questions<C, D, F>(
ctx: &Ctx<'_>,
generator: &RoleClient<C>,
mut new_dedup: F,
) -> Result<StageStats, PipelineError>
where
C: LlmClient,
D: Deduplicator,
F: FnMut() -> D,
{
let topics = ctx.topics()?;
let subtopics: Vec<Subtopic> = read(&ctx.files.subtopics)?;
let mut existing: Vec<Question> = read(&ctx.files.questions)?;
let rejected: Vec<Rejected> = read(&ctx.files.rejected)?;
if ctx.force {
existing = without_topics(existing, &topics, |question| &question.topic);
rewrite(&ctx.files.questions, &existing)?;
}
ctx.bus.publish(Event::StageStarted {
stage: Stage::Questions,
total: selected_subtopics(&subtopics, &topics),
});
ctx.bus.publish(Event::StageModel {
stage: Stage::Questions,
model: generator.model.model.clone(),
});
let mut stats = StageStats::default();
let mut ready: Vec<(&Topic, Mutex<D>)> = Vec::new();
for topic in topics {
let mut dedup = new_dedup();
let pending = subtopics.iter().any(|subtopic| {
subtopic.topic == topic.name && needs_filling(subtopic, &existing, &[topic])
});
if pending {
let known = seed_texts(&topic.name, &existing, &rejected);
if !seed(ctx, topic, &known, &mut dedup, &mut stats).await? {
continue;
}
}
ready.push((topic, Mutex::new(dedup)));
}
let mut slots = Vec::new();
for (topic, dedup) in &ready {
for subtopic in subtopics.iter().filter(|s| s.topic == topic.name) {
let accepted: Vec<String> = existing
.iter()
.filter(|question| question.subtopic_id == subtopic.id)
.map(|question| question.text.clone())
.collect();
let slot = Slot::new(topic, subtopic, accepted, dedup);
if slot.accepted.len() >= slot.target {
stats.skipped += 1;
ctx.bus.publish(Event::ItemDone {
stage: Stage::Questions,
id: slot.item.id,
usage: None,
cost: None,
excluded: false,
});
} else {
slots.push(slot);
}
}
}
let filler = Filler {
ctx,
generator,
out: Mutex::new(Appender::open(&ctx.files.questions)?),
stats: Mutex::new(stats),
};
let fills: Vec<Fill<'_>> = slots
.into_iter()
.map(|slot| Box::pin(filler.fill(slot)) as Fill<'_>)
.collect();
stream::iter(fills)
.buffer_unordered(ctx.settings.pipeline.concurrency.max(1))
.try_for_each(|()| std::future::ready(Ok(())))
.await?;
let stats = filler.stats.into_inner();
ctx.bus.publish(Event::StageFinished {
stage: Stage::Questions,
stats: stats.clone(),
});
Ok(stats)
}
fn seed_texts(topic_name: &str, existing: &[Question], rejected: &[Rejected]) -> Vec<String> {
let stored = existing
.iter()
.filter(|question| question.topic == topic_name)
.map(|question| question.text.clone());
let deleted = rejected.iter().filter_map(|record| match record {
Rejected::Question { topic, text, .. } if topic == topic_name => Some(text.clone()),
_ => None,
});
stored.chain(deleted).collect()
}
async fn seed<D: Deduplicator>(
ctx: &Ctx<'_>,
topic: &Topic,
known: &[String],
dedup: &mut D,
stats: &mut StageStats,
) -> Result<bool, PipelineError> {
let Err(error) = dedup.record(known).await else {
return Ok(true);
};
let item = Item {
stage: Stage::Questions,
id: topic.name.clone(),
};
item_error(ctx, &item, error, stats)?;
Ok(false)
}
fn selected_subtopics(subtopics: &[Subtopic], topics: &[&Topic]) -> usize {
subtopics
.iter()
.filter(|subtopic| topics.iter().any(|topic| topic.name == subtopic.topic))
.count()
}
fn needs_filling(subtopic: &Subtopic, existing: &[Question], topics: &[&Topic]) -> bool {
let Some(topic) = topics.iter().find(|topic| topic.name == subtopic.topic) else {
return false;
};
let accepted = existing
.iter()
.filter(|question| question.subtopic_id == subtopic.id)
.count();
accepted < usize::try_from(topic.questions_per_subtopic).unwrap_or(usize::MAX)
}
struct Slot<'a, D> {
topic: &'a Topic,
subtopic: &'a Subtopic,
item: Item,
target: usize,
accepted: Vec<String>,
dedup: &'a Mutex<D>,
}
impl<'a, D> Slot<'a, D> {
fn new(
topic: &'a Topic,
subtopic: &'a Subtopic,
accepted: Vec<String>,
dedup: &'a Mutex<D>,
) -> Self {
Self {
topic,
subtopic,
item: Item {
stage: Stage::Questions,
id: subtopic.id.to_string(),
},
target: usize::try_from(topic.questions_per_subtopic).unwrap_or(usize::MAX),
accepted,
dedup,
}
}
}
struct Filler<'a, C> {
ctx: &'a Ctx<'a>,
generator: &'a RoleClient<C>,
out: Mutex<Appender>,
stats: Mutex<StageStats>,
}
impl<C: LlmClient> Filler<'_, C> {
async fn fill<D: Deduplicator>(&self, mut slot: Slot<'_, D>) -> Result<(), PipelineError> {
let patience = self.ctx.settings.pipeline.max_retries.max(1);
let mut stalls = 0;
let mut usage = Usage::default();
while slot.accepted.len() < slot.target && stalls < patience {
let Some((added, batch_usage)) = self.batch(&slot).await? else {
return Ok(());
};
usage += batch_usage;
stalls = if added.is_empty() { stalls + 1 } else { 0 };
slot.accepted.extend(added);
}
if slot.accepted.len() < slot.target {
short(&slot);
}
self.stats.lock().await.done += 1;
self.ctx.bus.publish(Event::ItemDone {
stage: Stage::Questions,
id: slot.item.id,
usage: Some(usage),
cost: self.generator.price.as_ref().map(|price| price.cost(usage)),
excluded: false,
});
Ok(())
}
async fn batch<D: Deduplicator>(
&self,
slot: &Slot<'_, D>,
) -> Result<Option<(Vec<String>, Usage)>, PipelineError> {
let batch_size =
usize::try_from(self.ctx.settings.pipeline.question_batch_size).unwrap_or(usize::MAX);
let count = batch_size.min(slot.target - slot.accepted.len());
let prompt = self.ctx.prompts.render(
prompts::QUESTIONS,
Context {
topic: &slot.topic.name,
description: slot.topic.description.as_deref(),
subtopic: &slot.subtopic.name,
count,
accepted: &slot.accepted,
},
)?;
let mut spent = StageStats::default();
let asked = self
.generator
.ask_list(&self.ctx.asking(), prompt, &slot.item, &mut spent)
.await;
self.stats.lock().await.merge(&spent);
let (candidates, usage) = match asked {
Ok(asked) => asked,
Err(error) => return self.failed(&slot.item, error).await,
};
let candidates = candidates.into_iter().take(count).collect();
let admitted = slot.dedup.lock().await.admit(candidates).await;
let added = match admitted {
Ok(added) => added,
Err(error) => return self.failed(&slot.item, error).await,
};
let mut out = self.out.lock().await;
for text in &added {
out.append(&Question {
id: Id::question(&slot.subtopic.id, text),
topic: slot.topic.name.clone(),
subtopic_id: slot.subtopic.id.clone(),
subtopic: slot.subtopic.name.clone(),
text: text.clone(),
})?;
}
Ok(Some((added, usage)))
}
async fn failed<T>(&self, item: &Item, error: LlmError) -> Result<Option<T>, PipelineError> {
let mut stats = self.stats.lock().await;
item_error(self.ctx, item, error, &mut stats)?;
Ok(None)
}
}
fn short<D>(slot: &Slot<'_, D>) {
tracing::warn!(
"subtopic `{}`: stopped at {} of {} questions, the generator kept repeating itself",
slot.subtopic.name,
slot.accepted.len(),
slot.target
);
}