Compare commits
23 Commits
feat/admin
...
c040e6f3dd
| Author | SHA1 | Date | |
|---|---|---|---|
| c040e6f3dd | |||
| f1639dbdec | |||
| 7237468e3c | |||
| e1f128659e | |||
| acf829e5ca | |||
| 0a14cf00c5 | |||
| 9f5df20d2b | |||
| 224541a4ce | |||
| 3ae5adebed | |||
| 9f20110f56 | |||
| d6b541636a | |||
| d01afd6203 | |||
| 840b9435a3 | |||
| 6120e9cd1f | |||
| 31a98b4f3a | |||
| 3394726ca1 | |||
| 03e3155bd6 | |||
| 929a3c8c3c | |||
| 2af72d6e82 | |||
| 70b1fc510b | |||
| 46ca2a934d | |||
| dd06f923cd | |||
| 539d9b6c34 |
13
.env.example
13
.env.example
@@ -91,6 +91,19 @@ SPELUNKER_S3_REGION_NAME=us-east-1
|
|||||||
SPELUNKER_S3_USE_SSL=True
|
SPELUNKER_S3_USE_SSL=True
|
||||||
SPELUNKER_S3_VERIFY=True
|
SPELUNKER_S3_VERIFY=True
|
||||||
|
|
||||||
|
# --- Kairos S3 (cross-bucket reads for ingest, source="kairos-mail") ---
|
||||||
|
# Consumed by: worker only
|
||||||
|
# Kairos renders synced mail to text documents in its own bucket and posts
|
||||||
|
# ingest requests with source="kairos-mail". These creds should be scoped
|
||||||
|
# read-only to the Kairos bucket in your secret manager.
|
||||||
|
KAIROS_S3_ENDPOINT_URL=https://nyx.helu.ca:8555
|
||||||
|
KAIROS_S3_ACCESS_KEY_ID=
|
||||||
|
KAIROS_S3_SECRET_ACCESS_KEY=
|
||||||
|
KAIROS_S3_BUCKET_NAME=kairos
|
||||||
|
KAIROS_S3_REGION_NAME=us-east-1
|
||||||
|
KAIROS_S3_USE_SSL=True
|
||||||
|
KAIROS_S3_VERIFY=True
|
||||||
|
|
||||||
# --- Celery / RabbitMQ (Oberon) ---------------------------------------------
|
# --- Celery / RabbitMQ (Oberon) ---------------------------------------------
|
||||||
# Consumed by: app (producer), worker (consumer). NOT mcp.
|
# Consumed by: app (producer), worker (consumer). NOT mcp.
|
||||||
# Remember to percent-encode any password characters that have meaning in a
|
# Remember to percent-encode any password characters that have meaning in a
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ name: CVE Scan & Docker Build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.helu.ca
|
REGISTRY: git.helu.ca
|
||||||
@@ -75,8 +73,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,13 @@ services:
|
|||||||
- SPELUNKER_S3_REGION_NAME=${SPELUNKER_S3_REGION_NAME}
|
- SPELUNKER_S3_REGION_NAME=${SPELUNKER_S3_REGION_NAME}
|
||||||
- SPELUNKER_S3_USE_SSL=${SPELUNKER_S3_USE_SSL}
|
- SPELUNKER_S3_USE_SSL=${SPELUNKER_S3_USE_SSL}
|
||||||
- SPELUNKER_S3_VERIFY=${SPELUNKER_S3_VERIFY}
|
- SPELUNKER_S3_VERIFY=${SPELUNKER_S3_VERIFY}
|
||||||
|
- KAIROS_S3_ENDPOINT_URL=${KAIROS_S3_ENDPOINT_URL}
|
||||||
|
- KAIROS_S3_ACCESS_KEY_ID=${KAIROS_S3_ACCESS_KEY_ID}
|
||||||
|
- KAIROS_S3_SECRET_ACCESS_KEY=${KAIROS_S3_SECRET_ACCESS_KEY}
|
||||||
|
- KAIROS_S3_BUCKET_NAME=${KAIROS_S3_BUCKET_NAME}
|
||||||
|
- KAIROS_S3_REGION_NAME=${KAIROS_S3_REGION_NAME}
|
||||||
|
- KAIROS_S3_USE_SSL=${KAIROS_S3_USE_SSL}
|
||||||
|
- KAIROS_S3_VERIFY=${KAIROS_S3_VERIFY}
|
||||||
# Celery / RabbitMQ
|
# Celery / RabbitMQ
|
||||||
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
|
- CELERY_BROKER_URL=${CELERY_BROKER_URL}
|
||||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
||||||
@@ -371,7 +378,12 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- media:/mnt/media
|
- media:/mnt/media
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "celery", "-A", "mnemosyne", "inspect", "ping", "-d", "celery@$$HOSTNAME"]
|
# No -d destination: exec-form CMD has no shell, so $$HOSTNAME never
|
||||||
|
# expanded and the literal "celery@$HOSTNAME" matched no node → every
|
||||||
|
# check failed. There's one worker per container, so an unfiltered ping
|
||||||
|
# (any node replies = healthy) is correct. -t gives the reply room to
|
||||||
|
# round-trip through the broker on Oberon (~450ms observed) under jitter.
|
||||||
|
test: ["CMD", "celery", "-A", "mnemosyne", "inspect", "ping", "-t", "8"]
|
||||||
interval: 60s
|
interval: 60s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -85,26 +85,21 @@ an explicit `when: mnemosyne_first_deploy` flag.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Apply Django ORM migrations (PostgreSQL schema)
|
# Apply Django ORM migrations (PostgreSQL schema)
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml run --rm app migrate
|
docker compose run --rm app migrate
|
||||||
|
|
||||||
# Create Neo4j vector + full-text indexes and load library-type defaults
|
# Create Neo4j vector + full-text indexes and load library-type defaults
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
docker compose run --rm app setup
|
||||||
run --rm app setup
|
|
||||||
|
|
||||||
# Seed the MCPSigningKey used to sign long-lived Pallas team JWTs.
|
# Seed the MCPSigningKey used to sign long-lived Pallas team JWTs.
|
||||||
# --retire-other deactivates any previously-active key. The hex
|
# --retire-other deactivates any previously-active key. The hex
|
||||||
# emitted to stdout is persisted in Mnemosyne's database and is
|
# emitted to stdout is persisted in Mnemosyne's database and is
|
||||||
# not re-injected from the vault — no operator action required
|
# not re-injected from the vault — no operator action required
|
||||||
# beyond running this command once per fresh deployment.
|
# beyond running this command once per fresh deployment.
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
docker compose run --rm app python manage.py seed_signing_key --kid daedalus-1 --retire-other
|
||||||
run --rm app \
|
|
||||||
python manage.py seed_signing_key --kid daedalus-1 --retire-other
|
|
||||||
|
|
||||||
# Create Django groups for SSO role mapping (View Only / Staff / SME / Admin).
|
# Create Django groups for SSO role mapping (View Only / Staff / SME / Admin).
|
||||||
# Safe to re-run — idempotent.
|
# Safe to re-run — idempotent.
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
docker compose run --rm app python manage.py create_sso_groups
|
||||||
run --rm app \
|
|
||||||
python manage.py create_sso_groups
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `seed_signing_key` command prints the generated secret once to stdout — it
|
The `seed_signing_key` command prints the generated secret once to stdout — it
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ LIBRARY_TYPE_CHOICES = [
|
|||||||
"film",
|
"film",
|
||||||
"art",
|
"art",
|
||||||
"journal",
|
"journal",
|
||||||
|
"email",
|
||||||
"business",
|
"business",
|
||||||
"finance",
|
"finance",
|
||||||
]
|
]
|
||||||
@@ -36,6 +37,7 @@ class LibrarySerializer(serializers.Serializer):
|
|||||||
required=False, allow_blank=True, default=""
|
required=False, allow_blank=True, default=""
|
||||||
)
|
)
|
||||||
workspace_id = serializers.CharField(read_only=True)
|
workspace_id = serializers.CharField(read_only=True)
|
||||||
|
managed_by = serializers.CharField(read_only=True)
|
||||||
created_at = serializers.DateTimeField(read_only=True)
|
created_at = serializers.DateTimeField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -192,6 +194,7 @@ class WorkspaceStatusSerializer(serializers.Serializer):
|
|||||||
name = serializers.CharField()
|
name = serializers.CharField()
|
||||||
library_type = serializers.CharField()
|
library_type = serializers.CharField()
|
||||||
description = serializers.CharField(allow_blank=True)
|
description = serializers.CharField(allow_blank=True)
|
||||||
|
managed_by = serializers.CharField(allow_null=True, required=False)
|
||||||
item_count = serializers.IntegerField()
|
item_count = serializers.IntegerField()
|
||||||
chunk_count = serializers.IntegerField()
|
chunk_count = serializers.IntegerField()
|
||||||
created_at = serializers.DateTimeField()
|
created_at = serializers.DateTimeField()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import os
|
|||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from neomodel.exceptions import UniqueProperty
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
||||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
@@ -17,6 +18,8 @@ from rest_framework.permissions import IsAuthenticated
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from library.content_types import get_library_type_config
|
from library.content_types import get_library_type_config
|
||||||
|
from library.services.library_delete import delete_library_cascade
|
||||||
|
from mcp_server.drf_auth import request_token_label
|
||||||
|
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
CollectionSerializer,
|
CollectionSerializer,
|
||||||
@@ -50,7 +53,7 @@ def library_list_create(request):
|
|||||||
a per-library ``item_count``. Off by default because the count is a
|
a per-library ``item_count``. Off by default because the count is a
|
||||||
Cypher aggregate; on for the Daedalus-side registry poll.
|
Cypher aggregate; on for the Daedalus-side registry poll.
|
||||||
"""
|
"""
|
||||||
from library.models import Library
|
from library.models import Library, find_library_by_name_ci
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
include_workspace = request.GET.get("include_workspace", "true").lower() != "false"
|
include_workspace = request.GET.get("include_workspace", "true").lower() != "false"
|
||||||
@@ -84,6 +87,28 @@ def library_list_create(request):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
|
|
||||||
|
# Library names are unique. The Neo4j index is case-sensitive, but
|
||||||
|
# clients (Spelunker, humans) treat names case-insensitively, so the
|
||||||
|
# create-time check is case-insensitive too — otherwise "amazon connect"
|
||||||
|
# silently creates a near-duplicate of "Amazon Connect". Reject with a
|
||||||
|
# clean 409 (uid + managed_by included so the caller can say who owns
|
||||||
|
# the name) instead of letting the unique-index save raise a 500.
|
||||||
|
existing = find_library_by_name_ci(data["name"])
|
||||||
|
if existing is not None:
|
||||||
|
logger.warning(
|
||||||
|
"library_create name_conflict name=%s existing_uid=%s caller=%s",
|
||||||
|
data["name"], existing.uid, request.user.username,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": f"A library named '{existing.name}' already exists.",
|
||||||
|
"code": "name_conflict",
|
||||||
|
"uid": existing.uid,
|
||||||
|
"managed_by": existing.managed_by_display or None,
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
# Populate defaults from content-type config if not provided
|
# Populate defaults from content-type config if not provided
|
||||||
library_type = data["library_type"]
|
library_type = data["library_type"]
|
||||||
defaults = get_library_type_config(library_type)
|
defaults = get_library_type_config(library_type)
|
||||||
@@ -92,6 +117,7 @@ def library_list_create(request):
|
|||||||
name=data["name"],
|
name=data["name"],
|
||||||
library_type=library_type,
|
library_type=library_type,
|
||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
|
managed_by=request_token_label(request),
|
||||||
chunking_config=data.get("chunking_config") or defaults["chunking_config"],
|
chunking_config=data.get("chunking_config") or defaults["chunking_config"],
|
||||||
embedding_instruction=(
|
embedding_instruction=(
|
||||||
data.get("embedding_instruction") or defaults["embedding_instruction"]
|
data.get("embedding_instruction") or defaults["embedding_instruction"]
|
||||||
@@ -103,7 +129,22 @@ def library_list_create(request):
|
|||||||
data.get("llm_context_prompt") or defaults["llm_context_prompt"]
|
data.get("llm_context_prompt") or defaults["llm_context_prompt"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
lib.save()
|
try:
|
||||||
|
lib.save()
|
||||||
|
except UniqueProperty:
|
||||||
|
# Race between the pre-check and save — an exact-name twin landed
|
||||||
|
# in between. Same 409 shape, minus the loser's uid/manager.
|
||||||
|
logger.warning(
|
||||||
|
"library_create name_conflict (save race) name=%s caller=%s",
|
||||||
|
data["name"], request.user.username,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": f"A library named '{data['name']}' already exists.",
|
||||||
|
"code": "name_conflict",
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
return Response(LibrarySerializer(lib).data, status=status.HTTP_201_CREATED)
|
return Response(LibrarySerializer(lib).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,8 +182,16 @@ def library_detail(request, uid):
|
|||||||
lib.save()
|
lib.save()
|
||||||
return Response(LibrarySerializer(lib).data)
|
return Response(LibrarySerializer(lib).data)
|
||||||
|
|
||||||
# DELETE
|
# DELETE — use the shared cascade so child nodes (Collections/Items/
|
||||||
lib.delete()
|
# Chunks/Images) and orphan Concepts are removed too; a bare
|
||||||
|
# lib.delete() would leak them all.
|
||||||
|
result = delete_library_cascade(lib)
|
||||||
|
logger.info(
|
||||||
|
"Library deleted via API library_uid=%s name=%s items=%d "
|
||||||
|
"orphans_deleted=%d caller=%s",
|
||||||
|
result["library_uid"], result["name"], result["item_count"],
|
||||||
|
result["orphans_deleted"], request.user.username,
|
||||||
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ across users.
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from neomodel import db
|
from neomodel import db
|
||||||
|
from neomodel.exceptions import UniqueProperty
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
@@ -24,6 +25,7 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
from library.content_types import get_library_type_config
|
from library.content_types import get_library_type_config
|
||||||
from library.services.library_delete import delete_library_cascade
|
from library.services.library_delete import delete_library_cascade
|
||||||
|
from mcp_server.drf_auth import request_token_label
|
||||||
|
|
||||||
from .serializers import WorkspaceCreateSerializer, WorkspaceStatusSerializer
|
from .serializers import WorkspaceCreateSerializer, WorkspaceStatusSerializer
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ def _serialize_workspace(lib):
|
|||||||
"name": lib.name,
|
"name": lib.name,
|
||||||
"library_type": lib.library_type,
|
"library_type": lib.library_type,
|
||||||
"description": lib.description or "",
|
"description": lib.description or "",
|
||||||
|
"managed_by": lib.managed_by,
|
||||||
"item_count": item_count,
|
"item_count": item_count,
|
||||||
"chunk_count": chunk_count,
|
"chunk_count": chunk_count,
|
||||||
"created_at": lib.created_at,
|
"created_at": lib.created_at,
|
||||||
@@ -64,7 +67,7 @@ def workspace_create(request):
|
|||||||
workspace (200) — not an error. The library_type is frozen at first
|
workspace (200) — not an error. The library_type is frozen at first
|
||||||
create; subsequent calls are not allowed to change it.
|
create; subsequent calls are not allowed to change it.
|
||||||
"""
|
"""
|
||||||
from library.models import Library
|
from library.models import Library, find_library_by_name_ci
|
||||||
|
|
||||||
serializer = WorkspaceCreateSerializer(data=request.data)
|
serializer = WorkspaceCreateSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -85,7 +88,10 @@ def workspace_create(request):
|
|||||||
data["workspace_id"], request.user.username,
|
data["workspace_id"], request.user.username,
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Workspace id is already in use."},
|
{
|
||||||
|
"detail": "Workspace id is already in use.",
|
||||||
|
"code": "owner_conflict",
|
||||||
|
},
|
||||||
status=status.HTTP_409_CONFLICT,
|
status=status.HTTP_409_CONFLICT,
|
||||||
)
|
)
|
||||||
if existing.library_type != data["library_type"]:
|
if existing.library_type != data["library_type"]:
|
||||||
@@ -95,10 +101,23 @@ def workspace_create(request):
|
|||||||
"library_type is immutable for an existing workspace "
|
"library_type is immutable for an existing workspace "
|
||||||
f"(have '{existing.library_type}', "
|
f"(have '{existing.library_type}', "
|
||||||
f"got '{data['library_type']}')."
|
f"got '{data['library_type']}')."
|
||||||
)
|
),
|
||||||
|
"code": "library_type_immutable",
|
||||||
},
|
},
|
||||||
status=status.HTTP_409_CONFLICT,
|
status=status.HTTP_409_CONFLICT,
|
||||||
)
|
)
|
||||||
|
# Lazy backfill: pre-managed_by libraries pick up the label from
|
||||||
|
# the first idempotent re-POST. Null-only — an already-stamped
|
||||||
|
# library never changes manager.
|
||||||
|
if not existing.managed_by:
|
||||||
|
label = request_token_label(request)
|
||||||
|
if label:
|
||||||
|
existing.managed_by = label
|
||||||
|
existing.save()
|
||||||
|
logger.info(
|
||||||
|
"Backfilled managed_by=%s workspace_id=%s library_uid=%s",
|
||||||
|
label, existing.workspace_id, existing.uid,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Workspace already exists workspace_id=%s library_uid=%s",
|
"Workspace already exists workspace_id=%s library_uid=%s",
|
||||||
data["workspace_id"], existing.uid,
|
data["workspace_id"], existing.uid,
|
||||||
@@ -108,6 +127,28 @@ def workspace_create(request):
|
|||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# New workspace: reject a name already taken by any other library,
|
||||||
|
# case-insensitively — the Neo4j index is case-sensitive, so without
|
||||||
|
# this check "amazon connect" would silently coexist with an existing
|
||||||
|
# "Amazon Connect" and confuse every name-matching client.
|
||||||
|
name_taken = find_library_by_name_ci(data["name"])
|
||||||
|
if name_taken is not None:
|
||||||
|
logger.warning(
|
||||||
|
"workspace_create name_conflict workspace_id=%s name=%s "
|
||||||
|
"existing_uid=%s",
|
||||||
|
data["workspace_id"], data["name"], name_taken.uid,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": (
|
||||||
|
f"A library named '{name_taken.name}' already exists in "
|
||||||
|
"Mnemosyne."
|
||||||
|
),
|
||||||
|
"code": "name_conflict",
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
defaults = get_library_type_config(data["library_type"])
|
defaults = get_library_type_config(data["library_type"])
|
||||||
lib = Library(
|
lib = Library(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -115,12 +156,35 @@ def workspace_create(request):
|
|||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
workspace_id=data["workspace_id"],
|
workspace_id=data["workspace_id"],
|
||||||
owner_username=request.user.username,
|
owner_username=request.user.username,
|
||||||
|
managed_by=request_token_label(request),
|
||||||
chunking_config=defaults["chunking_config"],
|
chunking_config=defaults["chunking_config"],
|
||||||
embedding_instruction=defaults["embedding_instruction"],
|
embedding_instruction=defaults["embedding_instruction"],
|
||||||
reranker_instruction=defaults["reranker_instruction"],
|
reranker_instruction=defaults["reranker_instruction"],
|
||||||
llm_context_prompt=defaults["llm_context_prompt"],
|
llm_context_prompt=defaults["llm_context_prompt"],
|
||||||
)
|
)
|
||||||
lib.save()
|
try:
|
||||||
|
lib.save()
|
||||||
|
except UniqueProperty:
|
||||||
|
# Library.name is globally unique. A name collision here almost always
|
||||||
|
# means an orphaned Library survived a failed Daedalus workspace delete
|
||||||
|
# (the old node kept the name), and the recreate under a new
|
||||||
|
# workspace_id now clashes. Surface a clean 409 instead of a 500 so
|
||||||
|
# Daedalus can record + report it; the operator clears the orphan
|
||||||
|
# (admin delete) or renames the workspace.
|
||||||
|
logger.warning(
|
||||||
|
"workspace_create name_conflict workspace_id=%s name=%s",
|
||||||
|
data["workspace_id"], data["name"],
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": (
|
||||||
|
f"A library named '{data['name']}' already exists in "
|
||||||
|
"Mnemosyne."
|
||||||
|
),
|
||||||
|
"code": "name_conflict",
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Workspace created workspace_id=%s library_uid=%s library_type=%s",
|
"Workspace created workspace_id=%s library_uid=%s library_type=%s",
|
||||||
data["workspace_id"], lib.uid, lib.library_type,
|
data["workspace_id"], lib.uid, lib.library_type,
|
||||||
|
|||||||
@@ -241,6 +241,38 @@ LIBRARY_TYPE_DEFAULTS = {
|
|||||||
"4) The commercial purpose — positioning, pricing, capability demonstration."
|
"4) The commercial purpose — positioning, pricing, capability demonstration."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"email": {
|
||||||
|
"chunking_config": {
|
||||||
|
"strategy": "entry_level",
|
||||||
|
"chunk_size": 512,
|
||||||
|
"chunk_overlap": 32,
|
||||||
|
"respect_boundaries": ["message", "quote", "paragraph"],
|
||||||
|
},
|
||||||
|
"embedding_instruction": (
|
||||||
|
"Represent this email message for retrieval. "
|
||||||
|
"Focus on the sender, recipients, subject, dates, requests and "
|
||||||
|
"commitments made, and the people, organizations, and events discussed."
|
||||||
|
),
|
||||||
|
"reranker_instruction": (
|
||||||
|
"Re-rank email messages based on relevance to the query. "
|
||||||
|
"Prioritize messages matching the correspondents, subject matter, "
|
||||||
|
"time period, and any specific commitments or requests mentioned."
|
||||||
|
),
|
||||||
|
"llm_context_prompt": (
|
||||||
|
"The following excerpts are from personal email correspondence. "
|
||||||
|
"This is private content — answer with discretion. Attribute "
|
||||||
|
"statements to their senders, note dates, and distinguish what was "
|
||||||
|
"asked from what was agreed. Quoted text below a reply is earlier "
|
||||||
|
"context, not the sender's own words."
|
||||||
|
),
|
||||||
|
"vision_prompt": (
|
||||||
|
"Analyze this image from an email message. Identify:\n"
|
||||||
|
"1) Image type (photograph, screenshot, scanned document, chart, signature graphic).\n"
|
||||||
|
"2) What it depicts — people, places, documents, data.\n"
|
||||||
|
"3) Any visible text, dates, or figures.\n"
|
||||||
|
"4) Its role in the message — attachment content, inline illustration, or boilerplate."
|
||||||
|
),
|
||||||
|
},
|
||||||
"finance": {
|
"finance": {
|
||||||
"chunking_config": {
|
"chunking_config": {
|
||||||
"strategy": "section_aware",
|
"strategy": "section_aware",
|
||||||
@@ -282,7 +314,7 @@ def get_library_type_config(library_type):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
library_type: One of 'fiction', 'nonfiction', 'technical', 'music',
|
library_type: One of 'fiction', 'nonfiction', 'technical', 'music',
|
||||||
'film', 'art', 'journal', 'business', 'finance'
|
'film', 'art', 'journal', 'email', 'business', 'finance'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with keys: chunking_config, embedding_instruction,
|
dict with keys: chunking_config, embedding_instruction,
|
||||||
|
|||||||
111
mnemosyne/library/management/commands/backfill_managed_by.py
Normal file
111
mnemosyne/library/management/commands/backfill_managed_by.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""One-off backfill of ``Library.managed_by`` for pre-existing libraries.
|
||||||
|
|
||||||
|
``managed_by`` is stamped from the creating API token's name, so
|
||||||
|
libraries created before the property existed have it null. This
|
||||||
|
command labels them:
|
||||||
|
|
||||||
|
* Workspace-scoped libraries get :func:`infer_legacy_manager`'s answer —
|
||||||
|
``Kairos`` for ``kairos-mail-*`` workspace ids, ``Daedalus`` otherwise.
|
||||||
|
* Global libraries that Spelunker ingested into (any ``IngestJob`` with
|
||||||
|
``source="spelunker"``) get the Spelunker label.
|
||||||
|
* Everything else stays null (hand-made in the web UI).
|
||||||
|
|
||||||
|
Label flags let the operator match the *actual* production token names
|
||||||
|
so backfilled rows render identically to newly stamped ones.
|
||||||
|
|
||||||
|
Idempotent: only null ``managed_by`` is ever written, and the lazy fill
|
||||||
|
in ``workspace_create`` is also null-only, so re-runs and later API
|
||||||
|
traffic never overwrite these labels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from library.models import IngestJob
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Backfill Library.managed_by for libraries created before the "
|
||||||
|
"property existed. Workspace libraries are labelled by inference "
|
||||||
|
"(kairos-mail-* → Kairos, else Daedalus); global libraries with "
|
||||||
|
"Spelunker ingest jobs get the Spelunker label."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--daedalus-label", default="Daedalus",
|
||||||
|
help="Label for non-Kairos workspace libraries (default: Daedalus).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--kairos-label", default="Kairos",
|
||||||
|
help="Label for kairos-mail-* workspace libraries (default: Kairos).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--spelunker-label", default="Spelunker",
|
||||||
|
help="Label for global libraries with Spelunker ingest jobs "
|
||||||
|
"(default: Spelunker).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="Report what would be labelled, don't persist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
from library.models import Library, infer_legacy_manager
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
raise CommandError(
|
||||||
|
f"Could not import library.models.Library (Neo4j unreachable?): {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
"Daedalus": options["daedalus_label"],
|
||||||
|
"Kairos": options["kairos_label"],
|
||||||
|
}
|
||||||
|
|
||||||
|
spelunker_uids = set(
|
||||||
|
IngestJob.objects
|
||||||
|
.filter(source="spelunker")
|
||||||
|
.values_list("library_uid", flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = list(Library.nodes.filter(managed_by__isnull=True))
|
||||||
|
|
||||||
|
to_label = []
|
||||||
|
for lib in candidates:
|
||||||
|
label = infer_legacy_manager(lib.workspace_id)
|
||||||
|
if label:
|
||||||
|
label = overrides[label]
|
||||||
|
elif lib.uid in spelunker_uids:
|
||||||
|
label = options["spelunker_label"]
|
||||||
|
if label:
|
||||||
|
to_label.append((lib, label))
|
||||||
|
|
||||||
|
self.stdout.write(f"Libraries with null managed_by: {len(candidates)}")
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Will label: {len(to_label)} "
|
||||||
|
f"(unmanaged, left null: {len(candidates) - len(to_label)})")
|
||||||
|
)
|
||||||
|
|
||||||
|
for lib, label in to_label:
|
||||||
|
self.stdout.write(f" {lib.uid} {lib.name!r} → {label}")
|
||||||
|
|
||||||
|
if options["dry_run"]:
|
||||||
|
self.stdout.write(self.style.WARNING("--dry-run: nothing written."))
|
||||||
|
return
|
||||||
|
|
||||||
|
for lib, label in to_label:
|
||||||
|
lib.managed_by = label
|
||||||
|
lib.save()
|
||||||
|
logger.info(
|
||||||
|
"backfill_managed_by uid=%s name=%s label=%s",
|
||||||
|
lib.uid, lib.name, label,
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS("Done."))
|
||||||
@@ -51,6 +51,35 @@ class NearbyImageRel(StructuredRel):
|
|||||||
# --- Node models ---
|
# --- Node models ---
|
||||||
|
|
||||||
|
|
||||||
|
def infer_legacy_manager(workspace_id):
|
||||||
|
"""Manager label for pre-``managed_by`` workspace libraries, else None.
|
||||||
|
|
||||||
|
Kairos mail workspaces are recognisable by their deterministic
|
||||||
|
``kairos-mail-`` id prefix; every other workspace id is a Daedalus
|
||||||
|
workspace UUID.
|
||||||
|
"""
|
||||||
|
if not workspace_id:
|
||||||
|
return None
|
||||||
|
return "Kairos" if workspace_id.startswith("kairos-mail-") else "Daedalus"
|
||||||
|
|
||||||
|
|
||||||
|
def find_library_by_name_ci(name):
|
||||||
|
"""Case-insensitively find a Library by name, or None.
|
||||||
|
|
||||||
|
Parameterised Cypher rather than neomodel's ``iexact``, which embeds
|
||||||
|
the value in a regex and so breaks on names containing regex
|
||||||
|
metacharacters (e.g. "C++ Notes").
|
||||||
|
"""
|
||||||
|
from neomodel import db
|
||||||
|
|
||||||
|
rows, _ = db.cypher_query(
|
||||||
|
"MATCH (l:Library) WHERE toLower(l.name) = toLower($name) "
|
||||||
|
"RETURN l LIMIT 1",
|
||||||
|
{"name": name},
|
||||||
|
)
|
||||||
|
return Library.inflate(rows[0][0]) if rows else None
|
||||||
|
|
||||||
|
|
||||||
class Library(StructuredNode):
|
class Library(StructuredNode):
|
||||||
"""
|
"""
|
||||||
Top-level container representing a content library.
|
Top-level container representing a content library.
|
||||||
@@ -63,6 +92,11 @@ class Library(StructuredNode):
|
|||||||
across the whole instance) or *workspace-scoped* (workspace_id set —
|
across the whole instance) or *workspace-scoped* (workspace_id set —
|
||||||
visible only to agents inside that Daedalus workspace). Scoping is
|
visible only to agents inside that Daedalus workspace). Scoping is
|
||||||
enforced structurally by every search query.
|
enforced structurally by every search query.
|
||||||
|
|
||||||
|
Independently of scoping, a library may be *app-managed*
|
||||||
|
(``managed_by`` set — created through the API by an external app such
|
||||||
|
as Daedalus, Kairos, or Spelunker, which owns its content lifecycle)
|
||||||
|
or unmanaged (created by hand in the web UI).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
uid = UniqueIdProperty()
|
uid = UniqueIdProperty()
|
||||||
@@ -77,6 +111,7 @@ class Library(StructuredNode):
|
|||||||
"film": "Film",
|
"film": "Film",
|
||||||
"art": "Art",
|
"art": "Art",
|
||||||
"journal": "Journal",
|
"journal": "Journal",
|
||||||
|
"email": "Email",
|
||||||
"business": "Business",
|
"business": "Business",
|
||||||
"finance": "Finance",
|
"finance": "Finance",
|
||||||
},
|
},
|
||||||
@@ -92,6 +127,12 @@ class Library(StructuredNode):
|
|||||||
# this user. Null for global libraries.
|
# this user. Null for global libraries.
|
||||||
owner_username = StringProperty(required=False, index=True)
|
owner_username = StringProperty(required=False, index=True)
|
||||||
|
|
||||||
|
# Name of the API token that created this library ("Daedalus",
|
||||||
|
# "Kairos", "Spelunker", ...). Null for libraries created in the
|
||||||
|
# web UI. Stamped at create time only — token rotation or edits by
|
||||||
|
# another token never change it.
|
||||||
|
managed_by = StringProperty(required=False, index=True)
|
||||||
|
|
||||||
# Content-type configuration
|
# Content-type configuration
|
||||||
chunking_config = JSONProperty(default={})
|
chunking_config = JSONProperty(default={})
|
||||||
embedding_instruction = StringProperty(default="")
|
embedding_instruction = StringProperty(default="")
|
||||||
@@ -103,6 +144,16 @@ class Library(StructuredNode):
|
|||||||
# Relationships
|
# Relationships
|
||||||
collections = RelationshipTo("Collection", "CONTAINS")
|
collections = RelationshipTo("Collection", "CONTAINS")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def managed_by_display(self):
|
||||||
|
"""Managing-app label for display; empty string when unmanaged.
|
||||||
|
|
||||||
|
Falls back to inference for workspace libraries created before
|
||||||
|
``managed_by`` existed, so rendering is identical before and
|
||||||
|
after the backfill command runs.
|
||||||
|
"""
|
||||||
|
return self.managed_by or infer_legacy_manager(self.workspace_id) or ""
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.library_type})"
|
return f"{self.name} ({self.library_type})"
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ from .text_utils import remove_excessive_whitespace, sanitize_text
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedFileTypeError(ValueError):
|
||||||
|
"""Raised when a file's type cannot be parsed.
|
||||||
|
|
||||||
|
Deterministic and input-driven — re-parsing identical bytes can never
|
||||||
|
succeed — so ingest must treat it as a terminal failure and never retry.
|
||||||
|
Subclasses ``ValueError`` so existing ``except ValueError`` callers still
|
||||||
|
catch it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# File extensions supported by PyMuPDF
|
# File extensions supported by PyMuPDF
|
||||||
PYMUPDF_EXTENSIONS = {
|
PYMUPDF_EXTENSIONS = {
|
||||||
"pdf", "epub", "xps", "mobi", "fb2", "cbz", "svg",
|
"pdf", "epub", "xps", "mobi", "fb2", "cbz", "svg",
|
||||||
@@ -31,8 +42,11 @@ PYMUPDF_EXTENSIONS = {
|
|||||||
# Plain text extensions — read directly, no PyMuPDF needed
|
# Plain text extensions — read directly, no PyMuPDF needed
|
||||||
PLAINTEXT_EXTENSIONS = {"txt", "md", "csv", "tsv", "log", "json", "yaml", "yml", "xml"}
|
PLAINTEXT_EXTENSIONS = {"txt", "md", "csv", "tsv", "log", "json", "yaml", "yml", "xml"}
|
||||||
|
|
||||||
# Image extensions — store as Image nodes directly
|
# Image extensions — store as Image nodes directly.
|
||||||
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "svg"}
|
# SVG is deliberately absent: it is vector XML that Pillow cannot decode and
|
||||||
|
# that the vision stage cannot send as a data URI, so it gets rasterized by
|
||||||
|
# _parse_svg_file instead.
|
||||||
|
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp"}
|
||||||
|
|
||||||
# Minimum image dimensions to extract (skip tiny icons/bullets)
|
# Minimum image dimensions to extract (skip tiny icons/bullets)
|
||||||
MIN_IMAGE_WIDTH = 50
|
MIN_IMAGE_WIDTH = 50
|
||||||
@@ -85,7 +99,7 @@ class DocumentParser:
|
|||||||
:param file_path: Path to the document file.
|
:param file_path: Path to the document file.
|
||||||
:param file_type: File extension (without dot), e.g. 'pdf', 'epub'.
|
:param file_type: File extension (without dot), e.g. 'pdf', 'epub'.
|
||||||
:returns: ParseResult with text blocks, images, and metadata.
|
:returns: ParseResult with text blocks, images, and metadata.
|
||||||
:raises ValueError: If the file type is not supported.
|
:raises UnsupportedFileTypeError: If the file type is not supported.
|
||||||
"""
|
"""
|
||||||
file_type = file_type.lower().lstrip(".")
|
file_type = file_type.lower().lstrip(".")
|
||||||
|
|
||||||
@@ -98,6 +112,12 @@ class DocumentParser:
|
|||||||
if file_type in PLAINTEXT_EXTENSIONS:
|
if file_type in PLAINTEXT_EXTENSIONS:
|
||||||
return self._parse_plaintext(file_path, file_type)
|
return self._parse_plaintext(file_path, file_type)
|
||||||
|
|
||||||
|
# Checked before PYMUPDF_EXTENSIONS: PyMuPDF can open an SVG, but
|
||||||
|
# rendering a multi-page Write note as one document yields a blank or
|
||||||
|
# illegible image (see svg_raster), so it needs page-aware handling.
|
||||||
|
if file_type == "svg":
|
||||||
|
return self._parse_svg_file(file_path, file_type)
|
||||||
|
|
||||||
if file_type in IMAGE_EXTENSIONS:
|
if file_type in IMAGE_EXTENSIONS:
|
||||||
return self._parse_image_file(file_path, file_type)
|
return self._parse_image_file(file_path, file_type)
|
||||||
|
|
||||||
@@ -108,7 +128,7 @@ class DocumentParser:
|
|||||||
if file_type in ("html", "htm"):
|
if file_type in ("html", "htm"):
|
||||||
return self._parse_with_pymupdf(file_path, file_type)
|
return self._parse_with_pymupdf(file_path, file_type)
|
||||||
|
|
||||||
raise ValueError(
|
raise UnsupportedFileTypeError(
|
||||||
f"Unsupported file type '{file_type}'. "
|
f"Unsupported file type '{file_type}'. "
|
||||||
f"Supported: {sorted(PYMUPDF_EXTENSIONS | PLAINTEXT_EXTENSIONS | IMAGE_EXTENSIONS)}"
|
f"Supported: {sorted(PYMUPDF_EXTENSIONS | PLAINTEXT_EXTENSIONS | IMAGE_EXTENSIONS)}"
|
||||||
)
|
)
|
||||||
@@ -309,6 +329,61 @@ class DocumentParser:
|
|||||||
file_type=file_type,
|
file_type=file_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _parse_svg_file(self, file_path: str, file_type: str) -> ParseResult:
|
||||||
|
"""
|
||||||
|
Rasterize an SVG into one ExtractedImage per page.
|
||||||
|
|
||||||
|
SVG is vector XML: Pillow cannot decode it and the vision stage cannot
|
||||||
|
put it in a data URI, so it is rendered to PNG here. Multi-page Write
|
||||||
|
notes become one image per page so each page reaches vision/OCR at a
|
||||||
|
legible size.
|
||||||
|
|
||||||
|
:param file_path: Path to the SVG file.
|
||||||
|
:param file_type: Normalized file extension ("svg").
|
||||||
|
:returns: ParseResult with one image per rendered page.
|
||||||
|
"""
|
||||||
|
with DOCUMENT_PARSE_DURATION.labels(file_type=file_type).time():
|
||||||
|
try:
|
||||||
|
from library.services.svg_raster import render_svg_pages
|
||||||
|
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
pages = render_svg_pages(data)
|
||||||
|
except Exception as exc:
|
||||||
|
DOCUMENTS_PARSED_TOTAL.labels(file_type=file_type, status="error").inc()
|
||||||
|
logger.error("Failed to rasterize SVG file_type=%s: %s", file_type, exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
images = [
|
||||||
|
ExtractedImage(
|
||||||
|
data=png,
|
||||||
|
ext="png",
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
source_page=index,
|
||||||
|
source_index=0,
|
||||||
|
)
|
||||||
|
for index, (png, width, height) in enumerate(pages)
|
||||||
|
]
|
||||||
|
|
||||||
|
DOCUMENTS_PARSED_TOTAL.labels(file_type=file_type, status="success").inc()
|
||||||
|
IMAGES_EXTRACTED_TOTAL.labels(file_type=file_type).inc(len(images))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Parsed SVG file_type=%s pages=%d bytes=%d",
|
||||||
|
file_type,
|
||||||
|
len(images),
|
||||||
|
len(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ParseResult(
|
||||||
|
text_blocks=[],
|
||||||
|
images=images,
|
||||||
|
metadata={"page_count": len(images)},
|
||||||
|
file_type=file_type,
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_image_file(self, file_path: str, file_type: str) -> ParseResult:
|
def _parse_image_file(self, file_path: str, file_type: str) -> ParseResult:
|
||||||
"""
|
"""
|
||||||
Handle a standalone image file — store as a single ExtractedImage.
|
Handle a standalone image file — store as a single ExtractedImage.
|
||||||
|
|||||||
263
mnemosyne/library/services/svg_raster.py
Normal file
263
mnemosyne/library/services/svg_raster.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""SVG rasterization for the ingest pipeline.
|
||||||
|
|
||||||
|
The vision stage sends each extracted image to a vision LLM as a ``data:`` URI,
|
||||||
|
which cannot carry ``image/svg+xml`` — so an SVG has to become raster before it
|
||||||
|
can be described, OCR'd, or embedded. PyMuPDF (already a dependency for PDF
|
||||||
|
parsing) renders SVG natively, so this needs no cairo/rsvg system libraries.
|
||||||
|
|
||||||
|
The non-obvious part is page splitting. Handwritten notes from the Write
|
||||||
|
(Stylus Labs) app are a single SVG document holding one or more
|
||||||
|
``<svg class="write-page">`` children stacked vertically via x/y offsets. Two
|
||||||
|
root formats exist in the wild and *both* must be split per page:
|
||||||
|
|
||||||
|
- Older files carry no width/height on the root ``<svg>``. Rendering the
|
||||||
|
document as-is makes PyMuPDF fall back to US-Letter and emit the top-left
|
||||||
|
corner only — ruled lines and no handwriting, in a perfectly valid PNG.
|
||||||
|
- Newer files (Write commit eeab021) do carry root width/height, spanning the
|
||||||
|
full stacked extent. Rendering those as-is yields one very tall strip; capped
|
||||||
|
to a sane longest edge, a 5-page note squashes to ~209px wide, well past
|
||||||
|
illegible.
|
||||||
|
|
||||||
|
Write never rewrites existing files, so the old format is permanent rather than
|
||||||
|
a migration window. Page geometry lives on the ``write-page`` element and is
|
||||||
|
identical across both formats, so splitting ignores the root dimensions
|
||||||
|
entirely and needs no format detection.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
Daedalus carries a twin of this module at
|
||||||
|
``backend/daedalus/extraction/svg.py``, which returns base64 for direct
|
||||||
|
chat attachment. The two are deliberately duplicated rather than shared —
|
||||||
|
the repos ship no common package — so fixes belong in both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SVG_NS = "http://www.w3.org/2000/svg"
|
||||||
|
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||||||
|
|
||||||
|
_SVG = f"{{{SVG_NS}}}"
|
||||||
|
_PAGE_CLASS = "write-page"
|
||||||
|
|
||||||
|
# Write documents render on a grey backdrop; pages themselves are transparent,
|
||||||
|
# so each rendered page needs an explicit white underlay or strokes land on
|
||||||
|
# black in the flattened PNG.
|
||||||
|
_PAGE_BACKGROUND = "#ffffff"
|
||||||
|
|
||||||
|
#: Longest edge of a rendered page, in pixels. Enough for a vision model to
|
||||||
|
#: read handwriting without spending tokens on unusable resolution.
|
||||||
|
DEFAULT_TARGET_PX = 1568
|
||||||
|
|
||||||
|
#: Cap on pages rendered from one document.
|
||||||
|
DEFAULT_MAX_PAGES = 20
|
||||||
|
|
||||||
|
# Unquoted attribute value in the root tag, e.g. Write's `width=auto`. Matches
|
||||||
|
# only bare alphabetic values so numeric or already-quoted attributes are left
|
||||||
|
# alone.
|
||||||
|
_UNQUOTED_ATTR = re.compile(rb"(\s[-\w:]+)=([A-Za-z][-\w]*)(?=[\s>])")
|
||||||
|
_ROOT_TAG = re.compile(rb"<svg[^>]*>")
|
||||||
|
|
||||||
|
|
||||||
|
class SvgRenderError(Exception):
|
||||||
|
"""Raised when an SVG cannot be parsed or contains no renderable page."""
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_root_tag(data: bytes) -> bytes:
|
||||||
|
"""Quote unquoted attribute values in the root ``<svg>`` tag.
|
||||||
|
|
||||||
|
Some Write files emit ``width=auto height=auto``, which is not valid XML.
|
||||||
|
A malformation *inside* the root tag defeats ``recover=True`` differently
|
||||||
|
from one in the body: rather than dropping a subtree, libxml2 abandons the
|
||||||
|
whole document and yields a bare root, so every page becomes invisible.
|
||||||
|
Quoting the values first recovers the full tree.
|
||||||
|
"""
|
||||||
|
match = _ROOT_TAG.search(data)
|
||||||
|
if not match:
|
||||||
|
return data
|
||||||
|
repaired = _UNQUOTED_ATTR.sub(rb'\1="\2"', match.group(0))
|
||||||
|
if repaired == match.group(0):
|
||||||
|
return data
|
||||||
|
return data[: match.start()] + repaired + data[match.end() :]
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> etree.XMLParser:
|
||||||
|
"""Build the hardened parser used for all untrusted SVG input.
|
||||||
|
|
||||||
|
``resolve_entities=False`` blocks XXE — ingest content is untrusted.
|
||||||
|
``huge_tree`` is required because handwriting path data runs to megabytes.
|
||||||
|
``recover`` salvages the handful of Write files that emit unescaped
|
||||||
|
attribute content and are not well-formed XML.
|
||||||
|
"""
|
||||||
|
return etree.XMLParser(
|
||||||
|
huge_tree=True,
|
||||||
|
resolve_entities=False,
|
||||||
|
no_network=True,
|
||||||
|
recover=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dimension(element: etree._Element, name: str) -> float | None:
|
||||||
|
"""Read a CSS-pixel dimension attribute, tolerating a ``px`` suffix."""
|
||||||
|
raw = element.get(name)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw.strip().removesuffix("px"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _viewbox_size(element: etree._Element) -> tuple[float, float] | None:
|
||||||
|
"""Derive width/height from a viewBox extent."""
|
||||||
|
raw = element.get("viewBox")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
parts = raw.replace(",", " ").split()
|
||||||
|
if len(parts) != 4:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
width, height = float(parts[2]), float(parts[3])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return (width, height) if width > 0 and height > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _element_size(element: etree._Element) -> tuple[float, float] | None:
|
||||||
|
"""Resolve an element's rendered size from width/height, else viewBox."""
|
||||||
|
width = _dimension(element, "width")
|
||||||
|
height = _dimension(element, "height")
|
||||||
|
if width and height:
|
||||||
|
return width, height
|
||||||
|
return _viewbox_size(element)
|
||||||
|
|
||||||
|
|
||||||
|
def _standalone_page(
|
||||||
|
page: etree._Element,
|
||||||
|
defs: etree._Element | None,
|
||||||
|
width: float,
|
||||||
|
height: float,
|
||||||
|
) -> bytes:
|
||||||
|
"""Wrap one ``write-page`` element as its own renderable SVG document.
|
||||||
|
|
||||||
|
The page is repositioned to the origin (its x/y place it within the stacked
|
||||||
|
parent) and given an explicit viewBox so the renderer has an unambiguous
|
||||||
|
size. ``defs`` is copied in because pen and ruling definitions live on the
|
||||||
|
root and are referenced by page content.
|
||||||
|
"""
|
||||||
|
root = etree.Element(f"{_SVG}svg", nsmap={None: SVG_NS, "xlink": XLINK_NS})
|
||||||
|
root.set("width", str(width))
|
||||||
|
root.set("height", str(height))
|
||||||
|
root.set("viewBox", f"0 0 {width} {height}")
|
||||||
|
|
||||||
|
background = etree.SubElement(root, f"{_SVG}rect")
|
||||||
|
background.set("width", "100%")
|
||||||
|
background.set("height", "100%")
|
||||||
|
background.set("fill", _PAGE_BACKGROUND)
|
||||||
|
|
||||||
|
if defs is not None:
|
||||||
|
root.append(copy.deepcopy(defs))
|
||||||
|
|
||||||
|
element = copy.deepcopy(page)
|
||||||
|
for positional in ("x", "y"):
|
||||||
|
element.attrib.pop(positional, None)
|
||||||
|
element.set("viewBox", f"0 0 {width} {height}")
|
||||||
|
root.append(element)
|
||||||
|
|
||||||
|
return etree.tostring(root)
|
||||||
|
|
||||||
|
|
||||||
|
def split_svg_pages(data: bytes) -> list[bytes]:
|
||||||
|
"""Split an SVG into one standalone document per renderable page.
|
||||||
|
|
||||||
|
Write multi-page notes yield one document per ``write-page`` child. Any
|
||||||
|
other SVG (a diagram, a logo) yields a single document — the input itself,
|
||||||
|
which the renderer sizes from its own width/height or viewBox.
|
||||||
|
|
||||||
|
:param data: Raw SVG bytes.
|
||||||
|
:returns: One or more standalone SVG documents, in page order.
|
||||||
|
:raises SvgRenderError: If the SVG cannot be parsed or has no usable size.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = etree.fromstring(_repair_root_tag(data), _parser())
|
||||||
|
except etree.XMLSyntaxError as exc:
|
||||||
|
raise SvgRenderError(f"Could not parse SVG: {exc}") from exc
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
raise SvgRenderError("Could not parse SVG: no root element.")
|
||||||
|
|
||||||
|
defs = root.find(f"{_SVG}defs")
|
||||||
|
|
||||||
|
pages: list[bytes] = []
|
||||||
|
for element in root.iter(f"{_SVG}svg"):
|
||||||
|
if element is root:
|
||||||
|
continue
|
||||||
|
if _PAGE_CLASS not in (element.get("class") or "").split():
|
||||||
|
continue
|
||||||
|
size = _element_size(element)
|
||||||
|
if not size:
|
||||||
|
continue
|
||||||
|
pages.append(_standalone_page(element, defs, *size))
|
||||||
|
|
||||||
|
if pages:
|
||||||
|
return pages
|
||||||
|
|
||||||
|
# Generic SVG: render as-is. It must still be sizeable, or the renderer
|
||||||
|
# would silently substitute a default page box.
|
||||||
|
if not _element_size(root):
|
||||||
|
raise SvgRenderError(
|
||||||
|
"SVG has no width/height or viewBox, so its size is undefined."
|
||||||
|
)
|
||||||
|
return [data]
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg_pages(
|
||||||
|
data: bytes,
|
||||||
|
max_pages: int = DEFAULT_MAX_PAGES,
|
||||||
|
target_px: int = DEFAULT_TARGET_PX,
|
||||||
|
) -> list[tuple[bytes, int, int]]:
|
||||||
|
"""Rasterize an SVG to one PNG per page.
|
||||||
|
|
||||||
|
:param data: Raw SVG bytes.
|
||||||
|
:param max_pages: Cap on pages rendered.
|
||||||
|
:param target_px: Longest edge of each rendered page, in pixels.
|
||||||
|
:returns: One ``(png_bytes, width, height)`` tuple per page, in page order.
|
||||||
|
:raises SvgRenderError: If nothing could be rendered.
|
||||||
|
"""
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
pages = split_svg_pages(data)
|
||||||
|
|
||||||
|
rendered: list[tuple[bytes, int, int]] = []
|
||||||
|
for index, page_svg in enumerate(pages[:max_pages]):
|
||||||
|
try:
|
||||||
|
with fitz.open(stream=page_svg, filetype="svg") as document:
|
||||||
|
page = document[0]
|
||||||
|
longest = max(page.rect.width, page.rect.height)
|
||||||
|
zoom = target_px / longest if longest else 1.0
|
||||||
|
pixmap = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
|
||||||
|
rendered.append(
|
||||||
|
(pixmap.tobytes("png"), pixmap.width, pixmap.height)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise SvgRenderError(
|
||||||
|
f"Could not render SVG page {index + 1}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not rendered:
|
||||||
|
raise SvgRenderError("No renderable pages in SVG.")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Rasterized SVG pages_rendered=%d total_pages=%d target_px=%d",
|
||||||
|
len(rendered),
|
||||||
|
len(pages),
|
||||||
|
target_px,
|
||||||
|
)
|
||||||
|
|
||||||
|
return rendered
|
||||||
@@ -347,6 +347,7 @@ def ingest_from_daedalus(self, job_id: str):
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from library.models import IngestJob, Item, Library
|
from library.models import IngestJob, Item, Library
|
||||||
|
from library.services.parsers import UnsupportedFileTypeError
|
||||||
from library.services.source_s3 import (
|
from library.services.source_s3 import (
|
||||||
copy_into_mnemosyne,
|
copy_into_mnemosyne,
|
||||||
fetch_from_source,
|
fetch_from_source,
|
||||||
@@ -465,6 +466,24 @@ def ingest_from_daedalus(self, job_id: str):
|
|||||||
**result,
|
**result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except UnsupportedFileTypeError as exc:
|
||||||
|
# Deterministic, input-driven — re-parsing identical bytes can never
|
||||||
|
# succeed. Terminal client-data failure, never retried, and logged at
|
||||||
|
# WARNING (not ERROR) because an unparseable input is not a server fault.
|
||||||
|
logger.warning(
|
||||||
|
"Task ingest_from_daedalus rejected job_id=%s: %s", job_id, exc,
|
||||||
|
)
|
||||||
|
job.status = "failed"
|
||||||
|
job.error = str(exc)
|
||||||
|
job.completed_at = datetime.now(timezone.utc)
|
||||||
|
job.save(update_fields=["status", "error", "completed_at"])
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"job_id": job_id,
|
||||||
|
"error": str(exc),
|
||||||
|
"reason": "unsupported_file_type",
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Task ingest_from_daedalus failed job_id=%s: %s",
|
"Task ingest_from_daedalus failed job_id=%s: %s",
|
||||||
|
|||||||
@@ -12,15 +12,22 @@
|
|||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning mb-6">
|
||||||
<span>Are you sure you want to delete <strong>{{ library.name }}</strong>? This action cannot be undone.</span>
|
<span>Are you sure you want to delete <strong>{{ library.name }}</strong>? This action cannot be undone.</span>
|
||||||
</div>
|
</div>
|
||||||
{% if library.workspace_id %}
|
{% if library.managed_by_display %}
|
||||||
<div class="alert alert-error mb-6">
|
<div class="alert alert-error mb-6">
|
||||||
<span>
|
<span>
|
||||||
<strong>This Library is managed by Daedalus</strong>
|
<strong>This Library is managed by {{ library.managed_by_display }}</strong>{% if library.workspace_id %}
|
||||||
(workspace <code>{{ library.workspace_id }}</code>).
|
(workspace <code>{{ library.workspace_id }}</code>){% endif %}.
|
||||||
|
{% if library.workspace_id %}
|
||||||
Deleting it here removes its embedded content from Mnemosyne, but the
|
Deleting it here removes its embedded content from Mnemosyne, but the
|
||||||
source files still live in Daedalus — it will be <strong>recreated and
|
source files still live in {{ library.managed_by_display }} — it will
|
||||||
re-embedded on the next Daedalus sync</strong>. Use this to clear an
|
be <strong>recreated and re-embedded on the next sync</strong>. Use
|
||||||
orphaned Library that is blocking workspace re-registration.
|
this to clear an orphaned Library that is blocking workspace
|
||||||
|
re-registration.
|
||||||
|
{% else %}
|
||||||
|
Deleting it here removes its embedded content from Mnemosyne;
|
||||||
|
{{ library.managed_by_display }} may recreate and re-embed it on its
|
||||||
|
next sync.
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
<h1 class="text-3xl font-bold">{{ library.name }}</h1>
|
<h1 class="text-3xl font-bold">{{ library.name }}</h1>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<div class="badge badge-primary">{{ library.library_type }}</div>
|
<div class="badge badge-primary">{{ library.library_type }}</div>
|
||||||
{% if library.workspace_id %}
|
{% if library.managed_by_display %}
|
||||||
<div class="badge badge-warning gap-1"
|
<div class="badge badge-warning gap-1"
|
||||||
title="Workspace {{ library.workspace_id }}">
|
{% if library.workspace_id %}title="Workspace {{ library.workspace_id }}"{% endif %}>
|
||||||
Daedalus workspace
|
Managed by {{ library.managed_by_display }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -25,26 +25,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{% url 'library:library-edit' uid=library.uid %}" class="btn btn-sm btn-outline">Edit</a>
|
<a href="{% url 'library:library-edit' uid=library.uid %}" class="btn btn-sm btn-outline">Edit</a>
|
||||||
{% if library.workspace_id %}
|
|
||||||
<button type="button" class="btn btn-sm btn-error btn-outline" disabled
|
|
||||||
title="This library is managed by Daedalus. Delete it from the Daedalus workspace, not here.">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<a href="{% url 'library:library-delete' uid=library.uid %}" class="btn btn-sm btn-error btn-outline">Delete</a>
|
<a href="{% url 'library:library-delete' uid=library.uid %}" class="btn btn-sm btn-error btn-outline">Delete</a>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if library.workspace_id %}
|
{% if library.managed_by_display %}
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning mb-6">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold">Managed by Daedalus</div>
|
<div class="font-semibold">Managed by {{ library.managed_by_display }}</div>
|
||||||
<div class="text-sm opacity-80">
|
<div class="text-sm opacity-80">
|
||||||
This library was created for Daedalus workspace
|
{% if library.workspace_id %}
|
||||||
|
This library was created for workspace
|
||||||
<code class="font-mono">{{ library.workspace_id }}</code>.
|
<code class="font-mono">{{ library.workspace_id }}</code>.
|
||||||
Items here are owned by the workspace; deleting the workspace in
|
Normally you manage it from {{ library.managed_by_display }}.
|
||||||
Daedalus will remove this library. Do not delete it manually.
|
Deleting it here removes its embedded content from Mnemosyne, but
|
||||||
|
the source files still live in {{ library.managed_by_display }} —
|
||||||
|
it will be recreated and re-embedded on the next sync.
|
||||||
|
Use Delete to clear an orphaned library that is blocking workspace
|
||||||
|
re-registration.
|
||||||
|
{% else %}
|
||||||
|
Content in this library is pushed by
|
||||||
|
{{ library.managed_by_display }}. Edits made here may be
|
||||||
|
overwritten or re-created on its next sync.
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="mb-4 flex flex-wrap gap-3 items-end">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Scope</span></label>
|
||||||
|
<select name="scope" class="select select-bordered select-sm">
|
||||||
|
<option value="all" {% if scope == "all" %}selected{% endif %}>All libraries</option>
|
||||||
|
<option value="unmanaged" {% if scope == "unmanaged" %}selected{% endif %}>Unmanaged only</option>
|
||||||
|
<option value="managed" {% if scope == "managed" %}selected{% endif %}>App-managed only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline">Filter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-warning mb-4">
|
<div class="alert alert-warning mb-4">
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
@@ -33,9 +45,9 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<div class="badge badge-outline">{{ lib.library_type }}</div>
|
<div class="badge badge-outline">{{ lib.library_type }}</div>
|
||||||
{% if lib.workspace_id %}
|
{% if lib.managed_by_display %}
|
||||||
<div class="badge badge-warning gap-1" title="Managed by Daedalus workspace {{ lib.workspace_id }} — do not delete from Mnemosyne.">
|
<div class="badge badge-warning gap-1" title="Managed by {{ lib.managed_by_display }}{% if lib.workspace_id %} (workspace {{ lib.workspace_id }}){% endif %} — do not delete from Mnemosyne.">
|
||||||
Daedalus workspace
|
Managed by {{ lib.managed_by_display }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,8 +65,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% if not error %}
|
{% if not error %}
|
||||||
<div class="text-center py-12 opacity-60">
|
<div class="text-center py-12 opacity-60">
|
||||||
|
{% if scope == "all" %}
|
||||||
<p class="text-lg">No libraries yet.</p>
|
<p class="text-lg">No libraries yet.</p>
|
||||||
<p class="mt-2">Create your first library to get started.</p>
|
<p class="mt-2">Create your first library to get started.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-lg">No libraries match this filter.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -48,30 +48,3 @@ class ConceptExtractionParsingTests(TestCase):
|
|||||||
result = self.extractor._parse_concept_response(response)
|
result = self.extractor._parse_concept_response(response)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0]["name"], "valid")
|
self.assertEqual(result[0]["name"], "valid")
|
||||||
|
|
||||||
|
|
||||||
class SampleIndexSelectionTests(TestCase):
|
|
||||||
"""Tests for sample index selection."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.extractor = ConceptExtractor(MagicMock())
|
|
||||||
|
|
||||||
def test_small_total_returns_all(self):
|
|
||||||
indices = self.extractor._select_sample_indices(5, max_samples=10)
|
|
||||||
self.assertEqual(indices, [0, 1, 2, 3, 4])
|
|
||||||
|
|
||||||
def test_equal_total_returns_all(self):
|
|
||||||
indices = self.extractor._select_sample_indices(10, max_samples=10)
|
|
||||||
self.assertEqual(indices, list(range(10)))
|
|
||||||
|
|
||||||
def test_large_total_returns_max_samples(self):
|
|
||||||
indices = self.extractor._select_sample_indices(100, max_samples=10)
|
|
||||||
self.assertEqual(len(indices), 10)
|
|
||||||
# Should be evenly spaced
|
|
||||||
self.assertEqual(indices[0], 0)
|
|
||||||
self.assertEqual(indices[-1], 90)
|
|
||||||
|
|
||||||
def test_returns_integers(self):
|
|
||||||
indices = self.extractor._select_sample_indices(50, max_samples=7)
|
|
||||||
for idx in indices:
|
|
||||||
self.assertIsInstance(idx, int)
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class LibraryTypeDefaultsTests(TestCase):
|
|||||||
"film",
|
"film",
|
||||||
"art",
|
"art",
|
||||||
"journal",
|
"journal",
|
||||||
|
"email",
|
||||||
"business",
|
"business",
|
||||||
"finance",
|
"finance",
|
||||||
}
|
}
|
||||||
|
|||||||
70
mnemosyne/library/tests/test_library_api.py
Normal file
70
mnemosyne/library/tests/test_library_api.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Tests for the plain library REST endpoints beyond create.
|
||||||
|
|
||||||
|
Currently covers the DELETE cascade: ``DELETE /library/api/libraries/{uid}/``
|
||||||
|
must go through ``delete_library_cascade`` (shared with the HTML and
|
||||||
|
workspace delete paths) — a bare ``lib.delete()`` leaks Collections, Items,
|
||||||
|
Chunks, and Images and skips orphan-Concept GC. Neo4j is stubbed via
|
||||||
|
``sys.modules``, same style as ``test_managed_by.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryApiDeleteTests(TestCase):
|
||||||
|
"""DELETE on the plain library endpoint cascades."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="op", password="pw")
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def _fake_models_module(self, lib):
|
||||||
|
fake_nodes = MagicMock()
|
||||||
|
if lib is None:
|
||||||
|
class DoesNotExist(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_library = SimpleNamespace(nodes=fake_nodes, DoesNotExist=DoesNotExist)
|
||||||
|
fake_nodes.get.side_effect = DoesNotExist()
|
||||||
|
else:
|
||||||
|
fake_library = SimpleNamespace(nodes=fake_nodes, DoesNotExist=Exception)
|
||||||
|
fake_nodes.get.return_value = lib
|
||||||
|
return SimpleNamespace(Library=fake_library)
|
||||||
|
|
||||||
|
def test_delete_uses_shared_cascade(self):
|
||||||
|
lib = SimpleNamespace(uid="lib-1", name="Docs")
|
||||||
|
cascade_result = {
|
||||||
|
"library_uid": "lib-1",
|
||||||
|
"name": "Docs",
|
||||||
|
"item_count": 3,
|
||||||
|
"item_s3_keys": [],
|
||||||
|
"orphans_deleted": 1,
|
||||||
|
}
|
||||||
|
with patch.dict(
|
||||||
|
"sys.modules", {"library.models": self._fake_models_module(lib)}
|
||||||
|
), patch(
|
||||||
|
"library.api.views.delete_library_cascade",
|
||||||
|
return_value=cascade_result,
|
||||||
|
) as mock_cascade:
|
||||||
|
response = self.client.delete("/library/api/libraries/lib-1/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
mock_cascade.assert_called_once_with(lib)
|
||||||
|
|
||||||
|
def test_delete_missing_library_returns_404(self):
|
||||||
|
with patch.dict(
|
||||||
|
"sys.modules", {"library.models": self._fake_models_module(None)}
|
||||||
|
), patch("library.api.views.delete_library_cascade") as mock_cascade:
|
||||||
|
response = self.client.delete("/library/api/libraries/nope/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
mock_cascade.assert_not_called()
|
||||||
207
mnemosyne/library/tests/test_managed_by.py
Normal file
207
mnemosyne/library/tests/test_managed_by.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""Tests for the per-app ``managed_by`` concept.
|
||||||
|
|
||||||
|
Covers the pure helpers (``infer_legacy_manager``,
|
||||||
|
``Library.managed_by_display``), the token-derived stamping and
|
||||||
|
duplicate-name rejection on the plain create endpoint (Neo4j stubbed
|
||||||
|
via ``sys.modules``, same style as ``test_views.py``), and the
|
||||||
|
``WorkspaceStatusSerializer`` surface. Cypher-touching paths are
|
||||||
|
covered by the manual end-to-end plan, not these unit tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from library.api.serializers import WorkspaceStatusSerializer
|
||||||
|
from library.models import Library, infer_legacy_manager
|
||||||
|
from mcp_server.models import UserToken
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class InferLegacyManagerTests(TestCase):
|
||||||
|
"""Truth table for the pre-``managed_by`` inference."""
|
||||||
|
|
||||||
|
def test_null_workspace_is_unmanaged(self):
|
||||||
|
self.assertIsNone(infer_legacy_manager(None))
|
||||||
|
self.assertIsNone(infer_legacy_manager(""))
|
||||||
|
|
||||||
|
def test_kairos_mail_prefix_is_kairos(self):
|
||||||
|
self.assertEqual(
|
||||||
|
infer_legacy_manager("kairos-mail-abc123-7"), "Kairos"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_other_workspace_is_daedalus(self):
|
||||||
|
self.assertEqual(
|
||||||
|
infer_legacy_manager("2f9c4a1e-uuid-ish"), "Daedalus"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedByDisplayTests(TestCase):
|
||||||
|
"""``managed_by_display`` on in-memory (unsaved) Library nodes."""
|
||||||
|
|
||||||
|
def test_stamped_label_wins(self):
|
||||||
|
lib = Library(name="x", managed_by="Spelunker")
|
||||||
|
self.assertEqual(lib.managed_by_display, "Spelunker")
|
||||||
|
|
||||||
|
def test_stamped_label_wins_over_inference(self):
|
||||||
|
lib = Library(
|
||||||
|
name="x", managed_by="My Token", workspace_id="kairos-mail-a-1"
|
||||||
|
)
|
||||||
|
self.assertEqual(lib.managed_by_display, "My Token")
|
||||||
|
|
||||||
|
def test_legacy_workspace_falls_back_to_inference(self):
|
||||||
|
lib = Library(name="x", workspace_id="ws-uuid")
|
||||||
|
self.assertEqual(lib.managed_by_display, "Daedalus")
|
||||||
|
|
||||||
|
def test_unmanaged_is_empty_string(self):
|
||||||
|
lib = Library(name="x")
|
||||||
|
self.assertEqual(lib.managed_by_display, "")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLibrary:
|
||||||
|
"""Stand-in for the neomodel Library on the plain create endpoint."""
|
||||||
|
|
||||||
|
class DoesNotExist(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
existing = None # what find_library_by_name_ci returns
|
||||||
|
instances = [] # constructor kwargs, in order
|
||||||
|
save_raises = None # exception instance save() should raise, if any
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
type(self).instances.append(kwargs)
|
||||||
|
self.__dict__.update(kwargs)
|
||||||
|
self.uid = "lib-new"
|
||||||
|
self.workspace_id = None
|
||||||
|
self.created_at = None
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
if type(self).save_raises is not None:
|
||||||
|
raise type(self).save_raises
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_find_ci(name):
|
||||||
|
_FakeLibrary.ci_queries.append(name)
|
||||||
|
return _FakeLibrary.existing
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_models_module():
|
||||||
|
return SimpleNamespace(
|
||||||
|
Library=_FakeLibrary, find_library_by_name_ci=_fake_find_ci
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryCreateStampingTests(TestCase):
|
||||||
|
"""POST /library/api/libraries/ stamps ``managed_by`` and rejects dupes."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="op", password="pw")
|
||||||
|
self.client = APIClient()
|
||||||
|
_FakeLibrary.existing = None
|
||||||
|
_FakeLibrary.instances = []
|
||||||
|
_FakeLibrary.save_raises = None
|
||||||
|
_FakeLibrary.ci_queries = []
|
||||||
|
|
||||||
|
def _post(self, token=None, name="Docs"):
|
||||||
|
self.client.force_authenticate(user=self.user, token=token)
|
||||||
|
with patch.dict("sys.modules", {"library.models": _fake_models_module()}):
|
||||||
|
return self.client.post(
|
||||||
|
"/library/api/libraries/",
|
||||||
|
{"name": name, "library_type": "technical"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_token_create_stamps_token_name(self):
|
||||||
|
response = self._post(token=UserToken(name="Spelunker"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(_FakeLibrary.instances[0]["managed_by"], "Spelunker")
|
||||||
|
self.assertEqual(response.json()["managed_by"], "Spelunker")
|
||||||
|
|
||||||
|
def test_session_create_leaves_managed_by_null(self):
|
||||||
|
response = self._post(token=None)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertIsNone(_FakeLibrary.instances[0]["managed_by"])
|
||||||
|
|
||||||
|
def test_duplicate_name_returns_409_name_conflict(self):
|
||||||
|
_FakeLibrary.existing = SimpleNamespace(
|
||||||
|
uid="lib-old", name="Docs", managed_by_display="Daedalus"
|
||||||
|
)
|
||||||
|
response = self._post(token=UserToken(name="Spelunker"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["code"], "name_conflict")
|
||||||
|
self.assertIn("Docs", body["detail"])
|
||||||
|
self.assertEqual(body["uid"], "lib-old")
|
||||||
|
self.assertEqual(body["managed_by"], "Daedalus")
|
||||||
|
self.assertEqual(_FakeLibrary.instances, [])
|
||||||
|
|
||||||
|
def test_duplicate_check_is_case_insensitive(self):
|
||||||
|
"""A case-variant name 409s and reports the existing spelling."""
|
||||||
|
_FakeLibrary.existing = SimpleNamespace(
|
||||||
|
uid="lib-old", name="Amazon Connect", managed_by_display="Spelunker"
|
||||||
|
)
|
||||||
|
response = self._post(token=None, name="amazon connect")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
# The lookup received the posted name (case-folding happens in
|
||||||
|
# Cypher), and the response names the existing spelling.
|
||||||
|
self.assertEqual(_FakeLibrary.ci_queries, ["amazon connect"])
|
||||||
|
self.assertIn("Amazon Connect", response.json()["detail"])
|
||||||
|
self.assertEqual(_FakeLibrary.instances, [])
|
||||||
|
|
||||||
|
def test_duplicate_of_unmanaged_reports_null_manager(self):
|
||||||
|
_FakeLibrary.existing = SimpleNamespace(
|
||||||
|
uid="lib-old", name="Docs", managed_by_display=""
|
||||||
|
)
|
||||||
|
response = self._post(token=None)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertIsNone(response.json()["managed_by"])
|
||||||
|
|
||||||
|
def test_save_race_returns_409_not_500(self):
|
||||||
|
"""An exact-name twin landing between pre-check and save 409s."""
|
||||||
|
from neomodel.exceptions import UniqueProperty
|
||||||
|
|
||||||
|
_FakeLibrary.save_raises = UniqueProperty("name")
|
||||||
|
response = self._post(token=None)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertEqual(response.json()["code"], "name_conflict")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceStatusSerializerManagedByTests(TestCase):
|
||||||
|
"""The workspace status payload carries ``managed_by`` (nullable)."""
|
||||||
|
|
||||||
|
BASE = {
|
||||||
|
"workspace_id": "ws_a",
|
||||||
|
"library_uid": "lib_1",
|
||||||
|
"name": "W",
|
||||||
|
"library_type": "technical",
|
||||||
|
"description": "",
|
||||||
|
"item_count": 0,
|
||||||
|
"chunk_count": 0,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_managed_by_value_round_trips(self):
|
||||||
|
s = WorkspaceStatusSerializer(data={**self.BASE, "managed_by": "Daedalus"})
|
||||||
|
self.assertTrue(s.is_valid(), s.errors)
|
||||||
|
self.assertEqual(s.validated_data["managed_by"], "Daedalus")
|
||||||
|
|
||||||
|
def test_managed_by_null_accepted(self):
|
||||||
|
s = WorkspaceStatusSerializer(data={**self.BASE, "managed_by": None})
|
||||||
|
self.assertTrue(s.is_valid(), s.errors)
|
||||||
|
|
||||||
|
def test_managed_by_absent_accepted(self):
|
||||||
|
s = WorkspaceStatusSerializer(data=self.BASE)
|
||||||
|
self.assertTrue(s.is_valid(), s.errors)
|
||||||
@@ -48,7 +48,7 @@ class EmbeddingPipelineInitTests(TestCase):
|
|||||||
class PipelineItemNotFoundTests(TestCase):
|
class PipelineItemNotFoundTests(TestCase):
|
||||||
"""Tests for handling missing items."""
|
"""Tests for handling missing items."""
|
||||||
|
|
||||||
@patch("library.services.pipeline.Item")
|
@patch("library.models.Item")
|
||||||
def test_process_nonexistent_item_raises(self, mock_item_cls):
|
def test_process_nonexistent_item_raises(self, mock_item_cls):
|
||||||
mock_item_cls.nodes.get.side_effect = Exception("Not found")
|
mock_item_cls.nodes.get.side_effect = Exception("Not found")
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class PipelineItemNotFoundTests(TestCase):
|
|||||||
pipeline.process_item("nonexistent-uid")
|
pipeline.process_item("nonexistent-uid")
|
||||||
self.assertIn("Item not found", str(ctx.exception))
|
self.assertIn("Item not found", str(ctx.exception))
|
||||||
|
|
||||||
@patch("library.services.pipeline.Item")
|
@patch("library.models.Item")
|
||||||
def test_reprocess_nonexistent_item_raises(self, mock_item_cls):
|
def test_reprocess_nonexistent_item_raises(self, mock_item_cls):
|
||||||
mock_item_cls.nodes.get.side_effect = Exception("Not found")
|
mock_item_cls.nodes.get.side_effect = Exception("Not found")
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ class PipelineItemNotFoundTests(TestCase):
|
|||||||
class PipelineNoEmbeddingModelTests(TestCase):
|
class PipelineNoEmbeddingModelTests(TestCase):
|
||||||
"""Tests for handling missing system embedding model."""
|
"""Tests for handling missing system embedding model."""
|
||||||
|
|
||||||
@patch("library.services.pipeline.LLMModel")
|
@patch("llm_manager.models.LLMModel")
|
||||||
@patch("library.services.pipeline.default_storage")
|
@patch("library.services.pipeline.default_storage")
|
||||||
@patch("library.services.pipeline.DocumentParser")
|
@patch("library.services.parsers.DocumentParser")
|
||||||
def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm):
|
def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm):
|
||||||
"""Pipeline raises ValueError if no system embedding model is configured."""
|
"""Pipeline raises ValueError if no system embedding model is configured."""
|
||||||
mock_llm.get_system_embedding_model.return_value = None
|
mock_llm.get_system_embedding_model.return_value = None
|
||||||
@@ -86,7 +86,7 @@ class PipelineNoEmbeddingModelTests(TestCase):
|
|||||||
mock_item.chunks.all.return_value = []
|
mock_item.chunks.all.return_value = []
|
||||||
mock_item.images.all.return_value = []
|
mock_item.images.all.return_value = []
|
||||||
|
|
||||||
with patch("library.services.pipeline.Item") as mock_item_cls:
|
with patch("library.models.Item") as mock_item_cls:
|
||||||
mock_item_cls.nodes.get.return_value = mock_item
|
mock_item_cls.nodes.get.return_value = mock_item
|
||||||
|
|
||||||
# Mock S3 read
|
# Mock S3 read
|
||||||
@@ -166,11 +166,11 @@ class PipelineVisionStageTests(TestCase):
|
|||||||
item.images.all.return_value = []
|
item.images.all.return_value = []
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@patch("library.services.pipeline.ConceptExtractor")
|
@patch("library.services.concepts.ConceptExtractor")
|
||||||
@patch("library.services.pipeline.EmbeddingClient")
|
@patch("library.services.embedding_client.EmbeddingClient")
|
||||||
@patch("library.services.pipeline.ContentTypeChunker")
|
@patch("library.services.chunker.ContentTypeChunker")
|
||||||
@patch("library.services.pipeline.DocumentParser")
|
@patch("library.services.parsers.DocumentParser")
|
||||||
@patch("library.services.pipeline.LLMModel")
|
@patch("llm_manager.models.LLMModel")
|
||||||
@patch("library.services.pipeline.default_storage")
|
@patch("library.services.pipeline.default_storage")
|
||||||
def test_no_vision_model_marks_images_skipped(
|
def test_no_vision_model_marks_images_skipped(
|
||||||
self, mock_storage, mock_llm, mock_parser_cls,
|
self, mock_storage, mock_llm, mock_parser_cls,
|
||||||
@@ -227,12 +227,12 @@ class PipelineVisionStageTests(TestCase):
|
|||||||
img_node.save.assert_called()
|
img_node.save.assert_called()
|
||||||
self.assertEqual(result["images_analyzed"], 0)
|
self.assertEqual(result["images_analyzed"], 0)
|
||||||
|
|
||||||
@patch("library.services.pipeline.VisionAnalyzer")
|
@patch("library.services.vision.VisionAnalyzer")
|
||||||
@patch("library.services.pipeline.ConceptExtractor")
|
@patch("library.services.concepts.ConceptExtractor")
|
||||||
@patch("library.services.pipeline.EmbeddingClient")
|
@patch("library.services.embedding_client.EmbeddingClient")
|
||||||
@patch("library.services.pipeline.ContentTypeChunker")
|
@patch("library.services.chunker.ContentTypeChunker")
|
||||||
@patch("library.services.pipeline.DocumentParser")
|
@patch("library.services.parsers.DocumentParser")
|
||||||
@patch("library.services.pipeline.LLMModel")
|
@patch("llm_manager.models.LLMModel")
|
||||||
@patch("library.services.pipeline.default_storage")
|
@patch("library.services.pipeline.default_storage")
|
||||||
def test_vision_model_triggers_analysis(
|
def test_vision_model_triggers_analysis(
|
||||||
self, mock_storage, mock_llm, mock_parser_cls,
|
self, mock_storage, mock_llm, mock_parser_cls,
|
||||||
@@ -287,7 +287,7 @@ class PipelineVisionStageTests(TestCase):
|
|||||||
mock_vision_cls.assert_called_once_with(mock_vision_model, user=None)
|
mock_vision_cls.assert_called_once_with(mock_vision_model, user=None)
|
||||||
mock_analyzer.analyze_images.assert_called_once()
|
mock_analyzer.analyze_images.assert_called_once()
|
||||||
|
|
||||||
@patch("library.services.pipeline.LLMModel")
|
@patch("llm_manager.models.LLMModel")
|
||||||
def test_no_images_skips_vision_entirely(self, mock_llm):
|
def test_no_images_skips_vision_entirely(self, mock_llm):
|
||||||
"""When there are no images, vision stage is a no-op regardless of model."""
|
"""When there are no images, vision stage is a no-op regardless of model."""
|
||||||
mock_vision_model = MagicMock()
|
mock_vision_model = MagicMock()
|
||||||
@@ -309,10 +309,10 @@ class PipelineVisionStageTests(TestCase):
|
|||||||
patch.object(pipeline, "_store_chunks", return_value=[]), \
|
patch.object(pipeline, "_store_chunks", return_value=[]), \
|
||||||
patch.object(pipeline, "_store_images", return_value=[]), \
|
patch.object(pipeline, "_store_images", return_value=[]), \
|
||||||
patch.object(pipeline, "_associate_images_with_chunks"), \
|
patch.object(pipeline, "_associate_images_with_chunks"), \
|
||||||
patch("library.services.pipeline.DocumentParser") as mock_parser_cls, \
|
patch("library.services.parsers.DocumentParser") as mock_parser_cls, \
|
||||||
patch("library.services.pipeline.ContentTypeChunker") as mock_chunker_cls, \
|
patch("library.services.chunker.ContentTypeChunker") as mock_chunker_cls, \
|
||||||
patch("library.services.pipeline.EmbeddingClient"), \
|
patch("library.services.embedding_client.EmbeddingClient"), \
|
||||||
patch("library.services.pipeline.VisionAnalyzer") as mock_vision_cls:
|
patch("library.services.vision.VisionAnalyzer") as mock_vision_cls:
|
||||||
|
|
||||||
mock_parser = MagicMock()
|
mock_parser = MagicMock()
|
||||||
mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[])
|
mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[])
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class SearchAPIResponseTest(TestCase):
|
|||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
@patch("library.api.views.SearchService")
|
@patch("library.services.search.SearchService")
|
||||||
def test_successful_search_response_format(self, MockService):
|
def test_successful_search_response_format(self, MockService):
|
||||||
"""Successful search returns expected JSON structure."""
|
"""Successful search returns expected JSON structure."""
|
||||||
mock_response = SearchResponse(
|
mock_response = SearchResponse(
|
||||||
@@ -159,7 +159,7 @@ class SearchAPIResponseTest(TestCase):
|
|||||||
self.assertEqual(image["image_uid"], "img1")
|
self.assertEqual(image["image_uid"], "img1")
|
||||||
self.assertEqual(image["image_type"], "diagram")
|
self.assertEqual(image["image_type"], "diagram")
|
||||||
|
|
||||||
@patch("library.api.views.SearchService")
|
@patch("library.services.search.SearchService")
|
||||||
def test_vector_only_endpoint(self, MockService):
|
def test_vector_only_endpoint(self, MockService):
|
||||||
"""Vector-only endpoint sets correct search types."""
|
"""Vector-only endpoint sets correct search types."""
|
||||||
mock_response = SearchResponse(
|
mock_response = SearchResponse(
|
||||||
@@ -184,7 +184,7 @@ class SearchAPIResponseTest(TestCase):
|
|||||||
self.assertEqual(call_args.search_types, ["vector"])
|
self.assertEqual(call_args.search_types, ["vector"])
|
||||||
self.assertFalse(call_args.rerank)
|
self.assertFalse(call_args.rerank)
|
||||||
|
|
||||||
@patch("library.api.views.SearchService")
|
@patch("library.services.search.SearchService")
|
||||||
def test_fulltext_only_endpoint(self, MockService):
|
def test_fulltext_only_endpoint(self, MockService):
|
||||||
"""Fulltext-only endpoint sets correct search types."""
|
"""Fulltext-only endpoint sets correct search types."""
|
||||||
mock_response = SearchResponse(
|
mock_response = SearchResponse(
|
||||||
@@ -208,7 +208,7 @@ class SearchAPIResponseTest(TestCase):
|
|||||||
self.assertEqual(call_args.search_types, ["fulltext"])
|
self.assertEqual(call_args.search_types, ["fulltext"])
|
||||||
self.assertFalse(call_args.rerank)
|
self.assertFalse(call_args.rerank)
|
||||||
|
|
||||||
@patch("library.api.views.SearchService")
|
@patch("library.services.search.SearchService")
|
||||||
def test_reranker_skip_reason_surfaced_in_json(self, MockService):
|
def test_reranker_skip_reason_surfaced_in_json(self, MockService):
|
||||||
"""``reranker_skip_reason`` propagates through the JSON API."""
|
"""``reranker_skip_reason`` propagates through the JSON API."""
|
||||||
mock_response = SearchResponse(
|
mock_response = SearchResponse(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class AllLibraryUidsHelperTests(TestCase):
|
|||||||
|
|
||||||
def test_returns_empty_when_neo4j_unavailable(self):
|
def test_returns_empty_when_neo4j_unavailable(self):
|
||||||
"""Helper must not touch ``Library.nodes`` if Neo4j is down."""
|
"""Helper must not touch ``Library.nodes`` if Neo4j is down."""
|
||||||
with patch("library.views.neo4j_available", return_value=False):
|
with patch("library.utils.neo4j_available", return_value=False):
|
||||||
self.assertEqual(views._all_library_uids(), [])
|
self.assertEqual(views._all_library_uids(), [])
|
||||||
|
|
||||||
def test_returns_every_library_uid(self):
|
def test_returns_every_library_uid(self):
|
||||||
@@ -62,7 +62,7 @@ class AllLibraryUidsHelperTests(TestCase):
|
|||||||
fake_nodes.all.return_value = fake_libs
|
fake_nodes.all.return_value = fake_libs
|
||||||
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
|
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
|
||||||
|
|
||||||
with patch("library.views.neo4j_available", return_value=True), \
|
with patch("library.utils.neo4j_available", return_value=True), \
|
||||||
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
|
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
|
||||||
result = views._all_library_uids()
|
result = views._all_library_uids()
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ class AllLibraryUidsHelperTests(TestCase):
|
|||||||
fake_nodes.all.return_value = fake_libs
|
fake_nodes.all.return_value = fake_libs
|
||||||
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
|
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
|
||||||
|
|
||||||
with patch("library.views.neo4j_available", return_value=True), \
|
with patch("library.utils.neo4j_available", return_value=True), \
|
||||||
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
|
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
|
||||||
result = views._all_library_uids()
|
result = views._all_library_uids()
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class AllLibraryUidsHelperTests(TestCase):
|
|||||||
fake_nodes.all.side_effect = RuntimeError("neo4j blew up")
|
fake_nodes.all.side_effect = RuntimeError("neo4j blew up")
|
||||||
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
|
fake_library_cls = SimpleNamespace(nodes=fake_nodes)
|
||||||
|
|
||||||
with patch("library.views.neo4j_available", return_value=True), \
|
with patch("library.utils.neo4j_available", return_value=True), \
|
||||||
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
|
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
|
||||||
self.assertEqual(views._all_library_uids(), [])
|
self.assertEqual(views._all_library_uids(), [])
|
||||||
|
|
||||||
|
|||||||
195
mnemosyne/library/tests/test_svg_raster.py
Normal file
195
mnemosyne/library/tests/test_svg_raster.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Tests for SVG rasterization in the ingest pipeline.
|
||||||
|
|
||||||
|
SVG is vector XML: Pillow cannot decode it and the vision stage cannot send it
|
||||||
|
as a data URI, so it is rendered to PNG at parse time. The cases that matter
|
||||||
|
are the ones that fail *silently* — Write notes stack their pages inside one
|
||||||
|
document, and rendering that document whole yields a plausible-looking PNG that
|
||||||
|
is either blank or an illegible tall strip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from library.services.parsers import (
|
||||||
|
IMAGE_EXTENSIONS,
|
||||||
|
DocumentParser,
|
||||||
|
)
|
||||||
|
from library.services.svg_raster import (
|
||||||
|
SvgRenderError,
|
||||||
|
render_svg_pages,
|
||||||
|
split_svg_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Geometry copied from a real Write note; the page element is identical across
|
||||||
|
# both root formats, which is what makes splitting format-agnostic.
|
||||||
|
PAGE_WIDTH = 1094
|
||||||
|
PAGE_HEIGHT = 1654
|
||||||
|
PAGE_PITCH = 1674 # page height + inter-page gap
|
||||||
|
|
||||||
|
|
||||||
|
def write_document(pages: int, root_size: bool = False) -> bytes:
|
||||||
|
"""
|
||||||
|
Build a Write-style document.
|
||||||
|
|
||||||
|
:param pages: Number of stacked pages.
|
||||||
|
:param root_size: Emit width/height on the root <svg>. False models
|
||||||
|
pre-eeab021 files (the permanent majority), True models newer saves.
|
||||||
|
"""
|
||||||
|
root_attrs = ""
|
||||||
|
if root_size:
|
||||||
|
total = 10 + pages * PAGE_PITCH
|
||||||
|
root_attrs = f' width="{PAGE_WIDTH + 20}" height="{total}"'
|
||||||
|
|
||||||
|
body = "".join(
|
||||||
|
f'<svg class="write-page" x="10" y="{10 + i * PAGE_PITCH}" '
|
||||||
|
f'width="{PAGE_WIDTH}px" height="{PAGE_HEIGHT}px" '
|
||||||
|
f'xmlns="http://www.w3.org/2000/svg">'
|
||||||
|
f'<path d="M 100 {100 + i * 40} L {600 + i * 120} {700 + i * 40}" '
|
||||||
|
f'stroke="#000000" stroke-width="12" fill="none"/></svg>'
|
||||||
|
for i in range(pages)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'<svg id="write-document"{root_attrs} '
|
||||||
|
f'xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
f'xmlns:xlink="http://www.w3.org/1999/xlink">'
|
||||||
|
f'<rect id="write-doc-background" width="100%" height="100%" fill="#808080"/>'
|
||||||
|
f'<defs id="write-defs"><style>.write-flat-pen{{fill:none}}</style></defs>'
|
||||||
|
f"{body}</svg>"
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
GENERIC_SVG = (
|
||||||
|
b'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" '
|
||||||
|
b'viewBox="0 0 400 200">'
|
||||||
|
b'<path d="M 20 20 L 380 180" stroke="#000" stroke-width="10"/></svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ink_fraction(png: bytes) -> float:
|
||||||
|
"""Fraction of non-white pixels — the blank-render canary."""
|
||||||
|
grey = Image.open(io.BytesIO(png)).convert("L")
|
||||||
|
histogram = grey.histogram()
|
||||||
|
return sum(histogram[:200]) / sum(histogram)
|
||||||
|
|
||||||
|
|
||||||
|
class SvgSplitTests(TestCase):
|
||||||
|
"""Splitting a Write document into per-page SVGs."""
|
||||||
|
|
||||||
|
def test_splits_one_document_per_page(self):
|
||||||
|
for pages in (1, 2, 5):
|
||||||
|
for root_size in (False, True):
|
||||||
|
with self.subTest(pages=pages, root_size=root_size):
|
||||||
|
document = write_document(pages, root_size=root_size)
|
||||||
|
self.assertEqual(len(split_svg_pages(document)), pages)
|
||||||
|
|
||||||
|
def test_both_root_formats_split_identically(self):
|
||||||
|
"""Root width/height (Write eeab021) must not change the outcome.
|
||||||
|
|
||||||
|
Existing notes are never rewritten, so both formats persist
|
||||||
|
indefinitely and have to render the same.
|
||||||
|
"""
|
||||||
|
without = split_svg_pages(write_document(3, root_size=False))
|
||||||
|
with_size = split_svg_pages(write_document(3, root_size=True))
|
||||||
|
self.assertEqual(len(without), len(with_size))
|
||||||
|
self.assertEqual(len(without), 3)
|
||||||
|
|
||||||
|
def test_generic_svg_is_a_single_page(self):
|
||||||
|
self.assertEqual(len(split_svg_pages(GENERIC_SVG)), 1)
|
||||||
|
|
||||||
|
def test_unsized_svg_is_rejected_rather_than_guessed(self):
|
||||||
|
unsized = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||||||
|
with self.assertRaises(SvgRenderError):
|
||||||
|
split_svg_pages(unsized)
|
||||||
|
|
||||||
|
def test_script_inside_defs_does_not_desync_the_split(self):
|
||||||
|
"""Regression: a non-greedy <defs>...</defs> regex mis-parses these."""
|
||||||
|
document = write_document(2).replace(
|
||||||
|
b'<defs id="write-defs">',
|
||||||
|
b'<defs id="write-defs"><script><float value="770" /></script>',
|
||||||
|
)
|
||||||
|
self.assertEqual(len(split_svg_pages(document)), 2)
|
||||||
|
|
||||||
|
def test_unquoted_root_attributes_are_repaired(self):
|
||||||
|
"""Write can emit ``width=auto``, which is not valid XML.
|
||||||
|
|
||||||
|
A malformation inside the root tag defeats recovery differently from
|
||||||
|
one in the body: libxml2 abandons the whole document and returns a
|
||||||
|
bare root, so every page silently disappears.
|
||||||
|
"""
|
||||||
|
document = write_document(2).replace(
|
||||||
|
b'<svg id="write-document"',
|
||||||
|
b'<svg width=auto height=auto id="write-document"',
|
||||||
|
)
|
||||||
|
self.assertEqual(len(split_svg_pages(document)), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class SvgRenderTests(TestCase):
|
||||||
|
"""Rasterizing pages to PNG."""
|
||||||
|
|
||||||
|
def test_renders_one_png_per_page(self):
|
||||||
|
pages = render_svg_pages(write_document(3))
|
||||||
|
self.assertEqual(len(pages), 3)
|
||||||
|
for png, width, height in pages:
|
||||||
|
self.assertEqual(Image.open(io.BytesIO(png)).format, "PNG")
|
||||||
|
self.assertEqual(max(width, height), 1568)
|
||||||
|
|
||||||
|
def test_rendered_pages_are_not_blank(self):
|
||||||
|
"""The pre-patch trap: a whole-document render emits ruling, no ink."""
|
||||||
|
for root_size in (False, True):
|
||||||
|
with self.subTest(root_size=root_size):
|
||||||
|
for png, _, _ in render_svg_pages(
|
||||||
|
write_document(3, root_size=root_size)
|
||||||
|
):
|
||||||
|
self.assertGreater(ink_fraction(png), 0.001)
|
||||||
|
|
||||||
|
def test_pages_are_page_shaped_not_a_stacked_strip(self):
|
||||||
|
"""The post-patch trap: root dimensions span every stacked page.
|
||||||
|
|
||||||
|
Rendering that whole gives one tall strip which, once capped, squashes
|
||||||
|
a 5-page note to ~209px wide.
|
||||||
|
"""
|
||||||
|
expected = PAGE_HEIGHT / PAGE_WIDTH
|
||||||
|
for root_size in (False, True):
|
||||||
|
with self.subTest(root_size=root_size):
|
||||||
|
for _, width, height in render_svg_pages(
|
||||||
|
write_document(5, root_size=root_size)
|
||||||
|
):
|
||||||
|
self.assertAlmostEqual(height / width, expected, delta=0.05)
|
||||||
|
|
||||||
|
def test_page_cap_is_respected(self):
|
||||||
|
self.assertEqual(len(render_svg_pages(write_document(8), max_pages=5)), 5)
|
||||||
|
|
||||||
|
|
||||||
|
class SvgParserIntegrationTests(TestCase):
|
||||||
|
"""The parser dispatch — SVG must not reach the Pillow image path."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.parser = DocumentParser()
|
||||||
|
|
||||||
|
def test_svg_is_not_in_image_extensions(self):
|
||||||
|
# It was, and PIL.Image.open cannot decode SVG, so ingest always failed.
|
||||||
|
self.assertNotIn("svg", IMAGE_EXTENSIONS)
|
||||||
|
|
||||||
|
def test_parse_multipage_svg_yields_one_image_per_page(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
|
||||||
|
f.write(write_document(3))
|
||||||
|
f.flush()
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
result = self.parser.parse(path, "svg")
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
self.assertEqual(len(result.images), 3)
|
||||||
|
self.assertEqual(result.metadata["page_count"], 3)
|
||||||
|
self.assertEqual(result.text_blocks, [])
|
||||||
|
for index, image in enumerate(result.images):
|
||||||
|
# PNG, not svg — the vision stage needs raster for its data URI.
|
||||||
|
self.assertEqual(image.ext, "png")
|
||||||
|
self.assertEqual(image.source_page, index)
|
||||||
|
self.assertGreater(ink_fraction(image.data), 0.001)
|
||||||
@@ -13,7 +13,7 @@ from django.test import TestCase, override_settings
|
|||||||
class EmbedItemTaskTests(TestCase):
|
class EmbedItemTaskTests(TestCase):
|
||||||
"""Tests for the embed_item task."""
|
"""Tests for the embed_item task."""
|
||||||
|
|
||||||
@patch("library.tasks.EmbeddingPipeline")
|
@patch("library.services.pipeline.EmbeddingPipeline")
|
||||||
def test_embed_item_success(self, mock_pipeline_cls):
|
def test_embed_item_success(self, mock_pipeline_cls):
|
||||||
from library.tasks import embed_item
|
from library.tasks import embed_item
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class EmbedItemTaskTests(TestCase):
|
|||||||
self.assertEqual(result["item_uid"], "test-uid-123")
|
self.assertEqual(result["item_uid"], "test-uid-123")
|
||||||
mock_pipeline.process_item.assert_called_once()
|
mock_pipeline.process_item.assert_called_once()
|
||||||
|
|
||||||
@patch("library.tasks.EmbeddingPipeline")
|
@patch("library.services.pipeline.EmbeddingPipeline")
|
||||||
def test_embed_item_failure(self, mock_pipeline_cls):
|
def test_embed_item_failure(self, mock_pipeline_cls):
|
||||||
from library.tasks import embed_item
|
from library.tasks import embed_item
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ class EmbedItemTaskTests(TestCase):
|
|||||||
class ReembedItemTaskTests(TestCase):
|
class ReembedItemTaskTests(TestCase):
|
||||||
"""Tests for the reembed_item task."""
|
"""Tests for the reembed_item task."""
|
||||||
|
|
||||||
@patch("library.tasks.EmbeddingPipeline")
|
@patch("library.services.pipeline.EmbeddingPipeline")
|
||||||
def test_reembed_item_success(self, mock_pipeline_cls):
|
def test_reembed_item_success(self, mock_pipeline_cls):
|
||||||
from library.tasks import reembed_item
|
from library.tasks import reembed_item
|
||||||
|
|
||||||
@@ -67,6 +67,114 @@ class ReembedItemTaskTests(TestCase):
|
|||||||
mock_pipeline.reprocess_item.assert_called_once()
|
mock_pipeline.reprocess_item.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
|
||||||
|
class IngestFromDaedalusFailureClassificationTests(TestCase):
|
||||||
|
"""A deterministic parse failure is terminal; a transient error retries.
|
||||||
|
|
||||||
|
Neo4j and S3 are mocked at the task's boundaries so the test exercises the
|
||||||
|
exception-classification branch (parsers.UnsupportedFileTypeError vs. any
|
||||||
|
other Exception) without a live graph or object store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from library.tasks import ingest_from_daedalus
|
||||||
|
|
||||||
|
# Calling .run() bypasses Celery's request setup, but the task
|
||||||
|
# persists self.request.id into the NOT NULL celery_task_id column
|
||||||
|
# and reads self.request.retries — push a real request context.
|
||||||
|
ingest_from_daedalus.push_request(id="test-task-id", retries=0)
|
||||||
|
self.addCleanup(ingest_from_daedalus.pop_request)
|
||||||
|
|
||||||
|
def _make_job(self):
|
||||||
|
from library.models import IngestJob
|
||||||
|
|
||||||
|
return IngestJob.objects.create(
|
||||||
|
id="job_test_unsupported",
|
||||||
|
library_uid="lib-uid-123",
|
||||||
|
source="daedalus",
|
||||||
|
s3_key="incoming/bad.drawio",
|
||||||
|
file_type="vnd.jgraph.mxfile",
|
||||||
|
title="bad.drawio",
|
||||||
|
content_hash="abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patched_boundaries(self, pipeline_side_effect):
|
||||||
|
"""Patch every boundary the task hits before the pipeline runs.
|
||||||
|
|
||||||
|
Returns a context-manager list; the pipeline's ``process_item`` is set
|
||||||
|
to raise ``pipeline_side_effect``.
|
||||||
|
"""
|
||||||
|
from library.services.parsers import UnsupportedFileTypeError # noqa: F401
|
||||||
|
|
||||||
|
patchers = [
|
||||||
|
patch("library.tasks.db"),
|
||||||
|
patch("library.models.Library"),
|
||||||
|
patch("library.models.Item"),
|
||||||
|
patch("library.services.source_s3.fetch_from_source", return_value=b"data"),
|
||||||
|
patch("library.services.source_s3.copy_into_mnemosyne"),
|
||||||
|
patch("library.tasks._resolve_or_create_default_collection"),
|
||||||
|
patch("library.services.pipeline.EmbeddingPipeline"),
|
||||||
|
]
|
||||||
|
mocks = [p.start() for p in patchers]
|
||||||
|
self.addCleanup(lambda: [p.stop() for p in patchers])
|
||||||
|
|
||||||
|
# db.cypher_query returns (rows, meta); no prior item to supersede.
|
||||||
|
mocks[0].cypher_query.return_value = ([], None)
|
||||||
|
# Library.nodes.get returns a stand-in library node.
|
||||||
|
mocks[1].nodes.get.return_value = MagicMock(uid="lib-uid-123")
|
||||||
|
# Item() instances carry a uid used for the S3 key.
|
||||||
|
item_instance = MagicMock(uid="item-uid-abc")
|
||||||
|
mocks[2].return_value = item_instance
|
||||||
|
# The pipeline raises the classification-relevant error.
|
||||||
|
pipeline_instance = MagicMock()
|
||||||
|
pipeline_instance.process_item.side_effect = pipeline_side_effect
|
||||||
|
mocks[6].return_value = pipeline_instance
|
||||||
|
return pipeline_instance
|
||||||
|
|
||||||
|
def test_unsupported_file_type_is_terminal_and_not_retried(self):
|
||||||
|
from library.models import IngestJob
|
||||||
|
from library.services.parsers import UnsupportedFileTypeError
|
||||||
|
from library.tasks import ingest_from_daedalus
|
||||||
|
|
||||||
|
job = self._make_job()
|
||||||
|
self._patched_boundaries(
|
||||||
|
UnsupportedFileTypeError("Unsupported file type 'vnd.jgraph.mxfile'.")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(ingest_from_daedalus, "retry") as mock_retry:
|
||||||
|
result = ingest_from_daedalus.run(job.id)
|
||||||
|
|
||||||
|
# Never retried.
|
||||||
|
mock_retry.assert_not_called()
|
||||||
|
# Terminal failure with a machine-readable reason.
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["reason"], "unsupported_file_type")
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, "failed")
|
||||||
|
self.assertEqual(job.retry_count, 0)
|
||||||
|
self.assertIsNotNone(job.completed_at)
|
||||||
|
self.assertIn("Unsupported file type", job.error)
|
||||||
|
|
||||||
|
def test_transient_error_takes_the_retry_path(self):
|
||||||
|
from library.tasks import ingest_from_daedalus
|
||||||
|
|
||||||
|
job = self._make_job()
|
||||||
|
self._patched_boundaries(ConnectionError("S3 hiccup"))
|
||||||
|
|
||||||
|
# self.retry raises Retry in real Celery; simulate that so the task
|
||||||
|
# doesn't fall through to the terminal branch.
|
||||||
|
from celery.exceptions import Retry
|
||||||
|
|
||||||
|
with patch.object(ingest_from_daedalus, "retry", side_effect=Retry()) as mock_retry:
|
||||||
|
with self.assertRaises(Retry):
|
||||||
|
ingest_from_daedalus.run(job.id)
|
||||||
|
|
||||||
|
mock_retry.assert_called_once()
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.retry_count, 1)
|
||||||
|
|
||||||
|
|
||||||
class ResolveUserTests(TestCase):
|
class ResolveUserTests(TestCase):
|
||||||
"""Tests for the _resolve_user helper."""
|
"""Tests for the _resolve_user helper."""
|
||||||
|
|
||||||
|
|||||||
110
mnemosyne/library/tests/test_views.py
Normal file
110
mnemosyne/library/tests/test_views.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Tests for the library CRUD HTML views.
|
||||||
|
|
||||||
|
Currently covers ``library_list``'s app-managed scope filter. The view
|
||||||
|
loads every ``Library`` node from Neo4j and narrows it in Python by a
|
||||||
|
``scope`` GET param (``all`` / ``unmanaged`` / ``managed``, with legacy
|
||||||
|
``global`` / ``daedalus`` aliases). These tests stub out Neo4j entirely —
|
||||||
|
patching ``neo4j_available`` and injecting a fake ``Library`` class via
|
||||||
|
``sys.modules`` — so they assert on the filtering the view does and the
|
||||||
|
context it renders, not on real graph behaviour. Mirrors the mocking
|
||||||
|
style in ``test_search_views_admin_scope.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _lib(name, managed_by_display):
|
||||||
|
return SimpleNamespace(
|
||||||
|
uid=f"uid-{name}",
|
||||||
|
name=name,
|
||||||
|
library_type="technical",
|
||||||
|
description="",
|
||||||
|
workspace_id=None,
|
||||||
|
managed_by_display=managed_by_display,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryListScopeFilterTests(TestCase):
|
||||||
|
"""Cover the ``scope`` filter branches of ``library_list``."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="op", email="op@example.com", password="pw"
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.url = reverse("library:library-list")
|
||||||
|
self.managed = _lib("Docs", "Spelunker")
|
||||||
|
self.unmanaged = _lib("Notes", "")
|
||||||
|
|
||||||
|
def _fake_library_cls(self):
|
||||||
|
"""Return a Library stub whose ``nodes.order_by`` yields two libraries."""
|
||||||
|
fake_nodes = MagicMock()
|
||||||
|
fake_nodes.order_by.return_value = [self.managed, self.unmanaged]
|
||||||
|
return SimpleNamespace(nodes=fake_nodes)
|
||||||
|
|
||||||
|
def _get(self, **params):
|
||||||
|
with patch("library.views.neo4j_available", return_value=True), \
|
||||||
|
patch.dict(
|
||||||
|
"sys.modules",
|
||||||
|
{"library.models": SimpleNamespace(Library=self._fake_library_cls())},
|
||||||
|
):
|
||||||
|
return self.client.get(self.url, params)
|
||||||
|
|
||||||
|
def test_default_scope_is_all_and_returns_everything(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.context["scope"], "all")
|
||||||
|
self.assertEqual(
|
||||||
|
list(response.context["libraries"]), [self.managed, self.unmanaged]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_managed_scope_keeps_only_managed(self):
|
||||||
|
response = self._get(scope="managed")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["scope"], "managed")
|
||||||
|
self.assertEqual(list(response.context["libraries"]), [self.managed])
|
||||||
|
|
||||||
|
def test_unmanaged_scope_keeps_only_unmanaged(self):
|
||||||
|
response = self._get(scope="unmanaged")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["scope"], "unmanaged")
|
||||||
|
self.assertEqual(list(response.context["libraries"]), [self.unmanaged])
|
||||||
|
|
||||||
|
def test_legacy_daedalus_scope_aliases_to_managed(self):
|
||||||
|
response = self._get(scope="daedalus")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["scope"], "managed")
|
||||||
|
self.assertEqual(list(response.context["libraries"]), [self.managed])
|
||||||
|
|
||||||
|
def test_legacy_global_scope_aliases_to_unmanaged(self):
|
||||||
|
response = self._get(scope="global")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["scope"], "unmanaged")
|
||||||
|
self.assertEqual(list(response.context["libraries"]), [self.unmanaged])
|
||||||
|
|
||||||
|
def test_unknown_scope_does_not_filter(self):
|
||||||
|
"""An unexpected scope value degrades to the unfiltered list."""
|
||||||
|
response = self._get(scope="bogus")
|
||||||
|
|
||||||
|
self.assertEqual(response.context["scope"], "bogus")
|
||||||
|
self.assertEqual(
|
||||||
|
list(response.context["libraries"]), [self.managed, self.unmanaged]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_neo4j_unavailable_sets_error_and_empty_list(self):
|
||||||
|
with patch("library.views.neo4j_available", return_value=False):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(list(response.context["libraries"]), [])
|
||||||
|
self.assertEqual(response.context["error"], "Neo4j is not available.")
|
||||||
@@ -31,14 +31,23 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def library_list(request):
|
def library_list(request):
|
||||||
"""List all libraries."""
|
"""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 = []
|
libraries = []
|
||||||
error = None
|
error = None
|
||||||
if neo4j_available():
|
if neo4j_available():
|
||||||
try:
|
try:
|
||||||
from .models import Library
|
from .models import Library
|
||||||
|
|
||||||
libraries = Library.nodes.order_by("name")
|
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:
|
except Exception as e:
|
||||||
error = f"Could not connect to Neo4j: {e}"
|
error = f"Could not connect to Neo4j: {e}"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
@@ -47,7 +56,7 @@ def library_list(request):
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"library/library_list.html",
|
"library/library_list.html",
|
||||||
{"libraries": libraries, "error": error},
|
{"libraries": libraries, "error": error, "scope": scope},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ from rest_framework import authentication, exceptions
|
|||||||
from .auth import MCPAuthError, resolve_mcp_user
|
from .auth import MCPAuthError, resolve_mcp_user
|
||||||
|
|
||||||
|
|
||||||
|
def request_token_label(request):
|
||||||
|
"""Name of the ``UserToken`` authenticating this request, or None.
|
||||||
|
|
||||||
|
Session-authenticated requests (``request.auth`` is None) and blank
|
||||||
|
token names return None — the caller treats both as "no managing app".
|
||||||
|
"""
|
||||||
|
from .models import UserToken
|
||||||
|
|
||||||
|
token = getattr(request, "auth", None)
|
||||||
|
if isinstance(token, UserToken):
|
||||||
|
return token.name.strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class UserTokenAuthentication(authentication.BaseAuthentication):
|
class UserTokenAuthentication(authentication.BaseAuthentication):
|
||||||
"""Authenticate DRF requests with a ``UserToken`` bearer."""
|
"""Authenticate DRF requests with a ``UserToken`` bearer."""
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class UserTokenCreateForm(forms.Form):
|
|||||||
"class": "input input-bordered w-full",
|
"class": "input input-bordered w-full",
|
||||||
"placeholder": "e.g. Claude Desktop, CI script",
|
"placeholder": "e.g. Claude Desktop, CI script",
|
||||||
}),
|
}),
|
||||||
|
help_text=(
|
||||||
|
"A friendly label so you can identify this token later. It also "
|
||||||
|
"labels any library the token creates (shown as “Managed by "
|
||||||
|
"<name>”) — for an app integration, use the app's name, e.g. "
|
||||||
|
"Daedalus, Kairos, Spelunker."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
expires_at = forms.DateTimeField(
|
expires_at = forms.DateTimeField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{{ form.name }}
|
{{ form.name }}
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt opacity-60">A friendly label so you can identify this token later (e.g. “Claude Desktop”).</span>
|
<span class="label-text-alt opacity-60">{{ form.name.help_text }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control mt-4">
|
<div class="form-control mt-4">
|
||||||
|
|||||||
@@ -105,6 +105,44 @@ class UserTokenAuthenticationTest(TestCase):
|
|||||||
resp = self._get(f"Bearer {self.plaintext} extra")
|
resp = self._get(f"Bearer {self.plaintext} extra")
|
||||||
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_request_token_label_reads_token_name(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from mcp_server.drf_auth import request_token_label
|
||||||
|
|
||||||
|
token = UserToken(name=" Spelunker ")
|
||||||
|
self.assertEqual(
|
||||||
|
request_token_label(SimpleNamespace(auth=token)), "Spelunker"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_token_label_none_for_session(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from mcp_server.drf_auth import request_token_label
|
||||||
|
|
||||||
|
self.assertIsNone(request_token_label(SimpleNamespace(auth=None)))
|
||||||
|
# A request object with no auth attribute at all (plain Django).
|
||||||
|
self.assertIsNone(request_token_label(SimpleNamespace()))
|
||||||
|
|
||||||
|
def test_request_token_label_none_for_blank_name(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from mcp_server.drf_auth import request_token_label
|
||||||
|
|
||||||
|
self.assertIsNone(
|
||||||
|
request_token_label(SimpleNamespace(auth=UserToken(name=" ")))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_request_token_label_none_for_foreign_auth_object(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from mcp_server.drf_auth import request_token_label
|
||||||
|
|
||||||
|
# e.g. a JWT dict from another auth class — not a UserToken.
|
||||||
|
self.assertIsNone(
|
||||||
|
request_token_label(SimpleNamespace(auth={"iss": "daedalus"}))
|
||||||
|
)
|
||||||
|
|
||||||
def test_request_auth_stashes_token(self):
|
def test_request_auth_stashes_token(self):
|
||||||
# The auth class returns (user, token); DRF places the token on
|
# The auth class returns (user, token); DRF places the token on
|
||||||
# request.auth. Re-use a UserToken-aware endpoint to verify.
|
# request.auth. Re-use a UserToken-aware endpoint to verify.
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ env = environ.Env(
|
|||||||
DEBUG=(bool, True),
|
DEBUG=(bool, True),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read .env file if it exists
|
# Read .env file if it exists. Lives at the repo root (one level above the
|
||||||
environ.Env.read_env(BASE_DIR / ".env")
|
# Django project package), shared with the docker-compose stack.
|
||||||
|
environ.Env.read_env(BASE_DIR.parent / ".env")
|
||||||
|
|
||||||
# --- Security ---
|
# --- Security ---
|
||||||
SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-me-in-production")
|
SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-me-in-production")
|
||||||
@@ -267,6 +268,15 @@ SPELUNKER_S3_REGION_NAME = env("SPELUNKER_S3_REGION_NAME", default="us-east-1")
|
|||||||
SPELUNKER_S3_USE_SSL = env.bool("SPELUNKER_S3_USE_SSL", default=False)
|
SPELUNKER_S3_USE_SSL = env.bool("SPELUNKER_S3_USE_SSL", default=False)
|
||||||
SPELUNKER_S3_VERIFY = env.bool("SPELUNKER_S3_VERIFY", default=True)
|
SPELUNKER_S3_VERIFY = env.bool("SPELUNKER_S3_VERIFY", default=True)
|
||||||
|
|
||||||
|
# Kairos renders synced mail to text documents in its own bucket.
|
||||||
|
KAIROS_S3_ENDPOINT_URL = env("KAIROS_S3_ENDPOINT_URL", default="")
|
||||||
|
KAIROS_S3_ACCESS_KEY_ID = env("KAIROS_S3_ACCESS_KEY_ID", default="")
|
||||||
|
KAIROS_S3_SECRET_ACCESS_KEY = env("KAIROS_S3_SECRET_ACCESS_KEY", default="")
|
||||||
|
KAIROS_S3_BUCKET_NAME = env("KAIROS_S3_BUCKET_NAME", default="kairos")
|
||||||
|
KAIROS_S3_REGION_NAME = env("KAIROS_S3_REGION_NAME", default="us-east-1")
|
||||||
|
KAIROS_S3_USE_SSL = env.bool("KAIROS_S3_USE_SSL", default=False)
|
||||||
|
KAIROS_S3_VERIFY = env.bool("KAIROS_S3_VERIFY", default=True)
|
||||||
|
|
||||||
# Registry keyed by the ingest `source` field. Unknown/blank sources fall
|
# Registry keyed by the ingest `source` field. Unknown/blank sources fall
|
||||||
# back to "daedalus" for backwards compatibility.
|
# back to "daedalus" for backwards compatibility.
|
||||||
SOURCE_S3_BUCKETS = {
|
SOURCE_S3_BUCKETS = {
|
||||||
@@ -288,6 +298,15 @@ SOURCE_S3_BUCKETS = {
|
|||||||
"use_ssl": SPELUNKER_S3_USE_SSL,
|
"use_ssl": SPELUNKER_S3_USE_SSL,
|
||||||
"verify": SPELUNKER_S3_VERIFY,
|
"verify": SPELUNKER_S3_VERIFY,
|
||||||
},
|
},
|
||||||
|
"kairos-mail": {
|
||||||
|
"endpoint_url": KAIROS_S3_ENDPOINT_URL,
|
||||||
|
"access_key_id": KAIROS_S3_ACCESS_KEY_ID,
|
||||||
|
"secret_access_key": KAIROS_S3_SECRET_ACCESS_KEY,
|
||||||
|
"bucket_name": KAIROS_S3_BUCKET_NAME,
|
||||||
|
"region_name": KAIROS_S3_REGION_NAME,
|
||||||
|
"use_ssl": KAIROS_S3_USE_SSL,
|
||||||
|
"verify": KAIROS_S3_VERIFY,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Celery / RabbitMQ ---
|
# --- Celery / RabbitMQ ---
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ dependencies = [
|
|||||||
"semantic-text-splitter>=0.20,<1.0",
|
"semantic-text-splitter>=0.20,<1.0",
|
||||||
"tokenizers>=0.20,<1.0",
|
"tokenizers>=0.20,<1.0",
|
||||||
"Pillow>=10.0,<12.0",
|
"Pillow>=10.0,<12.0",
|
||||||
|
# SVG page splitting — needs recover=True for Write notes that emit
|
||||||
|
# unescaped attribute content and aren't well-formed XML
|
||||||
|
"lxml>=5.3,<7",
|
||||||
"requests>=2.31,<3.0",
|
"requests>=2.31,<3.0",
|
||||||
# Phase 5: MCP Server
|
# Phase 5: MCP Server
|
||||||
"fastmcp>=2.0,<3.0",
|
"fastmcp>=2.0,<3.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user