use crate::error::ProjectionError;
use crate::rules;
use klieo_core::ids::{FactId, RunId};
use klieo_core::memory::{Episode, EpisodicMemory, Scope};
use klieo_memory_graph::{EntityExtractor, IndexEntry, KnowledgeGraph, RecallMetrics};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::broadcast::{self, error::RecvError};
use tokio_util::sync::CancellationToken;
#[non_exhaustive]
pub struct EpisodeProjector {
graph: Arc<dyn KnowledgeGraph>,
extractor: Arc<dyn EntityExtractor>,
scope: Scope,
metrics: Arc<RecallMetrics>,
seq: AtomicU64,
}
impl EpisodeProjector {
pub fn new(
graph: Arc<dyn KnowledgeGraph>,
extractor: Arc<dyn EntityExtractor>,
scope: Scope,
metrics: Arc<RecallMetrics>,
) -> Self {
Self {
graph,
extractor,
scope,
metrics,
seq: AtomicU64::new(0),
}
}
pub fn builder() -> EpisodeProjectorBuilder {
EpisodeProjectorBuilder::default()
}
pub fn spawn(
self: Arc<Self>,
mut rx: broadcast::Receiver<(RunId, Episode)>,
cancel: CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel.cancelled() => break,
msg = rx.recv() => match msg {
Ok((run_id, episode)) => {
let kind = episode_kind(&episode);
if let Err(e) = self.project_episode(run_id, &episode).await {
tracing::error!(
error = %e,
run_id = %run_id,
episode_kind = kind,
"episode projection failed",
);
}
}
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
scope = ?self.scope,
"projector lagged; episodes dropped",
);
self.metrics.record_projector_lag(skipped);
}
Err(RecvError::Closed) => break,
}
}
}
})
}
pub async fn project_run(
&self,
episodic: &dyn EpisodicMemory,
run_id: RunId,
) -> Result<usize, ProjectionError> {
let episodes = episodic
.replay(run_id)
.await
.map_err(ProjectionError::replay)?;
let mut batch: Vec<IndexEntry> = Vec::with_capacity(episodes.len());
for episode in &episodes {
if let Some(entry) = self.build_index_entry(run_id, episode).await? {
batch.push(entry);
}
}
if !batch.is_empty() {
self.graph
.index_many(self.scope.clone(), &batch)
.await
.map_err(ProjectionError::graph)?;
}
Ok(episodes.len())
}
async fn project_episode(
&self,
run_id: RunId,
episode: &Episode,
) -> Result<(), ProjectionError> {
let Some(entry) = self.build_index_entry(run_id, episode).await? else {
return Ok(());
};
self.graph
.index(
self.scope.clone(),
&entry.fact_id,
&entry.entities,
&entry.text,
entry.valid_from,
)
.await
.map_err(ProjectionError::graph)
}
async fn build_index_entry(
&self,
run_id: RunId,
episode: &Episode,
) -> Result<Option<IndexEntry>, ProjectionError> {
let hints = rules::entities_for_episode(episode);
let entities = self
.extractor
.extract("", &hints)
.await
.map_err(ProjectionError::extraction)?;
if entities.is_empty() {
return Ok(None);
}
let fact_id = self.next_fact_id(run_id, episode);
Ok(Some(IndexEntry::new(fact_id, entities, "", None)))
}
fn next_fact_id(&self, run_id: RunId, episode: &Episode) -> FactId {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
FactId::new(format!("{run_id}-{}-{seq}", episode_kind(episode)))
}
}
#[derive(Default)]
pub struct EpisodeProjectorBuilder {
metrics: Option<Arc<RecallMetrics>>,
}
impl EpisodeProjectorBuilder {
pub fn metrics(mut self, metrics: Arc<RecallMetrics>) -> Self {
self.metrics = Some(metrics);
self
}
pub fn build(
self,
graph: Arc<dyn KnowledgeGraph>,
extractor: Arc<dyn EntityExtractor>,
scope: Scope,
) -> EpisodeProjector {
EpisodeProjector::new(
graph,
extractor,
scope,
self.metrics
.unwrap_or_else(|| Arc::new(RecallMetrics::default())),
)
}
}
fn episode_kind(episode: &Episode) -> &'static str {
match episode {
Episode::Started { .. } => "started",
Episode::LlmCall { .. } => "llmcall",
Episode::ToolCall { .. } => "toolcall",
Episode::BusPublish { .. } => "buspublish",
Episode::BusReceive { .. } => "busreceive",
Episode::Completed => "completed",
Episode::Failed { .. } => "failed",
Episode::SummaryCheckpoint { .. } => "summarycheckpoint",
Episode::Ops(_) => "ops",
_ => "unknown",
}
}