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:
@@ -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,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.10.0"
|
||||
__version__ = "1.11.0"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
+73
-9
@@ -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,11 +359,12 @@ 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 []
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 --- #}
|
||||
|
||||
@@ -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