8 Commits

Author SHA1 Message Date
9f20110f56 🐾 feat(library): add 'email' library type
Kairos is about to provision per-user app-managed (workspace) libraries for
synced mail, and none of the existing types fit correspondence. New entry in
LIBRARY_TYPE_DEFAULTS mirrors journal's entry-level chunking (one message ≈
one entry) with instructions tuned for email: correspondents, dates,
requests/commitments, and quoted-reply context. Registered in the Library
node choices and the API serializer choice list; forms and
load_library_types derive from the registry, and the compose init sidecar
re-runs the seeder on deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:03:28 -04:00
d6b541636a Merge pull request '🐾 feat(settings): register kairos-mail source bucket for ingest' (#3) from feat/kairos-mail-bucket into main
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 4m22s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m26s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m53s
Reviewed-on: #3
2026-07-12 11:50:53 +00:00
d01afd6203 🐾 feat(settings): register kairos-mail source bucket for ingest
Kairos is about to start announcing rendered mail documents to
POST /library/api/ingest/ with source="kairos-mail". Without a registry
entry, unknown sources fall back to the daedalus bucket and every worker
fetch fails — this adds the KAIROS_S3_* env vars (pattern-copy of the
spelunker block) and the SOURCE_S3_BUCKETS["kairos-mail"] entry, plumbed
through .env.example and the worker service in docker-compose.

Settings-only; no code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:36:12 -04:00
840b9435a3 ci: remove branch and PR tags from docker metadata
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m25s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m34s
Drop type=ref tags for branch and PR events, keeping only SHA-based
and latest tags to simplify the image tagging scheme.
2026-06-25 08:14:56 -04:00
6120e9cd1f fix(mnemosyne): correct celery healthcheck command
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 4m14s
CVE Scan & Docker Build / build-and-push (push) Successful in 47s
The exec-form CMD has no shell, so $$HOSTNAME was never expanded,
causing "celery@$HOSTNAME" to match no node and every healthcheck to
fail. Remove the -d filter (one worker per container makes it
unnecessary) and add -t 8 to accommodate broker round-trip latency.
2026-06-19 11:39:38 -04:00
31a98b4f3a feat(library): add workspace scope filter to library list view
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 3m52s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m15s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m43s
Add a `scope` GET parameter to `library_list` allowing users to filter
libraries by all, global-only, or Daedalus workspace-only. Includes a
filter form in the template, an updated empty state message, and tests
covering each scope branch with mocked Neo4j dependencies.
2026-06-19 06:45:19 -04:00
3394726ca1 Merge branch 'main' of ssh://git.helu.ca:22022/r/mnemosyne
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 4m5s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m26s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m35s
2026-06-19 06:30:21 -04:00
03e3155bd6 fix(settings): load .env from repo root instead of project dir 2026-06-19 06:30:07 -04:00
11 changed files with 200 additions and 9 deletions

View File

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

View File

@@ -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' }}

View File

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

View File

@@ -15,6 +15,7 @@ LIBRARY_TYPE_CHOICES = [
"film",
"art",
"journal",
"email",
"business",
"finance",
]

View File

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

View File

@@ -77,6 +77,7 @@ class Library(StructuredNode):
"film": "Film",
"art": "Art",
"journal": "Journal",
"email": "Email",
"business": "Business",
"finance": "Finance",
},

View File

@@ -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 %}

View File

@@ -21,6 +21,7 @@ class LibraryTypeDefaultsTests(TestCase):
"film",
"art",
"journal",
"email",
"business",
"finance",
}

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

View File

@@ -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},
)

View File

@@ -23,8 +23,9 @@ env = environ.Env(
DEBUG=(bool, True),
)
# Read .env file if it exists
environ.Env.read_env(BASE_DIR / ".env")
# Read .env file if it exists. Lives at the repo root (one level above the
# Django project package), shared with the docker-compose stack.
environ.Env.read_env(BASE_DIR.parent / ".env")
# --- Security ---
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_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 = {
@@ -288,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 ---