feat: add Phase 3 hybrid search with Synesis reranking
Implement hybrid search pipeline combining vector, fulltext, and graph search across Neo4j, with cross-attention reranking via Synesis (Qwen3-VL-Reranker-2B) `/v1/rerank` endpoint. - Add SearchService with vector, fulltext, and graph search strategies - Add SynesisRerankerClient for multimodal reranking via HTTP API - Add search API endpoint (POST /search/) with filtering by library, collection, and library_type - Add SearchRequest/Response serializers and image search results - Add "nonfiction" to library_type choices - Consolidate reranker stack from two models to single Synesis service - Handle image analysis_status as "skipped" when analysis is unavailable - Add comprehensive tests for search pipeline and reranker client
This commit is contained in:
381
mnemosyne/llm_manager/tests/test_admin.py
Normal file
381
mnemosyne/llm_manager/tests/test_admin.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Tests for LLM Manager admin configuration.
|
||||
|
||||
Covers system model admin actions, badges, and save_model validation.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from llm_manager.admin import LLMModelAdmin
|
||||
from llm_manager.models import LLMApi, LLMModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AdminTestBase(TestCase):
|
||||
"""Base class with common admin test setup."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.site = AdminSite()
|
||||
self.admin = LLMModelAdmin(LLMModel, self.site)
|
||||
self.user = User.objects.create_superuser(
|
||||
username="admin", password="admin123", email="admin@test.com"
|
||||
)
|
||||
self.api = LLMApi.objects.create(
|
||||
name="Test API",
|
||||
api_type="vllm",
|
||||
base_url="http://localhost:8000/v1",
|
||||
)
|
||||
|
||||
def _make_request(self):
|
||||
request = self.factory.post("/admin/")
|
||||
request.user = self.user
|
||||
# Django admin uses _messages attribute
|
||||
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||
|
||||
setattr(request, "session", "session")
|
||||
setattr(request, "_messages", FallbackStorage(request))
|
||||
return request
|
||||
|
||||
|
||||
class SystemVisionModelActionTests(AdminTestBase):
|
||||
"""Tests for the set_as_system_vision_model admin action."""
|
||||
|
||||
def test_set_vision_type_as_system_vision_model(self):
|
||||
"""A 'vision' type model can be set as system vision model."""
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="qwen3-vl-72b",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk=model.pk)
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertTrue(model.is_system_vision_model)
|
||||
|
||||
def test_set_chat_type_as_system_vision_model(self):
|
||||
"""A 'chat' type model can be set as system vision model (vision-capable chat)."""
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="gpt-4o",
|
||||
model_type="chat",
|
||||
context_window=128000,
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk=model.pk)
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertTrue(model.is_system_vision_model)
|
||||
|
||||
def test_embedding_type_rejected_as_vision_model(self):
|
||||
"""An 'embedding' type model cannot be set as system vision model."""
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="embed-model",
|
||||
model_type="embedding",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk=model.pk)
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertFalse(model.is_system_vision_model)
|
||||
|
||||
def test_reranker_type_rejected_as_vision_model(self):
|
||||
"""A 'reranker' type model cannot be set as system vision model."""
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="reranker-model",
|
||||
model_type="reranker",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk=model.pk)
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertFalse(model.is_system_vision_model)
|
||||
|
||||
def test_inactive_model_rejected(self):
|
||||
"""An inactive model cannot be set as system vision model."""
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="inactive-vision",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
is_active=False,
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk=model.pk)
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertFalse(model.is_system_vision_model)
|
||||
|
||||
def test_multiple_selection_rejected(self):
|
||||
"""Selecting more than one model is rejected."""
|
||||
m1 = LLMModel.objects.create(
|
||||
api=self.api, name="v1", model_type="vision", context_window=8192
|
||||
)
|
||||
m2 = LLMModel.objects.create(
|
||||
api=self.api, name="v2", model_type="vision", context_window=8192
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk__in=[m1.pk, m2.pk])
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
m1.refresh_from_db()
|
||||
m2.refresh_from_db()
|
||||
self.assertFalse(m1.is_system_vision_model)
|
||||
self.assertFalse(m2.is_system_vision_model)
|
||||
|
||||
def test_replaces_previous_system_vision_model(self):
|
||||
"""Setting a new system vision model clears the previous one."""
|
||||
old = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="old-vision",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
is_system_vision_model=True,
|
||||
)
|
||||
new = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="new-vision",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
queryset = LLMModel.objects.filter(pk=new.pk)
|
||||
|
||||
self.admin.set_as_system_vision_model(request, queryset)
|
||||
|
||||
old.refresh_from_db()
|
||||
new.refresh_from_db()
|
||||
self.assertFalse(old.is_system_vision_model)
|
||||
self.assertTrue(new.is_system_vision_model)
|
||||
|
||||
|
||||
class SystemEmbeddingModelActionTests(AdminTestBase):
|
||||
"""Tests for the set_as_system_embedding_model admin action."""
|
||||
|
||||
def test_set_embedding_model(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="embed",
|
||||
model_type="embedding",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
self.admin.set_as_system_embedding_model(request, LLMModel.objects.filter(pk=model.pk))
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertTrue(model.is_system_embedding_model)
|
||||
|
||||
def test_multimodal_embed_accepted(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="multimodal",
|
||||
model_type="multimodal_embed",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
self.admin.set_as_system_embedding_model(request, LLMModel.objects.filter(pk=model.pk))
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertTrue(model.is_system_embedding_model)
|
||||
|
||||
def test_chat_rejected_as_embedding(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="chat",
|
||||
model_type="chat",
|
||||
context_window=128000,
|
||||
)
|
||||
request = self._make_request()
|
||||
self.admin.set_as_system_embedding_model(request, LLMModel.objects.filter(pk=model.pk))
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertFalse(model.is_system_embedding_model)
|
||||
|
||||
|
||||
class SystemChatModelActionTests(AdminTestBase):
|
||||
"""Tests for the set_as_system_chat_model admin action."""
|
||||
|
||||
def test_set_chat_model(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="chat",
|
||||
model_type="chat",
|
||||
context_window=128000,
|
||||
)
|
||||
request = self._make_request()
|
||||
self.admin.set_as_system_chat_model(request, LLMModel.objects.filter(pk=model.pk))
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertTrue(model.is_system_chat_model)
|
||||
|
||||
def test_embedding_rejected_as_chat(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="embed",
|
||||
model_type="embedding",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
self.admin.set_as_system_chat_model(request, LLMModel.objects.filter(pk=model.pk))
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertFalse(model.is_system_chat_model)
|
||||
|
||||
|
||||
class SystemRerankerModelActionTests(AdminTestBase):
|
||||
"""Tests for the set_as_system_reranker_model admin action."""
|
||||
|
||||
def test_set_reranker_model(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="reranker",
|
||||
model_type="reranker",
|
||||
context_window=8192,
|
||||
)
|
||||
request = self._make_request()
|
||||
self.admin.set_as_system_reranker_model(request, LLMModel.objects.filter(pk=model.pk))
|
||||
|
||||
model.refresh_from_db()
|
||||
self.assertTrue(model.is_system_reranker_model)
|
||||
|
||||
|
||||
class BadgeDisplayTests(AdminTestBase):
|
||||
"""Tests for system model badge display methods."""
|
||||
|
||||
def test_vision_badge_for_vision_default(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="vision",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
is_system_vision_model=True,
|
||||
)
|
||||
badge = self.admin.system_vision_badge(model)
|
||||
self.assertIn("SYSTEM DEFAULT", badge)
|
||||
self.assertIn("6f42c1", badge) # Purple color
|
||||
|
||||
def test_vision_badge_for_chat_vision_default(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="chat-vision",
|
||||
model_type="chat",
|
||||
context_window=128000,
|
||||
is_system_vision_model=True,
|
||||
)
|
||||
badge = self.admin.system_vision_badge(model)
|
||||
self.assertIn("SYSTEM DEFAULT", badge)
|
||||
|
||||
def test_vision_badge_empty_when_not_default(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="not-default",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
is_system_vision_model=False,
|
||||
)
|
||||
badge = self.admin.system_vision_badge(model)
|
||||
self.assertEqual(badge, "")
|
||||
|
||||
def test_vision_badge_empty_for_wrong_type(self):
|
||||
"""Even if is_system_vision_model is True, wrong model_type shows no badge."""
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="embed-mislabeled",
|
||||
model_type="embedding",
|
||||
context_window=8192,
|
||||
is_system_vision_model=True,
|
||||
)
|
||||
badge = self.admin.system_vision_badge(model)
|
||||
self.assertEqual(badge, "")
|
||||
|
||||
def test_embedding_badge_shows(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="embed",
|
||||
model_type="embedding",
|
||||
context_window=8192,
|
||||
is_system_embedding_model=True,
|
||||
)
|
||||
badge = self.admin.system_embedding_badge(model)
|
||||
self.assertIn("SYSTEM DEFAULT", badge)
|
||||
|
||||
def test_chat_badge_shows(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="chat",
|
||||
model_type="chat",
|
||||
context_window=128000,
|
||||
is_system_chat_model=True,
|
||||
)
|
||||
badge = self.admin.system_chat_badge(model)
|
||||
self.assertIn("SYSTEM DEFAULT", badge)
|
||||
|
||||
def test_reranker_badge_shows(self):
|
||||
model = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="reranker",
|
||||
model_type="reranker",
|
||||
context_window=8192,
|
||||
is_system_reranker_model=True,
|
||||
)
|
||||
badge = self.admin.system_reranker_badge(model)
|
||||
self.assertIn("SYSTEM DEFAULT", badge)
|
||||
|
||||
|
||||
class AdminActionDescriptionTests(TestCase):
|
||||
"""Tests that admin actions have proper short_description."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = LLMModelAdmin(LLMModel, AdminSite())
|
||||
|
||||
def test_vision_action_description(self):
|
||||
self.assertEqual(
|
||||
self.admin.set_as_system_vision_model.short_description,
|
||||
"Set as System Vision Model",
|
||||
)
|
||||
|
||||
def test_embedding_action_description(self):
|
||||
self.assertEqual(
|
||||
self.admin.set_as_system_embedding_model.short_description,
|
||||
"Set as System Embedding Model",
|
||||
)
|
||||
|
||||
def test_chat_action_description(self):
|
||||
self.assertEqual(
|
||||
self.admin.set_as_system_chat_model.short_description,
|
||||
"Set as System Chat Model",
|
||||
)
|
||||
|
||||
def test_reranker_action_description(self):
|
||||
self.assertEqual(
|
||||
self.admin.set_as_system_reranker_model.short_description,
|
||||
"Set as System Reranker Model",
|
||||
)
|
||||
|
||||
def test_vision_badge_description(self):
|
||||
self.assertEqual(
|
||||
self.admin.system_vision_badge.short_description,
|
||||
"Vision Default",
|
||||
)
|
||||
@@ -159,11 +159,54 @@ class LLMModelModelTest(TestCase):
|
||||
result = LLMModel.get_system_reranker_model()
|
||||
self.assertEqual(result.pk, reranker.pk)
|
||||
|
||||
def test_get_system_vision_model_with_vision_type(self):
|
||||
vision = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="vision-model",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
is_system_vision_model=True,
|
||||
)
|
||||
result = LLMModel.get_system_vision_model()
|
||||
self.assertEqual(result.pk, vision.pk)
|
||||
|
||||
def test_get_system_vision_model_with_chat_type(self):
|
||||
"""Vision-capable chat models can serve as system vision model."""
|
||||
self.model.is_system_vision_model = True
|
||||
self.model.save()
|
||||
result = LLMModel.get_system_vision_model()
|
||||
self.assertEqual(result.pk, self.model.pk)
|
||||
|
||||
def test_get_system_vision_model_excludes_embedding_type(self):
|
||||
"""Embedding models should not be returned as vision model."""
|
||||
embed = LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="embed-only",
|
||||
model_type="embedding",
|
||||
context_window=8191,
|
||||
is_system_vision_model=True,
|
||||
)
|
||||
result = LLMModel.get_system_vision_model()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_system_vision_model_excludes_inactive(self):
|
||||
LLMModel.objects.create(
|
||||
api=self.api,
|
||||
name="inactive-vision",
|
||||
model_type="vision",
|
||||
context_window=8192,
|
||||
is_system_vision_model=True,
|
||||
is_active=False,
|
||||
)
|
||||
result = LLMModel.get_system_vision_model()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_system_model_returns_none(self):
|
||||
"""Returns None when no system model is configured."""
|
||||
self.assertIsNone(LLMModel.get_system_embedding_model())
|
||||
self.assertIsNone(LLMModel.get_system_chat_model())
|
||||
self.assertIsNone(LLMModel.get_system_reranker_model())
|
||||
self.assertIsNone(LLMModel.get_system_vision_model())
|
||||
|
||||
|
||||
class LLMUsageModelTest(TestCase):
|
||||
@@ -212,7 +255,7 @@ class LLMUsageModelTest(TestCase):
|
||||
self.assertAlmostEqual(float(usage.total_cost), 0.01, places=4)
|
||||
|
||||
def test_purpose_choices(self):
|
||||
for purpose in ["responder", "reviewer", "embeddings", "search", "reranking", "multimodal_embed", "other"]:
|
||||
for purpose in ["responder", "reviewer", "embeddings", "search", "reranking", "multimodal_embed", "vision_analysis", "other"]:
|
||||
usage = LLMUsage.objects.create(
|
||||
user=self.user,
|
||||
model=self.model,
|
||||
@@ -222,6 +265,18 @@ class LLMUsageModelTest(TestCase):
|
||||
)
|
||||
self.assertEqual(usage.purpose, purpose)
|
||||
|
||||
def test_vision_analysis_purpose(self):
|
||||
"""Vision analysis usage can be tracked."""
|
||||
usage = LLMUsage.objects.create(
|
||||
user=self.user,
|
||||
model=self.model,
|
||||
input_tokens=500,
|
||||
output_tokens=200,
|
||||
purpose="vision_analysis",
|
||||
)
|
||||
self.assertEqual(usage.purpose, "vision_analysis")
|
||||
self.assertGreater(usage.total_cost, 0)
|
||||
|
||||
def test_protect_model_delete(self):
|
||||
"""Deleting a model with usage records should raise ProtectedError."""
|
||||
LLMUsage.objects.create(
|
||||
|
||||
Reference in New Issue
Block a user