test: Phase H knowledge plugin — 9/9 integration tests pass (extract, review queue, approve/reject, model fields, defaults), LLM mocked for test env
This commit is contained in:
@@ -80,6 +80,8 @@ for _plugin_name in _registry.list_discovered():
|
||||
from app.models.plugin_allowlist import PluginAllowlist # noqa: F401
|
||||
# Wiki plugin models — not loaded by get_entity_models()
|
||||
from app.plugins.builtins.wiki.models import WikiArticle, WikiArticleVersion, WikiCategory # noqa: F401
|
||||
# Knowledge plugin models — new plugin, ensure table is created in test-DB
|
||||
from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401
|
||||
|
||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||
from app.core.permission_registry import init_permission_registry # noqa: F401
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Phase H Knowledge Plugin Tests — integration tests with real DB."""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from sqlalchemy import select
|
||||
from app.core.db import Base
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.core.auth import hash_password
|
||||
from app.plugins.builtins.knowledge.models import KnowledgeExtraction
|
||||
from app.plugins.builtins.knowledge.services import extract_knowledge, ask_knowledge, get_review_queue, review_extraction
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed(db_session):
|
||||
"""Create tenant + admin user for tests."""
|
||||
tenant = Tenant(name="Test Tenant", slug="test-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
admin = User(
|
||||
email="admin@test.local",
|
||||
name="Admin",
|
||||
password_hash=hash_password("Test123!"),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(admin)
|
||||
await db_session.flush()
|
||||
return {"tenant": tenant, "admin": admin}
|
||||
|
||||
|
||||
def _mock_llm_response(content: str = '{"entities": [], "relationships": [], "confidence": 0.5}'):
|
||||
"""Create a mock LLM response."""
|
||||
return AsyncMock(return_value={"content": content, "cost_usd": 0.001})
|
||||
|
||||
|
||||
class TestKnowledgeExtraction:
|
||||
"""Test extract_knowledge service."""
|
||||
|
||||
async def test_extract_creates_extraction_record(self, db_session, seed):
|
||||
"""extract_knowledge creates a KnowledgeExtraction record in DB."""
|
||||
source_id = uuid.uuid4()
|
||||
mock_response = {"content": '{"entities": [{"type": "person", "name": "John Doe"}], "relationships": [], "confidence": 0.7}', "cost_usd": 0.001}
|
||||
with patch("app.plugins.builtins.knowledge.services.llm_complete", new_callable=AsyncMock, return_value=mock_response):
|
||||
result = await extract_knowledge(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="wiki_article",
|
||||
source_id=source_id,
|
||||
source_title="Test Article",
|
||||
source_text="John Doe works at ACME Corp. He is the CEO.",
|
||||
)
|
||||
assert "id" in result
|
||||
assert "entities" in result
|
||||
assert "relationships" in result
|
||||
assert "confidence" in result
|
||||
assert "status" in result
|
||||
# Verify DB record
|
||||
ext = await db_session.execute(
|
||||
select(KnowledgeExtraction).where(
|
||||
KnowledgeExtraction.tenant_id == seed["tenant"].id
|
||||
)
|
||||
)
|
||||
records = ext.scalars().all()
|
||||
assert len(records) >= 1
|
||||
assert records[0].source_type == "wiki_article"
|
||||
assert records[0].source_title == "Test Article"
|
||||
|
||||
async def test_extract_with_empty_text(self, db_session, seed):
|
||||
"""extract_knowledge handles empty text gracefully."""
|
||||
source_id = uuid.uuid4()
|
||||
mock_response = {"content": '{"entities": [], "relationships": [], "confidence": 0.0}', "cost_usd": 0.001}
|
||||
with patch("app.plugins.builtins.knowledge.services.llm_complete", new_callable=AsyncMock, return_value=mock_response):
|
||||
result = await extract_knowledge(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="wiki_article",
|
||||
source_id=source_id,
|
||||
source_title="Empty Article",
|
||||
source_text="",
|
||||
)
|
||||
assert "id" in result
|
||||
assert result["entities"] == []
|
||||
assert result["relationships"] == []
|
||||
|
||||
|
||||
class TestReviewQueue:
|
||||
"""Test review queue service."""
|
||||
|
||||
async def test_review_queue_returns_pending(self, db_session, seed):
|
||||
"""get_review_queue returns pending extractions."""
|
||||
ext = KnowledgeExtraction(
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="wiki_article",
|
||||
source_id=uuid.uuid4(),
|
||||
source_title="Test",
|
||||
extracted_entities=[{"type": "person", "name": "John"}],
|
||||
extracted_relationships=[],
|
||||
confidence=0.5,
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(ext)
|
||||
await db_session.flush()
|
||||
|
||||
result = await get_review_queue(db=db_session, tenant_id=seed["tenant"].id)
|
||||
assert result["total"] >= 1
|
||||
assert any(item["id"] == str(ext.id) for item in result["items"])
|
||||
|
||||
async def test_review_queue_excludes_approved(self, db_session, seed):
|
||||
"""get_review_queue excludes approved extractions."""
|
||||
ext = KnowledgeExtraction(
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="wiki_article",
|
||||
source_id=uuid.uuid4(),
|
||||
source_title="Approved",
|
||||
extracted_entities=[],
|
||||
extracted_relationships=[],
|
||||
confidence=0.9,
|
||||
status="approved",
|
||||
)
|
||||
db_session.add(ext)
|
||||
await db_session.flush()
|
||||
|
||||
result = await get_review_queue(db=db_session, tenant_id=seed["tenant"].id)
|
||||
assert not any(item["id"] == str(ext.id) for item in result["items"])
|
||||
|
||||
async def test_review_approve(self, db_session, seed):
|
||||
"""review_extraction approves a pending extraction."""
|
||||
ext = KnowledgeExtraction(
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="wiki_article",
|
||||
source_id=uuid.uuid4(),
|
||||
source_title="To Approve",
|
||||
extracted_entities=[{"type": "person", "name": "Jane"}],
|
||||
extracted_relationships=[],
|
||||
confidence=0.6,
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(ext)
|
||||
await db_session.flush()
|
||||
|
||||
result = await review_extraction(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
extraction_id=ext.id,
|
||||
approved=True,
|
||||
user_id=seed["admin"].id,
|
||||
notes="Looks good",
|
||||
)
|
||||
assert result["status"] == "approved"
|
||||
await db_session.refresh(ext)
|
||||
assert ext.status == "approved"
|
||||
assert ext.reviewed_by == seed["admin"].id
|
||||
assert ext.review_notes == "Looks good"
|
||||
|
||||
async def test_review_reject(self, db_session, seed):
|
||||
"""review_extraction rejects a pending extraction."""
|
||||
ext = KnowledgeExtraction(
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="wiki_article",
|
||||
source_id=uuid.uuid4(),
|
||||
source_title="To Reject",
|
||||
extracted_entities=[],
|
||||
extracted_relationships=[],
|
||||
confidence=0.3,
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(ext)
|
||||
await db_session.flush()
|
||||
|
||||
result = await review_extraction(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
extraction_id=ext.id,
|
||||
approved=False,
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
assert result["status"] == "rejected"
|
||||
await db_session.refresh(ext)
|
||||
assert ext.status == "rejected"
|
||||
|
||||
async def test_review_nonexistent(self, db_session, seed):
|
||||
"""review_extraction returns error for nonexistent extraction."""
|
||||
result = await review_extraction(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
extraction_id=uuid.uuid4(),
|
||||
approved=True,
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestKnowledgeModel:
|
||||
"""Test KnowledgeExtraction model fields."""
|
||||
|
||||
async def test_model_fields(self, db_session, seed):
|
||||
"""KnowledgeExtraction has all required fields."""
|
||||
ext = KnowledgeExtraction(
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="dms_file",
|
||||
source_id=uuid.uuid4(),
|
||||
source_title="Test File",
|
||||
extracted_entities=[{"type": "company", "name": "ACME"}],
|
||||
extracted_relationships=[{"source": "John", "target": "ACME", "type": "works_for"}],
|
||||
confidence=0.85,
|
||||
status="auto_created",
|
||||
llm_model="openai/gpt-4o-mini",
|
||||
llm_cost_usd=0.001,
|
||||
)
|
||||
db_session.add(ext)
|
||||
await db_session.flush()
|
||||
|
||||
assert ext.id is not None
|
||||
assert ext.source_type == "dms_file"
|
||||
assert ext.confidence == 0.85
|
||||
assert ext.status == "auto_created"
|
||||
assert ext.llm_model == "openai/gpt-4o-mini"
|
||||
assert len(ext.extracted_entities) == 1
|
||||
assert len(ext.extracted_relationships) == 1
|
||||
|
||||
async def test_model_defaults(self, db_session, seed):
|
||||
"""KnowledgeExtraction has correct defaults."""
|
||||
ext = KnowledgeExtraction(
|
||||
tenant_id=seed["tenant"].id,
|
||||
source_type="mail",
|
||||
source_id=uuid.uuid4(),
|
||||
)
|
||||
db_session.add(ext)
|
||||
await db_session.flush()
|
||||
|
||||
assert ext.confidence == 0.0
|
||||
assert ext.status == "pending"
|
||||
assert ext.llm_cost_usd == 0.0
|
||||
assert ext.extracted_entities == []
|
||||
assert ext.extracted_relationships == []
|
||||
Reference in New Issue
Block a user