klieo-memory-graph-rag 3.5.0

Graph-first RAG composer over KnowledgeGraph + LongTermMemory. Stable at 1.x per ADR-039.
Documentation
//! `BuiltinExtractor` — regex-driven entity recogniser for known typed
//! formats, merged with caller-supplied hints.
//!
//! Sync I/O-free recogniser used by `GraphAwareLongTerm` as the
//! `EntityExtractor` primary path (M2 Task 2.4.6). Chains under
//! `FallbackExtractor` (Task 2.4.3) with `LlmEntityExtractor`
//! (Task 2.4.4) when extra recall is needed for free-form text.

use async_trait::async_trait;
use klieo_core::error::MemoryError;
use klieo_memory_graph::{EntityExtractor, EntityRef, EntityType};
use std::collections::HashSet;

/// Regex-driven entity extractor. Recognises typed-prefix identifiers
/// (e.g. `TICKET-123`, `POL-7`) and merges them with caller-supplied
/// hints, deduplicating by `(EntityType::as_str(), name)` after the
/// `EntityRef::new` lowercase normalisation.
pub struct BuiltinExtractor {
    /// Catches alphanumeric-prefix identifiers like `TICKET-1` / `POL-42`.
    /// Names are normalised to lowercase by `EntityRef::new`.
    typed_id_re: regex::Regex,
}

impl Default for BuiltinExtractor {
    fn default() -> Self {
        Self {
            typed_id_re: regex::Regex::new(r"\b[A-Z][A-Z0-9]*-\d+\b")
                .expect("BuiltinExtractor typed-id regex is statically valid"),
        }
    }
}

#[async_trait]
impl EntityExtractor for BuiltinExtractor {
    async fn extract(
        &self,
        text: &str,
        hints: &[EntityRef],
    ) -> Result<Vec<EntityRef>, MemoryError> {
        let mut seen: HashSet<(String, String)> = HashSet::new();
        let mut out: Vec<EntityRef> = Vec::with_capacity(hints.len());

        // Hints take precedence — never drop a hint silently.
        for hint in hints {
            let key = (hint.entity_type.as_str().to_owned(), hint.name.clone());
            if seen.insert(key) {
                out.push(hint.clone());
            }
        }

        // Regex hits classified as Ticket today; M6 introduces Policy /
        // Article disambiguation rules and this becomes a prefix-table
        // lookup. Until then any typed-prefix-id maps to Ticket — the
        // call-site contract is "use the typed variant; Other{hint} is
        // the escape hatch".
        for hit in self.typed_id_re.find_iter(text) {
            let candidate = EntityRef::new(EntityType::Ticket, hit.as_str());
            let key = (
                candidate.entity_type.as_str().to_owned(),
                candidate.name.clone(),
            );
            if seen.insert(key) {
                out.push(candidate);
            }
        }
        Ok(out)
    }
}