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}