Model rules: who a chat's model may bring into a conversation

Rules read from the main model decide who it is offered as a crowd member,
a friend and on its roster; any-to-any with denies by default, or
none-to-none with allows. The crowd picker names what it holds back and
why, and a model held back only by a person's own rule -- or by anything,
with the new rules.override -- can still be added by hand. Another data
group is now a deny that an explicit rule opens. Admin -> Model rules and
a card in Settings, each with a matrix drawn by the enforcing function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 17:20:40 +00:00
co-authored by Claude Opus 5.5
parent 9970bb43c6
commit c27fe47d4d
24 changed files with 1324 additions and 29 deletions
+85
View File
@@ -0,0 +1,85 @@
"""Model rules: which model may bring which into a conversation.
The instance's layer. A person's own rules and mode are on their settings page
(`api/preferences.py`), and `services/talk.py` is where the two are combined.
The page ends in a matrix -- every main model against every other -- drawn by
`talk.matrix`, which calls the same `decide` that enforces the rules. A preview
computed any other way would be a second copy of the logic, and a preview that
disagrees with enforcement is worse than none: it is believed.
"""
from __future__ import annotations
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.api.pages import describe_verdict
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import chat as chat_service
from lembas.services import settings_store, talk
from lembas.web.templating import render
router = APIRouter(prefix="/admin/rules", tags=["admin-rules"])
def model_ids(db, user) -> list[str]:
"""Every model id this person can reach, once each, in the admin's order."""
seen: list[str] = []
for model in chat_service.available_models(db, user):
if model.model_id not in seen:
seen.append(model.model_id)
return seen
@router.get("")
async def rules_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
models = chat_service.available_models(db, user)
return render(
request,
"admin/rules.html",
{
"mode": settings_store.rules(db)["mode"],
"rules": talk.rules_of(db, None),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"matrix_models": models,
# The instance's own view: no person's layer and no override, which
# is what somebody without `rules.override` gets unless they narrow.
"matrix": talk.matrix(db, None, models),
"describe_verdict": describe_verdict,
"saved": saved,
},
)
@router.post("/mode")
async def save_mode(db: Db, user: AdminUser, mode: str = Form(talk.MODE_OPEN)) -> Response:
settings_store.update(
db,
{"mode": talk.MODE_CLOSED if mode == talk.MODE_CLOSED else talk.MODE_OPEN},
key=settings_store.RULES,
)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("")
async def add_rule(
db: Db,
user: AdminUser,
from_model: str = Form(ANY_MODEL),
to_model: str = Form(ANY_MODEL),
effect: str = Form(EFFECT_DENY),
both: bool = Form(False),
) -> Response:
talk.set_rule(db, None, from_model, to_model, effect, both=both)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/{rule_id}/delete")
async def delete_rule(db: Db, user: AdminUser, rule_id: str) -> Response:
talk.delete_rule(db, None, rule_id)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)