diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4c553..3d0ed83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index c180b74..84cdf83 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "1.10.0" +__version__ = "1.11.0" diff --git a/src/lembas/api/admin_rules.py b/src/lembas/api/admin_rules.py new file mode 100644 index 0000000..566e3fb --- /dev/null +++ b/src/lembas/api/admin_rules.py @@ -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) diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 213fada..c132916 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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: diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 88aec22..7177c89 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -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, diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py index 6ae939b..df38859 100644 --- a/src/lembas/api/preferences.py +++ b/src/lembas/api/preferences.py @@ -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) diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py index 75e3adf..e20143e 100644 --- a/src/lembas/db/models/__init__.py +++ b/src/lembas/db/models/__init__.py @@ -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", diff --git a/src/lembas/db/models/talk.py b/src/lembas/db/models/talk.py new file mode 100644 index 0000000..993ffca --- /dev/null +++ b/src/lembas/db/models/talk.py @@ -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" {self.to_model} {self.effect}>" diff --git a/src/lembas/main.py b/src/lembas/main.py index c6eb6ef..419334a 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -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) diff --git a/src/lembas/security/permissions.py b/src/lembas/security/permissions.py index 758158e..ad118a0 100644 --- a/src/lembas/security/permissions.py +++ b/src/lembas/security/permissions.py @@ -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", diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py index a0adc7a..72dfa21 100644 --- a/src/lembas/services/chat.py +++ b/src/lembas/services/chat.py @@ -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( diff --git a/src/lembas/services/crowd.py b/src/lembas/services/crowd.py index a703cfc..7318591 100644 --- a/src/lembas/services/crowd.py +++ b/src/lembas/services/crowd.py @@ -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 diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py index b9e8ec1..b1fa4c6 100644 --- a/src/lembas/services/settings_store.py +++ b/src/lembas/services/settings_store.py @@ -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. diff --git a/src/lembas/services/talk.py b/src/lembas/services/talk.py new file mode 100644 index 0000000..4b5266f --- /dev/null +++ b/src/lembas/services/talk.py @@ -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", +] diff --git a/src/lembas/web/i18n/sk.py b/src/lembas/web/i18n/sk.py index 3b805fc..fe4a516 100644 --- a/src/lembas/web/i18n/sk.py +++ b/src/lembas/web/i18n/sk.py @@ -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.', + } +) diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index f4aa529..4519659 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -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; } diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html index d56be84..f6b0f5e 100644 --- a/src/lembas/web/templates/admin/_layout.html +++ b/src/lembas/web/templates/admin/_layout.html @@ -63,6 +63,10 @@ {{ icon("users", "icon--sm") }} {{ t("A crowd") }} + + {{ icon("users", "icon--sm") }} + {{ t("Model rules") }} + {{ icon("speaker", "icon--sm") }} {{ t("Audio") }} diff --git a/src/lembas/web/templates/admin/rules.html b/src/lembas/web/templates/admin/rules.html new file mode 100644 index 0000000..2e16fc2 --- /dev/null +++ b/src/lembas/web/templates/admin/rules.html @@ -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 %} +

{{ 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.") }}

+ +{% if saved %} +
{{ icon("check", "icon--sm") }} {{ t("Settings saved.") }}
+{% endif %} + +
+

{{ t("The starting point") }}

+
+
+ + +

{{ t("People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.") }}

+
+
+
+
+ +
+

{{ t("Rules") }}

+ {% set rules_action = "/admin/rules" %} + {% include "partials/_talk_rules.html" %} +
+{% endblock %} diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html index bf109e4..fa2acd3 100644 --- a/src/lembas/web/templates/chat/_composer.html +++ b/src/lembas/web/templates/chat/_composer.html @@ -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 %}
+
+ + {% endfor %} + +{% else %} +

{{ t("No rules yet.") }}

+{% endif %} + +
+
+ + +

{{ t("The chat's own model.") }}

+
+
+ + +

+
+
+ + +

{{ t("A crowd member, a friend it asks, a model on its roster.") }}

+
+
+ + +
+
+ +{% if matrix_models|length > 1 %} +

{{ t("Who may talk to whom") }}

+

{{ 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.") }}

+
+ + + + + {% for model in matrix_models %}{% endfor %} + + + + {% for row in matrix %} + + + {% for cell in row.cells %} + {% if cell is none %} + + {% elif cell.offered %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
{{ model.model_id }}
{{ row.main.model_id }}·✓✗
+
+{% endif %} diff --git a/src/lembas/web/templates/settings.html b/src/lembas/web/templates/settings.html index d14ed70..d53fc25 100644 --- a/src/lembas/web/templates/settings.html +++ b/src/lembas/web/templates/settings.html @@ -185,6 +185,37 @@ {% 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. + #} +
+

{{ t("Who your models may talk to") }}

+

+ {% 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 %} +

+
+ + + +
+ {% 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" %} +
{# --- Appearance --- #} diff --git a/tests/test_data_group_chat_pin.py b/tests/test_data_group_chat_pin.py index 82e375b..8fa69db 100644 --- a/tests/test_data_group_chat_pin.py +++ b/tests/test_data_group_chat_pin.py @@ -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)) diff --git a/tests/test_talk_decide.py b/tests/test_talk_decide.py new file mode 100644 index 0000000..f579fdb --- /dev/null +++ b/tests/test_talk_decide.py @@ -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 == "" diff --git a/tests/test_talk_rules.py b/tests/test_talk_rules.py new file mode 100644 index 0000000..030ee0e --- /dev/null +++ b/tests/test_talk_rules.py @@ -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"]