Files
LLeMbas/tests/test_data_group_chat_pin.py
T
HomerandClaude Opus 5.5 9970bb43c6 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>
2026-09-29 16:07:55 +00:00

246 lines
9.1 KiB
Python

"""A chat stays in the data group it was started in.
Its history is the group's data, so every way a chat could reach a model in
another group is closed here: switching its model, the endpoint fallback, a
crowd member, a friend, the roster, a base, the `@` menu, and Messages. And if
a chat's own model is moved into another group afterwards, the next reply is
refused rather than sent.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import (
DEFAULT_GROUP,
TARGET_MESSAGES,
Chat,
Connection,
CrowdMember,
DataGroup,
Model,
User,
)
from lembas.services import chat as chat_service
from lembas.services import data_groups, schedules, settings_store
from lembas.services import subagent as subagent_service
from lembas.services.crypto import encrypt
from lembas.services.library import notes
HOSTED = "hosted"
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def setup(db, owner):
"""Two local models in the default group, one hosted model in its own."""
db.add(DataGroup(id=HOSTED, name="Hosted"))
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(
name="Cloud",
base_url="http://127.0.0.1:2",
api_key_encrypted=encrypt(""),
data_group_id=HOSTED,
)
db.add_all([local, cloud])
db.flush()
db.add_all(
[
Model(connection_id=local.id, model_id="local-a", position=0),
Model(connection_id=local.id, model_id="local-b", position=1),
Model(connection_id=cloud.id, model_id="cloud-model", position=2),
]
)
db.commit()
return local, cloud
def _chat(db, owner, model_id="local-a", connection=None, group=DEFAULT_GROUP) -> Chat:
chat = Chat(
user_id=owner.id,
model_id=model_id,
connection_id=connection.id if connection else None,
data_group_id=group,
title="t",
)
db.add(chat)
db.commit()
return chat
# --- Starting and switching ---------------------------------------------------------
def test_a_new_chat_takes_its_models_group(client, db, setup):
client.post("/api/chats/start", data={"content": "hi", "model_id": "cloud-model"})
chat = db.scalars(select(Chat).order_by(Chat.created_at.desc())).first()
assert chat.data_group_id == HOSTED
def test_a_chat_cannot_be_switched_to_another_groups_model(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
response = client.patch(f"/api/chats/{chat.id}", data={"model_id": "cloud-model"})
assert response.status_code == 409
assert "data group" in response.text
db.refresh(chat)
assert chat.model_id == "local-a"
def test_a_chat_can_switch_within_its_group(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
response = client.patch(f"/api/chats/{chat.id}", data={"model_id": "local-b"})
assert response.status_code in (200, 204)
db.refresh(chat)
assert chat.model_id == "local-b"
def test_the_picker_names_the_models_it_leaves_out(client, db, owner, setup):
"""Named, not silently missing -- and not offered as options either."""
local, _ = setup
chat = _chat(db, owner, connection=local)
page = client.get(f"/chat/{chat.id}").text
assert "In another data group" in page
assert 'data-picker-value="cloud-model"' not in page
def test_available_models_narrow_to_a_group(db, owner, setup):
ids = [m.model_id for m in chat_service.available_models(db, owner, HOSTED)]
assert ids == ["cloud-model"]
everything = [m.model_id for m in chat_service.available_models(db, owner)]
assert everything == ["local-a", "local-b", "cloud-model"]
# --- The endpoint fallback ----------------------------------------------------------
def test_the_fallback_never_repoints_a_chat_into_another_group(db, owner, setup):
"""A model id served by two connections: the chat's own going away must not
land it on the other provider, which would be handed the whole history."""
local, cloud = setup
db.add(Model(connection_id=cloud.id, model_id="local-a"))
local.enabled = False
db.commit()
chat = _chat(db, owner, connection=local)
with pytest.raises(chat_service.LLMError):
chat_service.resolve_endpoint(db, chat)
db.refresh(chat)
assert chat.connection_id == local.id
def test_a_chat_whose_model_moved_is_refused_rather_than_sent(db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
speaker = chat_service.speaker_for(db, chat)
assert data_groups.refusal(db, owner, chat, speaker) == ""
local.data_group_id = HOSTED
db.commit()
refusal = data_groups.refusal(db, owner, chat, speaker)
assert "Default" in refusal and "Hosted" in refusal
# --- Other models reaching the conversation ------------------------------------------
def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
local, _ = setup
chat = _chat(db, owner, connection=local)
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["local-b", "cloud-model"]})
members = [row.model_id for row in db.scalars(select(CrowdMember))]
assert members == ["local-b"]
def test_a_crowd_member_that_left_the_group_does_not_speak(db, owner, setup):
local, cloud = setup
chat = _chat(db, owner, connection=local)
db.add(CrowdMember(chat_id=chat.id, model_id="cloud-model", connection_id=cloud.id))
db.commit()
from lembas.services import crowd
speakers = [s.model_id for s in crowd.member_speakers(db, chat, owner)]
assert speakers == ["local-a"]
def test_the_roster_and_the_friend_stay_in_the_group(db, owner, setup):
roster = chat_service.roster_block(db, owner, exclude="local-a", group=DEFAULT_GROUP)
assert "local-b" in roster and "cloud-model" not in roster
friend, refusal = subagent_service._resolve_friend(
db, owner, "cloud-model", asking="local-a", group=DEFAULT_GROUP
)
assert friend is None
# The name asked for is echoed back; the list of who *can* be asked is not
# allowed to carry it.
offered = refusal.split("These are the ones you can:")[-1]
assert "cloud-model" not in offered and "local-b" in offered
def test_a_friend_reads_its_own_group(db, owner, setup):
local, cloud = setup
parent = _chat(db, owner, connection=local)
friend = db.scalar(select(Model).where(Model.model_id == "cloud-model"))
child = subagent_service._create_child(db, parent, title="q", write=False, friend=friend)
assert child.data_group_id == HOSTED
# --- Bases and the @ menu -------------------------------------------------------------
def test_a_base_from_another_group_cannot_be_attached(client, db, owner, setup):
from lembas.services.library import documents
local, _ = setup
chat = _chat(db, owner, connection=local)
base = documents.create_base(db, owner=owner, name="Hosted base", group=HOSTED)
response = client.post(f"/api/chats/{chat.id}/bases", data={"base_id": base.id})
assert response.status_code == 404
def test_the_mention_menu_offers_only_the_chats_group(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
notes.create(db, owner=owner, title="Home note", body="x")
notes.create(db, owner=owner, title="Hosted note", body="x", group=HOSTED)
page = client.get(f"/api/files/mention-picker?q=&chat_id={chat.id}").text
assert "Home note" in page and "Hosted note" not in page
def test_on_the_new_chat_screen_the_chosen_model_decides(client, db, owner, setup):
notes.create(db, owner=owner, title="Hosted note", body="x", group=HOSTED)
page = client.get("/api/files/mention-picker?q=&model_id=cloud-model").text
assert "Hosted note" in page
def test_a_note_from_another_group_cannot_be_attached(client, db, owner, setup):
local, _ = setup
chat = _chat(db, owner, connection=local)
hosted = notes.create(db, owner=owner, title="Hosted note", body="x", group=HOSTED)
response = client.post(
"/api/files/from-note", data={"note_id": hosted.id, "chat_id": chat.id}
)
assert "not available" in response.text
# --- Messages and schedules -------------------------------------------------------------
def test_a_schedule_from_another_group_cannot_post_to_messages(db, owner, setup):
rule = {"at": {"weekdays": [0], "times": ["15:00"]}}
with pytest.raises(schedules.ScheduleError, match="Messages"):
schedules.create(
db,
owner=owner,
title="t",
instruction="i",
rule=rule,
target=TARGET_MESSAGES,
model_id="cloud-model",
)
def test_a_schedule_is_stamped_with_its_models_group(db, owner, setup):
rule = {"at": {"weekdays": [0], "times": ["15:00"]}}
schedule = schedules.create(
db, owner=owner, title="t", instruction="i", rule=rule, model_id="cloud-model"
)
assert schedule.data_group_id == HOSTED
assert db.get(Chat, schedule.chat_id).data_group_id == HOSTED