Libraries created through the API are now stamped with the name of the UserToken that created them (Library.managed_by), on both the workspace and plain create endpoints; web-session creates stay null/unmanaged. The idempotent workspace re-POST lazily backfills null managed_by, and a one-off backfill_managed_by command labels pre-existing rows (workspace inference: kairos-mail-* → Kairos, else Daedalus; Spelunker via ingest job provenance). UI badges and warnings now render "Managed by <app>" via managed_by_display (inference fallback keeps legacy rows accurate before backfill). The list scope filter becomes all/managed/unmanaged with the old daedalus/global values aliased for bookmarks. The plain create endpoint also gains an explicit 409 name_conflict (previously a raw UniqueProperty 500) reporting the existing library's uid and manager. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
984 lines
34 KiB
Python
984 lines
34 KiB
Python
"""
|
|
Custom admin views for Library, Collection, and Item CRUD.
|
|
|
|
Since neomodel StructuredNodes cannot use Django's standard ModelAdmin,
|
|
these FBVs provide CRUD operations rendered within Themis's template structure.
|
|
All views require login.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.core.files.base import ContentFile
|
|
from django.core.files.storage import default_storage
|
|
from django.http import Http404
|
|
from django.shortcuts import redirect, render
|
|
|
|
from .content_types import get_library_type_config
|
|
from .forms import CollectionForm, ItemForm, LibraryForm
|
|
from .utils import neo4j_available
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Library views
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def library_list(request):
|
|
"""List libraries, optionally filtered by app-managed scope."""
|
|
scope = request.GET.get("scope", "all")
|
|
# Legacy bookmark values from before managed_by existed.
|
|
scope = {"daedalus": "managed", "global": "unmanaged"}.get(scope, scope)
|
|
libraries = []
|
|
error = None
|
|
if neo4j_available():
|
|
try:
|
|
from .models import Library
|
|
|
|
libraries = list(Library.nodes.order_by("name"))
|
|
# managed_by_display covers legacy workspace libraries that
|
|
# predate the managed_by property, so filter in Python.
|
|
if scope == "managed":
|
|
libraries = [l for l in libraries if l.managed_by_display]
|
|
elif scope == "unmanaged":
|
|
libraries = [l for l in libraries if not l.managed_by_display]
|
|
except Exception as e:
|
|
error = f"Could not connect to Neo4j: {e}"
|
|
logger.error(error)
|
|
else:
|
|
error = "Neo4j is not available."
|
|
return render(
|
|
request,
|
|
"library/library_list.html",
|
|
{"libraries": libraries, "error": error, "scope": scope},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def library_create(request):
|
|
"""Create a new library."""
|
|
if request.method == "POST":
|
|
form = LibraryForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
from .models import Library
|
|
|
|
# If content-type fields are empty, populate from defaults
|
|
library_type = form.cleaned_data["library_type"]
|
|
defaults = get_library_type_config(library_type)
|
|
|
|
lib = Library(
|
|
name=form.cleaned_data["name"],
|
|
library_type=library_type,
|
|
description=form.cleaned_data.get("description", ""),
|
|
chunking_config=defaults["chunking_config"],
|
|
embedding_instruction=(
|
|
form.cleaned_data.get("embedding_instruction")
|
|
or defaults["embedding_instruction"]
|
|
),
|
|
reranker_instruction=(
|
|
form.cleaned_data.get("reranker_instruction")
|
|
or defaults["reranker_instruction"]
|
|
),
|
|
llm_context_prompt=(
|
|
form.cleaned_data.get("llm_context_prompt")
|
|
or defaults["llm_context_prompt"]
|
|
),
|
|
)
|
|
lib.save()
|
|
messages.success(request, f'Library "{lib.name}" created.')
|
|
return redirect("library:library-detail", uid=lib.uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating library: {e}")
|
|
else:
|
|
form = LibraryForm()
|
|
return render(request, "library/library_form.html", {"form": form, "editing": False})
|
|
|
|
|
|
def _library_detail_context(library):
|
|
"""
|
|
Build the base context for the library detail page.
|
|
|
|
Shared between ``library_detail`` and ``library_search`` so the search
|
|
POST handler renders the same page chrome plus its results layered on
|
|
top.
|
|
"""
|
|
from llm_manager.models import LLMModel
|
|
|
|
embedding_model = LLMModel.get_system_embedding_model()
|
|
multimodal_available = bool(embedding_model and embedding_model.supports_multimodal)
|
|
|
|
return {
|
|
"library": library,
|
|
"collections": library.collections.all(),
|
|
"multimodal_available": multimodal_available,
|
|
"search_query": "",
|
|
"search_used_image": False,
|
|
"results_baseline": None,
|
|
"results_reranked": None,
|
|
"search_error": None,
|
|
}
|
|
|
|
|
|
@login_required
|
|
def library_detail(request, uid):
|
|
"""View library details and its collections."""
|
|
try:
|
|
from .models import Library
|
|
|
|
lib = Library.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Library not found: {e}")
|
|
return redirect("library:library-list")
|
|
return render(
|
|
request,
|
|
"library/library_detail.html",
|
|
_library_detail_context(lib),
|
|
)
|
|
|
|
|
|
# Cap query-image uploads at 8 MB. Multimodal embedders happily accept
|
|
# larger payloads but they're slow and almost never come from a real
|
|
# in-browser screenshot/photo.
|
|
_MAX_QUERY_IMAGE_BYTES = 8 * 1024 * 1024
|
|
|
|
|
|
def _all_library_uids() -> list[str]:
|
|
"""Legacy alias for :func:`library.utils.all_library_uids`.
|
|
|
|
Kept here so existing tests that patch
|
|
``library.views._all_library_uids`` continue to work during the
|
|
Phase-2 refactor. New code should import ``all_library_uids``
|
|
directly from ``library.utils``.
|
|
"""
|
|
from .utils import all_library_uids
|
|
|
|
return all_library_uids()
|
|
|
|
|
|
@login_required
|
|
def library_search(request, uid):
|
|
"""
|
|
Run an A/B search (with and without re-ranker) scoped to a single
|
|
library, and re-render ``library_detail.html`` with both result sets.
|
|
"""
|
|
try:
|
|
from .models import Library
|
|
|
|
lib = Library.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Library not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
context = _library_detail_context(lib)
|
|
|
|
if request.method != "POST":
|
|
return redirect("library:library-detail", uid=uid)
|
|
|
|
query = (request.POST.get("query") or "").strip()
|
|
context["search_query"] = query
|
|
|
|
image_bytes = None
|
|
image_ext = "png"
|
|
uploaded = request.FILES.get("query_image")
|
|
if uploaded and context["multimodal_available"]:
|
|
if uploaded.size > _MAX_QUERY_IMAGE_BYTES:
|
|
context["search_error"] = (
|
|
f"Image too large ({uploaded.size} bytes). "
|
|
f"Max is {_MAX_QUERY_IMAGE_BYTES} bytes."
|
|
)
|
|
return render(request, "library/library_detail.html", context)
|
|
image_bytes = uploaded.read()
|
|
# Derive extension from the filename; default to png. The embedder
|
|
# only uses this to set the MIME type for the multimodal request.
|
|
_, ext = os.path.splitext(uploaded.name or "")
|
|
if ext.startswith("."):
|
|
ext = ext[1:].lower()
|
|
if ext:
|
|
image_ext = ext
|
|
context["search_used_image"] = True
|
|
|
|
if not query and not image_bytes:
|
|
context["search_error"] = "Enter a query (text or image) before searching."
|
|
return render(request, "library/library_detail.html", context)
|
|
|
|
try:
|
|
from django.conf import settings as django_settings
|
|
|
|
from .services.search import SearchRequest, SearchService
|
|
|
|
# Admin UI searches every library the operator can see — including
|
|
# Daedalus workspace-scoped ones. See ``_all_library_uids`` for
|
|
# why this is needed (the default scope clause branch hides any
|
|
# library with a non-null ``workspace_id``).
|
|
allowed = _all_library_uids()
|
|
|
|
def _make_request(rerank: bool) -> "SearchRequest":
|
|
return SearchRequest(
|
|
query=query,
|
|
query_image=image_bytes,
|
|
query_image_ext=image_ext,
|
|
library_uid=uid,
|
|
resolved_libraries=allowed,
|
|
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
|
|
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
|
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
|
rerank=rerank,
|
|
include_images=True,
|
|
)
|
|
|
|
service = SearchService(user=request.user)
|
|
baseline = service.search(_make_request(rerank=False))
|
|
reranked = service.search(_make_request(rerank=True))
|
|
|
|
# Annotate the reranked candidates with a rank-delta label so the
|
|
# template can render a badge without doing arithmetic. ``new`` =
|
|
# the reranker pulled this in from outside the baseline top-N.
|
|
baseline_pos = {c.chunk_uid: i for i, c in enumerate(baseline.candidates)}
|
|
for new_index, cand in enumerate(reranked.candidates):
|
|
old_index = baseline_pos.get(cand.chunk_uid)
|
|
if old_index is None:
|
|
cand.rank_delta_label = "new"
|
|
cand.rank_delta_kind = "new"
|
|
else:
|
|
delta = old_index - new_index # +N == moved up
|
|
if delta > 0:
|
|
cand.rank_delta_label = f"↑{delta}"
|
|
cand.rank_delta_kind = "up"
|
|
elif delta < 0:
|
|
cand.rank_delta_label = f"↓{-delta}"
|
|
cand.rank_delta_kind = "down"
|
|
else:
|
|
cand.rank_delta_label = "="
|
|
cand.rank_delta_kind = "same"
|
|
|
|
context["results_baseline"] = baseline
|
|
context["results_reranked"] = reranked
|
|
except Exception as exc:
|
|
logger.error("Library search failed: %s", exc, exc_info=True)
|
|
context["search_error"] = str(exc)
|
|
|
|
return render(request, "library/library_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
def library_edit(request, uid):
|
|
"""Edit an existing library."""
|
|
try:
|
|
from .models import Library
|
|
|
|
lib = Library.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Library not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
form = LibraryForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
lib.name = form.cleaned_data["name"]
|
|
lib.library_type = form.cleaned_data["library_type"]
|
|
lib.description = form.cleaned_data.get("description", "")
|
|
lib.embedding_instruction = form.cleaned_data.get(
|
|
"embedding_instruction", ""
|
|
)
|
|
lib.reranker_instruction = form.cleaned_data.get(
|
|
"reranker_instruction", ""
|
|
)
|
|
lib.llm_context_prompt = form.cleaned_data.get(
|
|
"llm_context_prompt", ""
|
|
)
|
|
lib.save()
|
|
messages.success(request, f'Library "{lib.name}" updated.')
|
|
return redirect("library:library-detail", uid=lib.uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Error updating library: {e}")
|
|
else:
|
|
form = LibraryForm(
|
|
initial={
|
|
"name": lib.name,
|
|
"library_type": lib.library_type,
|
|
"description": lib.description,
|
|
"embedding_instruction": lib.embedding_instruction,
|
|
"reranker_instruction": lib.reranker_instruction,
|
|
"llm_context_prompt": lib.llm_context_prompt,
|
|
}
|
|
)
|
|
return render(
|
|
request,
|
|
"library/library_form.html",
|
|
{"form": form, "editing": True, "library": lib},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def library_delete(request, uid):
|
|
"""Delete a library (and confirm)."""
|
|
try:
|
|
from .models import Library
|
|
|
|
lib = Library.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Library not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
# Daedalus owns the lifecycle of workspace-scoped libraries. Deleting one
|
|
# here is allowed but discouraged: the confirm page warns that Daedalus
|
|
# still holds the source content and will recreate + re-embed it on the
|
|
# next sync. The risk is low (no data loss — only re-embedding cost), and
|
|
# this is the supported escape hatch for clearing an orphaned Library that
|
|
# blocks workspace re-registration.
|
|
if request.method == "POST":
|
|
name = lib.name
|
|
# Use the shared cascade so child nodes (Collections/Items/Chunks/
|
|
# Images) and orphan Concepts are removed too — a bare lib.delete()
|
|
# would leak them.
|
|
from .services.library_delete import delete_library_cascade
|
|
|
|
delete_library_cascade(lib)
|
|
messages.success(request, f'Library "{name}" deleted.')
|
|
return redirect("library:library-list")
|
|
return render(request, "library/library_confirm_delete.html", {"library": lib})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collection views
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def collection_create(request, library_uid):
|
|
"""Create a new collection within a library."""
|
|
try:
|
|
from .models import Library
|
|
|
|
lib = Library.nodes.get(uid=library_uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Library not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
form = CollectionForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
from .models import Collection
|
|
|
|
col = Collection(
|
|
name=form.cleaned_data["name"],
|
|
description=form.cleaned_data.get("description", ""),
|
|
)
|
|
col.save()
|
|
lib.collections.connect(col)
|
|
col.library.connect(lib)
|
|
messages.success(request, f'Collection "{col.name}" created.')
|
|
return redirect("library:collection-detail", uid=col.uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating collection: {e}")
|
|
else:
|
|
form = CollectionForm()
|
|
return render(
|
|
request,
|
|
"library/collection_form.html",
|
|
{"form": form, "library": lib, "editing": False},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def collection_detail(request, uid):
|
|
"""View collection details and its items."""
|
|
try:
|
|
from .models import Collection
|
|
|
|
col = Collection.nodes.get(uid=uid)
|
|
items = col.items.all()
|
|
libraries = col.library.all()
|
|
library = libraries[0] if libraries else None
|
|
except Exception as e:
|
|
messages.error(request, f"Collection not found: {e}")
|
|
return redirect("library:library-list")
|
|
return render(
|
|
request,
|
|
"library/collection_detail.html",
|
|
{"collection": col, "items": items, "library": library},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def collection_edit(request, uid):
|
|
"""Edit an existing collection."""
|
|
try:
|
|
from .models import Collection
|
|
|
|
col = Collection.nodes.get(uid=uid)
|
|
libraries = col.library.all()
|
|
library = libraries[0] if libraries else None
|
|
except Exception as e:
|
|
messages.error(request, f"Collection not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
form = CollectionForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
col.name = form.cleaned_data["name"]
|
|
col.description = form.cleaned_data.get("description", "")
|
|
col.save()
|
|
messages.success(request, f'Collection "{col.name}" updated.')
|
|
return redirect("library:collection-detail", uid=col.uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Error updating collection: {e}")
|
|
else:
|
|
form = CollectionForm(
|
|
initial={
|
|
"name": col.name,
|
|
"description": col.description,
|
|
}
|
|
)
|
|
return render(
|
|
request,
|
|
"library/collection_form.html",
|
|
{"form": form, "collection": col, "library": library, "editing": True},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def collection_delete(request, uid):
|
|
"""Delete a collection."""
|
|
try:
|
|
from .models import Collection
|
|
|
|
col = Collection.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Collection not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
name = col.name
|
|
col.delete()
|
|
messages.success(request, f'Collection "{name}" deleted.')
|
|
return redirect("library:library-list")
|
|
return render(
|
|
request,
|
|
"library/collection_confirm_delete.html",
|
|
{"collection": col},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Item views
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def item_create(request, collection_uid):
|
|
"""Create a new item within a collection, with optional file upload."""
|
|
try:
|
|
from .models import Collection
|
|
|
|
col = Collection.nodes.get(uid=collection_uid)
|
|
libraries = col.library.all()
|
|
library = libraries[0] if libraries else None
|
|
except Exception as e:
|
|
messages.error(request, f"Collection not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
form = ItemForm(request.POST, request.FILES)
|
|
if form.is_valid():
|
|
try:
|
|
from .models import Item
|
|
|
|
uploaded_file = request.FILES.get("file")
|
|
file_type = form.cleaned_data.get("file_type", "")
|
|
|
|
# Infer file_type from upload if not explicitly set
|
|
if uploaded_file and not file_type:
|
|
_, ext = os.path.splitext(uploaded_file.name)
|
|
file_type = ext.lstrip(".").lower()
|
|
|
|
item = Item(
|
|
title=form.cleaned_data["title"],
|
|
item_type=form.cleaned_data.get("item_type", ""),
|
|
file_type=file_type,
|
|
embedding_status="pending",
|
|
)
|
|
|
|
# Handle file upload
|
|
if uploaded_file:
|
|
file_data = uploaded_file.read()
|
|
item.file_size = len(file_data)
|
|
item.content_hash = hashlib.sha256(file_data).hexdigest()
|
|
item.save()
|
|
|
|
# Store in S3
|
|
s3_key = f"items/{item.uid}/original.{file_type}"
|
|
default_storage.save(s3_key, ContentFile(file_data))
|
|
item.s3_key = s3_key
|
|
item.save()
|
|
else:
|
|
item.save()
|
|
|
|
col.items.connect(item)
|
|
|
|
# Auto-trigger embedding if file uploaded and checkbox set
|
|
auto_embed = form.cleaned_data.get("auto_embed", True)
|
|
if uploaded_file and auto_embed:
|
|
try:
|
|
from .tasks import embed_item
|
|
|
|
task = embed_item.delay(item.uid, request.user.id)
|
|
messages.info(
|
|
request,
|
|
f"Embedding queued (task: {task.id})",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Failed to queue embedding: %s", exc)
|
|
|
|
messages.success(request, f'Item "{item.title}" created.')
|
|
return redirect("library:item-detail", uid=item.uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Error creating item: {e}")
|
|
else:
|
|
form = ItemForm(initial={"auto_embed": True})
|
|
return render(
|
|
request,
|
|
"library/item_form.html",
|
|
{"form": form, "collection": col, "library": library, "editing": False},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def item_detail(request, uid):
|
|
"""View item details."""
|
|
try:
|
|
from .models import Item
|
|
|
|
item = Item.nodes.get(uid=uid)
|
|
chunks = item.chunks.all()
|
|
images = item.images.all()
|
|
concepts = item.concepts.all()
|
|
except Exception as e:
|
|
messages.error(request, f"Item not found: {e}")
|
|
return redirect("library:library-list")
|
|
return render(
|
|
request,
|
|
"library/item_detail.html",
|
|
{"item": item, "chunks": chunks, "images": images, "concepts": concepts},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def item_edit(request, uid):
|
|
"""Edit an existing item."""
|
|
try:
|
|
from .models import Item
|
|
|
|
item = Item.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Item not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
form = ItemForm(request.POST)
|
|
if form.is_valid():
|
|
try:
|
|
item.title = form.cleaned_data["title"]
|
|
item.item_type = form.cleaned_data.get("item_type", "")
|
|
item.file_type = form.cleaned_data.get("file_type", "")
|
|
item.save()
|
|
messages.success(request, f'Item "{item.title}" updated.')
|
|
return redirect("library:item-detail", uid=item.uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Error updating item: {e}")
|
|
else:
|
|
form = ItemForm(
|
|
initial={
|
|
"title": item.title,
|
|
"item_type": item.item_type,
|
|
"file_type": item.file_type,
|
|
}
|
|
)
|
|
return render(
|
|
request,
|
|
"library/item_form.html",
|
|
{"form": form, "item": item, "editing": True},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def item_reembed(request, uid):
|
|
"""Trigger re-embedding for an item."""
|
|
try:
|
|
from .models import Item
|
|
|
|
item = Item.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Item not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
from .tasks import reembed_item
|
|
|
|
task = reembed_item.delay(uid, request.user.id)
|
|
messages.info(request, f"Re-embedding queued for \"{item.title}\" (task: {task.id})")
|
|
except Exception as exc:
|
|
messages.error(request, f"Failed to queue re-embedding: {exc}")
|
|
return redirect("library:item-detail", uid=uid)
|
|
|
|
return redirect("library:item-detail", uid=uid)
|
|
|
|
|
|
@login_required
|
|
def item_delete(request, uid):
|
|
"""Delete an item."""
|
|
try:
|
|
from .models import Item
|
|
|
|
item = Item.nodes.get(uid=uid)
|
|
except Exception as e:
|
|
messages.error(request, f"Item not found: {e}")
|
|
return redirect("library:library-list")
|
|
|
|
if request.method == "POST":
|
|
title = item.title
|
|
item.delete()
|
|
messages.success(request, f'Item "{title}" deleted.')
|
|
return redirect("library:library-list")
|
|
return render(request, "library/item_confirm_delete.html", {"item": item})
|
|
|
|
|
|
@login_required
|
|
def item_download(request, uid):
|
|
"""Redirect to a presigned/storage URL for the item's original file."""
|
|
try:
|
|
from .models import Item
|
|
|
|
item = Item.nodes.get(uid=uid)
|
|
except Exception:
|
|
raise Http404("Item not found.")
|
|
|
|
if not item.s3_key:
|
|
raise Http404("No file available for this item.")
|
|
|
|
try:
|
|
url = default_storage.url(item.s3_key)
|
|
except Exception as e:
|
|
logger.error("Failed to generate download URL for %s: %s", item.s3_key, e)
|
|
raise Http404("File not accessible.")
|
|
|
|
return redirect(url)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image views
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def image_serve(request, uid):
|
|
"""Redirect to a presigned/storage URL for an image file."""
|
|
try:
|
|
from .models import Image
|
|
|
|
img = Image.nodes.get(uid=uid)
|
|
except Exception:
|
|
raise Http404("Image not found.")
|
|
|
|
if not img.s3_key:
|
|
raise Http404("No file available for this image.")
|
|
|
|
try:
|
|
url = default_storage.url(img.s3_key)
|
|
except Exception as e:
|
|
logger.error("Failed to generate image URL for %s: %s", img.s3_key, e)
|
|
raise Http404("Image not accessible.")
|
|
|
|
return redirect(url)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Embedding Pipeline Dashboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def embedding_dashboard(request):
|
|
"""
|
|
Embedding pipeline dashboard — system model status, item embedding
|
|
progress, knowledge graph node counts, and batch actions.
|
|
"""
|
|
context = {
|
|
"system_embedding_model": None,
|
|
"system_chat_model": None,
|
|
"system_reranker_model": None,
|
|
"system_vision_model": None,
|
|
"status_counts": {},
|
|
"node_counts": {},
|
|
"total_items": 0,
|
|
"embedded_chunks": 0,
|
|
"total_chunks": 0,
|
|
"neo4j_available": False,
|
|
}
|
|
|
|
# Get system models from LLM Manager
|
|
try:
|
|
from llm_manager.models import LLMModel
|
|
|
|
context["system_embedding_model"] = LLMModel.get_system_embedding_model()
|
|
context["system_chat_model"] = LLMModel.get_system_chat_model()
|
|
context["system_reranker_model"] = LLMModel.get_system_reranker_model()
|
|
context["system_vision_model"] = LLMModel.get_system_vision_model()
|
|
except Exception as exc:
|
|
logger.warning("Could not load system models: %s", exc)
|
|
|
|
# Reachability of the system-default models (keyed by role for the
|
|
# template). A probe failure must never 500 the dashboard.
|
|
context["model_health"] = {}
|
|
try:
|
|
from library.services.model_health import probe_system_models
|
|
|
|
context["model_health"] = {r["role"]: r for r in probe_system_models()}
|
|
except Exception as exc:
|
|
logger.warning("Could not probe system model health: %s", exc)
|
|
|
|
# Get item status counts and node counts from Neo4j
|
|
if neo4j_available():
|
|
context["neo4j_available"] = True
|
|
try:
|
|
from neomodel import db
|
|
|
|
for status in ["pending", "processing", "completed", "failed"]:
|
|
results, _ = db.cypher_query(
|
|
"MATCH (i:Item {embedding_status: $status}) RETURN count(i)",
|
|
{"status": status},
|
|
)
|
|
context["status_counts"][status] = results[0][0] if results else 0
|
|
|
|
results, _ = db.cypher_query("MATCH (i:Item) RETURN count(i)")
|
|
context["total_items"] = results[0][0] if results else 0
|
|
|
|
for label in ["Library", "Collection", "Item", "Chunk", "Concept", "Image", "ImageEmbedding"]:
|
|
results, _ = db.cypher_query(f"MATCH (n:{label}) RETURN count(n)")
|
|
context["node_counts"][label] = results[0][0] if results else 0
|
|
|
|
results, _ = db.cypher_query(
|
|
"MATCH (c:Chunk) WHERE c.embedding IS NOT NULL RETURN count(c)"
|
|
)
|
|
context["embedded_chunks"] = results[0][0] if results else 0
|
|
context["total_chunks"] = context["node_counts"].get("Chunk", 0)
|
|
|
|
except Exception as exc:
|
|
logger.warning("Could not query Neo4j for dashboard: %s", exc)
|
|
messages.warning(request, f"Neo4j query error: {exc}")
|
|
|
|
return render(request, "library/embedding_dashboard.html", context)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search views (Phase 3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def search_page(request):
|
|
"""Search page — query input with filters and results display."""
|
|
from .utils import neo4j_available
|
|
|
|
context = {
|
|
"query": "",
|
|
"results": None,
|
|
"libraries": [],
|
|
"error": None,
|
|
}
|
|
|
|
# Load libraries for filter dropdown
|
|
if neo4j_available():
|
|
try:
|
|
from .models import Library
|
|
|
|
context["libraries"] = Library.nodes.order_by("name")
|
|
except Exception:
|
|
pass
|
|
|
|
if request.method == "POST" or request.GET.get("q"):
|
|
query = request.POST.get("query", "") or request.GET.get("q", "")
|
|
library_uid = request.POST.get("library_uid", "") or request.GET.get("library_uid", "")
|
|
library_type = request.POST.get("library_type", "") or request.GET.get("library_type", "")
|
|
rerank = request.POST.get("rerank", "on") == "on"
|
|
|
|
context["query"] = query
|
|
|
|
if query.strip():
|
|
try:
|
|
from django.conf import settings as django_settings
|
|
|
|
from .services.search import SearchRequest, SearchService
|
|
|
|
search_request = SearchRequest(
|
|
query=query,
|
|
library_uid=library_uid or None,
|
|
library_type=library_type or None,
|
|
# Admin UI is session-authenticated and sees every
|
|
# library, Daedalus-workspace-scoped or global.
|
|
# ``library.utils.all_library_uids`` materializes the
|
|
# full Library UID set as the request's
|
|
# ``resolved_libraries`` — see the unified auth model
|
|
# in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
|
|
resolved_libraries=_all_library_uids(),
|
|
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
|
|
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
|
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
|
rerank=rerank,
|
|
include_images=True,
|
|
)
|
|
|
|
service = SearchService(user=request.user)
|
|
context["results"] = service.search(search_request)
|
|
except Exception as exc:
|
|
logger.error("Search failed: %s", exc, exc_info=True)
|
|
context["error"] = str(exc)
|
|
|
|
return render(request, "library/search.html", context)
|
|
|
|
|
|
@login_required
|
|
def concept_list_page(request):
|
|
"""Browse concepts with optional search."""
|
|
context = {
|
|
"concepts": [],
|
|
"query": "",
|
|
"error": None,
|
|
}
|
|
|
|
query = request.GET.get("q", "")
|
|
context["query"] = query
|
|
|
|
try:
|
|
if query:
|
|
from neomodel import db
|
|
|
|
results, _ = db.cypher_query(
|
|
"CALL db.index.fulltext.queryNodes('concept_name_fulltext', $query) "
|
|
"YIELD node, score "
|
|
"RETURN node.uid AS uid, node.name AS name, "
|
|
" node.concept_type AS concept_type, score "
|
|
"ORDER BY score DESC LIMIT 50",
|
|
{"query": query},
|
|
)
|
|
context["concepts"] = [
|
|
{"uid": r[0], "name": r[1], "concept_type": r[2] or "", "score": r[3]}
|
|
for r in results
|
|
]
|
|
else:
|
|
from .models import Concept
|
|
|
|
concepts = Concept.nodes.order_by("name")[:100]
|
|
context["concepts"] = [
|
|
{"uid": c.uid, "name": c.name, "concept_type": c.concept_type or ""}
|
|
for c in concepts
|
|
]
|
|
except Exception as exc:
|
|
logger.error("Concept list failed: %s", exc)
|
|
context["error"] = str(exc)
|
|
|
|
return render(request, "library/concept_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def concept_detail_page(request, uid):
|
|
"""View a concept and its graph connections."""
|
|
context = {
|
|
"concept": None,
|
|
"items": [],
|
|
"related_concepts": [],
|
|
"chunk_count": 0,
|
|
"image_count": 0,
|
|
"error": None,
|
|
}
|
|
|
|
try:
|
|
from neomodel import db
|
|
|
|
results, _ = db.cypher_query(
|
|
"MATCH (c:Concept {uid: $uid}) "
|
|
"OPTIONAL MATCH (c)<-[:MENTIONS]-(chunk:Chunk)<-[:HAS_CHUNK]-(item:Item) "
|
|
"OPTIONAL MATCH (c)<-[:DEPICTS]-(img:Image)<-[:HAS_IMAGE]-(img_item:Item) "
|
|
"OPTIONAL MATCH (c)-[:RELATED_TO]-(related:Concept) "
|
|
"RETURN c.uid AS uid, c.name AS name, c.concept_type AS concept_type, "
|
|
" collect(DISTINCT {uid: item.uid, title: item.title})[..20] AS items, "
|
|
" collect(DISTINCT {uid: related.uid, name: related.name, "
|
|
" concept_type: related.concept_type}) AS related_concepts, "
|
|
" count(DISTINCT chunk) AS chunk_count, "
|
|
" count(DISTINCT img) AS image_count",
|
|
{"uid": uid},
|
|
)
|
|
|
|
if not results or not results[0][0]:
|
|
messages.error(request, "Concept not found.")
|
|
return redirect("library:concept-list")
|
|
|
|
row = results[0]
|
|
context["concept"] = {
|
|
"uid": row[0],
|
|
"name": row[1],
|
|
"concept_type": row[2] or "",
|
|
}
|
|
context["items"] = [i for i in (row[3] or []) if i.get("uid")]
|
|
context["related_concepts"] = [r for r in (row[4] or []) if r.get("uid")]
|
|
context["chunk_count"] = row[5] or 0
|
|
context["image_count"] = row[6] or 0
|
|
|
|
except Exception as exc:
|
|
logger.error("Concept detail failed: %s", exc)
|
|
context["error"] = str(exc)
|
|
|
|
return render(request, "library/concept_detail.html", context)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Batch Embedding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@login_required
|
|
def embed_all_pending(request):
|
|
"""
|
|
Trigger embedding for all pending items with uploaded files.
|
|
POST-only action, redirects back to dashboard.
|
|
"""
|
|
if request.method != "POST":
|
|
return redirect("library:embedding-dashboard")
|
|
|
|
try:
|
|
from neomodel import db
|
|
|
|
results, _ = db.cypher_query(
|
|
"MATCH (i:Item {embedding_status: 'pending'}) "
|
|
"WHERE i.s3_key IS NOT NULL AND i.s3_key <> '' "
|
|
"RETURN i.uid"
|
|
)
|
|
item_uids = [row[0] for row in results]
|
|
|
|
if not item_uids:
|
|
messages.info(request, "No pending items with files to embed.")
|
|
else:
|
|
from .tasks import batch_embed_items
|
|
|
|
task = batch_embed_items.delay(item_uids, request.user.id)
|
|
messages.success(
|
|
request,
|
|
f"Queued embedding for {len(item_uids)} items (task: {task.id})",
|
|
)
|
|
except Exception as exc:
|
|
logger.error("Failed to trigger batch embedding: %s", exc, exc_info=True)
|
|
messages.error(request, f"Failed to trigger embedding: {exc}")
|
|
|
|
return redirect("library:embedding-dashboard")
|