feat(classifier): add multilingual ECP inherence classifier POC

This commit is contained in:
2026-08-20 00:51:02 -03:00
parent d371b81aa4
commit 67cc40f91a
175 changed files with 30399 additions and 703 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Multilingual NLP Entity Inherence Classifier package."""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""Adapters package for optional vector embeddings and LLM providers."""
+31
View File
@@ -0,0 +1,31 @@
"""Base abstract adapter interface for optional Tier 2 / Tier 3 NLP enhancers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from src.models import ECPSnapshot, ClassificationResult
class BaseNLPAdapter(ABC):
"""Abstract interface for pluggable NLP classification adapters."""
@abstractmethod
def is_available(self) -> bool:
"""Return True if the underlying provider or model is installed and configured."""
pass
@abstractmethod
def evaluate_similarity(self, text: str, terms: List[str]) -> float:
"""Compute semantic similarity score between text and a set of candidate terms."""
pass
@abstractmethod
def disambiguate(
self,
ecp: ECPSnapshot,
content_md: str,
initial_result: ClassificationResult,
) -> Optional[ClassificationResult]:
"""Optionally refine an ambiguous classification result."""
pass
+42
View File
@@ -0,0 +1,42 @@
"""Optional local vector embeddings adapter (Tier 2).
Disabled by default. Provides fallback interface for semantic vector similarity
without requiring sentence-transformers to be pre-installed in the core environment.
"""
from __future__ import annotations
from typing import List, Optional
from src.models import ECPSnapshot, ClassificationResult
from src.adapters.base import BaseNLPAdapter
class LocalEmbeddingsAdapter(BaseNLPAdapter):
"""Optional adapter for local multilingual semantic vector embeddings."""
def __init__(self, model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
self.model_name = model_name
self._model = None
self._initialized = False
def is_available(self) -> bool:
try:
import sentence_transformers # noqa: F401
return True
except ImportError:
return False
def evaluate_similarity(self, text: str, terms: List[str]) -> float:
if not self.is_available() or not terms:
return 0.0
# Placeholder stub for local embedding computation
return 0.0
def disambiguate(
self,
ecp: ECPSnapshot,
content_md: str,
initial_result: ClassificationResult,
) -> Optional[ClassificationResult]:
# Embeddings adapter does not alter decisions in POC unless explicitly wired
return None
+38
View File
@@ -0,0 +1,38 @@
"""Optional LLM fallback adapter (Tier 3).
Disabled by default. Provides fallback interface for boundary disambiguation
without requiring OpenAI/Anthropic/Gemini API keys for core POC execution.
"""
from __future__ import annotations
import os
from typing import List, Optional
from src.models import ECPSnapshot, ClassificationResult
from src.adapters.base import BaseNLPAdapter
class LLMFallbackAdapter(BaseNLPAdapter):
"""Optional adapter for LLM fallback boundary disambiguation."""
def __init__(self, model_name: str = "gpt-4o-mini", api_key: Optional[str] = None) -> None:
self.model_name = model_name
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
def is_available(self) -> bool:
return bool(self.api_key)
def evaluate_similarity(self, text: str, terms: List[str]) -> float:
return 0.0
def disambiguate(
self,
ecp: ECPSnapshot,
content_md: str,
initial_result: ClassificationResult,
) -> Optional[ClassificationResult]:
# If API key is not configured or case is already clear, skip
if not self.is_available():
return None
# In POC, Tier 1 is definitive; LLM fallback stub is available for extension
return None
+219
View File
@@ -0,0 +1,219 @@
"""Core deterministic classification engine (Tier 1 core)."""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Tuple
from src.models import (
ECPSnapshot,
RelatedEntity,
ClassificationResult,
ClassificationError,
DecisionCategory,
ErrorCode,
)
from src.language import detect_language, normalize_text
from src.parser import strip_markdown, extract_evidence_snippets
def match_phrase_in_text(phrase: str, normalized_text: str) -> bool:
"""Check if a normalized phrase appears in normalized text with word boundary awareness."""
if not phrase or not normalized_text:
return False
norm_phrase = normalize_text(phrase)
if not norm_phrase:
return False
# Word boundary regex pattern
pattern = r"\b" + re.escape(norm_phrase) + r"\b"
return bool(re.search(pattern, normalized_text))
def count_phrase_occurrences(phrase: str, normalized_text: str) -> int:
"""Count occurrences of a phrase in text."""
if not phrase or not normalized_text:
return 0
norm_phrase = normalize_text(phrase)
if not norm_phrase:
return 0
pattern = r"\b" + re.escape(norm_phrase) + r"\b"
return len(re.findall(pattern, normalized_text))
class InherenceClassifier:
"""Tier 1 Deterministic NLP Entity Inherence Classifier."""
def __init__(self, enable_embeddings: bool = False, enable_llm: bool = False) -> None:
self.enable_embeddings = enable_embeddings
self.enable_llm = enable_llm
self._embeddings_adapter = None
self._llm_adapter = None
if enable_embeddings:
from src.adapters.embeddings import LocalEmbeddingsAdapter
self._embeddings_adapter = LocalEmbeddingsAdapter()
if enable_llm:
from src.adapters.llm import LLMFallbackAdapter
self._llm_adapter = LLMFallbackAdapter()
def classify(self, ecp: ECPSnapshot, content_md: str) -> ClassificationResult:
"""Classify inherence of content against an ECP snapshot."""
if not content_md or len(content_md.strip()) < 5:
raise ValueError("Content cannot be empty (minimum 5 non-whitespace characters)")
# 1. Language detection & text normalization
detected_lang, lang_confidence = detect_language(content_md)
plain_text = strip_markdown(content_md)
norm_text = normalize_text(plain_text)
# 2. Match Target Entity & Aliases (deduplicate normalized terms)
all_target_terms = [ecp.target_name] + [a for a in ecp.aliases if a != ecp.target_name]
matched_target_terms: List[str] = []
target_mention_count = 0
seen_norm_terms: set[str] = set()
for term in all_target_terms:
norm_term = normalize_text(term)
if not norm_term or norm_term in seen_norm_terms:
continue
seen_norm_terms.add(norm_term)
count = count_phrase_occurrences(term, norm_text)
if count > 0:
matched_target_terms.append(term)
target_mention_count += count
# 3. Match Context Anchors
matched_anchors: List[str] = []
for anchor in ecp.anchors:
if match_phrase_in_text(anchor, norm_text):
matched_anchors.append(anchor)
# 4. Match Negative Anchors (homonym disambiguators)
matched_negative_anchors: List[str] = []
for neg in ecp.negative_anchors:
if match_phrase_in_text(neg, norm_text):
matched_negative_anchors.append(neg)
# 5. Match Related Graph Entities
matched_graph_entities: List[Dict[str, Any]] = []
highest_graph_weight = 0.0
for rel in ecp.related_entities:
rel_name = rel.name if hasattr(rel, "name") else rel.get("name", "")
rel_id = rel.entity_id if hasattr(rel, "entity_id") else rel.get("entity_id", "")
rel_type = rel.relation_type if hasattr(rel, "relation_type") else rel.get("relation_type", "")
rel_weight = float(rel.weight if hasattr(rel, "weight") else rel.get("weight", 1.0))
rel_scope = str(rel.scope if hasattr(rel, "scope") else rel.get("scope", "general"))
rel_aliases = rel.aliases if hasattr(rel, "aliases") else rel.get("aliases", [])
rel_terms = [rel_name] + list(rel_aliases)
rel_matched = False
for term in rel_terms:
if match_phrase_in_text(term, norm_text):
rel_matched = True
break
if rel_matched:
matched_graph_entities.append({
"entity_id": rel_id,
"name": rel_name,
"relation_type": rel_type,
"weight": rel_weight,
"scope": rel_scope,
})
if rel_weight > highest_graph_weight:
highest_graph_weight = rel_weight
# 6. Evaluate Decision Rules
warnings: List[str] = []
has_direct_match = len(matched_target_terms) > 0
has_negative_match = len(matched_negative_anchors) > 0
has_graph_match = len(matched_graph_entities) > 0
has_anchor_match = len(matched_anchors) > 0
# Term collection for evidence extraction
evidence_terms = matched_target_terms + [g["name"] for g in matched_graph_entities] + matched_anchors
# Decision 1: Dominant Negative Anchors (overrides passing mentions)
if has_negative_match and (not has_anchor_match or len(matched_negative_anchors) >= len(matched_anchors)):
decision = DecisionCategory.NOT_RELATED
is_inherent = False
confidence = 0.90
rationale = f"Negative anchor '{matched_negative_anchors[0]}' detected indicating irrelevant context or homonym."
elif has_direct_match:
# Check context density
if has_anchor_match or target_mention_count >= 2:
# Strong direct match with supporting context
decision = DecisionCategory.DIRECT_INHERENT
is_inherent = True
confidence = min(0.98, 0.85 + (0.04 * len(matched_anchors)) + (0.02 * target_mention_count))
rationale = (
f"Direct match of target entity '{ecp.target_name}' with strong contextual anchor density "
f"({len(matched_anchors)} anchor(s) matched)."
)
else:
# Single passing mention without supporting context
decision = DecisionCategory.TANGENTIAL
is_inherent = False
confidence = 0.40
warnings.append("Low contextual density for target entity.")
rationale = (
f"Entity '{ecp.target_name}' mentioned in passing, but lacks sufficient domain anchors "
f"or substantive context."
)
elif has_graph_match:
if has_anchor_match:
# Contextual inherence via connected graph entity with domain anchor alignment
decision = DecisionCategory.CONTEXTUAL_INHERENT
is_inherent = True
confidence = round(min(0.95, 0.70 + (highest_graph_weight * 0.20) + (0.03 * len(matched_anchors))), 4)
top_rel = matched_graph_entities[0]
rationale = (
f"Matched connected entity '{top_rel['name']}' ({top_rel['relation_type']}) "
f"from ECP snapshot with weight {top_rel['weight']}."
)
else:
# Related entity mentioned in isolation without supporting domain context
decision = DecisionCategory.TANGENTIAL
is_inherent = False
confidence = 0.45
warnings.append("Related entity mentioned without supporting domain context.")
top_rel = matched_graph_entities[0]
rationale = (
f"Connected entity '{top_rel['name']}' from snapshot mentioned, "
f"but document lacks supporting domain anchors."
)
elif has_anchor_match and not has_direct_match and not has_graph_match:
# Anchors present but no target entity or related graph entity
decision = DecisionCategory.NOT_RELATED
is_inherent = False
confidence = 0.85
rationale = "General domain topics mentioned, but target entity or related entities are absent."
else:
# Completely unrelated
decision = DecisionCategory.NOT_RELATED
is_inherent = False
confidence = 0.95
rationale = "No relevant entity aliases, graph relations, or contextual anchors identified in document."
# Extract Evidence Snippets
evidence = extract_evidence_snippets(content_md, evidence_terms)
if not evidence and has_negative_match:
evidence = extract_evidence_snippets(content_md, matched_negative_anchors)
return ClassificationResult(
decision=decision,
is_inherent=is_inherent,
confidence=round(confidence, 4),
detected_language=detected_lang,
matched_anchors=matched_target_terms + matched_anchors,
negative_matches=matched_negative_anchors,
graph_matches=matched_graph_entities,
evidence=evidence,
rationale=rationale,
warnings=warnings,
)
+153
View File
@@ -0,0 +1,153 @@
"""Lightweight multilingual language detection and text normalization."""
from __future__ import annotations
import re
import unicodedata
from typing import Dict, List, Set, Tuple
# Supported ISO 639-1 language codes
SUPPORTED_LANGUAGES: Set[str] = {"pt", "en", "es", "de", "it", "fr"}
# Characteristic function words / stopwords for deterministic language identification
LANGUAGE_STOPWORDS: Dict[str, Set[str]] = {
"pt": {
"de", "a", "o", "que", "e", "do", "da", "em", "um", "para", "é", "com", "não",
"uma", "os", "no", "se", "na", "por", "mais", "as", "dos", "como", "mas", "foi",
"ao", "ele", "das", "tem", "à", "seu", "sua", "ou", "ser", "quando", "muito",
"nos", "já", "está", "eu", "também", "só", "pelo", "pela", "até", "isso", "ela",
"entre", "depois", "sem", "mesmo", "aos", "ter", "seus", "quem", "nas", "me",
"esse", "eles", "estão", "você", "tinha", "foram", "essa", "num", "nem", "suas",
"anunciou", "produção", "empresa", "mercado", "setor", "governo", "ano"
},
"en": {
"the", "be", "to", "of", "and", "a", "in", "that", "have", "i", "it", "for",
"not", "on", "with", "he", "as", "you", "do", "at", "this", "but", "his", "by",
"from", "they", "we", "say", "her", "she", "or", "an", "will", "my", "one",
"all", "would", "there", "their", "what", "so", "up", "out", "if", "about",
"who", "get", "which", "go", "me", "when", "make", "can", "like", "time", "no",
"just", "him", "know", "take", "people", "into", "year", "your", "good", "some",
"could", "them", "see", "other", "than", "then", "now", "look", "only", "come"
},
"es": {
"de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por", "un",
"para", "con", "no", "una", "su", "al", "lo", "como", "más", "pero", "sus", "le",
"ya", "o", "este", "sí", "porque", "esta", "entre", "cuando", "muy", "sin", "sobre",
"también", "me", "hasta", "hay", "donde", "quien", "desde", "todo", "nos", "durante",
"todos", "uno", "les", "ni", "contra", "otros", "ese", "eso", "ante", "ellos",
"e", "esto", "mí", "antes", "algunos", "qué", "unos", "yo", "otro", "otras",
"anunció", "producción", "empresa", "mercado", "sector", "año", "gobierno"
},
"de": {
"der", "die", "und", "in", "den", "von", "zu", "das", "mit", "sich", "des", "auf",
"für", "ist", "im", "dem", "nicht", "ein", "eine", "als", "auch", "es", "an",
"werden", "aus", "er", "hat", "dass", "sie", "nach", "wird", "bei", "einer", "um",
"am", "sind", "noch", "wie", "einem", "über", "einen", "so", "zum", "war", "haben",
"nur", "oder", "aber", "vor", "zur", "bis", "mehr", "durch", "man", "sein", "wurde",
"sei", "prozent", "hatte", "kann", "gegen", "vom", "können", "schon", "wenn", "habe",
"seine", "ihre", "unter", "wir", "sollen", "neue", "neuen", "batteriezellen", "unternehmen"
},
"it": {
"di", "e", "il", "che", "la", "a", "in", "per", "un", "del", "non", "i", "si", "da",
"le", "con", "sono", "della", "dei", "degli", "una", "al", "ma", "più", "delle",
"questo", "nel", "alla", "anche", "ha", "gli", "come", "dall", "dalla", "ed",
"se", "ci", "lo", "su", "loro", "dopo", "qualche", "nella", "uno", "mio", "tuo",
"suo", "nostro", "vostro", "loro", "stato", "stata", "tra", "fra", "mentre", "prima",
"quando", "molto", "tutto", "tutti", "tutte", "tutta", "senza", "ancora", "solo",
"azienda", "mercato", "settore", "anno", "governo", "produzione", "motori"
},
"fr": {
"de", "la", "le", "et", "les", "des", "en", "un", "du", "une", "que", "est", "pour",
"qui", "dans", "a", "par", "sur", "pas", "plus", "au", "avec", "ce", "il", "sont",
"se", "ne", "son", "sa", "ses", "aux", "ou", "comme", "mais", "nous", "vous", "ils",
"leur", "y", "tout", "faire", "été", "aussi", "ces", "ont", "si", "fait", "même",
"très", "après", "sans", "sous", "entre", "deux", "bien", "chez", "autre", "autres",
"entreprise", "marché", "secteur", "année", "gouvernement", "production", "véhicules"
}
}
def normalize_text(text: str) -> str:
"""Normalize text by converting to lowercase and stripping combining diacritical marks."""
if not text:
return ""
# Normalize unicode to NFD (canonical decomposition)
nfd = unicodedata.normalize("NFD", text.lower())
# Filter out nonspacing mark characters (accents/diacritics)
return "".join(c for c in nfd if unicodedata.category(c) != "Mn")
def extract_words(text: str) -> List[str]:
"""Tokenize text into lowercase alphanumeric words."""
return re.findall(r"\b\w+\b", text.lower())
def detect_language(text: str) -> Tuple[str, float]:
"""
Detect the ISO-639-1 language code of text among supported languages (pt, en, es, de, it, fr).
Returns (detected_language, confidence_score).
"""
if not text or not text.strip():
return "unknown", 0.0
words = extract_words(text)
if not words:
return "unknown", 0.0
total_words = len(words)
word_set = set(words)
# Score languages based on matched stopword counts
scores: Dict[str, int] = {}
for lang, stopwords in LANGUAGE_STOPWORDS.items():
matched = word_set.intersection(stopwords)
scores[lang] = len(matched)
# Sort languages by score descending
sorted_scores = sorted(scores.items(), key=lambda item: item[1], reverse=True)
top_lang, top_matches = sorted_scores[0]
second_lang, second_matches = sorted_scores[1] if len(sorted_scores) > 1 else ("", 0)
# Specific disambiguation rules for closely related languages (PT vs ES)
pt_exclusive = {
"não", "do", "da", "no", "na", "nos", "nas", "em", "um", "uma", "você", "são", "é", "dos",
"das", "foi", "está", "estão", "com", "pelo", "pela", "pelos", "pelas", "notícia",
"extração", "mês", "ano", "produção", "bateu"
}
es_exclusive = {
"el", "la", "y", "del", "al", "los", "las", "su", "sus", "con", "más", "pero",
"durante", "noticia", "extracción", "mes", "año", "producción"
}
# Normalize words to match accents cleanly
norm_word_set = set(normalize_text(w) for w in word_set)
norm_pt = set(normalize_text(w) for w in pt_exclusive)
norm_es = set(normalize_text(w) for w in es_exclusive)
pt_score = len(norm_word_set.intersection(norm_pt))
es_score = len(norm_word_set.intersection(norm_es))
# Check if 'y' (Spanish) or 'e' (Portuguese) is used as conjunction
if "y" in word_set and "e" not in word_set:
es_score += 2
elif "e" in word_set and "y" not in word_set:
pt_score += 1
if top_lang in ("pt", "es") or (top_lang == "pt" and es_score > pt_score) or (top_lang == "es" and pt_score > es_score):
if es_score > pt_score:
top_lang = "es"
top_matches = max(top_matches, es_score)
elif pt_score > es_score:
top_lang = "pt"
top_matches = max(top_matches, pt_score)
if top_matches == 0:
# Fallback to English if no stopwords matched
return "en", 0.5
confidence = min(1.0, round(top_matches / max(3, top_matches), 2))
if confidence < 0.6 and top_matches >= 2:
confidence = 0.75
elif top_matches >= 1:
confidence = max(0.6, confidence)
return top_lang, confidence
+166
View File
@@ -0,0 +1,166 @@
"""Data models and validation schemas for Multilingual NLP Entity Inherence Classifier."""
from __future__ import annotations
import json
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Dict, List, Optional
class DecisionCategory(str, Enum):
DIRECT_INHERENT = "DIRECT_INHERENT"
CONTEXTUAL_INHERENT = "CONTEXTUAL_INHERENT"
TANGENTIAL = "TANGENTIAL"
NOT_RELATED = "NOT_RELATED"
class ErrorCode(str, Enum):
INVALID_ECP_JSON = "invalid_ecp_json"
INVALID_MARKDOWN = "invalid_markdown"
UNSUPPORTED_LANGUAGE = "unsupported_language"
EMPTY_CONTENT = "empty_content"
MISSING_REQUIRED_FIELD = "missing_required_field"
@dataclass
class RelatedEntity:
entity_id: str
name: str
relation_type: str
weight: float
aliases: List[str] = field(default_factory=list)
scope: str = "general"
confidence: float = 1.0
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> RelatedEntity:
if not isinstance(data, dict):
raise ValueError("Related entity must be a JSON object")
required = ["entity_id", "name", "relation_type", "weight"]
for req in required:
if req not in data or data[req] is None:
raise ValueError(f"Missing required field in related entity: '{req}'")
return cls(
entity_id=str(data["entity_id"]),
name=str(data["name"]),
relation_type=str(data["relation_type"]),
weight=float(data["weight"]),
aliases=list(data.get("aliases", [])),
scope=str(data.get("scope", "general")),
confidence=float(data.get("confidence", 1.0)),
)
@dataclass
class ECPSnapshot:
target_entity_id: str
target_name: str
aliases: List[str]
domain: str
anchors: List[str]
negative_anchors: List[str] = field(default_factory=list)
graph_version: str = "1.0.0"
related_entities: List[RelatedEntity] = field(default_factory=list)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> ECPSnapshot:
if not isinstance(data, dict):
raise ValueError("ECP Snapshot payload must be a JSON object")
required_fields = ["target_entity_id", "target_name", "aliases", "domain", "anchors"]
for field_name in required_fields:
if field_name not in data or data[field_name] is None:
raise ValueError(f"Missing required field in ECP Snapshot: '{field_name}'")
if not isinstance(data["aliases"], list):
raise ValueError("Field 'aliases' must be a list of strings")
if not isinstance(data["anchors"], list):
raise ValueError("Field 'anchors' must be a list of strings")
neg_anchors = data.get("negative_anchors", [])
if neg_anchors is not None and not isinstance(neg_anchors, list):
raise ValueError("Field 'negative_anchors' must be a list of strings if provided")
related_data = data.get("related_entities", [])
if related_data is not None and not isinstance(related_data, list):
raise ValueError("Field 'related_entities' must be a list if provided")
related_objs = [RelatedEntity.from_dict(item) for item in (related_data or [])]
return cls(
target_entity_id=str(data["target_entity_id"]),
target_name=str(data["target_name"]),
aliases=[str(a) for a in data["aliases"]],
domain=str(data["domain"]),
anchors=[str(a) for a in data["anchors"]],
negative_anchors=[str(na) for na in (neg_anchors or [])],
graph_version=str(data.get("graph_version", "1.0.0")),
related_entities=related_objs,
)
@classmethod
def from_json_str(cls, json_str: str) -> ECPSnapshot:
try:
data = json.loads(json_str)
except Exception as e:
raise ValueError(f"Invalid JSON format: {e}") from e
return cls.from_dict(data)
@dataclass
class MatchedGraphEntity:
entity_id: str
name: str
relation_type: str
weight: float
@dataclass
class ClassificationResult:
decision: DecisionCategory
is_inherent: bool
confidence: float
detected_language: str
matched_anchors: List[str] = field(default_factory=list)
negative_matches: List[str] = field(default_factory=list)
graph_matches: List[Dict[str, Any]] = field(default_factory=list)
evidence: List[str] = field(default_factory=list)
rationale: str = ""
warnings: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"decision": self.decision.value if isinstance(self.decision, DecisionCategory) else str(self.decision),
"is_inherent": bool(self.is_inherent),
"confidence": round(float(self.confidence), 4),
"detected_language": str(self.detected_language),
"matched_anchors": list(self.matched_anchors),
"negative_matches": list(self.negative_matches),
"graph_matches": list(self.graph_matches),
"evidence": list(self.evidence),
"rationale": str(self.rationale),
"warnings": list(self.warnings),
}
def to_json_str(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
@dataclass
class ClassificationError:
error_code: ErrorCode
message: str
details: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"error_code": self.error_code.value if isinstance(self.error_code, ErrorCode) else str(self.error_code),
"message": str(self.message),
"details": dict(self.details),
}
def to_json_str(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
+78
View File
@@ -0,0 +1,78 @@
"""Markdown content parser and excerpt extraction utilities."""
from __future__ import annotations
import re
from typing import List, Tuple
def strip_markdown(markdown_text: str) -> str:
"""Remove markdown syntax markers (headers, bold, italics, links, code blocks) to obtain plain text."""
if not markdown_text:
return ""
text = markdown_text
# Remove code blocks
text = re.sub(r"```[\s\S]*?```", " ", text)
text = re.sub(r"`[^`]*`", " ", text)
# Remove headers (# Header)
text = re.sub(r"^#+\s+", " ", text, flags=re.MULTILINE)
# Replace markdown links [anchor](url) with just anchor
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
# Remove image links ![alt](url)
text = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", text)
# Remove bold/italics (*, _, **, __)
text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text)
text = re.sub(r"(\*|_)(.*?)\1", r"\2", text)
# Remove blockquotes and list markers
text = re.sub(r"^\s*[-*+]\s+", " ", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\s+", " ", text, flags=re.MULTILINE)
text = re.sub(r"^\s*>\s*", " ", text, flags=re.MULTILINE)
# Normalize whitespace
text = re.sub(r"\s+", " ", text).strip()
return text
def extract_sentences(text: str) -> List[str]:
"""Split text into individual sentences."""
# Split by period, exclamation, question mark followed by space or newline
raw_sentences = re.split(r"(?<=[.!?])\s+", text.strip())
sentences = [s.strip() for s in raw_sentences if len(s.strip()) > 3]
return sentences
def extract_evidence_snippets(markdown_text: str, match_terms: List[str], max_snippets: int = 3) -> List[str]:
"""
Extract relevant sentence excerpts from Markdown text that contain any of the given match terms.
Preserves original phrasing and formats as clean evidence.
"""
if not markdown_text or not match_terms:
return []
plain_text = strip_markdown(markdown_text)
sentences = extract_sentences(plain_text)
if not sentences:
sentences = [plain_text]
lower_terms = [t.lower() for t in match_terms if t]
evidence: List[str] = []
for sentence in sentences:
lower_sent = sentence.lower()
for term in lower_terms:
if re.search(r"\b" + re.escape(term) + r"\b", lower_sent) or term in lower_sent:
clean_snippet = sentence.strip()
if clean_snippet and clean_snippet not in evidence:
evidence.append(clean_snippet)
if len(evidence) >= max_snippets:
return evidence
break
return evidence