Skip to main content

klieo_memory_graph_rag/
extractor.rs

1//! `BuiltinExtractor` — regex-driven entity recogniser for known typed
2//! formats, merged with caller-supplied hints.
3//!
4//! Sync I/O-free recogniser used by `GraphAwareLongTerm` as the
5//! `EntityExtractor` primary path (M2 Task 2.4.6). Chains under
6//! `FallbackExtractor` (Task 2.4.3) with `LlmEntityExtractor`
7//! (Task 2.4.4) when extra recall is needed for free-form text.
8
9use async_trait::async_trait;
10use klieo_core::error::MemoryError;
11use klieo_memory_graph::{EntityExtractor, EntityRef, EntityType};
12use std::collections::HashSet;
13
14/// Regex-driven entity extractor. Recognises typed-prefix identifiers
15/// (e.g. `TICKET-123`, `POL-7`) and merges them with caller-supplied
16/// hints, deduplicating by `(EntityType::as_str(), name)` after the
17/// `EntityRef::new` lowercase normalisation.
18pub struct BuiltinExtractor {
19    /// Catches alphanumeric-prefix identifiers like `TICKET-1` / `POL-42`.
20    /// Names are normalised to lowercase by `EntityRef::new`.
21    typed_id_re: regex::Regex,
22}
23
24impl Default for BuiltinExtractor {
25    fn default() -> Self {
26        Self {
27            typed_id_re: regex::Regex::new(r"\b[A-Z][A-Z0-9]*-\d+\b")
28                .expect("BuiltinExtractor typed-id regex is statically valid"),
29        }
30    }
31}
32
33#[async_trait]
34impl EntityExtractor for BuiltinExtractor {
35    async fn extract(
36        &self,
37        text: &str,
38        hints: &[EntityRef],
39    ) -> Result<Vec<EntityRef>, MemoryError> {
40        let mut seen: HashSet<(String, String)> = HashSet::new();
41        let mut out: Vec<EntityRef> = Vec::with_capacity(hints.len());
42
43        // Hints take precedence — never drop a hint silently.
44        for hint in hints {
45            let key = (hint.entity_type.as_str().to_owned(), hint.name.clone());
46            if seen.insert(key) {
47                out.push(hint.clone());
48            }
49        }
50
51        // Regex hits classified as Ticket today; M6 introduces Policy /
52        // Article disambiguation rules and this becomes a prefix-table
53        // lookup. Until then any typed-prefix-id maps to Ticket — the
54        // call-site contract is "use the typed variant; Other{hint} is
55        // the escape hatch".
56        for hit in self.typed_id_re.find_iter(text) {
57            let candidate = EntityRef::new(EntityType::Ticket, hit.as_str());
58            let key = (
59                candidate.entity_type.as_str().to_owned(),
60                candidate.name.clone(),
61            );
62            if seen.insert(key) {
63                out.push(candidate);
64            }
65        }
66        Ok(out)
67    }
68}