diff --git a/links/search_urls.py b/links/search_urls.py index 7ca961f..8f6fde6 100644 --- a/links/search_urls.py +++ b/links/search_urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .search_views import SearchReactView, search, search_aliases, search_api_v2 +from .search_views import SearchReactView, search, search_aliases, search_api_v2, search_vector_api urlpatterns = [ path('', SearchReactView.as_view(), name='search_main'), path('api/', search, name='search_api'), path('api/v2/', search_api_v2, name='search_api_v2'), + path('api/vector/', search_vector_api, name='search_vector_api'), path('aliases/', search_aliases, name='search_aliases'), ] diff --git a/links/search_views.py b/links/search_views.py index 70a7569..13c06fe 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -11,9 +11,63 @@ from .search_backend import search_backend from django.views.decorators.http import require_http_methods from django.views.decorators.csrf import csrf_exempt import json +import requests +import os +from qdrant_client import QdrantClient logger = logging.getLogger(__name__) +def get_ollama_embedding(text): + ollama_url = os.getenv("OLLAMA_URL", "http://ollama-service.ollama.svc.cluster.local:11434") + model = os.getenv("OLLAMA_MODEL", "llama3") + try: + r = requests.post(f"{ollama_url}/api/embeddings", json={"model": model, "prompt": text}, timeout=5) + r.raise_for_status() + return r.json()["embedding"] + except Exception as e: + logger.error(f"Ollama embedding error: {e}") + return None + +@require_http_methods(["GET"]) +def search_vector_api(request): + query = request.GET.get('q', '').strip() + if not query: + return JsonResponse({'results': [], 'total': 0}) + + qdrant_host = os.getenv("QDRANT_HOST", "192.168.1.2") + qdrant_port = int(os.getenv("QDRANT_PORT", "6333")) + collection = os.getenv("COLLECTION_NAME", "links") + + try: + vector = get_ollama_embedding(query) + if not vector: + return JsonResponse({'error': 'Failed to generate embedding'}, status=500) + + client = QdrantClient(host=qdrant_host, port=qdrant_port) + search_result = client.search( + collection_name=collection, + query_vector=vector, + limit=20 + ) + + results = [] + for hit in search_result: + p = hit.payload + results.append({ + 'id': p.get('id'), + 'type': 'post' if 'content' in p else 'link', + 'title': p.get('title'), + 'url': p.get('url'), + 'summary': p.get('description') or p.get('summary', ''), + 'score': hit.score, + 'tags': p.get('tags', []) + }) + + return JsonResponse({'results': results, 'total': len(results)}) + except Exception as e: + logger.error(f"Vector search error: {e}") + return JsonResponse({'error': str(e)}, status=500) + class SearchView(ListView): template_name = 'links/search.html' context_object_name = 'results' @@ -32,7 +86,6 @@ class SearchView(ListView): if query: try: - # Use Whoosh search backend for full-text search search_results = search_backend.search( query_string=query, model_type=type_filter if type_filter else None, @@ -41,14 +94,12 @@ class SearchView(ListView): sort_by=sort_by ) - # Enrich results with actual model data enriched_results = [] for result in search_results['results']: enriched_result = self._enrich_result(result) if enriched_result: enriched_results.append(enriched_result) - # Store search metadata for pagination self.search_total = search_results['total'] self.search_has_next = search_results['has_next'] self.search_has_prev = search_results['has_prev'] @@ -56,205 +107,80 @@ class SearchView(ListView): return enriched_results except Exception as e: logger.error(f"Search error: {e}", exc_info=True) - # Store error for display self.search_error = str(e) return [] return [] def _enrich_result(self, result): - """Fetch full model data for search result""" model_type = result['model_type'] model_id = result['model_id'] - try: if model_type == 'link': link = Link.objects.get(id=model_id) - return { - 'id': link.id, - 'model_type': 'link', - 'alias': link.alias, - 'original_url': link.original_url, - 'description': link.description, - 'click_count': link.click_count, - 'created_at': link.created_at, - 'tags': list(link.tags.all()) - } + return {'id': link.id, 'model_type': 'link', 'alias': link.alias, 'original_url': link.original_url, 'description': link.description, 'click_count': link.click_count, 'created_at': link.created_at, 'tags': list(link.tags.all())} elif model_type == 'page': page = Page.objects.get(id=model_id) - return { - 'id': page.id, - 'model_type': 'page', - 'title': page.title, - 'url': page.url, - 'summary': page.summary, - 'created_at': page.created_at, - 'tags': list(page.tags.all()) - } + return {'id': page.id, 'model_type': 'page', 'title': page.title, 'url': page.url, 'summary': page.summary, 'created_at': page.created_at, 'tags': list(page.tags.all())} elif model_type == 'post': post = Post.objects.get(id=model_id) - return { - 'id': post.id, - 'model_type': 'post', - 'title': post.title, - 'summary': post.summary, - 'created_at': post.created_at, - 'tags': list(post.tags.all()) - } + return {'id': post.id, 'model_type': 'post', 'title': post.title, 'summary': post.summary, 'created_at': post.created_at, 'tags': list(post.tags.all())} except Exception as e: logger.error(f"Error enriching result {model_type} {model_id}: {e}") return None def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context.update({ - 'query': self.request.GET.get('q', ''), - 'selected_type': self.request.GET.get('type', ''), - 'sort_by': self.request.GET.get('sort', 'relevance'), - 'total_results': getattr(self, 'search_total', 0), - 'search_error': getattr(self, 'search_error', None), - }) + context.update({'query': self.request.GET.get('q', ''), 'selected_type': self.request.GET.get('type', ''), 'sort_by': self.request.GET.get('sort', 'relevance'), 'total_results': getattr(self, 'search_total', 0), 'search_error': getattr(self, 'search_error', None)}) return context def search(request): query = request.GET.get('q', '').strip() if query: results = [] - - # Search links - links = Link.objects.filter( - Q(alias__icontains=query) | - Q(original_url__icontains=query) - )[:5] - results.extend([{ - 'type': 'link', - 'alias': link.alias, - 'url': link.original_url - } for link in links]) - - # Search posts - posts = Post.objects.filter( - Q(title__icontains=query) | - Q(summary__icontains=query) | - Q(content__icontains=query) - )[:5] - results.extend([{ - 'type': 'post', - 'title': post.title, - 'url': f'/ui/posts/{post.id}' - } for post in posts]) - + links = Link.objects.filter(Q(alias__icontains=query) | Q(original_url__icontains=query))[:5] + results.extend([{'type': 'link', 'alias': link.alias, 'url': link.original_url} for link in links]) + posts = Post.objects.filter(Q(title__icontains=query) | Q(summary__icontains=query) | Q(content__icontains=query))[:5] + results.extend([{'type': 'post', 'title': post.title, 'url': f'/ui/posts/{post.id}'} for post in posts]) else: results = [] return JsonResponse(results, safe=False) @require_http_methods(["GET"]) def search_api_v2(request): - """ - Modern API endpoint for React search interface - """ query = request.GET.get('q', '').strip() type_filter = request.GET.get('type', '') sort_by = request.GET.get('sort', 'relevance') page = int(request.GET.get('page', 1)) per_page = int(request.GET.get('per_page', 20)) - if not query: - return JsonResponse({ - 'results': [], - 'total': 0, - 'page': page, - 'per_page': per_page, - 'has_next': False, - 'has_prev': False - }) - + return JsonResponse({'results': [], 'total': 0, 'page': page, 'per_page': per_page, 'has_next': False, 'has_prev': False}) try: - # Use Whoosh search backend - search_results = search_backend.search( - query_string=query, - model_type=type_filter if type_filter else None, - page=page, - per_page=per_page, - sort_by=sort_by - ) - - # Enrich results with actual model data + search_results = search_backend.search(query_string=query, model_type=type_filter if type_filter else None, page=page, per_page=per_page, sort_by=sort_by) enriched_results = [] for result in search_results['results']: model_type = result['model_type'] model_id = result['model_id'] - try: if model_type == 'link': link = Link.objects.get(id=model_id) - enriched_results.append({ - 'id': link.id, - 'type': 'link', - 'title': link.alias, - 'url': f'/{link.alias}', - 'original_url': link.original_url, - 'description': link.description or '', - 'click_count': link.click_count, - 'created_at': link.created_at.isoformat(), - 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in link.tags.all()] - }) + enriched_results.append({'id': link.id, 'type': 'link', 'title': link.alias, 'url': f'/{link.alias}', 'original_url': link.original_url, 'description': link.description or '', 'click_count': link.click_count, 'created_at': link.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in link.tags.all()]}) elif model_type == 'page': page_obj = Page.objects.get(id=model_id) - enriched_results.append({ - 'id': page_obj.id, - 'type': 'page', - 'title': page_obj.title or 'Untitled', - 'url': page_obj.url, - 'detail_url': f'/ui/pages/{page_obj.id}/', - 'summary': page_obj.summary or '', - 'created_at': page_obj.created_at.isoformat(), - 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in page_obj.tags.all()] - }) + enriched_results.append({'id': page_obj.id, 'type': 'page', 'title': page_obj.title or 'Untitled', 'url': page_obj.url, 'detail_url': f'/ui/pages/{page_obj.id}/', 'summary': page_obj.summary or '', 'created_at': page_obj.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in page_obj.tags.all()]}) elif model_type == 'post': post = Post.objects.get(id=model_id) - enriched_results.append({ - 'id': post.id, - 'type': 'post', - 'title': post.title, - 'url': f'/ui/posts/{post.id}/', - 'summary': post.summary or '', - 'created_at': post.created_at.isoformat(), - 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in post.tags.all()] - }) + enriched_results.append({'id': post.id, 'type': 'post', 'title': post.title, 'url': f'/ui/posts/{post.id}/', 'summary': post.summary or '', 'created_at': post.created_at.isoformat(), 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in post.tags.all()]}) except Exception as e: logger.error(f"Error enriching {model_type} {model_id}: {e}") continue - - return JsonResponse({ - 'results': enriched_results, - 'total': search_results['total'], - 'page': search_results['page'], - 'per_page': search_results['per_page'], - 'has_next': search_results['has_next'], - 'has_prev': search_results['has_prev'] - }) - + return JsonResponse({'results': enriched_results, 'total': search_results['total'], 'page': search_results['page'], 'per_page': search_results['per_page'], 'has_next': search_results['has_next'], 'has_prev': search_results['has_prev']}) except Exception as e: logger.error(f"Search API error: {e}", exc_info=True) - return JsonResponse({ - 'error': str(e), - 'results': [], - 'total': 0 - }, status=500) + return JsonResponse({'error': str(e), 'results': [], 'total': 0}, status=500) def search_aliases(request): query = request.GET.get('q', '').strip() - - # Prioritize results: exact match first, then starts with, then contains - links = Link.objects.filter(alias__icontains=query).annotate( - priority=Case( - When(alias__iexact=query, then=Value(1)), - When(alias__istartswith=query, then=Value(2)), - default=Value(3), - output_field=IntegerField() - ) - ).order_by('priority', 'alias')[:10] - + links = Link.objects.filter(alias__icontains=query).annotate(priority=Case(When(alias__iexact=query, then=Value(1)), When(alias__istartswith=query, then=Value(2)), default=Value(3), output_field=IntegerField())).order_by('priority', 'alias')[:10] results = [] for link in links: try: @@ -263,9 +189,7 @@ def search_aliases(request): except Exception as e: logger.error(f"Error creating URL for alias {link.alias}: {str(e)}") continue - return JsonResponse(results, safe=False) class SearchReactView(TemplateView): - """Modern React-based search interface""" template_name = 'links/search_react.html' diff --git a/links/templates/links/search_react.html b/links/templates/links/search_react.html index 1daed4d..df77032 100644 --- a/links/templates/links/search_react.html +++ b/links/templates/links/search_react.html @@ -200,6 +200,7 @@ const [query, setQuery] = useState(''); const [type, setType] = useState(''); const [sort, setSort] = useState('relevance'); + const [isVector, setIsVector] = useState(false); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); @@ -226,7 +227,8 @@ per_page: perPage }); - const response = await fetch(`/search/api/v2/?${params}`); + const endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/"; + const response = await fetch(`${endpoint}?${params}`); const data = await response.json(); if (response.ok) { @@ -336,7 +338,8 @@ - setIsVector(e.target.checked)} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /> +