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
+37 -1
View File
@@ -31,6 +31,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
AUTHOR_MODEL,
AUTHOR_USER,
DEFAULT_GROUP,
Impression,
Persona,
PersonaRevision,
@@ -54,8 +55,39 @@ MAX_VIEW_CHARS = 800
# a model editing itself every turn cannot grow the table without limit.
MAX_REVISIONS = 20
# Between a model id and a data group in a person's key. Two characters, because
# one `@` is a character a model id could plausibly contain and this must never
# split one.
KEY_SEPARATOR = "@@"
def key_for(model_id: str, group: str | None) -> str:
"""The key a person's personality and impression are stored under.
**Namespaced by data group, and the reason is a constraint.** Both tables
are `UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint, and
this project's schema changes are additive only -- so a `data_group_id`
column could not let one person hold a personality for the same model id in
two groups, which is exactly what one model id served by two providers in
different groups needs.
The default group keeps the bare model id, which is what every row written
before groups existed already holds, so an upgrade moves nothing. The
administrator's default (`owner_id NULL`) is always bare: it is their text,
not a person's data, and every group falls back to it.
"""
if not model_id or not group or group == DEFAULT_GROUP:
return model_id
return f"{model_id}{KEY_SEPARATOR}{group}"
def split_key(model_key: str) -> tuple[str, str]:
"""(model id, data group) out of a stored key."""
model_id, separator, group = (model_key or "").rpartition(KEY_SEPARATOR)
if not separator:
return model_key or "", DEFAULT_GROUP
return model_id, group or DEFAULT_GROUP
def get(db: DBSession, model_key: str, owner: User | None) -> Persona | None:
"""One personality row, exactly as asked for and with no fallback.
@@ -86,7 +118,8 @@ def effective(db: DBSession, model_key: str, owner: User | None) -> Persona | No
own = get(db, model_key, owner)
if own is not None:
return own
return get(db, model_key, None) if owner is not None else None
# The default is keyed on the bare model id whatever group asked.
return get(db, split_key(model_key)[0], None) if owner is not None else None
def personas_of(db: DBSession, owner: User | None) -> list[Persona]:
@@ -302,6 +335,7 @@ def view_block(db: DBSession, model_key: str, owner: User | None) -> str:
__all__ = [
"KEY_SEPARATOR",
"MAX_PERSONA_CHARS",
"MAX_REVISIONS",
"MAX_VIEW_CHARS",
@@ -312,8 +346,10 @@ __all__ = [
"get",
"impression",
"impressions_for",
"key_for",
"personas_for",
"personas_of",
"split_key",
"view_block",
"revert",
"write",