Compare commits
6 Commits
3394726ca1
...
feat/email
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f20110f56 | |||
| d6b541636a | |||
| d01afd6203 | |||
| 840b9435a3 | |||
| 6120e9cd1f | |||
| 31a98b4f3a |
13
.env.example
13
.env.example
@@ -91,6 +91,19 @@ SPELUNKER_S3_REGION_NAME=us-east-1
|
||||
SPELUNKER_S3_USE_SSL=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) ---------------------------------------------
|
||||
# Consumed by: app (producer), worker (consumer). NOT mcp.
|
||||
# Remember to percent-encode any password characters that have meaning in a
|
||||
|
||||
@@ -73,8 +73,6 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=
|
||||
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_USE_SSL=${SPELUNKER_S3_USE_SSL}
|
||||
- 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_BROKER_URL=${CELERY_BROKER_URL}
|
||||
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND}
|
||||
@@ -371,7 +378,12 @@ services:
|
||||
volumes:
|
||||
- media:/mnt/media
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -15,6 +15,7 @@ LIBRARY_TYPE_CHOICES = [
|
||||
"film",
|
||||
"art",
|
||||
"journal",
|
||||
"email",
|
||||
"business",
|
||||
"finance",
|
||||
]
|
||||
|
||||
@@ -241,6 +241,38 @@ LIBRARY_TYPE_DEFAULTS = {
|
||||
"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": {
|
||||
"chunking_config": {
|
||||
"strategy": "section_aware",
|
||||
@@ -282,7 +314,7 @@ def get_library_type_config(library_type):
|
||||
|
||||
Args:
|
||||
library_type: One of 'fiction', 'nonfiction', 'technical', 'music',
|
||||
'film', 'art', 'journal', 'business', 'finance'
|
||||
'film', 'art', 'journal', 'email', 'business', 'finance'
|
||||
|
||||
Returns:
|
||||
dict with keys: chunking_config, embedding_instruction,
|
||||
|
||||
@@ -77,6 +77,7 @@ class Library(StructuredNode):
|
||||
"film": "Film",
|
||||
"art": "Art",
|
||||
"journal": "Journal",
|
||||
"email": "Email",
|
||||
"business": "Business",
|
||||
"finance": "Finance",
|
||||
},
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
</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="global" {% if scope == "global" %}selected{% endif %}>Global only</option>
|
||||
<option value="daedalus" {% if scope == "daedalus" %}selected{% endif %}>Daedalus workspaces only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline">Filter</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-warning mb-4">
|
||||
<span>{{ error }}</span>
|
||||
@@ -53,8 +65,12 @@
|
||||
{% else %}
|
||||
{% if not error %}
|
||||
<div class="text-center py-12 opacity-60">
|
||||
{% if scope == "all" %}
|
||||
<p class="text-lg">No libraries yet.</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>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -21,6 +21,7 @@ class LibraryTypeDefaultsTests(TestCase):
|
||||
"film",
|
||||
"art",
|
||||
"journal",
|
||||
"email",
|
||||
"business",
|
||||
"finance",
|
||||
}
|
||||
|
||||
92
mnemosyne/library/tests/test_views.py
Normal file
92
mnemosyne/library/tests/test_views.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Tests for the library CRUD HTML views.
|
||||
|
||||
Currently covers ``library_list``'s Daedalus-workspace scope filter. The
|
||||
view loads every ``Library`` node from Neo4j and narrows it by a ``scope``
|
||||
GET param (``all`` / ``global`` / ``daedalus``). These tests stub out
|
||||
Neo4j entirely — patching ``neo4j_available`` and injecting a fake
|
||||
``Library`` class via ``sys.modules`` — so they assert on the queryset
|
||||
``.filter(...)`` call the view makes 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()
|
||||
|
||||
|
||||
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")
|
||||
|
||||
def _fake_library_cls(self):
|
||||
"""Return (Library stub, nodes mock) where ``nodes`` chains fluently.
|
||||
|
||||
``Library.nodes`` → ``.filter(...)`` → ``.order_by(...)`` all return
|
||||
the same MagicMock so the view's queryset building works regardless
|
||||
of which branch it takes, and ``.filter`` records its kwargs.
|
||||
"""
|
||||
fake_nodes = MagicMock()
|
||||
fake_nodes.filter.return_value = fake_nodes
|
||||
fake_nodes.order_by.return_value = []
|
||||
return SimpleNamespace(nodes=fake_nodes), fake_nodes
|
||||
|
||||
def _get(self, fake_library_cls, **params):
|
||||
with patch("library.views.neo4j_available", return_value=True), \
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{"library.models": SimpleNamespace(Library=fake_library_cls)},
|
||||
):
|
||||
return self.client.get(self.url, params)
|
||||
|
||||
def test_default_scope_is_all_and_does_not_filter(self):
|
||||
fake_cls, fake_nodes = self._fake_library_cls()
|
||||
response = self._get(fake_cls)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["scope"], "all")
|
||||
fake_nodes.filter.assert_not_called()
|
||||
fake_nodes.order_by.assert_called_once_with("name")
|
||||
|
||||
def test_global_scope_filters_workspace_isnull_true(self):
|
||||
fake_cls, fake_nodes = self._fake_library_cls()
|
||||
response = self._get(fake_cls, scope="global")
|
||||
|
||||
self.assertEqual(response.context["scope"], "global")
|
||||
fake_nodes.filter.assert_called_once_with(workspace_id__isnull=True)
|
||||
|
||||
def test_daedalus_scope_filters_workspace_isnull_false(self):
|
||||
fake_cls, fake_nodes = self._fake_library_cls()
|
||||
response = self._get(fake_cls, scope="daedalus")
|
||||
|
||||
self.assertEqual(response.context["scope"], "daedalus")
|
||||
fake_nodes.filter.assert_called_once_with(workspace_id__isnull=False)
|
||||
|
||||
def test_unknown_scope_does_not_filter(self):
|
||||
"""An unexpected scope value degrades to the unfiltered list."""
|
||||
fake_cls, fake_nodes = self._fake_library_cls()
|
||||
response = self._get(fake_cls, scope="bogus")
|
||||
|
||||
self.assertEqual(response.context["scope"], "bogus")
|
||||
fake_nodes.filter.assert_not_called()
|
||||
|
||||
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,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@login_required
|
||||
def library_list(request):
|
||||
"""List all libraries."""
|
||||
"""List libraries, optionally filtered by Daedalus-workspace scope."""
|
||||
scope = request.GET.get("scope", "all")
|
||||
libraries = []
|
||||
error = None
|
||||
if neo4j_available():
|
||||
try:
|
||||
from .models import Library
|
||||
|
||||
libraries = Library.nodes.order_by("name")
|
||||
qs = Library.nodes
|
||||
if scope == "daedalus":
|
||||
qs = qs.filter(workspace_id__isnull=False)
|
||||
elif scope == "global":
|
||||
qs = qs.filter(workspace_id__isnull=True)
|
||||
libraries = qs.order_by("name")
|
||||
except Exception as e:
|
||||
error = f"Could not connect to Neo4j: {e}"
|
||||
logger.error(error)
|
||||
@@ -47,7 +53,7 @@ def library_list(request):
|
||||
return render(
|
||||
request,
|
||||
"library/library_list.html",
|
||||
{"libraries": libraries, "error": error},
|
||||
{"libraries": libraries, "error": error, "scope": scope},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -268,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_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
|
||||
# back to "daedalus" for backwards compatibility.
|
||||
SOURCE_S3_BUCKETS = {
|
||||
@@ -289,6 +298,15 @@ SOURCE_S3_BUCKETS = {
|
||||
"use_ssl": SPELUNKER_S3_USE_SSL,
|
||||
"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 ---
|
||||
|
||||
Reference in New Issue
Block a user