klieo-memory-graph-rag 3.4.0

Graph-first RAG composer over KnowledgeGraph + LongTermMemory. Stable at 1.x per ADR-039.
Documentation
//! `FallbackExtractor` — chains primary → secondary `EntityExtractor`s.
//! Secondary runs only when primary returns an empty `Vec` (not on error).

use async_trait::async_trait;
use klieo_core::error::MemoryError;
use klieo_memory_graph::{EntityExtractor, EntityRef};

/// Two-stage extractor: secondary runs only when `primary.extract`
/// succeeds with an empty `Vec`. Errors from primary propagate
/// unchanged — secondary is a recall-extension fallback, not an
/// error-handling retry path.
///
/// Default M2 wiring (Task 2.4.6): `BuiltinExtractor` → `LlmEntityExtractor`.
/// The cheap regex primary handles known typed-prefix IDs synchronously;
/// the LLM secondary only fires on truly free-form text.
pub struct FallbackExtractor<P, S> {
    /// First-pass extractor — usually a cheap, deterministic recogniser.
    pub primary: P,
    /// Recall-extension extractor — runs only when `primary` returned `Ok(vec![])`.
    pub secondary: S,
}

impl<P, S> FallbackExtractor<P, S> {
    /// Build a chain with the given primary + secondary extractors.
    pub fn new(primary: P, secondary: S) -> Self {
        Self { primary, secondary }
    }
}

#[async_trait]
impl<P, S> EntityExtractor for FallbackExtractor<P, S>
where
    P: EntityExtractor,
    S: EntityExtractor,
{
    async fn extract(
        &self,
        text: &str,
        hints: &[EntityRef],
    ) -> Result<Vec<EntityRef>, MemoryError> {
        let primary_hits = self.primary.extract(text, hints).await?;
        if !primary_hits.is_empty() {
            return Ok(primary_hits);
        }
        self.secondary.extract(text, hints).await
    }
}