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:
2026-09-29 17:20:40 +00:00
co-authored by Claude Opus 5.5
parent 9970bb43c6
commit c27fe47d4d
24 changed files with 1324 additions and 29 deletions
+33
View File
@@ -16,6 +16,39 @@ for 1.0.0 have something to be assembled from.
## Unreleased
## 1.11.0
Rules for which model may talk to which. This is the second of three releases;
the third adds helpers on a different model.
- **Model rules.** The new **Admin → Model rules** page sets who a chat's model
may bring into the conversation: as a crowd member, as a friend it asks, and
on its list of other models. A rule names two models, or *any model* on either
side, and allows or forbids. The starting point is either *any model may talk
to any other* (the default, so nothing changes) or *no model may talk to
another*. The most specific rule wins. The page ends with a table of every
model against every other, drawn by the same rules that are enforced.
- **A rule is read from the chat's own model.** If gpt-oss may not talk to
qwen38, then in a gpt-oss chat qwen38 is not on its list of other models, it
cannot be asked as a friend, and the crowd picker does not offer it. Members
of a crowd are not checked against each other.
- **The crowd picker names what it holds back, and why.** Models the rules do
not offer are listed under *Not offered to this model* with the reason. You
can still tick one by hand when the rule holding it back is your own, or when
you may override the instance's rules. A member added by hand keeps speaking
when the round runs; one the instance later forbids is skipped and shown
crossed out, as a member that cannot be reached always was.
- **Your own rules.** Settings → Models has a card for your own starting point
and rules, and the same table for you. Anybody can narrow the instance's
rules for themselves. With the new *Override the model rules for themselves*
permission (off by default), your rules and starting point win over the
instance's, and you can add any model to a crowd by hand.
- **Another data group is a rule, not a wall.** In 1.10.0 a model from another
data group could never join a conversation. Now it is not offered unless a
rule explicitly allows it: the instance's, or your own with the override.
When it does join, it reads its own group's memories and notes, never the
chat's.
## 1.10.0
Data groups: a provider's models read only the data of the group their
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.10.0"
__version__ = "1.11.0"
+85
View File
@@ -0,0 +1,85 @@
"""Model rules: which model may bring which into a conversation.
The instance's layer. A person's own rules and mode are on their settings page
(`api/preferences.py`), and `services/talk.py` is where the two are combined.
The page ends in a matrix -- every main model against every other -- drawn by
`talk.matrix`, which calls the same `decide` that enforces the rules. A preview
computed any other way would be a second copy of the logic, and a preview that
disagrees with enforcement is worse than none: it is believed.
"""
from __future__ import annotations
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.api.pages import describe_verdict
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import chat as chat_service
from lembas.services import settings_store, talk
from lembas.web.templating import render
router = APIRouter(prefix="/admin/rules", tags=["admin-rules"])
def model_ids(db, user) -> list[str]:
"""Every model id this person can reach, once each, in the admin's order."""
seen: list[str] = []
for model in chat_service.available_models(db, user):
if model.model_id not in seen:
seen.append(model.model_id)
return seen
@router.get("")
async def rules_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
models = chat_service.available_models(db, user)
return render(
request,
"admin/rules.html",
{
"mode": settings_store.rules(db)["mode"],
"rules": talk.rules_of(db, None),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"matrix_models": models,
# The instance's own view: no person's layer and no override, which
# is what somebody without `rules.override` gets unless they narrow.
"matrix": talk.matrix(db, None, models),
"describe_verdict": describe_verdict,
"saved": saved,
},
)
@router.post("/mode")
async def save_mode(db: Db, user: AdminUser, mode: str = Form(talk.MODE_OPEN)) -> Response:
settings_store.update(
db,
{"mode": talk.MODE_CLOSED if mode == talk.MODE_CLOSED else talk.MODE_OPEN},
key=settings_store.RULES,
)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("")
async def add_rule(
db: Db,
user: AdminUser,
from_model: str = Form(ANY_MODEL),
to_model: str = Form(ANY_MODEL),
effect: str = Form(EFFECT_DENY),
both: bool = Form(False),
) -> Response:
talk.set_rule(db, None, from_model, to_model, effect, both=both)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/{rule_id}/delete")
async def delete_rule(db: Db, user: AdminUser, rule_id: str) -> Response:
talk.delete_rule(db, None, rule_id)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+5 -5
View File
@@ -1546,15 +1546,15 @@ def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> No
and a model offered by two connections is two rows with different capabilities.
"""
from lembas.db.models import CrowdMember
from lembas.services import talk
settings = settings_store.crowd(db)
# Only models in the chat's own data group: a member is sent the whole
# conversation, so one from another group would carry it to that provider.
# What this person may add by hand, by the talk rules from the chat's main
# model. A member is sent the whole conversation, so one in another data
# group comes in only when a rule explicitly lets it.
reachable = {
model.model_id: model
for model in chat_service.available_models(
db, user, data_groups.for_chat(db, chat)
)
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
wanted: list[str] = []
for value in values:
+74 -10
View File
@@ -213,6 +213,27 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
}
def _talk_settings(db: DBSession, user: User) -> dict:
"""The person's own talk rules, and the matrix as it applies to them."""
from lembas.api.admin_rules import model_ids
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import talk
models = chat_service.available_models(db, user)
return {
"talk_mode": talk.user_mode(user),
"talk_override": talk.may_override(db, user),
"talk_rules": talk.rules_of(db, user),
"talk_matrix_models": models,
"talk_matrix": talk.matrix(db, user, models),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"describe_verdict": describe_verdict,
}
def _data_group_settings(db: DBSession, user: User) -> dict:
"""What the Data tab on /settings shows: which group each connection reads.
@@ -251,6 +272,32 @@ def _data_group_settings(db: DBSession, user: User) -> dict:
}
def describe_verdict(verdict) -> str:
"""Why a model is not offered, in the reader's language.
`talk.Verdict` carries a code so this can be said here with `t()`, while a
model refused a friend is told the same thing in English.
"""
from lembas.services import talk
rule = verdict.rule
if verdict.why in (talk.WHY_INSTANCE_RULE, talk.WHY_YOUR_RULE) and rule is not None:
frm = i18n.t("any model") if rule.from_model == "*" else rule.from_model
to = i18n.t("any model") if rule.to_model == "*" else rule.to_model
if verdict.why == talk.WHY_INSTANCE_RULE:
if rule.effect == "allow":
return i18n.t("The instance's rule %(a)s → %(b)s allows it.", a=frm, b=to)
return i18n.t("The instance's rule %(a)s → %(b)s forbids it.", a=frm, b=to)
if rule.effect == "allow":
return i18n.t("Your rule %(a)s → %(b)s allows it.", a=frm, b=to)
return i18n.t("Your rule %(a)s → %(b)s forbids it.", a=frm, b=to)
return {
talk.WHY_GROUP: i18n.t("It is in another data group."),
talk.WHY_INSTANCE_CLOSED: i18n.t("The instance lets no model talk to another."),
talk.WHY_YOUR_CLOSED: i18n.t("Your setting lets no model talk to another."),
}.get(verdict.why, "")
def _crowd_context(
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
) -> dict:
@@ -271,24 +318,39 @@ def _crowd_context(
settings = settings_store.crowd(db)
if not settings["enabled"]:
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
return {
"crowd_available": [],
"crowd_held_back": [],
"crowd_member_ids": [],
"crowd_skipped": [],
}
# On the new-chat screen the "own" model is whichever one the picker is
# showing, so the list excludes it for the same reason it does in a chat:
# adding it would have it answer twice in a row.
own = chat.model_id if chat is not None else default_model_id
# Only the main model's data group: a member is sent the whole conversation.
# On the new-chat screen that is the group of the model the picker shows,
# which is the one the chat will be pinned to.
# The talk rules, evaluated from the main model -- on the new-chat screen the
# one the picker shows, whose data group the chat will be pinned to. Offered
# models are the main list; the rest are named below it with the reason, and
# may still be ticked by hand where the rules let this person do that.
from lembas.services import talk
group = (
data_groups.for_chat(db, chat)
if chat is not None
else (data_groups.for_pair(db, user, own) if own else None)
)
if group is not None:
allowed = {m.id for m in chat_service.available_models(db, user, group)}
models = [model for model in models if model.id in allowed]
others = [model for model in models if model.model_id != own]
if own and group is not None:
pairs = talk.candidates(db, user, own, group)
else:
pairs = [(model, talk.Verdict(offered=True, addable=True)) for model in models]
pairs = [(model, verdict) for model, verdict in pairs if model.model_id != own]
others = [model for model, verdict in pairs if verdict.offered]
held_back = [
{"model": model, "addable": verdict.addable, "reason": describe_verdict(verdict)}
for model, verdict in pairs
if not verdict.offered
]
members = (
[
row.model_id
@@ -297,16 +359,17 @@ def _crowd_context(
if chat is not None
else []
)
reachable = {model.model_id for model in others}
reachable = {model.model_id for model, verdict in pairs if verdict.addable}
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
rounds = int(settings["max_rounds"])
return {
"crowd_available": others,
"crowd_held_back": held_back,
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
"crowd_skipped": (
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
),
# One round is out and back: everybody answers, everybody but the last is
# One round is out and back: everybody answers, everybody but the last is
# asked whether they disagree, and the main model closes.
"crowd_replies": max(1, speakers * 2 - 1),
"crowd_rounds": rounds,
@@ -1034,6 +1097,7 @@ async def settings_page(
# template shows the two apart rather than printing the raw key.
"split_key": personas_service.split_key,
**_data_group_settings(db, user),
**_talk_settings(db, user),
# Sorted rather than left in set order, because a list of six
# hundred zones that is not alphabetical is one nobody can use.
"languages": i18n.LANGUAGES,
+41
View File
@@ -371,3 +371,44 @@ async def delete_personal_group(db: Db, user: RequiredUser, group_id: str) -> Re
except ValueError as exc:
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
return RedirectResponse("/settings?saved=Data+group+deleted.", status_code=303)
# --- Talk rules ----------------------------------------------------------------
# A person's own layer of who may talk to whom. Anybody may keep one: without
# `rules.override` it can only narrow what the instance allows, which is theirs
# to decide; with it, it wins. See services/talk.py.
@router.post("/talk-mode")
async def set_talk_mode(db: Db, user: RequiredUser, mode: str = Form("")) -> Response:
from lembas.services import talk
settings_map = {**(user.settings_json or {})}
if mode in talk.MODES:
settings_map[talk.SETTING_KEY] = mode
else:
settings_map.pop(talk.SETTING_KEY, None)
user.settings_json = settings_map
db.commit()
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
@router.post("/talk-rules")
async def add_talk_rule(
db: Db,
user: RequiredUser,
from_model: str = Form("*"),
to_model: str = Form("*"),
effect: str = Form("deny"),
both: bool = Form(False),
) -> Response:
from lembas.services import talk
talk.set_rule(db, user, from_model, to_model, effect, both=both)
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
@router.post("/talk-rules/{rule_id}/delete")
async def delete_talk_rule(db: Db, user: RequiredUser, rule_id: str) -> Response:
from lembas.services import talk
talk.delete_rule(db, user, rule_id)
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
+6
View File
@@ -84,6 +84,7 @@ from lembas.db.models.schedule import (
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.talk import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY, EFFECTS, TalkRule
from lembas.db.models.tool import (
RESPONSE_JSON,
RESPONSE_MODES,
@@ -170,6 +171,11 @@ __all__ = [
"Connection",
"DEFAULT_GROUP",
"DataGroup",
"ANY_MODEL",
"EFFECT_ALLOW",
"EFFECT_DENY",
"EFFECTS",
"TalkRule",
"InDataGroup",
"CustomTool",
"CHUNK_DOCUMENT",
+43
View File
@@ -0,0 +1,43 @@
"""Talk rules: which model may talk to which.
A rule names a *main* model -- the one a chat belongs to -- and a *target*, and
says allow or deny. It governs who a main model is offered as a crowd member, as
a friend to ask, and on its roster of peers. Keyed on the models' text ids, never
a `Model` primary key, for the reason every such reference here is: "Test &
refresh" recreates the row, and a rule that silently stopped applying after a
refresh is the worst kind of rule.
`owner_id` NULL is an instance rule, written by an administrator. Set, it is one
person's own. `*` on either side means any model. See services/talk.py for how
the two layers and the instance's mode are combined.
"""
from __future__ import annotations
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
ANY_MODEL = "*"
EFFECT_ALLOW = "allow"
EFFECT_DENY = "deny"
EFFECTS = (EFFECT_ALLOW, EFFECT_DENY)
class TalkRule(UUIDPrimaryKey, Timestamps, Base):
"""One rule: may `from_model` talk to `to_model`, for everybody or one person."""
__tablename__ = "talk_rules"
__table_args__ = (UniqueConstraint("owner_id", "from_model", "to_model"),)
owner_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
from_model: Mapped[str] = mapped_column(String(300), nullable=False)
to_model: Mapped[str] = mapped_column(String(300), nullable=False)
effect: Mapped[str] = mapped_column(String(8), nullable=False, default=EFFECT_DENY)
def __repr__(self) -> str:
whose = self.owner_id or "instance"
return f"<TalkRule {whose} {self.from_model} -> {self.to_model} {self.effect}>"
+2
View File
@@ -23,6 +23,7 @@ from lembas.api import (
admin_images,
admin_models,
admin_prompts,
admin_rules,
admin_schedules,
admin_search,
admin_suggestions,
@@ -231,6 +232,7 @@ def create_app() -> FastAPI:
app.include_router(admin_agents.router)
app.include_router(admin_crowd.router)
app.include_router(admin_data_groups.router)
app.include_router(admin_rules.router)
app.include_router(push.router)
app.include_router(branding.router)
+13
View File
@@ -334,6 +334,19 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
# themselves, and move their own records between groups. Off by default,
# because it moves what a provider can read -- and an instance that never
# looks should keep the arrangement its administrator made.
# Talk rules: which model may bring which into a conversation. The
# instance's rules apply to everybody; a person may always narrow them for
# themselves, and with this they may also widen them -- their own rules and
# mode then win, and they may add any model to a crowd by hand. Off by
# default, because an instance rule is usually there for a reason.
PermissionDef(
"rules.override",
"Override the model rules for themselves",
"Let this person's own rules about which model may talk to which win over "
"the instance's, and let them add any model to a crowd by hand.",
False,
"Chat",
),
PermissionDef(
"data.manage",
"Manage their own data groups",
+8 -1
View File
@@ -906,7 +906,14 @@ def roster_models(
would be both a leak and a dead end, since asking it anything is refused by
the same check.
"""
return [model for model in available_models(db, user, group) if model.model_id != exclude]
if group is not None:
# Who the main model may talk to, by the talk rules -- a different data
# group counting as a deny that only an explicit rule opens. `exclude`
# is the main model at every call site that passes a group.
from lembas.services import talk
return talk.offered(db, user, exclude, group)
return [model for model in available_models(db, user) if model.model_id != exclude]
def roster_block(
+9 -11
View File
@@ -273,17 +273,18 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
Deduplicated against the main model: adding the chat's own model to the crowd
would have it answer twice in a row, which is not what anybody meant by it.
And narrowed to the chat's data group: a member from another group would be
handed this conversation, which is exactly what groups exist to prevent.
And narrowed by the talk rules, evaluated from the main model -- which is
also where a different data group counts as a deny that only a rule opens.
"""
from lembas.services import chat as chat_service
from lembas.services import data_groups
from lembas.services import data_groups, talk
# `addable`, not `offered`: a member somebody added by hand is exactly one the
# rules would not have offered, and skipping it when the round runs would
# quietly undo their choice.
reachable = {
model.model_id: model
for model in chat_service.roster_models(
db, user, exclude="", group=data_groups.for_chat(db, chat)
)
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
seen = {chat.model_id}
@@ -297,14 +298,11 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
def unreachable_members(db: DBSession, chat: Chat, user=None) -> list[str]:
"""Members that will be skipped, so a screen can say so rather than lie."""
from lembas.services import chat as chat_service
from lembas.services import data_groups
from lembas.services import data_groups, talk
reachable = {
model.model_id
for model in chat_service.roster_models(
db, user, exclude="", group=data_groups.for_chat(db, chat)
)
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
return [
member.model_id
+19
View File
@@ -33,6 +33,7 @@ IMAGES = "images"
SCHEDULES = "schedules"
SUBAGENTS = "subagents"
CROWD = "crowd"
RULES = "rules"
BRANDING = "branding"
EXTRACTION = "extraction"
@@ -349,6 +350,16 @@ def _schedules_defaults() -> dict[str, Any]:
}
def _rules_defaults() -> dict[str, Any]:
"""Who may talk to whom, instance-wide. See services/talk.py.
`open` is any model to any model, with deny rules; `closed` is none to none,
with allow rules. Open by default, so an instance that never looks behaves
exactly as it did before rules existed.
"""
return {"mode": "open"}
def _crowd_defaults() -> dict[str, Any]:
"""Several models answering one turn, in order, then again in reverse.
@@ -431,6 +442,7 @@ _DEFAULTS: dict[str, Any] = {
SCHEDULES: _schedules_defaults,
SUBAGENTS: _subagents_defaults,
CROWD: _crowd_defaults,
RULES: _rules_defaults,
# Whose instance this is. The defaults live in `services/branding.py`
# beside the code that reads them, because every one of them is paired with
# a label and a hint for the admin page and splitting the three across two
@@ -726,6 +738,13 @@ def crowd(db: DBSession) -> dict[str, Any]:
return values
def rules(db: DBSession) -> dict[str, Any]:
"""The talk-rules group, with the mode clamped to the two that exist."""
values = get_group(db, RULES)
values["mode"] = "closed" if values.get("mode") == "closed" else "open"
return values
def images_ready(db: DBSession) -> bool:
"""Whether image generation can actually happen.
+354
View File
@@ -0,0 +1,354 @@
"""Who may talk to whom: the rules behind the crowd, `ask_friend` and the roster.
Always evaluated **from the chat's main model**. If the main model may not talk
to a target, the target is not *offered* -- not listed on its roster, not named
as a friend it may ask, not in the crowd picker's main list. Members of a crowd
are not checked against each other: the rule is about who a conversation's own
model brings in, and a member loses `friend` and `subagent` anyway.
Two answers, because the owner asked for two things:
* **offered** -- what happens on its own: the roster, a friend a model names, the
picker's main list.
* **addable** -- what a person may do by hand in the crowd picker. A rule a
person wrote for themselves is soft for them; an instance rule is hard, unless
they hold `rules.override`.
`decide` is pure -- plain values in, a `Verdict` out -- so every combination is
tested without a database, and the admin page's matrix is drawn by the same
function that enforces the rules, which is what makes the matrix trustworthy.
**A different data group is an implicit deny.** A crowd member or a friend is
sent the conversation, so a model in another group joins only when a rule says
so explicitly: the administrator's, or a person's own when they hold the
override. It then reads its own group's stores, never the chat's.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
ANY_MODEL,
EFFECT_ALLOW,
EFFECT_DENY,
EFFECTS,
Model,
TalkRule,
User,
)
MODE_OPEN = "open"
MODE_CLOSED = "closed"
MODES = (MODE_OPEN, MODE_CLOSED)
# Where a person's own mode is kept in `settings_json`. Empty follows the instance.
SETTING_KEY = "talk_mode"
# Lets a person's own rules and mode win over the instance's, for them alone.
PERMISSION = "rules.override"
@dataclass(frozen=True)
class Rule:
from_model: str
to_model: str
effect: str
# Why something is not offered. A code rather than a sentence, so a screen can
# say it in the reader's language (`api/admin_rules.py:describe`) while a model
# refused a friend is told it in English (`Verdict.reason`).
WHY_INSTANCE_RULE = "instance_rule"
WHY_YOUR_RULE = "your_rule"
WHY_GROUP = "group"
WHY_INSTANCE_CLOSED = "instance_closed"
WHY_YOUR_CLOSED = "your_closed"
@dataclass(frozen=True)
class Verdict:
offered: bool
addable: bool
why: str = ""
rule: Rule | None = None
@property
def reason(self) -> str:
"""The reason in English, for a model -- or "" when it is offered."""
if self.offered or not self.why:
return ""
if self.why in (WHY_INSTANCE_RULE, WHY_YOUR_RULE) and self.rule is not None:
whose = "the instance's" if self.why == WHY_INSTANCE_RULE else "your"
return _named(self.rule, whose)
return {
WHY_GROUP: "it is in another data group",
WHY_INSTANCE_CLOSED: "the instance allows no model to talk to another",
WHY_YOUR_CLOSED: "your setting allows no model to talk to another",
}.get(self.why, "")
def match(rules: list[Rule], main: str, target: str) -> Rule | None:
"""The most specific rule for a pair: exact, then `main -> *`, `* -> target`, `* -> *`."""
for wanted in ((main, target), (main, ANY_MODEL), (ANY_MODEL, target), (ANY_MODEL, ANY_MODEL)):
for rule in rules:
if (rule.from_model, rule.to_model) == wanted:
return rule
return None
def _named(rule: Rule, whose: str) -> str:
frm = "any model" if rule.from_model == ANY_MODEL else rule.from_model
to = "any model" if rule.to_model == ANY_MODEL else rule.to_model
verb = "allows" if rule.effect == EFFECT_ALLOW else "forbids"
return f"{whose} rule {frm} → {to} {verb} it"
def decide(
*,
instance_mode: str,
instance_rule: Rule | None,
user_mode: str = "",
user_rule: Rule | None = None,
override: bool = False,
same_group: bool = True,
) -> Verdict:
"""Whether a main model may talk to a target, offered and by hand.
The instance's verdict is its most specific rule, or failing that its mode
-- with a different data group counting as a deny that only an explicit
allow opens.
Without the override a person can only narrow: their own rule or their
`closed` mode can take something off what is offered, and since those are
theirs, they may still add it by hand. With the override, their explicit
rule wins outright, then their mode, then the instance's verdict; and they
may add anything by hand, because doing so is their explicit decision.
"""
if instance_rule is not None:
instance_ok = instance_rule.effect == EFFECT_ALLOW
instance_why: tuple[str, Rule | None] = (WHY_INSTANCE_RULE, instance_rule)
elif not same_group:
instance_ok, instance_why = False, (WHY_GROUP, None)
else:
instance_ok = instance_mode != MODE_CLOSED
instance_why = (WHY_INSTANCE_CLOSED, None)
if not override:
if user_rule is not None:
user_ok = user_rule.effect == EFFECT_ALLOW
user_why: tuple[str, Rule | None] = (WHY_YOUR_RULE, user_rule)
elif user_mode == MODE_CLOSED:
user_ok, user_why = False, (WHY_YOUR_CLOSED, None)
else:
user_ok, user_why = True, ("", None)
offered = instance_ok and user_ok
why, rule = ("", None) if offered else (instance_why if not instance_ok else user_why)
return Verdict(offered=offered, addable=instance_ok, why=why, rule=rule)
if user_rule is not None:
ok = user_rule.effect == EFFECT_ALLOW
why, rule = (WHY_YOUR_RULE, user_rule)
elif user_mode == MODE_CLOSED:
ok, (why, rule) = False, (WHY_YOUR_CLOSED, None)
elif user_mode == MODE_OPEN:
allowed = instance_rule is not None and instance_rule.effect == EFFECT_ALLOW
ok = same_group or allowed
why, rule = (WHY_GROUP, None)
else:
ok = instance_ok
why, rule = instance_why
if ok:
why, rule = "", None
return Verdict(offered=ok, addable=True, why=why, rule=rule)
# --- Loading what `decide` needs ---------------------------------------------------
def _rules(db: DBSession, owner_id: str | None) -> list[Rule]:
rows = db.scalars(
select(TalkRule).where(
TalkRule.owner_id.is_(None) if owner_id is None else TalkRule.owner_id == owner_id
)
)
return [Rule(r.from_model, r.to_model, r.effect) for r in rows]
def user_mode(user: User | None) -> str:
if user is None:
return ""
value = str((user.settings_json or {}).get(SETTING_KEY) or "")
return value if value in MODES else ""
def may_override(db: DBSession, user: User | None) -> bool:
from lembas.security import permissions
return user is not None and permissions.has(db, user, PERMISSION)
class Judge:
"""Everything `decide` needs for one person, loaded once per request.
The model lists ask about every model they show; loading the rules and the
connection groups per question would be a query per row of every picker.
`user=None` is the instance's own view, for the admin page's matrix.
"""
def __init__(self, db: DBSession, user: User | None) -> None:
from lembas.services import data_groups, settings_store
self.instance_mode = settings_store.rules(db)["mode"]
self.instance_rules = _rules(db, None)
self.user_rules = _rules(db, user.id) if user is not None else []
self.user_mode = user_mode(user)
self.override = may_override(db, user)
self.groups = data_groups.connection_groups(db, user)
self.default = data_groups.DEFAULT_GROUP
def group_of(self, model: Model) -> str:
return self.groups.get(model.connection_id, self.default)
def verdict(self, main_model: str, main_group: str, target: Model) -> Verdict:
return decide(
instance_mode=self.instance_mode,
instance_rule=match(self.instance_rules, main_model, target.model_id),
user_mode=self.user_mode,
user_rule=match(self.user_rules, main_model, target.model_id),
override=self.override,
same_group=self.group_of(target) == main_group,
)
def candidates(
db: DBSession, user: User | None, main_model: str, main_group: str
) -> list[tuple[Model, Verdict]]:
"""Every model this person can reach other than the main one, with its verdict."""
from lembas.services import chat as chat_service
judge = Judge(db, user)
return [
(model, judge.verdict(main_model, main_group, model))
for model in chat_service.available_models(db, user)
if model.model_id != main_model
]
def offered(db: DBSession, user: User | None, main_model: str, main_group: str) -> list[Model]:
"""The models a main model is offered on its own: the roster, a friend, the picker."""
found = candidates(db, user, main_model, main_group)
return [model for model, verdict in found if verdict.offered]
def addable(db: DBSession, user: User | None, main_model: str, main_group: str) -> list[Model]:
"""The models a person may add to a crowd by hand."""
found = candidates(db, user, main_model, main_group)
return [model for model, verdict in found if verdict.addable]
# --- Changing rules --------------------------------------------------------------------
def rules_of(db: DBSession, owner: User | None) -> list[TalkRule]:
return list(
db.scalars(
select(TalkRule)
.where(
TalkRule.owner_id.is_(None)
if owner is None
else TalkRule.owner_id == owner.id
)
.order_by(TalkRule.from_model, TalkRule.to_model)
)
)
def set_rule(
db: DBSession,
owner: User | None,
from_model: str,
to_model: str,
effect: str,
*,
both: bool = False,
) -> None:
"""Write one rule, or a pair in both directions. Replaces one for the same pair."""
from_model = (from_model or "").strip()[:300] or ANY_MODEL
to_model = (to_model or "").strip()[:300] or ANY_MODEL
if effect not in EFFECTS:
effect = EFFECT_DENY
pairs = [(from_model, to_model)]
if both and from_model != to_model:
pairs.append((to_model, from_model))
for frm, to in pairs:
existing = db.scalar(
select(TalkRule).where(
(TalkRule.owner_id.is_(None) if owner is None else TalkRule.owner_id == owner.id),
TalkRule.from_model == frm,
TalkRule.to_model == to,
)
)
if existing is not None:
existing.effect = effect
else:
db.add(
TalkRule(
owner_id=owner.id if owner is not None else None,
from_model=frm,
to_model=to,
effect=effect,
)
)
db.commit()
def delete_rule(db: DBSession, owner: User | None, rule_id: str) -> bool:
"""Remove one rule, only from the layer it belongs to."""
rule = db.get(TalkRule, rule_id)
if rule is None or rule.owner_id != (owner.id if owner is not None else None):
return False
db.delete(rule)
db.commit()
return True
def matrix(db: DBSession, user: User | None, models: list[Model]) -> list[dict]:
"""Main x target, drawn by the same `decide` that enforces the rules.
Evaluated as if the main model's chat were in the main model's own group,
which is what a chat started on it is.
"""
judge = Judge(db, user)
rows = []
for main in models:
group = judge.group_of(main)
cells = []
for target in models:
if target.model_id == main.model_id:
cells.append(None)
continue
cells.append(judge.verdict(main.model_id, group, target))
rows.append({"main": main, "cells": cells})
return rows
__all__ = [
"MODES",
"MODE_CLOSED",
"MODE_OPEN",
"PERMISSION",
"Judge",
"Rule",
"Verdict",
"addable",
"candidates",
"decide",
"delete_rule",
"match",
"matrix",
"may_override",
"offered",
"rules_of",
"set_rule",
"user_mode",
]
+47
View File
@@ -1859,3 +1859,50 @@ MESSAGES.update(
'Image review': 'Posudzovanie obrázkov',
}
)
# --- Model rules (1.11.0) ------------------------------------------------------
MESSAGES.update(
{
'Model rules': 'Pravidlá modelov',
"Which model may bring which into a conversation: as a crowd member, as a friend it asks, and on the list of other models it is told about. A rule is read from the chat's own model. A model in another data group is not offered unless a rule allows it.": 'Ktorý model môže priviesť ktorý do konverzácie: ako člena skupiny, ako priateľa, ktorého sa pýta, a na zozname ďalších modelov, o ktorých sa dozvie. Pravidlo sa číta od vlastného modelu konverzácie. Model v inej dátovej oblasti sa neponúka, kým to pravidlo nepovolí.',
'The starting point': 'Východisko',
'Any model may talk to any other, except where a rule forbids it': 'Každý model môže hovoriť s každým, okrem prípadov, keď to pravidlo zakazuje',
'No model may talk to another, except where a rule allows it': 'Žiadny model nesmie hovoriť s iným, okrem prípadov, keď to pravidlo povoľuje',
'People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.': 'Ľudia si to pre seba môžu vždy zúžiť. Rozšíriť si to pre seba môžu len s oprávnením prekonať pravidlá modelov.',
'Rules': 'Pravidlá',
'Not offered to this model': 'Tomuto modelu sa neponúkajú',
'You may still add it yourself.': 'Môžete ho aj tak pridať sami.',
'any model': 'ľubovoľný model',
'may talk': 'smie hovoriť',
'may not talk': 'nesmie hovoriť',
'Delete this rule': 'Zmazať toto pravidlo',
'No rules yet.': 'Zatiaľ žiadne pravidlá.',
'This model': 'Tento model',
"The chat's own model.": 'Vlastný model konverzácie.',
'May': 'Smie',
'may not talk to': 'nesmie hovoriť s',
'may talk to': 'smie hovoriť s',
'That model': 'Tamten model',
'A crowd member, a friend it asks, a model on its roster.': 'Člen skupiny, priateľ, ktorého sa pýta, model na jeho zozname.',
'Both directions': 'Oboma smermi',
'Add rule': 'Pridať pravidlo',
'Who may talk to whom': 'Kto smie hovoriť s kým',
"Rows are the chat's own model, columns the model it would bring in. Drawn by the same rules that are enforced, so what this shows is what happens.": 'Riadky sú vlastný model konverzácie, stĺpce model, ktorý by priviedol. Kreslí to tie isté pravidlá, ktoré sa uplatňujú, takže čo tu vidíte, to sa aj stane.',
'itself': 'on sám',
'Offered': 'Ponúka sa',
'Who your models may talk to': 'S kým smú hovoriť vaše modely',
"Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.": 'Vaše pravidlá a vaše nastavenie majú pre vás prednosť pred pravidlami inštancie. Do skupiny môžete ručne pridať aj ľubovoľný model.',
"Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.": 'Vaše pravidlá môžu pravidlá inštancie len zúžiť. Model, ktorý zadrží vaše vlastné pravidlo, môžete do skupiny aj tak pridať ručne; model, ktorý zadrží inštancia, nie.',
'Your starting point': 'Vaše východisko',
'Follow the instance': 'Podľa inštancie',
'Any model may talk to any other': 'Každý model môže hovoriť s každým',
'No model may talk to another': 'Žiadny model nesmie hovoriť s iným',
"The instance's rule %(a)s → %(b)s allows it.": 'Pravidlo inštancie %(a)s → %(b)s to povoľuje.',
"The instance's rule %(a)s → %(b)s forbids it.": 'Pravidlo inštancie %(a)s → %(b)s to zakazuje.',
'Your rule %(a)s → %(b)s allows it.': 'Vaše pravidlo %(a)s → %(b)s to povoľuje.',
'Your rule %(a)s → %(b)s forbids it.': 'Vaše pravidlo %(a)s → %(b)s to zakazuje.',
'It is in another data group.': 'Je v inej dátovej oblasti.',
'The instance lets no model talk to another.': 'Inštancia nedovoľuje žiadnemu modelu hovoriť s iným.',
'Your setting lets no model talk to another.': 'Vaše nastavenie nedovoľuje žiadnemu modelu hovoriť s iným.',
}
)
+44
View File
@@ -1954,3 +1954,47 @@ body.is-resizing .canvas__body { pointer-events: none; }
}
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out); }
/* --- Who may talk to whom --------------------------------------------------
The talk-rules matrix: one row per main model, one column per model it would
bring in. Its own scroller, because a dozen model ids across is wider than a
phone and the page must never scroll sideways. */
.talk-matrix-scroll {
overflow-x: auto;
max-width: 100%;
}
.talk-matrix {
border-collapse: collapse;
font-size: var(--text-xs);
}
.talk-matrix th,
.talk-matrix td {
padding: var(--sp-1) var(--sp-2);
border: var(--border-w) solid var(--border);
text-align: center;
white-space: nowrap;
}
.talk-matrix thead th { color: var(--ink-muted); font-weight: 500; }
.talk-matrix tbody th { text-align: left; color: var(--ink-muted); font-weight: 500; }
.talk-matrix__yes { color: var(--leaf); }
.talk-matrix__no { color: var(--danger); }
.talk-matrix__self { color: var(--ink-faint); }
/* The add-a-rule form: three fields on a shared track so the label, the
control and the hint of each line up whatever the text does -- the tree's
subgrid rule. Stacks below a phone's width. */
.talk-rule-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
grid-template-rows: auto auto auto;
column-gap: var(--sp-3);
min-width: 0;
margin-top: var(--sp-4);
}
.talk-rule-form > .field {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
min-width: 0;
}
.talk-rule-form > .btn-row { grid-column: 1 / -1; }
@@ -63,6 +63,10 @@
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("A crowd") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'rules' }}" href="/admin/rules">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("Model rules") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">{{ t("Audio") }}</span>
+38
View File
@@ -0,0 +1,38 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "rules" %}
{% block title %}{{ t("Model rules") }} - {{ brand.name }}{% endblock %}
{% block heading %}{{ t("Model rules") }}{% endblock %}
{% block admin_content %}
<p class="admin-lede">{{ t("Which model may bring which into a conversation: as a crowd member, as a friend it asks, and on the list of other models it is told about. A rule is read from the chat's own model. A model in another data group is not offered unless a rule allows it.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
<section class="card">
<h2 class="card__title">{{ t("The starting point") }}</h2>
<form method="post" action="/admin/rules/mode">
<div class="field">
<label class="checkbox">
<input type="radio" name="mode" value="open" {{ 'checked' if mode == 'open' }}>
<span>{{ t("Any model may talk to any other, except where a rule forbids it") }}</span>
</label>
<label class="checkbox">
<input type="radio" name="mode" value="closed" {{ 'checked' if mode == 'closed' }}>
<span>{{ t("No model may talk to another, except where a rule allows it") }}</span>
</label>
<p class="field__hint">{{ t("People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.") }}</p>
</div>
<div class="btn-row"><button class="btn btn--primary" type="submit">{{ t("Save") }}</button></div>
</form>
</section>
<section class="card">
<h2 class="card__title">{{ t("Rules") }}</h2>
{% set rules_action = "/admin/rules" %}
{% include "partials/_talk_rules.html" %}
</section>
{% endblock %}
+33 -1
View File
@@ -314,7 +314,7 @@
because a browser submits only the ticked boxes and `start_chat`
needs to know which ones were not.
#}
{% if crowd_available %}
{% if crowd_available or crowd_held_back %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
@@ -366,6 +366,38 @@
</span>
</label>
{% endfor %}
{#
Models the talk rules do not offer to this chat's model, named
with the reason rather than left out. Tickable where the person
may still add them by hand -- their own rule, or the override --
and shown disabled where they may not, so the reason is visible
either way. Same field and same verb as the list above.
#}
{% if crowd_held_back %}
<p class="picker__group">{{ t("Not offered to this model") }}</p>
{% for row in crowd_held_back %}
<label class="picker__option picker__option--toggle">
{% if chat %}
<input type="checkbox" name="crowd_model_ids" value="{{ row.model.model_id }}"
{{ 'checked' if row.model.model_id in crowd_member_ids }}
{{ 'disabled' if not row.addable }}
form="crowd-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="crowd_model_ids" value="{{ row.model.model_id }}"
{{ 'checked' if row.model.model_id in crowd_member_ids }}
{{ 'disabled' if not row.addable }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ row.model.label }}</span>
<span class="picker__option-note">
{{ row.reason }}
{% if row.addable %}{{ t("You may still add it yourself.") }}{% endif %}
</span>
</span>
</label>
{% endfor %}
{% endif %}
{% if crowd_member_ids %}
{# One sentence and not three, with the numbers as placeholders: a
translation puts the parts in its own order, and two of these
@@ -0,0 +1,104 @@
{#
The rules list, the add form and the matrix, shared by the admin page and a
person's own settings. The caller sets `rules_action` (where the add form and
the deletes post), and passes `rules`, `model_ids`, `matrix`, `matrix_models`,
`any_model`, `allow` and `deny`.
The delete buttons reach one empty form each by `form=`, declared outside the
add form, so no form is ever nested inside another -- a nested <form>
truncates its parent, the 1.3.0 bug.
#}
{% from "_macros.html" import icon %}
{% if rules %}
<ul class="model-list">
{% for rule in rules %}
<li class="model-list__item">
<div style="min-width: 0">
<strong class="mono">{{ t("any model") if rule.from_model == any_model else rule.from_model }}</strong>
→
<strong class="mono">{{ t("any model") if rule.to_model == any_model else rule.to_model }}</strong>
</div>
<div class="btn-row">
{% if rule.effect == allow %}
<span class="badge badge--leaf">{{ t("may talk") }}</span>
{% else %}
<span class="badge badge--danger">{{ t("may not talk") }}</span>
{% endif %}
<form id="delete-rule-{{ rule.id }}" method="post" action="{{ rules_action }}/{{ rule.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="delete-rule-{{ rule.id }}"
aria-label="{{ t('Delete this rule') }}" title="{{ t('Delete this rule') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="field__hint">{{ t("No rules yet.") }}</p>
{% endif %}
<form method="post" action="{{ rules_action }}" class="talk-rule-form">
<div class="field">
<label class="field__label" for="rule-from">{{ t("This model") }}</label>
<select class="select" id="rule-from" name="from_model">
<option value="{{ any_model }}">{{ t("any model") }}</option>
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("The chat's own model.") }}</p>
</div>
<div class="field">
<label class="field__label" for="rule-effect">{{ t("May") }}</label>
<select class="select" id="rule-effect" name="effect">
<option value="{{ deny }}">{{ t("may not talk to") }}</option>
<option value="{{ allow }}">{{ t("may talk to") }}</option>
</select>
<p class="field__hint"></p>
</div>
<div class="field">
<label class="field__label" for="rule-to">{{ t("That model") }}</label>
<select class="select" id="rule-to" name="to_model">
<option value="{{ any_model }}">{{ t("any model") }}</option>
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("A crowd member, a friend it asks, a model on its roster.") }}</p>
</div>
<div class="btn-row">
<label class="checkbox">
<input type="checkbox" name="both" value="true">
<span>{{ t("Both directions") }}</span>
</label>
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add rule") }}</button>
</div>
</form>
{% if matrix_models|length > 1 %}
<h3 class="card__title" style="margin-top: var(--sp-6)">{{ t("Who may talk to whom") }}</h3>
<p class="field__hint">{{ t("Rows are the chat's own model, columns the model it would bring in. Drawn by the same rules that are enforced, so what this shows is what happens.") }}</p>
<div class="talk-matrix-scroll">
<table class="talk-matrix">
<thead>
<tr>
<th scope="col"></th>
{% for model in matrix_models %}<th scope="col" class="mono">{{ model.model_id }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in matrix %}
<tr>
<th scope="row" class="mono">{{ row.main.model_id }}</th>
{% for cell in row.cells %}
{% if cell is none %}
<td class="talk-matrix__self" aria-label="{{ t('itself') }}">·</td>
{% elif cell.offered %}
<td class="talk-matrix__yes" title="{{ t('Offered') }}">✓</td>
{% else %}
<td class="talk-matrix__no" title="{{ describe_verdict(cell) }}">✗</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
+31
View File
@@ -185,6 +185,37 @@
</ul>
</div>
{% endif %}
{#
Who your models may talk to. Anybody may narrow the instance's rules
for themselves; widening them takes `rules.override`, and the text
below says which of the two this person has, so a rule that does
nothing is not a mystery.
#}
<div class="card">
<h2 class="card__title">{{ t("Who your models may talk to") }}</h2>
<p class="card__lede">
{% if talk_override %}
{{ t("Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.") }}
{% else %}
{{ t("Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.") }}
{% endif %}
</p>
<form method="post" action="/api/preferences/talk-mode" class="row"
style="gap: var(--sp-2); margin-bottom: var(--sp-4)">
<label class="visually-hidden" for="talk-mode">{{ t("Your starting point") }}</label>
<select class="select" id="talk-mode" name="mode" style="flex: 1; min-width: 0">
<option value="" {{ 'selected' if not talk_mode }}>{{ t("Follow the instance") }}</option>
<option value="open" {{ 'selected' if talk_mode == 'open' }}>{{ t("Any model may talk to any other") }}</option>
<option value="closed" {{ 'selected' if talk_mode == 'closed' }}>{{ t("No model may talk to another") }}</option>
</select>
<button class="btn" type="submit">{{ t("Save") }}</button>
</form>
{% set rules_action = "/api/preferences/talk-rules" %}
{% set rules = talk_rules %}
{% set matrix = talk_matrix %}
{% set matrix_models = talk_matrix_models %}
{% include "partials/_talk_rules.html" %}
</div>
</section>
{# --- Appearance --- #}
+6
View File
@@ -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))
+121
View File
@@ -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 == ""
+203
View File
@@ -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&#39;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"]