Skip to main content

oxirs_core/ai/
relation_extraction.rs

1//! Relation Extraction from Text using NLP
2//!
3//! This module provides automated relation extraction capabilities to build
4//! knowledge graphs from unstructured text data.
5
6use crate::ai::AiConfig;
7use crate::model::{Literal, NamedNode, Triple};
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Relation extraction module
13pub struct RelationExtractor {
14    /// Configuration
15    config: ExtractionConfig,
16
17    /// Named Entity Recognition model
18    ner_model: Box<dyn NamedEntityRecognizer>,
19
20    /// Relation classification model
21    relation_model: Box<dyn RelationClassifier>,
22
23    /// Entity linking module
24    entity_linker: Box<dyn EntityLinker>,
25
26    /// Confidence threshold
27    confidence_threshold: f32,
28}
29
30/// Relation extraction configuration
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ExtractionConfig {
33    /// Enable named entity recognition
34    pub enable_ner: bool,
35
36    /// Enable relation classification
37    pub enable_relation_classification: bool,
38
39    /// Enable entity linking
40    pub enable_entity_linking: bool,
41
42    /// Confidence threshold for extractions
43    pub confidence_threshold: f32,
44
45    /// Maximum sentence length
46    pub max_sentence_length: usize,
47
48    /// Language model to use
49    pub language_model: String,
50
51    /// Enable coreference resolution
52    pub enable_coreference: bool,
53
54    /// Supported languages
55    pub supported_languages: Vec<String>,
56}
57
58impl Default for ExtractionConfig {
59    fn default() -> Self {
60        Self {
61            enable_ner: true,
62            enable_relation_classification: true,
63            enable_entity_linking: true,
64            confidence_threshold: 0.7,
65            max_sentence_length: 512,
66            language_model: "bert-base-uncased".to_string(),
67            enable_coreference: true,
68            supported_languages: vec!["en".to_string()],
69        }
70    }
71}
72
73/// Extracted relation from text
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ExtractedRelation {
76    /// Subject entity
77    pub subject: ExtractedEntity,
78
79    /// Predicate/relation type
80    pub predicate: String,
81
82    /// Object entity
83    pub object: ExtractedEntity,
84
85    /// Confidence score
86    pub confidence: f32,
87
88    /// Source text span
89    pub source_span: TextSpan,
90
91    /// Context sentence
92    pub context: String,
93
94    /// Additional metadata
95    pub metadata: HashMap<String, String>,
96}
97
98/// Extracted entity
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ExtractedEntity {
101    /// Entity text
102    pub text: String,
103
104    /// Entity type
105    pub entity_type: EntityType,
106
107    /// Linked knowledge base ID (if available)
108    pub kb_id: Option<String>,
109
110    /// Confidence score
111    pub confidence: f32,
112
113    /// Text span in original document
114    pub span: TextSpan,
115}
116
117/// Entity types
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub enum EntityType {
120    Person,
121    Organization,
122    Location,
123    Date,
124    Time,
125    Money,
126    Percent,
127    Product,
128    Event,
129    Concept,
130    Other(String),
131}
132
133/// Text span
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct TextSpan {
136    /// Start position
137    pub start: usize,
138
139    /// End position
140    pub end: usize,
141
142    /// Text content
143    pub text: String,
144}
145
146/// Named Entity Recognition trait
147pub trait NamedEntityRecognizer: Send + Sync {
148    /// Extract named entities from text
149    fn extract_entities(&self, text: &str) -> Result<Vec<ExtractedEntity>>;
150
151    /// Get supported entity types
152    fn supported_types(&self) -> Vec<EntityType>;
153}
154
155/// Relation classification trait
156pub trait RelationClassifier: Send + Sync {
157    /// Classify relation between two entities
158    fn classify_relation(
159        &self,
160        text: &str,
161        subject: &ExtractedEntity,
162        object: &ExtractedEntity,
163    ) -> Result<Option<(String, f32)>>;
164
165    /// Get supported relation types
166    fn supported_relations(&self) -> Vec<String>;
167}
168
169/// Entity linking trait
170pub trait EntityLinker: Send + Sync {
171    /// Link entity to knowledge base
172    fn link_entity(&self, entity: &ExtractedEntity, context: &str) -> Result<Option<String>>;
173
174    /// Get knowledge base info
175    fn kb_info(&self) -> KnowledgeBaseInfo;
176}
177
178/// Knowledge base information
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct KnowledgeBaseInfo {
181    /// Knowledge base name
182    pub name: String,
183
184    /// Base URI
185    pub base_uri: String,
186
187    /// Version
188    pub version: String,
189
190    /// Entity count
191    pub entity_count: usize,
192}
193
194impl RelationExtractor {
195    /// Create a new relation extractor backed by the built-in **heuristic**
196    /// (rule-based) NER, relation classifier, and entity linker.
197    ///
198    /// These heuristic backends are transparent, deterministic, and honest: they
199    /// do NOT emulate a trained ML model, they do not fabricate confidence scores
200    /// dressed up as model probabilities, and the entity linker does not invent
201    /// unverified knowledge-base URIs. Every relation they produce is tagged with
202    /// `metadata["extraction_method"] = "heuristic-keyword-match"` so downstream
203    /// consumers can distinguish heuristic output from a real model's.
204    ///
205    /// To use a real ML backend (BERT-NER, a trained relation classifier, a live
206    /// entity-linking service, etc.), construct real trait objects and pass them
207    /// to [`with_backends`](Self::with_backends).
208    ///
209    /// `_config` is currently unused by the heuristic backends (they have no
210    /// tunable model parameters); it is retained for API symmetry with the
211    /// backend-injecting constructor.
212    pub fn new(_config: &AiConfig) -> Result<Self> {
213        Ok(Self {
214            config: ExtractionConfig::default(),
215            ner_model: Box::new(HeuristicNer::new()),
216            relation_model: Box::new(HeuristicRelationClassifier::new()),
217            entity_linker: Box::new(LocalEntityLinker::new()),
218            confidence_threshold: 0.7,
219        })
220    }
221
222    /// Create a relation extractor with caller-provided backends.
223    ///
224    /// This is the path for wiring in a real NER model, relation classifier, and
225    /// entity linker. The `extraction_config`'s `confidence_threshold` is used to
226    /// filter extracted relations.
227    pub fn with_backends(
228        extraction_config: ExtractionConfig,
229        ner_model: Box<dyn NamedEntityRecognizer>,
230        relation_model: Box<dyn RelationClassifier>,
231        entity_linker: Box<dyn EntityLinker>,
232    ) -> Self {
233        let confidence_threshold = extraction_config.confidence_threshold;
234        Self {
235            config: extraction_config,
236            ner_model,
237            relation_model,
238            entity_linker,
239            confidence_threshold,
240        }
241    }
242
243    /// Extract relations from text
244    pub async fn extract_relations(&self, text: &str) -> Result<Vec<ExtractedRelation>> {
245        // Step 1: Sentence segmentation
246        let sentences = self.segment_sentences(text);
247
248        let mut all_relations = Vec::new();
249
250        for sentence in sentences {
251            // Step 2: Named Entity Recognition
252            let entities = if self.config.enable_ner {
253                self.ner_model.extract_entities(&sentence)?
254            } else {
255                Vec::new()
256            };
257
258            // Step 3: Entity Linking
259            let linked_entities = if self.config.enable_entity_linking {
260                self.link_entities(&entities, &sentence).await?
261            } else {
262                entities
263            };
264
265            // Step 4: Relation Classification
266            if self.config.enable_relation_classification {
267                let relations =
268                    self.extract_relations_from_entities(&sentence, &linked_entities)?;
269                all_relations.extend(relations);
270            }
271        }
272
273        // Step 5: Filter by confidence
274        let filtered_relations = all_relations
275            .into_iter()
276            .filter(|r| r.confidence >= self.confidence_threshold)
277            .collect();
278
279        Ok(filtered_relations)
280    }
281
282    /// Convert extracted relations to RDF triples
283    pub fn to_triples(&self, relations: &[ExtractedRelation]) -> Result<Vec<Triple>> {
284        let mut triples = Vec::new();
285
286        for relation in relations {
287            // Create subject
288            let subject = if let Some(kb_id) = &relation.subject.kb_id {
289                NamedNode::new(kb_id)?
290            } else {
291                // Use text as identifier (simplified)
292                NamedNode::new(format!(
293                    "http://example.org/entity/{}",
294                    relation.subject.text.replace(' ', "_")
295                ))?
296            };
297
298            // Create predicate
299            let predicate = NamedNode::new(format!(
300                "http://example.org/relation/{}",
301                relation.predicate.replace(' ', "_")
302            ))?;
303
304            // Create object
305            let object = if let Some(kb_id) = &relation.object.kb_id {
306                crate::model::Object::NamedNode(NamedNode::new(kb_id)?)
307            } else {
308                // Determine if it's a literal or named node
309                match relation.object.entity_type {
310                    EntityType::Date
311                    | EntityType::Time
312                    | EntityType::Money
313                    | EntityType::Percent => {
314                        crate::model::Object::Literal(Literal::new(&relation.object.text))
315                    }
316                    _ => crate::model::Object::NamedNode(NamedNode::new(format!(
317                        "http://example.org/entity/{}",
318                        relation.object.text.replace(' ', "_")
319                    ))?),
320                }
321            };
322
323            let triple = Triple::new(subject, predicate, object);
324            triples.push(triple);
325        }
326
327        Ok(triples)
328    }
329
330    /// Segment text into sentences
331    fn segment_sentences(&self, text: &str) -> Vec<String> {
332        // Simplified sentence segmentation
333        text.split(". ")
334            .map(|s| s.trim().to_string())
335            .filter(|s| !s.is_empty())
336            .collect()
337    }
338
339    /// Link entities to knowledge base
340    async fn link_entities(
341        &self,
342        entities: &[ExtractedEntity],
343        context: &str,
344    ) -> Result<Vec<ExtractedEntity>> {
345        let mut linked_entities = Vec::new();
346
347        for entity in entities {
348            let mut linked_entity = entity.clone();
349
350            // Propagate linker errors (fail-loud) rather than silently dropping
351            // them; a `None` result simply means "no confident KB link".
352            if let Some(kb_id) = self.entity_linker.link_entity(entity, context)? {
353                linked_entity.kb_id = Some(kb_id);
354            }
355
356            linked_entities.push(linked_entity);
357        }
358
359        Ok(linked_entities)
360    }
361
362    /// Extract relations from entities in a sentence
363    fn extract_relations_from_entities(
364        &self,
365        sentence: &str,
366        entities: &[ExtractedEntity],
367    ) -> Result<Vec<ExtractedRelation>> {
368        let mut relations = Vec::new();
369
370        // Try all pairs of entities
371        for (i, subject) in entities.iter().enumerate() {
372            for (j, object) in entities.iter().enumerate() {
373                if i != j {
374                    // Propagate classifier errors (fail-loud) instead of silently
375                    // swallowing them.
376                    if let Some((relation_type, confidence)) = self
377                        .relation_model
378                        .classify_relation(sentence, subject, object)?
379                    {
380                        let mut metadata = HashMap::new();
381                        metadata.insert(
382                            "extraction_method".to_string(),
383                            "heuristic-keyword-match".to_string(),
384                        );
385                        let relation = ExtractedRelation {
386                            subject: subject.clone(),
387                            predicate: relation_type,
388                            object: object.clone(),
389                            confidence,
390                            source_span: TextSpan {
391                                start: 0,
392                                end: sentence.len(),
393                                text: sentence.to_string(),
394                            },
395                            context: sentence.to_string(),
396                            metadata,
397                        };
398
399                        relations.push(relation);
400                    }
401                }
402            }
403        }
404
405        Ok(relations)
406    }
407}
408
409/// Known organization name/suffix tokens for the heuristic NER gazetteer.
410const ORG_GAZETTEER: &[&str] = &[
411    "Inc",
412    "Inc.",
413    "Corp",
414    "Corp.",
415    "Corporation",
416    "Ltd",
417    "Ltd.",
418    "LLC",
419    "Company",
420    "GmbH",
421    "Microsoft",
422    "Google",
423    "Apple",
424    "Amazon",
425    "IBM",
426    "Oracle",
427    "Meta",
428    "Intel",
429    "Nvidia",
430];
431
432/// Known location tokens for the heuristic NER gazetteer.
433const LOCATION_GAZETTEER: &[&str] = &[
434    "Seattle",
435    "London",
436    "Paris",
437    "Tokyo",
438    "Berlin",
439    "Washington",
440    "California",
441    "France",
442    "Germany",
443    "Japan",
444    "China",
445    "India",
446    "Boston",
447    "Chicago",
448    "Amsterdam",
449    "Madrid",
450];
451
452/// Heuristic (rule-based) named-entity recognizer.
453///
454/// This is deliberately NOT a trained model. It detects capitalized tokens as
455/// candidate entities, computes **real** byte offsets into the source text, and
456/// assigns a type only when a small gazetteer provides a real signal; otherwise
457/// the type is honestly reported as [`EntityType::Other`]`("Unknown")` rather
458/// than fabricating a specific class. Confidence reflects the heuristic's low
459/// certainty, not a model probability.
460struct HeuristicNer;
461
462impl HeuristicNer {
463    fn new() -> Self {
464        Self
465    }
466
467    fn classify_token(token: &str) -> (EntityType, f32) {
468        if LOCATION_GAZETTEER
469            .iter()
470            .any(|g| g.eq_ignore_ascii_case(token))
471        {
472            (EntityType::Location, 0.7)
473        } else if ORG_GAZETTEER.iter().any(|g| g.eq_ignore_ascii_case(token)) {
474            (EntityType::Organization, 0.7)
475        } else {
476            // No real signal about the class — do not fabricate "Person".
477            (EntityType::Other("Unknown".to_string()), 0.5)
478        }
479    }
480}
481
482impl NamedEntityRecognizer for HeuristicNer {
483    fn extract_entities(&self, text: &str) -> Result<Vec<ExtractedEntity>> {
484        let mut entities = Vec::new();
485        let mut search_start = 0usize;
486
487        for word in text.split_whitespace() {
488            // Locate this token's real byte offset in the source text.
489            let start = match text[search_start..].find(word) {
490                Some(rel) => search_start + rel,
491                None => continue,
492            };
493            search_start = start + word.len();
494
495            // Trim surrounding punctuation for the entity surface form.
496            let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric());
497            if trimmed.is_empty() {
498                continue;
499            }
500            let first = trimmed.chars().next().unwrap_or(' ');
501            if !first.is_uppercase() {
502                continue;
503            }
504
505            // Real offset of the trimmed token within the raw whitespace token.
506            let inner_offset = word.find(trimmed).unwrap_or(0);
507            let token_start = start + inner_offset;
508            let token_end = token_start + trimmed.len();
509
510            let (entity_type, confidence) = Self::classify_token(trimmed);
511            entities.push(ExtractedEntity {
512                text: trimmed.to_string(),
513                entity_type,
514                kb_id: None,
515                confidence,
516                span: TextSpan {
517                    start: token_start,
518                    end: token_end,
519                    text: trimmed.to_string(),
520                },
521            });
522        }
523
524        Ok(entities)
525    }
526
527    fn supported_types(&self) -> Vec<EntityType> {
528        vec![
529            EntityType::Organization,
530            EntityType::Location,
531            EntityType::Other("Unknown".to_string()),
532        ]
533    }
534}
535
536/// Heuristic (rule-based) relation classifier.
537///
538/// Matches surface keywords in the sentence. The returned confidences are honest
539/// heuristic scores (keyword-match strength), not model probabilities, and
540/// callers should treat the produced relations as heuristic (they are tagged with
541/// `metadata["extraction_method"] = "heuristic-keyword-match"`).
542struct HeuristicRelationClassifier;
543
544impl HeuristicRelationClassifier {
545    fn new() -> Self {
546        Self
547    }
548}
549
550impl RelationClassifier for HeuristicRelationClassifier {
551    fn classify_relation(
552        &self,
553        text: &str,
554        _subject: &ExtractedEntity,
555        _object: &ExtractedEntity,
556    ) -> Result<Option<(String, f32)>> {
557        if text.contains("work") || text.contains("employ") {
558            Ok(Some(("worksFor".to_string(), 0.75)))
559        } else if text.contains("live") || text.contains("reside") {
560            Ok(Some(("livesIn".to_string(), 0.75)))
561        } else if text.contains("born") || text.contains("birth") {
562            Ok(Some(("bornIn".to_string(), 0.75)))
563        } else {
564            Ok(None)
565        }
566    }
567
568    fn supported_relations(&self) -> Vec<String> {
569        vec![
570            "worksFor".to_string(),
571            "livesIn".to_string(),
572            "bornIn".to_string(),
573        ]
574    }
575}
576
577/// Entity linker that performs **no** knowledge-base lookup.
578///
579/// A genuine entity linker requires a knowledge base to resolve and verify
580/// against (e.g. DBpedia Spotlight, Wikidata). Without one, fabricating a
581/// `http://dbpedia.org/resource/<text>` URI would assert the existence of a KB
582/// resource that was never verified. This linker therefore returns `None` (no
583/// confident link) for every entity, which is the honest result. Inject a real
584/// [`EntityLinker`] via [`RelationExtractor::with_backends`] for actual linking.
585struct LocalEntityLinker;
586
587impl LocalEntityLinker {
588    fn new() -> Self {
589        Self
590    }
591}
592
593impl EntityLinker for LocalEntityLinker {
594    fn link_entity(&self, _entity: &ExtractedEntity, _context: &str) -> Result<Option<String>> {
595        // No knowledge base is available; do not fabricate an unverified URI.
596        Ok(None)
597    }
598
599    fn kb_info(&self) -> KnowledgeBaseInfo {
600        KnowledgeBaseInfo {
601            name: "none (no knowledge base configured)".to_string(),
602            base_uri: String::new(),
603            version: "n/a".to_string(),
604            entity_count: 0,
605        }
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::ai::AiConfig;
613
614    #[tokio::test]
615    async fn test_relation_extractor_creation() {
616        let config = AiConfig::default();
617        let extractor = RelationExtractor::new(&config);
618        assert!(extractor.is_ok());
619    }
620
621    #[tokio::test]
622    async fn test_relation_extraction() {
623        let config = AiConfig::default();
624        let extractor = RelationExtractor::new(&config).expect("construction should succeed");
625
626        let text = "John works for Microsoft. He lives in Seattle.";
627        let relations = extractor
628            .extract_relations(text)
629            .await
630            .expect("async operation should succeed");
631
632        // Should extract some relations (depends on dummy implementation)
633        assert!(!relations.is_empty());
634    }
635
636    #[test]
637    fn test_sentence_segmentation() {
638        let config = AiConfig::default();
639        let extractor = RelationExtractor::new(&config).expect("construction should succeed");
640
641        let text = "First sentence. Second sentence. Third sentence.";
642        let sentences = extractor.segment_sentences(text);
643
644        assert_eq!(sentences.len(), 3);
645        assert_eq!(sentences[0], "First sentence");
646    }
647
648    #[test]
649    fn test_to_triples() {
650        let config = AiConfig::default();
651        let extractor = RelationExtractor::new(&config).expect("construction should succeed");
652
653        let relation = ExtractedRelation {
654            subject: ExtractedEntity {
655                text: "John".to_string(),
656                entity_type: EntityType::Person,
657                kb_id: None,
658                confidence: 0.9,
659                span: TextSpan {
660                    start: 0,
661                    end: 4,
662                    text: "John".to_string(),
663                },
664            },
665            predicate: "worksFor".to_string(),
666            object: ExtractedEntity {
667                text: "Microsoft".to_string(),
668                entity_type: EntityType::Organization,
669                kb_id: None,
670                confidence: 0.85,
671                span: TextSpan {
672                    start: 15,
673                    end: 24,
674                    text: "Microsoft".to_string(),
675                },
676            },
677            confidence: 0.8,
678            source_span: TextSpan {
679                start: 0,
680                end: 25,
681                text: "John works for Microsoft.".to_string(),
682            },
683            context: "John works for Microsoft.".to_string(),
684            metadata: HashMap::new(),
685        };
686
687        let triples = extractor
688            .to_triples(&[relation])
689            .expect("operation should succeed");
690        assert_eq!(triples.len(), 1);
691    }
692
693    #[test]
694    fn regression_entity_linker_does_not_fabricate_dbpedia_uris() {
695        let linker = LocalEntityLinker::new();
696        let entity = ExtractedEntity {
697            text: "John".to_string(),
698            entity_type: EntityType::Person,
699            kb_id: None,
700            confidence: 0.5,
701            span: TextSpan {
702                start: 0,
703                end: 4,
704                text: "John".to_string(),
705            },
706        };
707        // Must NOT invent an unverified http://dbpedia.org/resource/John URI.
708        let linked = linker.link_entity(&entity, "context").expect("link");
709        assert_eq!(linked, None);
710
711        // kb_info must not claim to be a populated DBpedia.
712        let info = linker.kb_info();
713        assert_eq!(info.entity_count, 0);
714        assert!(!info.base_uri.contains("dbpedia"));
715    }
716
717    #[test]
718    fn regression_ner_reports_real_byte_offsets() {
719        let ner = HeuristicNer::new();
720        let text = "John works for Microsoft";
721        let entities = ner.extract_entities(text).expect("ner");
722
723        // Every reported span must correspond to the actual substring in `text`.
724        assert!(!entities.is_empty());
725        for entity in &entities {
726            assert_eq!(&text[entity.span.start..entity.span.end], entity.span.text);
727            assert_eq!(entity.span.text, entity.text);
728        }
729
730        // "Microsoft" is in the org gazetteer -> classified as Organization,
731        // and its offset must be the real position (15), not a fabricated i*5.
732        let microsoft = entities
733            .iter()
734            .find(|e| e.text == "Microsoft")
735            .expect("Microsoft detected");
736        assert_eq!(microsoft.span.start, 15);
737        assert!(matches!(microsoft.entity_type, EntityType::Organization));
738
739        // "John" has no gazetteer signal -> honestly typed Other, not Person.
740        let john = entities
741            .iter()
742            .find(|e| e.text == "John")
743            .expect("John detected");
744        assert!(matches!(john.entity_type, EntityType::Other(_)));
745    }
746
747    #[tokio::test]
748    async fn regression_extracted_relations_tagged_as_heuristic() {
749        let config = AiConfig::default();
750        let extractor = RelationExtractor::new(&config).expect("construction");
751        let relations = extractor
752            .extract_relations("John works for Microsoft")
753            .await
754            .expect("extract");
755        assert!(!relations.is_empty());
756        for relation in &relations {
757            assert_eq!(
758                relation
759                    .metadata
760                    .get("extraction_method")
761                    .map(String::as_str),
762                Some("heuristic-keyword-match")
763            );
764        }
765    }
766}