Model rules: who a chat's model may bring into a conversation
Rules read from the main model decide who it is offered as a crowd member, a friend and on its roster; any-to-any with denies by default, or none-to-none with allows. The crowd picker names what it holds back and why, and a model held back only by a person's own rule -- or by anything, with the new rules.override -- can still be added by hand. Another data group is now a deny that an explicit rule opens. Admin -> Model rules and a card in Settings, each with a matrix drawn by the enforcing function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -144,7 +144,11 @@ def test_a_chat_whose_model_moved_is_refused_rather_than_sent(db, owner, setup):
|
||||
|
||||
# --- Other models reaching the conversation ------------------------------------------
|
||||
def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
|
||||
"""For somebody without `rules.override`: a different group is a deny only a
|
||||
rule opens. (An administrator holds every permission, so the owner is demoted.)"""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
|
||||
owner.role = "user"
|
||||
db.commit()
|
||||
local, _ = setup
|
||||
chat = _chat(db, owner, connection=local)
|
||||
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["local-b", "cloud-model"]})
|
||||
@@ -153,6 +157,8 @@ def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
|
||||
|
||||
|
||||
def test_a_crowd_member_that_left_the_group_does_not_speak(db, owner, setup):
|
||||
owner.role = "user"
|
||||
db.commit()
|
||||
local, cloud = setup
|
||||
chat = _chat(db, owner, connection=local)
|
||||
db.add(CrowdMember(chat_id=chat.id, model_id="cloud-model", connection_id=cloud.id))
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""`talk.decide`, the one function behind every talk rule, as a table.
|
||||
|
||||
Pure -- plain values in, a verdict out -- so every combination is asserted here
|
||||
with no database, and the admin matrix, which calls the same function, cannot
|
||||
disagree with what is enforced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from lembas.services import talk
|
||||
from lembas.services.talk import Rule, decide, match
|
||||
|
||||
ALLOW = Rule("main", "target", "allow")
|
||||
DENY = Rule("main", "target", "deny")
|
||||
|
||||
|
||||
def _v(**kwargs):
|
||||
kwargs.setdefault("instance_mode", talk.MODE_OPEN)
|
||||
kwargs.setdefault("instance_rule", None)
|
||||
return decide(**kwargs)
|
||||
|
||||
|
||||
# --- The instance's layer ------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "rule", "offered"),
|
||||
[
|
||||
(talk.MODE_OPEN, None, True),
|
||||
(talk.MODE_OPEN, DENY, False),
|
||||
(talk.MODE_OPEN, ALLOW, True),
|
||||
(talk.MODE_CLOSED, None, False),
|
||||
(talk.MODE_CLOSED, ALLOW, True),
|
||||
(talk.MODE_CLOSED, DENY, False),
|
||||
],
|
||||
)
|
||||
def test_the_instance_mode_and_its_rules(mode, rule, offered):
|
||||
verdict = _v(instance_mode=mode, instance_rule=rule)
|
||||
assert verdict.offered is offered
|
||||
assert verdict.addable is offered
|
||||
|
||||
|
||||
def test_another_data_group_is_a_deny_only_an_explicit_allow_opens():
|
||||
assert _v(same_group=False).offered is False
|
||||
assert _v(same_group=False).why == talk.WHY_GROUP
|
||||
assert _v(same_group=False, instance_rule=ALLOW).offered is True
|
||||
# An open mode is not an explicit allow.
|
||||
assert _v(same_group=False, instance_mode=talk.MODE_OPEN).offered is False
|
||||
|
||||
|
||||
# --- A person without the override can only narrow ---------------------------------
|
||||
def test_a_persons_own_deny_takes_it_off_what_is_offered_but_not_off_what_is_addable():
|
||||
verdict = _v(user_rule=DENY)
|
||||
assert verdict.offered is False
|
||||
assert verdict.addable is True
|
||||
assert verdict.why == talk.WHY_YOUR_RULE
|
||||
|
||||
|
||||
def test_a_persons_closed_mode_narrows_too():
|
||||
verdict = _v(user_mode=talk.MODE_CLOSED)
|
||||
assert (verdict.offered, verdict.addable, verdict.why) == (False, True, talk.WHY_YOUR_CLOSED)
|
||||
|
||||
|
||||
def test_without_the_override_a_persons_allow_cannot_widen():
|
||||
verdict = _v(instance_rule=DENY, user_rule=ALLOW)
|
||||
assert (verdict.offered, verdict.addable) == (False, False)
|
||||
assert verdict.why == talk.WHY_INSTANCE_RULE
|
||||
|
||||
|
||||
def test_without_the_override_open_mode_cannot_widen_a_closed_instance():
|
||||
verdict = _v(instance_mode=talk.MODE_CLOSED, user_mode=talk.MODE_OPEN)
|
||||
assert (verdict.offered, verdict.addable) == (False, False)
|
||||
|
||||
|
||||
# --- With the override, the person's layer wins ---------------------------------------
|
||||
def test_with_the_override_a_persons_allow_beats_the_instances_deny():
|
||||
verdict = _v(instance_rule=DENY, user_rule=ALLOW, override=True)
|
||||
assert (verdict.offered, verdict.addable) == (True, True)
|
||||
|
||||
|
||||
def test_with_the_override_a_persons_rule_opens_another_group():
|
||||
assert _v(same_group=False, user_rule=ALLOW, override=True).offered is True
|
||||
|
||||
|
||||
def test_with_the_override_open_mode_widens_a_closed_instance_but_not_across_groups():
|
||||
assert _v(instance_mode=talk.MODE_CLOSED, user_mode=talk.MODE_OPEN, override=True).offered
|
||||
across = _v(same_group=False, user_mode=talk.MODE_OPEN, override=True)
|
||||
assert across.offered is False and across.why == talk.WHY_GROUP
|
||||
# ...unless some rule explicitly allows it.
|
||||
assert _v(
|
||||
same_group=False, user_mode=talk.MODE_OPEN, override=True, instance_rule=ALLOW
|
||||
).offered
|
||||
|
||||
|
||||
def test_with_the_override_and_no_layer_of_their_own_the_instance_decides():
|
||||
assert _v(instance_rule=DENY, override=True).offered is False
|
||||
|
||||
|
||||
def test_with_the_override_anything_may_be_added_by_hand():
|
||||
assert _v(instance_rule=DENY, override=True).addable is True
|
||||
assert _v(same_group=False, override=True).addable is True
|
||||
|
||||
|
||||
# --- Specificity -------------------------------------------------------------------------
|
||||
def test_the_most_specific_rule_wins():
|
||||
rules = [
|
||||
Rule("*", "*", "deny"),
|
||||
Rule("*", "b", "allow"),
|
||||
Rule("a", "*", "deny"),
|
||||
Rule("a", "b", "allow"),
|
||||
]
|
||||
assert match(rules, "a", "b").effect == "allow"
|
||||
assert match(rules, "a", "c") == Rule("a", "*", "deny")
|
||||
assert match(rules, "x", "b") == Rule("*", "b", "allow")
|
||||
assert match(rules, "x", "y") == Rule("*", "*", "deny")
|
||||
assert match([], "a", "b") is None
|
||||
|
||||
|
||||
def test_a_verdict_says_why_in_english_for_a_model():
|
||||
assert "forbids" in _v(instance_rule=DENY).reason
|
||||
assert _v().reason == ""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Talk rules end to end: stored, edited, and obeyed by the roster, a friend and a crowd.
|
||||
|
||||
The table of what `decide` answers is `test_talk_decide.py`. This file is where
|
||||
those answers reach something: the roster a model is told, the friend it may
|
||||
ask, the crowd picker's two lists, a member added by hand that must still speak,
|
||||
and a rule that lets a model from another data group in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import (
|
||||
DEFAULT_GROUP,
|
||||
Chat,
|
||||
Connection,
|
||||
CrowdMember,
|
||||
DataGroup,
|
||||
Model,
|
||||
TalkRule,
|
||||
User,
|
||||
)
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import crowd, settings_store, talk
|
||||
from lembas.services import subagent as subagent_service
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owner(db, registered) -> User:
|
||||
"""The first account, demoted: an administrator holds `rules.override`."""
|
||||
user = db.scalars(select(User).order_by(User.created_at)).first()
|
||||
user.role = "user"
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(db, owner):
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
|
||||
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()
|
||||
for position, (connection, name) in enumerate(
|
||||
[(local, "gpt-oss"), (local, "qwen38"), (local, "bonsai"), (cloud, "deepseek")]
|
||||
):
|
||||
db.add(Model(connection_id=connection.id, model_id=name, position=position))
|
||||
chat = Chat(
|
||||
user_id=owner.id,
|
||||
model_id="gpt-oss",
|
||||
connection_id=local.id,
|
||||
data_group_id=DEFAULT_GROUP,
|
||||
title="t",
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
return chat
|
||||
|
||||
|
||||
def _ids(models) -> list[str]:
|
||||
return [m.model_id for m in models]
|
||||
|
||||
|
||||
def _grant_override(db):
|
||||
settings_store.update(db, {"default_permissions": {talk.PERMISSION: True}})
|
||||
|
||||
|
||||
# --- The roster and the friend ------------------------------------------------------
|
||||
def test_an_instance_deny_takes_a_model_off_the_roster(db, owner, setup):
|
||||
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||
roster = chat_service.roster_block(db, owner, exclude="gpt-oss", group=DEFAULT_GROUP)
|
||||
assert "qwen38" not in roster and "bonsai" in roster
|
||||
|
||||
|
||||
def test_a_friend_the_rules_forbid_is_refused_with_the_reason(db, owner, setup):
|
||||
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||
friend, refusal = subagent_service._resolve_friend(
|
||||
db, owner, "qwen38", asking="gpt-oss", group=DEFAULT_GROUP
|
||||
)
|
||||
assert friend is None
|
||||
listed = refusal.split("These are the ones you can:")[-1]
|
||||
assert "qwen38" not in listed
|
||||
|
||||
|
||||
def test_the_rule_is_read_from_the_main_model_only(db, owner, setup):
|
||||
"""bonsai may not talk to qwen38 -- which says nothing about gpt-oss's chats."""
|
||||
talk.set_rule(db, None, "bonsai", "qwen38", "deny")
|
||||
assert "qwen38" in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
|
||||
assert "qwen38" not in _ids(talk.offered(db, owner, "bonsai", DEFAULT_GROUP))
|
||||
|
||||
|
||||
def test_a_closed_instance_offers_only_what_is_allowed(db, owner, setup):
|
||||
settings_store.update(db, {"mode": "closed"}, key=settings_store.RULES)
|
||||
talk.set_rule(db, None, "gpt-oss", "bonsai", "allow")
|
||||
assert _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP)) == ["bonsai"]
|
||||
|
||||
|
||||
# --- Other data groups -----------------------------------------------------------------
|
||||
def test_another_group_is_not_offered_until_a_rule_allows_it(db, owner, setup):
|
||||
assert "deepseek" not in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
|
||||
talk.set_rule(db, None, "gpt-oss", "deepseek", "allow")
|
||||
assert "deepseek" in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
|
||||
|
||||
|
||||
def test_a_member_from_another_group_reads_its_own_groups_data(db, owner, setup):
|
||||
from lembas.services import data_groups
|
||||
|
||||
speaker = chat_service.Speaker("deepseek", None)
|
||||
assert data_groups.for_speaker(db, owner, setup, speaker) == "hosted"
|
||||
|
||||
|
||||
# --- The crowd: offered, and by hand -------------------------------------------------------
|
||||
def test_the_picker_names_what_it_holds_back_and_why(client, db, owner, setup):
|
||||
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
|
||||
page = client.get(f"/chat/{setup.id}").text
|
||||
held = page.split("Not offered to this model")[1]
|
||||
assert "qwen38" in held and "bonsai" in held
|
||||
assert "The instance's rule" in held or "The instance's rule" in held
|
||||
assert "You may still add it yourself." in held
|
||||
|
||||
|
||||
def test_a_persons_own_rule_is_soft_for_them(client, db, owner, setup):
|
||||
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
|
||||
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["bonsai"]})
|
||||
assert [m.model_id for m in db.scalars(select(CrowdMember))] == ["bonsai"]
|
||||
|
||||
|
||||
def test_an_instance_rule_is_hard_without_the_override(client, db, owner, setup):
|
||||
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["qwen38"]})
|
||||
assert list(db.scalars(select(CrowdMember))) == []
|
||||
|
||||
|
||||
def test_with_the_override_an_instance_rule_can_be_passed_by_hand(client, db, owner, setup):
|
||||
_grant_override(db)
|
||||
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["qwen38"]})
|
||||
assert [m.model_id for m in db.scalars(select(CrowdMember))] == ["qwen38"]
|
||||
|
||||
|
||||
def test_a_member_added_by_hand_still_speaks(db, owner, setup):
|
||||
"""Send time asks `addable`, not `offered`, or the choice would be undone."""
|
||||
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
|
||||
db.add(CrowdMember(chat_id=setup.id, model_id="bonsai"))
|
||||
db.commit()
|
||||
assert _ids(crowd.member_speakers(db, setup, owner)) == ["gpt-oss", "bonsai"]
|
||||
|
||||
|
||||
def test_a_member_the_instance_later_forbids_is_skipped_and_named(db, owner, setup):
|
||||
db.add(CrowdMember(chat_id=setup.id, model_id="qwen38"))
|
||||
db.commit()
|
||||
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
|
||||
assert _ids(crowd.member_speakers(db, setup, owner)) == ["gpt-oss"]
|
||||
assert crowd.unreachable_members(db, setup, owner) == ["qwen38"]
|
||||
|
||||
|
||||
# --- Storing and editing ------------------------------------------------------------------------
|
||||
def test_both_directions_writes_two_rules_and_a_second_write_replaces(db, setup):
|
||||
talk.set_rule(db, None, "a", "b", "deny", both=True)
|
||||
assert {(r.from_model, r.to_model) for r in db.scalars(select(TalkRule))} == {
|
||||
("a", "b"),
|
||||
("b", "a"),
|
||||
}
|
||||
talk.set_rule(db, None, "a", "b", "allow")
|
||||
assert db.scalar(select(TalkRule).where(TalkRule.from_model == "a")).effect == "allow"
|
||||
|
||||
|
||||
def test_a_person_cannot_delete_an_instance_rule(db, owner, setup):
|
||||
talk.set_rule(db, None, "a", "b", "deny")
|
||||
rule = db.scalar(select(TalkRule))
|
||||
assert talk.delete_rule(db, owner, rule.id) is False
|
||||
assert talk.delete_rule(db, None, rule.id) is True
|
||||
|
||||
|
||||
def test_the_admin_page_adds_a_rule_and_draws_the_matrix(client, db, registered, setup):
|
||||
# The fixture demoted the first account; the page is an administrator's.
|
||||
user = db.scalars(select(User).order_by(User.created_at)).first()
|
||||
user.role = "admin"
|
||||
db.commit()
|
||||
client.post("/admin/rules", data={"from_model": "gpt-oss", "to_model": "bonsai",
|
||||
"effect": "deny"})
|
||||
page = client.get("/admin/rules").text
|
||||
assert "Who may talk to whom" in page
|
||||
assert page.count("talk-matrix__no") >= 1
|
||||
|
||||
|
||||
def test_the_settings_card_sets_a_persons_mode_and_rules(client, db, owner, setup):
|
||||
client.post("/api/preferences/talk-mode", data={"mode": "closed"})
|
||||
client.post("/api/preferences/talk-rules", data={"from_model": "gpt-oss",
|
||||
"to_model": "bonsai", "effect": "allow"})
|
||||
db.expire_all()
|
||||
user = db.get(User, owner.id)
|
||||
assert talk.user_mode(user) == "closed"
|
||||
assert _ids(talk.offered(db, user, "gpt-oss", DEFAULT_GROUP)) == ["bonsai"]
|
||||
Reference in New Issue
Block a user