| """ |
| fact_extractor.py |
| |
| Module: Explicit Fact Extraction for SIGMA Intelligence Analyzer |
| Purpose: |
| Extract structured, syntactic facts from narrative text. |
| Uses spaCy transformer pipeline for: |
| - Named Entity Recognition (NER) |
| - Dependency parsing |
| - Sentence segmentation |
| Output: |
| - Entities (with labels) |
| - Structured factual triples: |
| (subject → action → object) |
| |
| Design Philosophy: |
| Conservative extraction. |
| No speculative inference. |
| Only syntactically grounded relations. |
| """ |
|
|
| import spacy |
| from spacy.cli import download |
|
|
| class FactExtractor: |
| def __init__(self): |
| """ |
| Load spaCy transformer-based English model. |
| This model includes: |
| - NER |
| - POS tagging |
| - Dependency parsing |
| """ |
| |
| |
|
|
| try: |
| self.nlp = spacy.load("en_core_web_sm") |
| except: |
| download("en_core_web_sm") |
| self.nlp = spacy.load("en_core_web_sm") |
| |
|
|
| def extract_entities(self, text): |
| """ |
| Extract named entities from text. |
| Returns: |
| List[Dict] with: |
| { |
| "text": entity string, |
| "label": entity type (ORG, PERSON, GPE, DATE, etc.) |
| } |
| """ |
| doc = self.nlp(text) |
|
|
| return [ |
| {"text": ent.text, "label": ent.label_} |
| for ent in doc.ents |
| ] |
|
|
| def extract_structured_facts(self, text): |
| """ |
| Extract structured factual triples from text. |
| Strategy: |
| - Iterate through sentences |
| - Identify ROOT verb of each sentence |
| - Extract: |
| - Subject (nsubj / nsubjpass) |
| - Object/complement structures |
| - Expand subject and object to full phrase spans |
| Returns: List[Dict] |
| """ |
|
|
| doc = self.nlp(text) |
|
|
| facts = [] |
|
|
| for sent in doc.sents: |
|
|
| for token in sent: |
|
|
| |
| |
| if token.pos_ == "VERB" and token.dep_ == "ROOT": |
|
|
| subject = None |
| obj = None |
|
|
| |
| |
| |
| for child in token.children: |
|
|
| if child.dep_ in ("nsubj", "nsubjpass"): |
| subject = self._expand_phrase(child) |
|
|
| |
| |
| |
|
|
| for child in token.children: |
|
|
| |
| if child.dep_ in ("dobj", "attr", "oprd"): |
|
|
| obj = self._expand_phrase(child) |
|
|
| |
| elif child.dep_ in ("ccomp", "xcomp"): |
|
|
| obj = self._expand_phrase(child) |
|
|
| |
| if obj.startswith("that "): |
| obj = obj[5:] |
|
|
| |
| |
| |
|
|
| if obj: |
|
|
| prep_phrases = [] |
|
|
| for child in token.children: |
|
|
| if child.dep_ == "prep": |
|
|
| prep_text = self._expand_phrase(child) |
|
|
| |
| if prep_text not in obj: |
| prep_phrases.append(prep_text) |
|
|
| if prep_phrases: |
| obj += " " + " ".join(prep_phrases) |
|
|
|
|
| |
| negated = any( |
| child.dep_ == "neg" |
| for child in token.children |
| ) |
|
|
| action = token.lemma_ |
|
|
| if negated: |
| action = f"not {action}" |
|
|
| |
| |
| |
| facts.append({ |
| "sentence": sent.text.strip(), |
| "subject": subject, |
| "action": action, |
| "object": obj |
| }) |
|
|
| return facts |
| |
| def _expand_phrase(self, token): |
| """ |
| Expand token to its full subtree span and normalize whitespace. |
| Why: |
| Dependency parsing identifies only head tokens. |
| We want full noun phrases including modifiers. |
| Example: |
| Head token: "division" |
| Expanded: "The cybersecurity division" |
| Implementation: |
| - Collect all tokens in subtree |
| - Get span from first to last token |
| """ |
| subtree = list(token.subtree) |
|
|
| start = subtree[0].i |
| end = subtree[-1].i + 1 |
|
|
| span_text = token.doc[start:end].text |
|
|
| |
| return " ".join(span_text.strip().split()) |
|
|
| def extract_sentences(self, text): |
| """ |
| Return list of individual sentences. |
| Useful for: |
| - Candidate hypothesis generation |
| - Summarization preprocessing |
| """ |
| doc = self.nlp(text) |
| return [sent.text.strip() for sent in doc.sents] |