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:
2026-03-29 18:09:50 +00:00
parent fb38a881d9
commit 634845fee0
27 changed files with 5680 additions and 4 deletions

View 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",
)

View File

@@ -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(