feat(library-desk): improve entity linking with fuzzy matching
- Add fuzzy_match_entity_to_document with confidence scoring - Filter self-referential links (entity linking to current page) - Fix path cleaning to preserve full wiki paths - Add longest-first matching to prevent partial matches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,8 @@ from pydantic import BaseModel
|
|||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from src.config import get_settings
|
||||||
|
|
||||||
from src.core.dependencies import (
|
from src.core.dependencies import (
|
||||||
get_wiki_service,
|
get_wiki_service,
|
||||||
get_graph_service,
|
get_graph_service,
|
||||||
@@ -98,6 +100,13 @@ async def link_entities_in_page(
|
|||||||
found_entities = find_entity_mentions(page_content, entities)
|
found_entities = find_entity_mentions(page_content, entities)
|
||||||
logger.info(f"Found {len(found_entities)} entity mentions in page")
|
logger.info(f"Found {len(found_entities)} entity mentions in page")
|
||||||
|
|
||||||
|
# Filter out self-referential links (entities linking to the current page)
|
||||||
|
found_entities = [
|
||||||
|
e for e in found_entities
|
||||||
|
if e.get("doc_page_id") != request.page_id
|
||||||
|
]
|
||||||
|
logger.info(f"After filtering self-references: {len(found_entities)} entities to link")
|
||||||
|
|
||||||
# Step 4: Add hyperlinks to wiki content for entities with pages
|
# Step 4: Add hyperlinks to wiki content for entities with pages
|
||||||
content_links_added = 0
|
content_links_added = 0
|
||||||
content_updated = False
|
content_updated = False
|
||||||
@@ -209,7 +218,8 @@ def find_entity_mentions(content: str, entities: List[Dict[str, Any]]) -> List[D
|
|||||||
"type": entity.get("type", "unknown"),
|
"type": entity.get("type", "unknown"),
|
||||||
"mentions": len(matches),
|
"mentions": len(matches),
|
||||||
"entity_id": entity.get("id"),
|
"entity_id": entity.get("id"),
|
||||||
"path": entity.get("path") # Include path if available
|
"path": entity.get("path"), # Include path if available
|
||||||
|
"doc_page_id": entity.get("doc_page_id") # Include for self-reference filtering
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by number of mentions (descending)
|
# Sort by number of mentions (descending)
|
||||||
@@ -218,45 +228,156 @@ def find_entity_mentions(content: str, entities: List[Dict[str, Any]]) -> List[D
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy_match_entity_to_document(
|
||||||
|
entity_name: str,
|
||||||
|
doc_title: str,
|
||||||
|
settings
|
||||||
|
) -> Tuple[bool, float]:
|
||||||
|
"""
|
||||||
|
Match entity name to document title with tolerance for variations.
|
||||||
|
|
||||||
|
Uses multiple strategies with confidence scoring:
|
||||||
|
1. Exact match (confidence: 1.0)
|
||||||
|
2. Containment match (confidence: 0.85-0.80)
|
||||||
|
3. Token overlap match (confidence: 0.60-0.80)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_name: Entity name to match
|
||||||
|
doc_title: Document title to match against
|
||||||
|
settings: Application settings with matching thresholds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_match, confidence_score)
|
||||||
|
"""
|
||||||
|
e_lower = entity_name.lower().strip()
|
||||||
|
d_lower = doc_title.lower().strip()
|
||||||
|
|
||||||
|
# Strategy 1: Exact match (confidence: 1.0)
|
||||||
|
if e_lower == d_lower:
|
||||||
|
return True, 1.0
|
||||||
|
|
||||||
|
# Strategy 2: Containment match (confidence: 0.85-0.80)
|
||||||
|
# Require minimum length to avoid false positives
|
||||||
|
if len(entity_name) >= settings.entity_linking_min_entity_length:
|
||||||
|
# Entity is substring of title: "lingecollege" in "RSG Lingecollege"
|
||||||
|
if e_lower in d_lower:
|
||||||
|
confidence = len(entity_name) / len(doc_title)
|
||||||
|
if confidence >= settings.entity_linking_min_containment_ratio:
|
||||||
|
return True, 0.85
|
||||||
|
|
||||||
|
# Title is substring of entity: "Google" in "Google Cloud Platform"
|
||||||
|
if d_lower in e_lower:
|
||||||
|
confidence = len(doc_title) / len(entity_name)
|
||||||
|
if confidence >= settings.entity_linking_min_containment_ratio:
|
||||||
|
return True, 0.80
|
||||||
|
|
||||||
|
# Strategy 3: Word-level overlap (confidence: 0.60-0.80)
|
||||||
|
entity_tokens = set(e_lower.split())
|
||||||
|
title_tokens = set(d_lower.split())
|
||||||
|
|
||||||
|
# Remove common stop words to reduce false positives
|
||||||
|
stop_words = {'the', 'a', 'an', 'of', 'for', 'and', 'or', 'in', 'on', 'at', 'to'}
|
||||||
|
entity_tokens -= stop_words
|
||||||
|
title_tokens -= stop_words
|
||||||
|
|
||||||
|
if entity_tokens and title_tokens:
|
||||||
|
overlap = len(entity_tokens & title_tokens)
|
||||||
|
total = len(entity_tokens | title_tokens)
|
||||||
|
overlap_ratio = overlap / total
|
||||||
|
|
||||||
|
# Require significant overlap to avoid weak matches
|
||||||
|
if overlap_ratio >= settings.entity_linking_min_token_overlap:
|
||||||
|
confidence = 0.75 * overlap_ratio # Scale: 0.45-0.75
|
||||||
|
return True, confidence
|
||||||
|
|
||||||
|
return False, 0.0
|
||||||
|
|
||||||
|
|
||||||
async def get_entities_with_paths(graph_service: GraphService, user: str) -> List[Dict[str, Any]]:
|
async def get_entities_with_paths(graph_service: GraphService, user: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all entities and check which ones have corresponding wiki pages.
|
Get all entities and check which ones have corresponding wiki pages.
|
||||||
|
|
||||||
Returns entities with their names, types, and paths (if they have wiki pages).
|
Uses fuzzy matching to handle name variations, typos, and partial matches
|
||||||
|
while minimizing false positives through confidence thresholds.
|
||||||
|
|
||||||
|
Returns entities with their names, types, paths, and match confidence.
|
||||||
"""
|
"""
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
user_base_label = get_neo4j_user_base_label(user)
|
user_base_label = get_neo4j_user_base_label(user)
|
||||||
|
|
||||||
# Query to get entities and optionally join with Document nodes by matching title
|
# Step 1: Get all entities
|
||||||
query = f"""
|
entities_query = f"""
|
||||||
// Get all entities
|
|
||||||
MATCH (e:{user_base_label})
|
MATCH (e:{user_base_label})
|
||||||
WHERE NOT e:Document
|
WHERE NOT e:Document
|
||||||
|
|
||||||
// Optionally match Document with same name (case-insensitive)
|
|
||||||
OPTIONAL MATCH (d:Document)
|
|
||||||
WHERE toLower(d.title) = toLower(e.name)
|
|
||||||
|
|
||||||
RETURN e.name as name,
|
RETURN e.name as name,
|
||||||
e.type as type,
|
e.type as type,
|
||||||
e.id as id,
|
e.id as id
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Step 2: Get all documents
|
||||||
|
documents_query = """
|
||||||
|
MATCH (d:Document)
|
||||||
|
RETURN d.title as title,
|
||||||
d.path as path,
|
d.path as path,
|
||||||
d.page_id as doc_page_id
|
d.page_id as page_id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await graph_service.neo4j.execute_query(query, {})
|
entities = await graph_service.neo4j.execute_query(entities_query, {})
|
||||||
return [
|
documents = await graph_service.neo4j.execute_query(documents_query, {})
|
||||||
{
|
|
||||||
"name": r["name"],
|
# Step 3: Fuzzy match entities to documents
|
||||||
"type": r.get("type", "unknown"),
|
results = []
|
||||||
"id": r.get("id"),
|
for entity in entities:
|
||||||
"path": r.get("path"), # Will be None if no matching Document
|
entity_name = entity["name"]
|
||||||
"doc_page_id": r.get("doc_page_id")
|
if not entity_name:
|
||||||
}
|
continue
|
||||||
for r in results
|
|
||||||
]
|
best_match = None
|
||||||
|
best_confidence = 0.0
|
||||||
|
|
||||||
|
# Try to match this entity to any document
|
||||||
|
for doc in documents:
|
||||||
|
doc_title = doc["title"]
|
||||||
|
if not doc_title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_match, confidence = fuzzy_match_entity_to_document(
|
||||||
|
entity_name,
|
||||||
|
doc_title,
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_match and confidence > best_confidence:
|
||||||
|
best_match = doc
|
||||||
|
best_confidence = confidence
|
||||||
|
|
||||||
|
# Only include matches above minimum confidence threshold
|
||||||
|
if best_match and best_confidence >= settings.entity_linking_min_confidence:
|
||||||
|
results.append({
|
||||||
|
"name": entity_name,
|
||||||
|
"type": entity.get("type", "unknown"),
|
||||||
|
"id": entity.get("id"),
|
||||||
|
"path": best_match["path"],
|
||||||
|
"doc_page_id": best_match["page_id"],
|
||||||
|
"match_confidence": best_confidence # NEW: track confidence
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# No matching document found (or below threshold)
|
||||||
|
results.append({
|
||||||
|
"name": entity_name,
|
||||||
|
"type": entity.get("type", "unknown"),
|
||||||
|
"id": entity.get("id"),
|
||||||
|
"path": None,
|
||||||
|
"doc_page_id": None,
|
||||||
|
"match_confidence": 0.0
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Matched {len([r for r in results if r['path']])} entities to documents (threshold: {settings.entity_linking_min_confidence})")
|
||||||
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get entities with paths: {e}")
|
logger.error(f"Failed to get entities with paths: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -300,10 +421,9 @@ def add_entity_links_to_content(
|
|||||||
if len(entity_name) < 3:
|
if len(entity_name) < 3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create markdown link
|
# Create markdown link with full path (including user namespace)
|
||||||
# Remove any "users/{username}/" prefix to get clean relative path
|
# Wiki.js expects full paths like /users/jpmschweitzer/...
|
||||||
clean_path = re.sub(r'^users/[^/]+/', '', entity_path)
|
markdown_link = f"[{entity_name}](/{entity_path})"
|
||||||
markdown_link = f"[{entity_name}](/{clean_path})"
|
|
||||||
|
|
||||||
# Find all existing markdown links to protect them (recompute each iteration)
|
# Find all existing markdown links to protect them (recompute each iteration)
|
||||||
link_pattern = r'\[([^\]]+)\]\([^\)]+\)'
|
link_pattern = r'\[([^\]]+)\]\([^\)]+\)'
|
||||||
|
|||||||
Reference in New Issue
Block a user