feat(classifier): add multilingual ECP inherence classifier POC
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Multilingual NLP Entity Inherence Classifier package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""Adapters package for optional vector embeddings and LLM providers."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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 
|
||||
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
|
||||
Reference in New Issue
Block a user