105 findings to zero. Most were mechanical — 67 unused imports, and assorted
f-strings without placeholders. Three groups needed a decision.
The 15 F821 "undefined name" were forward references, not runtime errors. Each
annotation is quoted — `-> "WikiService"`, `Optional["IngestionService"]` — with
the real import inside the function body to break an import cycle. A quoted
annotation is never evaluated, so the code ran; the names were simply
unresolvable to any checker. They now have a TYPE_CHECKING block, which costs
nothing at import time and keeps the cycle broken.
The 6 E402 split two ways. `import secrets`, `Security`, `Request` and
`HTTPBearer` in dependencies.py had drifted below several hundred lines of
factory functions for no reason — stdlib and fastapi, no cycle to avoid — and
moved up. The other three are deliberate and now say so: the VectorService and
GraphService aliases import back into dependencies.py, and main.py's routers
expect a configured app, so both must stay put.
Bare `except:` narrowed to `except Exception:` in three places, which stops them
swallowing KeyboardInterrupt and SystemExit.
The 5 unused locals were all genuinely dead. One is worth naming rather than
fixing: qdrant_client.delete()'s return value was bound and never read, so a
failed delete is indistinguishable from a successful one — the assignment is
gone, but nothing checks the status either way and that has not changed here.
`timing = {}` in _retrieve_parallel looked like it might mean the reported
per-leg timings were always zero; traced, and they come from output["timing"],
so the local was only vestigial.
426 passed, 29 skipped, unchanged. The app imports and the service aliases still
resolve, which is the check that mattered after moving imports in
dependencies.py.
The gate still prints "not gated here yet: test (T-56)" — lint is green, tests
remain unwired, and that is left visible rather than silently absent.
Co-Authored-By: Claude <noreply@anthropic.com>
484 lines
16 KiB
Python
484 lines
16 KiB
Python
"""
|
|
Comprehensive tests for Entity Linking system.
|
|
|
|
Tests cover:
|
|
- Finding entity mentions in pages
|
|
- Creating MENTIONS relationships in Neo4j
|
|
- Adding hyperlinks to wiki content
|
|
- Idempotency (safe to run multiple times)
|
|
- Protection of existing links (no nesting)
|
|
- Multi-tenancy isolation
|
|
|
|
Run with: pytest tests/test_entity_linking.py -v -s
|
|
"""
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from typing import AsyncGenerator
|
|
|
|
from src.clients.neo4j_client import Neo4jClient
|
|
from src.clients.wikijs_client import WikiJSClient
|
|
from src.services.graph_service import GraphService
|
|
from src.services.wiki_service import WikiService
|
|
from src.routers.entity_linking import (
|
|
find_entity_mentions,
|
|
add_entity_links_to_content,
|
|
get_entities_with_paths
|
|
)
|
|
from src.config import get_settings
|
|
|
|
# Test user to isolate test data
|
|
TEST_USER = "entity-link-tester"
|
|
|
|
|
|
@pytest.fixture
|
|
def settings():
|
|
"""Get application settings."""
|
|
return get_settings()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
|
|
"""Get connected Neo4j client."""
|
|
client = Neo4jClient(
|
|
uri=neo4j_test_uri,
|
|
user=settings.neo4j_user,
|
|
password=settings.neo4j_password
|
|
)
|
|
await client.connect()
|
|
yield client
|
|
await client.close()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
|
|
"""Get Wiki.js client."""
|
|
client = WikiJSClient(
|
|
base_url=wikijs_test_config["base_url"],
|
|
api_token=wikijs_test_config["api_token"]
|
|
)
|
|
yield client
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def graph_service(neo4j_client, wiki_client):
|
|
"""Get GraphService instance."""
|
|
return GraphService(neo4j_client, wiki_client)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def wiki_service(wiki_client):
|
|
"""Get WikiService instance."""
|
|
return WikiService(wiki_client)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_entities(graph_service):
|
|
"""Create test entities in graph."""
|
|
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
|
|
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
|
|
|
# Clean up any existing test entities
|
|
cleanup_query = f"""
|
|
MATCH (n:{user_base_label})
|
|
WHERE n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
|
|
DETACH DELETE n
|
|
"""
|
|
await graph_service.neo4j.execute_query(cleanup_query)
|
|
|
|
# Create test entities
|
|
create_query = f"""
|
|
CREATE (d:{user_base_label}:Technology {{name: 'Docker', type: 'technology'}})
|
|
CREATE (k:{user_base_label}:Technology {{name: 'Kubernetes', type: 'technology'}})
|
|
CREATE (p:{user_base_label}:Technology {{name: 'PostgreSQL', type: 'technology'}})
|
|
RETURN d.name, k.name, p.name
|
|
"""
|
|
await graph_service.neo4j.execute_query(create_query)
|
|
|
|
yield ["Docker", "Kubernetes", "PostgreSQL"]
|
|
|
|
# Cleanup after test
|
|
await graph_service.neo4j.execute_query(cleanup_query)
|
|
|
|
|
|
# ============================================================================
|
|
# Unit Tests - Entity Mention Detection
|
|
# ============================================================================
|
|
|
|
class TestFindEntityMentions:
|
|
"""Test finding entity mentions in content."""
|
|
|
|
def test_find_single_mention(self):
|
|
"""Test finding a single entity mention."""
|
|
content = "Docker is a containerization platform."
|
|
entities = [
|
|
{"name": "Docker", "type": "technology"}
|
|
]
|
|
|
|
found = find_entity_mentions(content, entities)
|
|
|
|
assert len(found) == 1
|
|
assert found[0]["name"] == "Docker"
|
|
assert found[0]["mentions"] == 1
|
|
|
|
def test_find_multiple_mentions(self):
|
|
"""Test finding multiple mentions of same entity."""
|
|
content = "Docker containers run on Docker Engine. Docker is great!"
|
|
entities = [
|
|
{"name": "Docker", "type": "technology"}
|
|
]
|
|
|
|
found = find_entity_mentions(content, entities)
|
|
|
|
assert len(found) == 1
|
|
assert found[0]["name"] == "Docker"
|
|
assert found[0]["mentions"] == 3
|
|
|
|
def test_case_insensitive_matching(self):
|
|
"""Test case-insensitive entity matching."""
|
|
content = "docker and DOCKER and Docker are the same"
|
|
entities = [
|
|
{"name": "Docker", "type": "technology"}
|
|
]
|
|
|
|
found = find_entity_mentions(content, entities)
|
|
|
|
assert len(found) == 1
|
|
assert found[0]["mentions"] == 3
|
|
|
|
def test_whole_word_matching(self):
|
|
"""Test that partial word matches are excluded."""
|
|
content = "Kubernetes and Kubernetes-based and MyKubernetes"
|
|
entities = [
|
|
{"name": "Kubernetes", "type": "technology"}
|
|
]
|
|
|
|
found = find_entity_mentions(content, entities)
|
|
|
|
assert len(found) == 1
|
|
# Regex \b matches at hyphens, so "Kubernetes-based" contains "Kubernetes"
|
|
# Only "MyKubernetes" is excluded (no word boundary)
|
|
assert found[0]["mentions"] == 2 # "Kubernetes" and "Kubernetes-based"
|
|
|
|
def test_ignore_short_names(self):
|
|
"""Test that entities with names <3 chars are ignored."""
|
|
content = "Go is a programming language by Google"
|
|
entities = [
|
|
{"name": "Go", "type": "language"}, # Too short
|
|
{"name": "Google", "type": "organization"}
|
|
]
|
|
|
|
found = find_entity_mentions(content, entities)
|
|
|
|
assert len(found) == 1
|
|
assert found[0]["name"] == "Google"
|
|
|
|
def test_sort_by_mention_count(self):
|
|
"""Test results are sorted by mention count."""
|
|
content = "Docker Docker Docker. Kubernetes Kubernetes. PostgreSQL."
|
|
entities = [
|
|
{"name": "PostgreSQL", "type": "database"},
|
|
{"name": "Docker", "type": "technology"},
|
|
{"name": "Kubernetes", "type": "technology"}
|
|
]
|
|
|
|
found = find_entity_mentions(content, entities)
|
|
|
|
assert len(found) == 3
|
|
assert found[0]["name"] == "Docker" # Most mentions
|
|
assert found[0]["mentions"] == 3
|
|
assert found[1]["name"] == "Kubernetes"
|
|
assert found[1]["mentions"] == 2
|
|
assert found[2]["name"] == "PostgreSQL"
|
|
assert found[2]["mentions"] == 1
|
|
|
|
|
|
# ============================================================================
|
|
# Unit Tests - Content Link Addition
|
|
# ============================================================================
|
|
|
|
class TestAddEntityLinksToContent:
|
|
"""Test adding hyperlinks to content."""
|
|
|
|
def test_add_single_link(self):
|
|
"""Test adding a single entity link."""
|
|
content = "Docker is a containerization platform."
|
|
entities = [
|
|
{"name": "Docker", "path": "users/test/docker"}
|
|
]
|
|
|
|
updated, count = add_entity_links_to_content(content, entities)
|
|
|
|
assert count == 1
|
|
assert "[Docker](/users/test/docker)" in updated
|
|
|
|
def test_add_multiple_instances(self):
|
|
"""Test linking all instances of an entity."""
|
|
content = "Docker containers run on Docker Engine."
|
|
entities = [
|
|
{"name": "Docker", "path": "users/test/docker"}
|
|
]
|
|
|
|
updated, count = add_entity_links_to_content(content, entities)
|
|
|
|
assert count == 2 # Both instances linked
|
|
assert updated.count("[Docker](/users/test/docker)") == 2
|
|
|
|
def test_skip_entities_without_path(self):
|
|
"""Test that entities without wiki pages are not linked."""
|
|
content = "Docker and Kubernetes are used together."
|
|
entities = [
|
|
{"name": "Docker", "path": "users/test/docker"},
|
|
{"name": "Kubernetes", "path": None} # No page
|
|
]
|
|
|
|
updated, count = add_entity_links_to_content(content, entities)
|
|
|
|
assert count == 1 # Only Docker
|
|
assert "[Docker](/users/test/docker)" in updated
|
|
assert "[Kubernetes]" not in updated
|
|
|
|
def test_protect_existing_links(self):
|
|
"""Test that existing markdown links are not modified."""
|
|
content = "See [Docker](https://docker.com) for more info. Docker is great!"
|
|
entities = [
|
|
{"name": "Docker", "path": "users/test/docker"}
|
|
]
|
|
|
|
updated, count = add_entity_links_to_content(content, entities)
|
|
|
|
# Should link the second "Docker" but not the one already linked
|
|
assert count == 1
|
|
assert "[Docker](https://docker.com)" in updated # Preserved
|
|
assert updated.count("[Docker](/users/test/docker)") == 1
|
|
|
|
def test_no_nested_links(self):
|
|
"""Test that entity names in URLs are not linked."""
|
|
content = "Check [Docker Hub](/docker/hub) for images."
|
|
entities = [
|
|
{"name": "Docker", "path": "users/test/docker"}
|
|
]
|
|
|
|
updated, count = add_entity_links_to_content(content, entities)
|
|
|
|
# "Docker" in the URL path should not be linked
|
|
assert count == 0
|
|
assert "[Docker Hub](/docker/hub)" in updated # Unchanged
|
|
|
|
def test_longest_first_matching(self):
|
|
"""Test that longer entity names are matched first."""
|
|
content = "Machine Learning and Machine are different."
|
|
entities = [
|
|
{"name": "Machine Learning", "path": "users/test/ml"},
|
|
{"name": "Machine", "path": "users/test/machine"}
|
|
]
|
|
|
|
updated, count = add_entity_links_to_content(content, entities)
|
|
|
|
# Should link "Machine Learning" first, leaving "Machine" alone
|
|
assert "[Machine Learning](/users/test/ml)" in updated
|
|
assert count >= 1
|
|
|
|
|
|
# ============================================================================
|
|
# Integration Tests - Full Entity Linking Flow
|
|
# ============================================================================
|
|
|
|
class TestEntityLinkingIntegration:
|
|
"""Test full entity linking flow."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_entities_with_paths(self, graph_service, test_entities):
|
|
"""Test retrieving entities and their wiki page paths."""
|
|
entities = await get_entities_with_paths(graph_service, TEST_USER)
|
|
|
|
# Should find our test entities
|
|
entity_names = [e["name"] for e in entities]
|
|
assert "Docker" in entity_names
|
|
assert "Kubernetes" in entity_names
|
|
assert "PostgreSQL" in entity_names
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_mentions_relationships(self, graph_service, test_entities):
|
|
"""Test creating MENTIONS relationships."""
|
|
from src.core.multi_tenancy import get_neo4j_user_label
|
|
|
|
user_doc_label = get_neo4j_user_label(TEST_USER)
|
|
|
|
# Create a test document node
|
|
doc_query = f"""
|
|
CREATE (d:{user_doc_label}:Document {{
|
|
page_id: 9999,
|
|
title: 'Test Doc',
|
|
path: 'users/test/doc'
|
|
}})
|
|
RETURN d
|
|
"""
|
|
await graph_service.neo4j.execute_query(doc_query)
|
|
|
|
# Create MENTIONS relationships
|
|
found_entities = [
|
|
{"name": "Docker"},
|
|
{"name": "Kubernetes"}
|
|
]
|
|
|
|
new_links = await graph_service.create_entity_mentions(
|
|
page_id=9999,
|
|
user=TEST_USER,
|
|
entity_names=found_entities
|
|
)
|
|
|
|
assert new_links == 2
|
|
|
|
# Verify relationships exist
|
|
verify_query = f"""
|
|
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})-[r:MENTIONS]->(e)
|
|
RETURN count(r) as mention_count
|
|
"""
|
|
result = await graph_service.neo4j.execute_query(verify_query)
|
|
assert result[0]["mention_count"] == 2
|
|
|
|
# Cleanup
|
|
cleanup_query = f"""
|
|
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})
|
|
DETACH DELETE d
|
|
"""
|
|
await graph_service.neo4j.execute_query(cleanup_query)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idempotency(self, graph_service):
|
|
"""Test that entity linking is idempotent."""
|
|
from src.core.multi_tenancy import get_neo4j_user_label, get_neo4j_user_base_label
|
|
|
|
user_doc_label = get_neo4j_user_label(TEST_USER)
|
|
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
|
|
|
# Aggressively clean up ALL test data first (fresh start)
|
|
cleanup_all = f"""
|
|
MATCH (n)
|
|
WHERE (n:{user_base_label} OR n:{user_doc_label})
|
|
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
|
|
DETACH DELETE n
|
|
"""
|
|
await graph_service.neo4j.execute_query(cleanup_all)
|
|
|
|
# Create a unique test entity
|
|
entity_query = f"""
|
|
CREATE (e:{user_base_label}:Technology {{name: 'TestDockerEntity', type: 'technology'}})
|
|
RETURN e
|
|
"""
|
|
await graph_service.neo4j.execute_query(entity_query)
|
|
|
|
# Create test document
|
|
doc_query = f"""
|
|
CREATE (d:{user_doc_label}:Document {{
|
|
page_id: 9998,
|
|
title: 'Test Doc 2',
|
|
path: 'users/test/doc2'
|
|
}})
|
|
RETURN d
|
|
"""
|
|
await graph_service.neo4j.execute_query(doc_query)
|
|
|
|
found_entities = [{"name": "TestDockerEntity"}]
|
|
|
|
# Link once
|
|
first_run = await graph_service.create_entity_mentions(
|
|
page_id=9998,
|
|
user=TEST_USER,
|
|
entity_names=found_entities
|
|
)
|
|
assert first_run == 1
|
|
|
|
# Link again - should not create duplicates
|
|
second_run = await graph_service.create_entity_mentions(
|
|
page_id=9998,
|
|
user=TEST_USER,
|
|
entity_names=found_entities
|
|
)
|
|
assert second_run == 0 # No new links
|
|
|
|
# Verify only one relationship exists
|
|
verify_query = f"""
|
|
MATCH (d:{user_doc_label}:Document {{page_id: 9998}})-[r:MENTIONS]->()
|
|
RETURN count(r) as mention_count
|
|
"""
|
|
result = await graph_service.neo4j.execute_query(verify_query)
|
|
assert result[0]["mention_count"] == 1
|
|
|
|
# Cleanup
|
|
cleanup_query = f"""
|
|
MATCH (n)
|
|
WHERE (n:{user_base_label} OR n:{user_doc_label})
|
|
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
|
|
DETACH DELETE n
|
|
"""
|
|
await graph_service.neo4j.execute_query(cleanup_query)
|
|
|
|
|
|
# ============================================================================
|
|
# Multi-Tenancy Tests
|
|
# ============================================================================
|
|
|
|
class TestEntityLinkingMultiTenancy:
|
|
"""Test multi-tenancy isolation in entity linking."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_isolation(self, graph_service):
|
|
"""Test that entities are isolated by user."""
|
|
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
|
|
user1_label = get_neo4j_user_base_label("user1")
|
|
user2_label = get_neo4j_user_base_label("user2")
|
|
|
|
# Create entity for user1
|
|
create_user1 = f"""
|
|
CREATE (e:{user1_label}:Technology {{name: 'Docker', type: 'technology'}})
|
|
RETURN e
|
|
"""
|
|
await graph_service.neo4j.execute_query(create_user1)
|
|
|
|
# Create entity for user2
|
|
create_user2 = f"""
|
|
CREATE (e:{user2_label}:Technology {{name: 'Docker', type: 'technology'}})
|
|
RETURN e
|
|
"""
|
|
await graph_service.neo4j.execute_query(create_user2)
|
|
|
|
# Get entities for user1 - should only see user1's entities
|
|
entities_user1 = await get_entities_with_paths(graph_service, "user1")
|
|
entity_names_user1 = [e["name"] for e in entities_user1]
|
|
|
|
# Verify isolation
|
|
assert "Docker" in entity_names_user1
|
|
# We can't verify the exact count without knowing what else is in the DB,
|
|
# but we verified we can retrieve entities for user1
|
|
|
|
# Cleanup
|
|
await graph_service.neo4j.execute_query(f"MATCH (e:{user1_label}) WHERE e.name = 'Docker' DETACH DELETE e")
|
|
await graph_service.neo4j.execute_query(f"MATCH (e:{user2_label}) WHERE e.name = 'Docker' DETACH DELETE e")
|
|
|
|
|
|
# ============================================================================
|
|
# Cleanup
|
|
# ============================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_entity_linking_test_data(neo4j_client):
|
|
"""Clean up all test data created by entity linking tests."""
|
|
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
|
|
for user in [TEST_USER, "user1", "user2"]:
|
|
user_label = get_neo4j_user_base_label(user)
|
|
|
|
cleanup_query = f"""
|
|
MATCH (n:{user_label})
|
|
WHERE n.page_id IN [9999, 9998]
|
|
OR n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
|
|
DETACH DELETE n
|
|
"""
|
|
await neo4j_client.execute_query(cleanup_query)
|
|
|
|
print("\n✓ Cleaned up entity linking test data")
|