feat(H): H-EXT/H-ENT/H-AUTO/H-CONF — LLM knowledge extraction (entities, relationships, auto-create in GraphRAG, confidence scoring with review queue), 31 tests passing
This commit is contained in:
@@ -0,0 +1,361 @@
|
|||||||
|
"""Knowledge extraction — LLM-based relationship and entity extraction (H-EXT, H-ENT, H-AUTO, H-CONF).
|
||||||
|
|
||||||
|
Analyzes texts from knowledge sources (wiki, DMS, mail, communication)
|
||||||
|
and extracts:
|
||||||
|
- Named entities (persons, companies, projects) — H-ENT
|
||||||
|
- Relationships between entities — H-EXT
|
||||||
|
- Auto-creates relationships in GraphRAG — H-AUTO
|
||||||
|
- Confidence scores with low-confidence → review queue — H-CONF
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractedEntity:
|
||||||
|
"""A named entity extracted from text (H-ENT)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
entity_type: str # person, company, project, location, date, other
|
||||||
|
mentions: list[int] = field(default_factory=list) # character positions
|
||||||
|
confidence: float = 1.0
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractedRelationship:
|
||||||
|
"""A relationship between two entities extracted from text (H-EXT)."""
|
||||||
|
|
||||||
|
source_entity: str
|
||||||
|
source_type: str
|
||||||
|
target_entity: str
|
||||||
|
target_type: str
|
||||||
|
relationship_type: str # works_for, related_to, has_email, etc.
|
||||||
|
confidence: float = 0.0
|
||||||
|
evidence: str = "" # Text snippet that supports this relationship
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractionResult:
|
||||||
|
"""Result of a knowledge extraction run."""
|
||||||
|
|
||||||
|
entities: list[ExtractedEntity] = field(default_factory=list)
|
||||||
|
relationships: list[ExtractedRelationship] = field(default_factory=list)
|
||||||
|
source_type: str = ""
|
||||||
|
source_id: str = ""
|
||||||
|
tenant_id: str = ""
|
||||||
|
overall_confidence: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Extraction prompt ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_EXTRACTION_SYSTEM_PROMPT = """You are a knowledge extraction assistant. Analyze the given text and extract:
|
||||||
|
|
||||||
|
1. Named entities (persons, companies, projects, locations, dates)
|
||||||
|
2. Relationships between entities (e.g., "works_for", "related_to", "has_email", "part_of")
|
||||||
|
|
||||||
|
Return JSON in this format:
|
||||||
|
{
|
||||||
|
"entities": [
|
||||||
|
{"name": "John Doe", "type": "person", "confidence": 0.95}
|
||||||
|
],
|
||||||
|
"relationships": [
|
||||||
|
{
|
||||||
|
"source": "John Doe",
|
||||||
|
"source_type": "person",
|
||||||
|
"target": "Acme Corp",
|
||||||
|
"target_type": "company",
|
||||||
|
"type": "works_for",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"evidence": "John Doe works at Acme Corp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Only extract explicitly stated facts. Do not infer or hallucinate.
|
||||||
|
If no entities or relationships are found, return empty arrays."""
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_knowledge(
|
||||||
|
text: str,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
source_type: str = "",
|
||||||
|
source_id: str = "",
|
||||||
|
llm_model: str | None = None,
|
||||||
|
) -> ExtractionResult:
|
||||||
|
"""Extract entities and relationships from text using LLM (H-EXT, H-ENT).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to analyze.
|
||||||
|
tenant_id: Tenant ID for multi-tenancy.
|
||||||
|
source_type: Source type (wiki, dms, mail, communication).
|
||||||
|
source_id: Source entity ID.
|
||||||
|
llm_model: Optional LLM model override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExtractionResult with entities and relationships.
|
||||||
|
"""
|
||||||
|
if not text or len(text.strip()) < 10:
|
||||||
|
return ExtractionResult(
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.ai.llm_client import llm_complete
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _EXTRACTION_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": f"Analyze this text:\n\n{text[:8000]}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await llm_complete(
|
||||||
|
model=llm_model or "ollama/deepseek-v4-flash",
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse LLM response
|
||||||
|
import json
|
||||||
|
|
||||||
|
raw = response.get("content", "")
|
||||||
|
# Try to extract JSON from response
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Try to find JSON in the response
|
||||||
|
start = raw.find("{")
|
||||||
|
end = raw.rfind("}") + 1
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
data = json.loads(raw[start:end])
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to parse extraction response as JSON")
|
||||||
|
return ExtractionResult(
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build ExtractionResult
|
||||||
|
entities: list[ExtractedEntity] = []
|
||||||
|
for ent in data.get("entities", []):
|
||||||
|
entities.append(ExtractedEntity(
|
||||||
|
name=ent.get("name", ""),
|
||||||
|
entity_type=ent.get("type", "other"),
|
||||||
|
confidence=ent.get("confidence", 0.5),
|
||||||
|
metadata=ent.get("metadata", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
relationships: list[ExtractedRelationship] = []
|
||||||
|
for rel in data.get("relationships", []):
|
||||||
|
relationships.append(ExtractedRelationship(
|
||||||
|
source_entity=rel.get("source", ""),
|
||||||
|
source_type=rel.get("source_type", "other"),
|
||||||
|
target_entity=rel.get("target", ""),
|
||||||
|
target_type=rel.get("target_type", "other"),
|
||||||
|
relationship_type=rel.get("type", "related_to"),
|
||||||
|
confidence=rel.get("confidence", 0.5),
|
||||||
|
evidence=rel.get("evidence", ""),
|
||||||
|
metadata=rel.get("metadata", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Calculate overall confidence
|
||||||
|
all_confidences = [e.confidence for e in entities] + [r.confidence for r in relationships]
|
||||||
|
overall = sum(all_confidences) / len(all_confidences) if all_confidences else 0.0
|
||||||
|
|
||||||
|
return ExtractionResult(
|
||||||
|
entities=entities,
|
||||||
|
relationships=relationships,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
overall_confidence=overall,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Knowledge extraction failed: %s", e)
|
||||||
|
return ExtractionResult(
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Confidence scoring (H-CONF) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
LOW_CONFIDENCE_THRESHOLD = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def is_low_confidence(confidence: float) -> bool:
|
||||||
|
"""Check if a confidence score is below the review threshold (H-CONF)."""
|
||||||
|
return confidence < LOW_CONFIDENCE_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def filter_high_confidence(
|
||||||
|
relationships: list[ExtractedRelationship],
|
||||||
|
threshold: float = LOW_CONFIDENCE_THRESHOLD,
|
||||||
|
) -> tuple[list[ExtractedRelationship], list[ExtractedRelationship]]:
|
||||||
|
"""Split relationships into high-confidence and low-confidence (review queue).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (high_confidence, low_confidence) lists.
|
||||||
|
"""
|
||||||
|
high = [r for r in relationships if r.confidence >= threshold]
|
||||||
|
low = [r for r in relationships if r.confidence < threshold]
|
||||||
|
return high, low
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auto-relationship creation in GraphRAG (H-AUTO) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def auto_create_relationships(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
extraction: ExtractionResult,
|
||||||
|
min_confidence: float = LOW_CONFIDENCE_THRESHOLD,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Auto-create extracted relationships in GraphRAG (H-AUTO).
|
||||||
|
|
||||||
|
Only creates relationships with confidence >= min_confidence.
|
||||||
|
Low-confidence relationships are returned for the review queue.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``created``, ``skipped_low_confidence``, ``errors`` counts.
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.graph_rag.models import EntityRelationship
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
high_conf, low_conf = filter_high_confidence(extraction.relationships, min_confidence)
|
||||||
|
|
||||||
|
for rel in high_conf:
|
||||||
|
try:
|
||||||
|
# Try to resolve entity names to actual entity IDs
|
||||||
|
# For now, store as typed relationships with name-based references
|
||||||
|
source_id = await _resolve_entity_id(db, tenant_id, rel.source_entity, rel.source_type)
|
||||||
|
target_id = await _resolve_entity_id(db, tenant_id, rel.target_entity, rel.target_type)
|
||||||
|
|
||||||
|
if source_id is None or target_id is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if relationship already exists
|
||||||
|
existing = await db.execute(
|
||||||
|
select(EntityRelationship).where(
|
||||||
|
EntityRelationship.tenant_id == tenant_id,
|
||||||
|
EntityRelationship.source_type == rel.source_type,
|
||||||
|
EntityRelationship.source_id == source_id,
|
||||||
|
EntityRelationship.target_type == rel.target_type,
|
||||||
|
EntityRelationship.target_id == target_id,
|
||||||
|
EntityRelationship.relationship_type == rel.relationship_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new relationship
|
||||||
|
er = EntityRelationship(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_type=rel.source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
target_type=rel.target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
relationship_type=rel.relationship_type,
|
||||||
|
confidence=rel.confidence,
|
||||||
|
metadata={
|
||||||
|
"evidence": rel.evidence,
|
||||||
|
"source_type": extraction.source_type,
|
||||||
|
"source_id": extraction.source_id,
|
||||||
|
"auto_extracted": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(er)
|
||||||
|
created += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to auto-create relationship: %s", e)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"created": created,
|
||||||
|
"skipped_low_confidence": len(low_conf),
|
||||||
|
"skipped_existing": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"review_queue": [
|
||||||
|
{
|
||||||
|
"source": r.source_entity,
|
||||||
|
"target": r.target_entity,
|
||||||
|
"type": r.relationship_type,
|
||||||
|
"confidence": r.confidence,
|
||||||
|
"evidence": r.evidence,
|
||||||
|
}
|
||||||
|
for r in low_conf
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_entity_id(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
entity_type: str,
|
||||||
|
) -> uuid.UUID | None:
|
||||||
|
"""Try to resolve an entity name to an actual entity ID.
|
||||||
|
|
||||||
|
Searches contacts, companies, etc. by name.
|
||||||
|
Returns None if no match found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if entity_type == "person":
|
||||||
|
from app.models.contact import Contact
|
||||||
|
result = await db.execute(
|
||||||
|
select(Contact.id).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
Contact.name.ilike(f"%{name}%"),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
elif entity_type == "company":
|
||||||
|
from app.models.contact import Contact
|
||||||
|
result = await db.execute(
|
||||||
|
select(Contact.id).where(
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
Contact.is_company.is_(True),
|
||||||
|
Contact.name.ilike(f"%{name}%"),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExtractedEntity",
|
||||||
|
"ExtractedRelationship",
|
||||||
|
"ExtractionResult",
|
||||||
|
"extract_knowledge",
|
||||||
|
"is_low_confidence",
|
||||||
|
"filter_high_confidence",
|
||||||
|
"auto_create_relationships",
|
||||||
|
"LOW_CONFIDENCE_THRESHOLD",
|
||||||
|
]
|
||||||
@@ -275,3 +275,103 @@ class TestEvidenceReferences:
|
|||||||
|
|
||||||
refs = build_evidence_references([])
|
refs = build_evidence_references([])
|
||||||
assert refs == []
|
assert refs == []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── H-EXT/H-ENT/H-AUTO/H-CONF: Knowledge Extraction ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnowledgeExtraction:
|
||||||
|
"""Test the knowledge extraction module (H-EXT, H-ENT, H-AUTO, H-CONF)."""
|
||||||
|
|
||||||
|
def test_extracted_entity_dataclass(self):
|
||||||
|
"""ExtractedEntity dataclass works correctly."""
|
||||||
|
from app.ai.knowledge_extraction import ExtractedEntity
|
||||||
|
|
||||||
|
entity = ExtractedEntity(name="John Doe", entity_type="person", confidence=0.9)
|
||||||
|
assert entity.name == "John Doe"
|
||||||
|
assert entity.entity_type == "person"
|
||||||
|
assert entity.confidence == 0.9
|
||||||
|
assert entity.mentions == []
|
||||||
|
assert entity.metadata == {}
|
||||||
|
|
||||||
|
def test_extracted_relationship_dataclass(self):
|
||||||
|
"""ExtractedRelationship dataclass works correctly."""
|
||||||
|
from app.ai.knowledge_extraction import ExtractedRelationship
|
||||||
|
|
||||||
|
rel = ExtractedRelationship(
|
||||||
|
source_entity="John Doe", source_type="person",
|
||||||
|
target_entity="Acme Corp", target_type="company",
|
||||||
|
relationship_type="works_for", confidence=0.85,
|
||||||
|
evidence="John works at Acme",
|
||||||
|
)
|
||||||
|
assert rel.source_entity == "John Doe"
|
||||||
|
assert rel.relationship_type == "works_for"
|
||||||
|
assert rel.confidence == 0.85
|
||||||
|
assert rel.evidence == "John works at Acme"
|
||||||
|
|
||||||
|
def test_extraction_result_dataclass(self):
|
||||||
|
"""ExtractionResult dataclass works correctly."""
|
||||||
|
from app.ai.knowledge_extraction import ExtractionResult
|
||||||
|
|
||||||
|
result = ExtractionResult(source_type="wiki", source_id="abc", tenant_id="123")
|
||||||
|
assert result.entities == []
|
||||||
|
assert result.relationships == []
|
||||||
|
assert result.overall_confidence == 0.0
|
||||||
|
|
||||||
|
def test_is_low_confidence(self):
|
||||||
|
"""is_low_confidence correctly identifies low confidence scores."""
|
||||||
|
from app.ai.knowledge_extraction import is_low_confidence, LOW_CONFIDENCE_THRESHOLD
|
||||||
|
|
||||||
|
assert is_low_confidence(0.3) is True
|
||||||
|
assert is_low_confidence(0.5) is True
|
||||||
|
assert is_low_confidence(0.59) is True
|
||||||
|
assert is_low_confidence(0.6) is False
|
||||||
|
assert is_low_confidence(0.9) is False
|
||||||
|
assert LOW_CONFIDENCE_THRESHOLD == 0.6
|
||||||
|
|
||||||
|
def test_filter_high_confidence(self):
|
||||||
|
"""filter_high_confidence splits relationships correctly."""
|
||||||
|
from app.ai.knowledge_extraction import filter_high_confidence, ExtractedRelationship
|
||||||
|
|
||||||
|
rels = [
|
||||||
|
ExtractedRelationship("A", "person", "B", "company", "works_for", 0.9),
|
||||||
|
ExtractedRelationship("C", "person", "D", "company", "related_to", 0.3),
|
||||||
|
ExtractedRelationship("E", "person", "F", "company", "knows", 0.7),
|
||||||
|
ExtractedRelationship("G", "person", "H", "company", "met", 0.5),
|
||||||
|
]
|
||||||
|
high, low = filter_high_confidence(rels)
|
||||||
|
assert len(high) == 2 # 0.9 and 0.7
|
||||||
|
assert len(low) == 2 # 0.3 and 0.5
|
||||||
|
assert high[0].confidence == 0.9
|
||||||
|
assert high[1].confidence == 0.7
|
||||||
|
|
||||||
|
def test_filter_high_confidence_custom_threshold(self):
|
||||||
|
"""filter_high_confidence respects custom threshold."""
|
||||||
|
from app.ai.knowledge_extraction import filter_high_confidence, ExtractedRelationship
|
||||||
|
|
||||||
|
rels = [
|
||||||
|
ExtractedRelationship("A", "person", "B", "company", "works_for", 0.8),
|
||||||
|
ExtractedRelationship("C", "person", "D", "company", "related_to", 0.7),
|
||||||
|
]
|
||||||
|
high, low = filter_high_confidence(rels, threshold=0.75)
|
||||||
|
assert len(high) == 1 # 0.8
|
||||||
|
assert len(low) == 1 # 0.7
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_knowledge_empty_text(self):
|
||||||
|
"""extract_knowledge returns empty result for empty text."""
|
||||||
|
from app.ai.knowledge_extraction import extract_knowledge
|
||||||
|
|
||||||
|
result = await extract_knowledge("", uuid.uuid4())
|
||||||
|
assert result.entities == []
|
||||||
|
assert result.relationships == []
|
||||||
|
assert result.overall_confidence == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_knowledge_short_text(self):
|
||||||
|
"""extract_knowledge returns empty result for very short text."""
|
||||||
|
from app.ai.knowledge_extraction import extract_knowledge
|
||||||
|
|
||||||
|
result = await extract_knowledge("Hi", uuid.uuid4())
|
||||||
|
assert result.entities == []
|
||||||
|
assert result.relationships == []
|
||||||
|
|||||||
Reference in New Issue
Block a user