Data groups: a provider's models read only their own group's data

Every connection is in a data group. Its models are handed, and can find,
only that group's memories, notes, skills, knowledge, reports and
personality -- by search and by id. A chat stays in the group it was
started in: switching its model, the endpoint fallback, the crowd, friends,
bases and the @ menu all stay inside it, and a chat whose model has moved
is refused rather than sent. A group may name its own embedder and image
reviewer. data.manage lets a person make personal groups, remap
connections for themselves and move their own records.

Also: a search no longer mixes two embedders of the same width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:07:55 +00:00
co-authored by Claude Opus 5.5
parent e65ea90fe6
commit 9970bb43c6
61 changed files with 3595 additions and 200 deletions
+22 -9
View File
@@ -13,7 +13,7 @@ import logging
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_NOTE, Note, User
from lembas.db.models import AUTHOR_MODEL, AUTHOR_USER, CHUNK_NOTE, DEFAULT_GROUP, Note, User
from lembas.services import sharing
from lembas.services.library import retrieval
@@ -26,20 +26,25 @@ MAX_BODY_CHARS = 40_000
SNIPPET_CHARS = 800
def visible(db: DBSession, user: User | None):
return select(Note).where(sharing.visible_to(Note, user))
def visible(db: DBSession, user: User | None, group: str | None = None):
"""Notes this user may see; `group` narrows to one data group, for a model."""
return select(Note).where(sharing.visible_to(Note, user, group))
def get(db: DBSession, note_id: str, user: User | None) -> Note | None:
def get(
db: DBSession, note_id: str, user: User | None, group: str | None = None
) -> Note | None:
note = db.get(Note, note_id)
if note is None or not sharing.can_read(db, note, user):
if note is None or not sharing.can_read(db, note, user, group):
return None
return note
def recent(db: DBSession, user: User | None, *, limit: int = 20) -> list[Note]:
def recent(
db: DBSession, user: User | None, *, limit: int = 20, group: str | None = None
) -> list[Note]:
return list(
db.scalars(visible(db, user).order_by(Note.updated_at.desc()).limit(limit))
db.scalars(visible(db, user, group).order_by(Note.updated_at.desc()).limit(limit))
)
@@ -50,6 +55,7 @@ def search(
*,
limit: int = 10,
vector: list[float] | None = None,
group: str | None = None,
) -> list[Note]:
"""Notes matching `needle` that this user may see, best match first.
@@ -62,16 +68,23 @@ def search(
if not hits:
return []
order = {hit.id: position for position, hit in enumerate(hits)}
rows = list(db.scalars(visible(db, user).where(Note.id.in_(list(order)))))
rows = list(db.scalars(visible(db, user, group).where(Note.id.in_(list(order)))))
rows.sort(key=lambda note: order.get(note.id, len(order)))
return rows[:limit]
def create(
db: DBSession, *, owner: User, title: str, body: str, author: str = AUTHOR_USER
db: DBSession,
*,
owner: User,
title: str,
body: str,
author: str = AUTHOR_USER,
group: str = DEFAULT_GROUP,
) -> Note:
note = Note(
owner_id=owner.id,
data_group_id=group or DEFAULT_GROUP,
title=(title.strip() or "Untitled")[:MAX_TITLE_CHARS],
body=body.strip()[:MAX_BODY_CHARS],
author=author if author in (AUTHOR_USER, AUTHOR_MODEL) else AUTHOR_USER,