Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216e3a20d1
|
||
|
|
c27fe47d4d
|
@@ -16,6 +16,76 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.12.0
|
||||||
|
|
||||||
|
Helpers on another model. This is the last of the three releases.
|
||||||
|
|
||||||
|
- **A helper can run on a different model.** A helper is still the chat's own
|
||||||
|
model by default. On each model's page, **Helpers** lists other models this
|
||||||
|
one may send helpers to. Each is either *offered to the model*, so it can
|
||||||
|
choose that model itself, or *by hand only*, so it is used once somebody adds
|
||||||
|
it to a chat. When there is more than one choice, `subagent_run` gains a
|
||||||
|
`model` argument limited to exactly those models, and the model is told what
|
||||||
|
each is for. A helper on another model runs with that model's own default
|
||||||
|
reasoning effort, never the parent's, which it might refuse. It reads its own
|
||||||
|
data group's memories and notes.
|
||||||
|
- **The composer's Helpers button** lists the models designated for the chat's
|
||||||
|
model. Tick one to add it to this chat, on the new-chat screen as well.
|
||||||
|
- **Capacity is decided by logic, not by the model.** Two new switches, both off
|
||||||
|
by default, so nothing changes until they are set:
|
||||||
|
- **Serves one request at a time**, on a model. It cannot be its own helper,
|
||||||
|
because the helper would wait behind the reply that is waiting for it.
|
||||||
|
- **Holds one model at a time**, on a connection (llama-swap in front of one
|
||||||
|
GPU). Its models may still be their own helpers, but never send one to
|
||||||
|
another model on the same connection, because loading it would unload the
|
||||||
|
model whose reply is waiting.
|
||||||
|
|
||||||
|
A model with no helper it can use is no longer offered `subagent_run` at all,
|
||||||
|
instead of being offered a tool that refuses every call. Asking a friend and
|
||||||
|
the crowd are not affected: both are sequential, and the wait for a model to
|
||||||
|
load is accepted there.
|
||||||
|
- **The model rules apply to helpers too.** A designation the model chooses
|
||||||
|
itself must be allowed by the rules. One added to a chat by hand needs what a
|
||||||
|
crowd member added by hand needs. A model in another data group needs a rule
|
||||||
|
that allows it.
|
||||||
|
- **Your own helper models.** With the new *Choose their own helper models*
|
||||||
|
permission (off by default), Settings → Models lets a person designate helpers
|
||||||
|
for their models. These are added to the instance's, and a row of theirs for
|
||||||
|
the same pair replaces the instance's.
|
||||||
|
|
||||||
|
## 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
|
## 1.10.0
|
||||||
|
|
||||||
Data groups: a provider's models read only the data of the group their
|
Data groups: a provider's models read only the data of the group their
|
||||||
|
|||||||
@@ -324,6 +324,12 @@ def build_client():
|
|||||||
owner = db.query(User).first()
|
owner = db.query(User).first()
|
||||||
db.add(Note(owner_id=owner.id, title="A note at home", body="x", data_group_id="default"))
|
db.add(Note(owner_id=owner.id, title="A note at home", body="x", data_group_id="default"))
|
||||||
db.add(Note(owner_id=owner.id, title="A hosted note", body="x", data_group_id="hosted"))
|
db.add(Note(owner_id=owner.id, title="A hosted note", body="x", data_group_id="hosted"))
|
||||||
|
# A rule and a designation, so the rules page, the matrix and the
|
||||||
|
# model page's Helpers card render with something in them.
|
||||||
|
from lembas.db.models import HelperDesignation, TalkRule
|
||||||
|
|
||||||
|
db.add(TalkRule(from_model="gemma4-moe", to_model="qwen3-coder", effect="deny"))
|
||||||
|
db.add(HelperDesignation(main_model="gemma4-moe", helper_model="qwen3-coder"))
|
||||||
local = db.query(Connection).filter_by(name="local").first()
|
local = db.query(Connection).filter_by(name="local").first()
|
||||||
db.add(
|
db.add(
|
||||||
Chat(
|
Chat(
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.10.0"
|
__version__ = "1.12.0"
|
||||||
|
|||||||
@@ -177,8 +177,10 @@ async def update_connection(
|
|||||||
unload_method: str = Form("POST"),
|
unload_method: str = Form("POST"),
|
||||||
extra_headers: str = Form(""),
|
extra_headers: str = Form(""),
|
||||||
data_group_id: str = Form(""),
|
data_group_id: str = Form(""),
|
||||||
|
one_model_at_a_time: bool = Form(False),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
connection = _connection(db, connection_id)
|
connection = _connection(db, connection_id)
|
||||||
|
connection.one_model_at_a_time = one_model_at_a_time
|
||||||
# Which of the instance's data groups this provider reads. Empty is "not
|
# Which of the instance's data groups this provider reads. Empty is "not
|
||||||
# submitted" -- an older page -- and leaves it alone; a personal group is
|
# submitted" -- an older page -- and leaves it alone; a personal group is
|
||||||
# somebody else's arrangement and cannot be chosen here.
|
# somebody else's arrangement and cannot be chosen here.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session as DBSession
|
|||||||
from lembas.api.deps import AdminUser, Db, RequiredUser
|
from lembas.api.deps import AdminUser, Db, RequiredUser
|
||||||
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
|
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
from lembas.services import personas as personas_service
|
from lembas.services import personas as personas_service
|
||||||
from lembas.services import settings_store, uploads
|
from lembas.services import settings_store, uploads
|
||||||
from lembas.services.llm.openai_client import MAX_CONTEXT
|
from lembas.services.llm.openai_client import MAX_CONTEXT
|
||||||
@@ -209,6 +210,16 @@ async def model_detail(
|
|||||||
# and undo what a model wrote *before* they switched it off, which is
|
# and undo what a model wrote *before* they switched it off, which is
|
||||||
# exactly when they would come looking.
|
# exactly when they would come looking.
|
||||||
"persona": personas_service.get(db, model.model_id, None),
|
"persona": personas_service.get(db, model.model_id, None),
|
||||||
|
# Helpers designated for this model by the instance, and every other
|
||||||
|
# model id that could be one.
|
||||||
|
"designations": [
|
||||||
|
d for d in helpers_service.own_designations(db, None)
|
||||||
|
if d.main_model == model.model_id
|
||||||
|
],
|
||||||
|
"helper_choices": sorted(
|
||||||
|
{m.model_id for m in chat_service.available_models(db, user)}
|
||||||
|
- {model.model_id}
|
||||||
|
),
|
||||||
"persona_limit": personas_service.MAX_PERSONA_CHARS,
|
"persona_limit": personas_service.MAX_PERSONA_CHARS,
|
||||||
"position_of": index + 1,
|
"position_of": index + 1,
|
||||||
"total": len(ordered),
|
"total": len(ordered),
|
||||||
@@ -261,6 +272,7 @@ async def update_model(
|
|||||||
enabled: bool = Form(False),
|
enabled: bool = Form(False),
|
||||||
pinned: bool = Form(False),
|
pinned: bool = Form(False),
|
||||||
public: bool = Form(False),
|
public: bool = Form(False),
|
||||||
|
single_session: bool = Form(False),
|
||||||
position: str = Form(""),
|
position: str = Form(""),
|
||||||
context_length: str = Form(""),
|
context_length: str = Form(""),
|
||||||
default_effort: str = Form(""),
|
default_effort: str = Form(""),
|
||||||
@@ -284,6 +296,7 @@ async def update_model(
|
|||||||
model.enabled = enabled
|
model.enabled = enabled
|
||||||
model.pinned = pinned
|
model.pinned = pinned
|
||||||
model.public = public
|
model.public = public
|
||||||
|
model.single_session = single_session
|
||||||
|
|
||||||
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
# Merged rather than rebuilt, unlike the capabilities below: params_json
|
||||||
# holds whatever sampling defaults an administrator has set and this form
|
# holds whatever sampling defaults an administrator has set and this form
|
||||||
@@ -338,6 +351,35 @@ async def update_model(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/models/{model_id}/helpers")
|
||||||
|
async def add_helper(
|
||||||
|
db: Db,
|
||||||
|
user: AdminUser,
|
||||||
|
model_id: str,
|
||||||
|
helper_model: str = Form(""),
|
||||||
|
offer: bool = Form(False),
|
||||||
|
) -> Response:
|
||||||
|
"""Designate a model this one may send helpers to, for everybody.
|
||||||
|
|
||||||
|
Its own form, for the persona's reason: a list edited row by row, not a field
|
||||||
|
carried by the big save.
|
||||||
|
"""
|
||||||
|
model = _model(db, model_id)
|
||||||
|
helpers_service.set_designation(db, None, model.model_id, helper_model, offer=offer)
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/models/{model.id}/edit?saved=Helpers+updated.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/models/{model_id}/helpers/{designation_id}/delete")
|
||||||
|
async def remove_helper(db: Db, user: AdminUser, model_id: str, designation_id: str) -> Response:
|
||||||
|
model = _model(db, model_id)
|
||||||
|
helpers_service.delete_designation(db, None, designation_id)
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/models/{model.id}/edit?saved=Helpers+updated.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/models/{model_id}/persona")
|
@router.post("/admin/models/{model_id}/persona")
|
||||||
async def update_persona(
|
async def update_persona(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
|||||||
@@ -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)
|
||||||
+12
-5
@@ -37,6 +37,7 @@ from lembas.services import compaction as compaction_service
|
|||||||
from lembas.services import data_groups, interaction, settings_store, sse
|
from lembas.services import data_groups, interaction, settings_store, sse
|
||||||
from lembas.services import files as files_service
|
from lembas.services import files as files_service
|
||||||
from lembas.services import generation as generation_service
|
from lembas.services import generation as generation_service
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
from lembas.services import metrics as metrics_service
|
from lembas.services import metrics as metrics_service
|
||||||
from lembas.services import prompts as prompts_service
|
from lembas.services import prompts as prompts_service
|
||||||
from lembas.services import reports as reports_service
|
from lembas.services import reports as reports_service
|
||||||
@@ -309,6 +310,7 @@ async def start_chat(
|
|||||||
# -- the same mechanism the scope switches above use, and the reason the control
|
# -- the same mechanism the scope switches above use, and the reason the control
|
||||||
# lives inside the composer's form rather than in the topbar.
|
# lives inside the composer's form rather than in the topbar.
|
||||||
crowd_model_ids: list[str] = Form(default=[]),
|
crowd_model_ids: list[str] = Form(default=[]),
|
||||||
|
helper_model_ids: list[str] = Form(default=[]),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Create a chat from its first message.
|
"""Create a chat from its first message.
|
||||||
|
|
||||||
@@ -343,6 +345,7 @@ async def start_chat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_apply_crowd(db, chat, user, crowd_model_ids)
|
_apply_crowd(db, chat, user, crowd_model_ids)
|
||||||
|
helpers_service.apply_chat_helpers(db, chat, user, helper_model_ids)
|
||||||
|
|
||||||
_adopt_draft(db, user, draft_id, chat)
|
_adopt_draft(db, user, draft_id, chat)
|
||||||
|
|
||||||
@@ -1546,15 +1549,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.
|
and a model offered by two connections is two rows with different capabilities.
|
||||||
"""
|
"""
|
||||||
from lembas.db.models import CrowdMember
|
from lembas.db.models import CrowdMember
|
||||||
|
from lembas.services import talk
|
||||||
|
|
||||||
settings = settings_store.crowd(db)
|
settings = settings_store.crowd(db)
|
||||||
# Only models in the chat's own data group: a member is sent the whole
|
# What this person may add by hand, by the talk rules from the chat's main
|
||||||
# conversation, so one from another group would carry it to that provider.
|
# model. A member is sent the whole conversation, so one in another data
|
||||||
|
# group comes in only when a rule explicitly lets it.
|
||||||
reachable = {
|
reachable = {
|
||||||
model.model_id: model
|
model.model_id: model
|
||||||
for model in chat_service.available_models(
|
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
|
||||||
db, user, data_groups.for_chat(db, chat)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
wanted: list[str] = []
|
wanted: list[str] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
@@ -2200,6 +2203,10 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
|||||||
# every box clears the crowd.
|
# every box clears the crowd.
|
||||||
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
|
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
|
||||||
|
|
||||||
|
if "helper_model_ids" in form:
|
||||||
|
# The helpers picker, the same shape again.
|
||||||
|
helpers_service.apply_chat_helpers(db, chat, user, form.getlist("helper_model_ids"))
|
||||||
|
|
||||||
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
|
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
|
||||||
if submitted_params:
|
if submitted_params:
|
||||||
if not allowed.get("chat.params"):
|
if not allowed.get("chat.params"):
|
||||||
|
|||||||
+130
-10
@@ -100,6 +100,7 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
|||||||
**_crowd_context(
|
**_crowd_context(
|
||||||
db, user, chat, in_group, current.model_id if current is not None else ""
|
db, user, chat, in_group, current.model_id if current is not None else ""
|
||||||
),
|
),
|
||||||
|
**_helper_context(db, user, chat, current.model_id if current is not None else ""),
|
||||||
# What *this* model takes, not the three every model used to be assumed
|
# What *this* model takes, not the three every model used to be assumed
|
||||||
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
|
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
|
||||||
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
# Bonsai has no `high`, and sending the wrong one does not degrade, it
|
||||||
@@ -213,6 +214,43 @@ 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,
|
||||||
|
**_helper_settings(db, user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _helper_settings(db: DBSession, user: User) -> dict:
|
||||||
|
"""The person's own helper designations, and whether they may add any."""
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
|
|
||||||
|
may = helpers_service.may_designate(db, user)
|
||||||
|
shown = bool(settings_store.subagents(db).get("enabled")) and permissions.has(
|
||||||
|
db, user, "tools.subagent"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"helper_settings_shown": shown,
|
||||||
|
"may_designate": may,
|
||||||
|
"own_designations": helpers_service.own_designations(db, user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _data_group_settings(db: DBSession, user: User) -> dict:
|
def _data_group_settings(db: DBSession, user: User) -> dict:
|
||||||
"""What the Data tab on /settings shows: which group each connection reads.
|
"""What the Data tab on /settings shows: which group each connection reads.
|
||||||
|
|
||||||
@@ -251,6 +289,68 @@ 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 _helper_context(db: DBSession, user: User, chat: Chat | None, own: str) -> dict:
|
||||||
|
"""The helpers picker: models designated for this chat's model, to add by hand.
|
||||||
|
|
||||||
|
Only when helpers are on and this person may send one, and only when
|
||||||
|
something is designated -- with nothing designated the model is its own
|
||||||
|
helper and there is nothing to choose. On the new-chat screen the choice
|
||||||
|
rides along with the first message, exactly as the crowd's does.
|
||||||
|
"""
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
|
|
||||||
|
empty = {"helper_choices": [], "helper_member_ids": []}
|
||||||
|
if not settings_store.subagents(db).get("enabled"):
|
||||||
|
return empty
|
||||||
|
if not permissions.has(db, user, "tools.subagent"):
|
||||||
|
return empty
|
||||||
|
main = chat if chat is not None else own
|
||||||
|
if not main:
|
||||||
|
return empty
|
||||||
|
choices = helpers_service.picker(db, main, user)
|
||||||
|
return {
|
||||||
|
"helper_choices": choices,
|
||||||
|
"helper_reasons": {
|
||||||
|
choice.model.model_id: (
|
||||||
|
# One literal, not two adjacent ones: the catalogue extractor
|
||||||
|
# reads the first string of a `t()` call only, so a sentence split
|
||||||
|
# across two would be looked up under a key that exists nowhere.
|
||||||
|
i18n.t("It cannot run beside this model's reply: one of them serves one request at a time, or their connection holds one model at a time.") # noqa: E501
|
||||||
|
if choice.capacity
|
||||||
|
else (describe_verdict(choice.verdict) if not choice.addable else "")
|
||||||
|
)
|
||||||
|
for choice in choices
|
||||||
|
},
|
||||||
|
"helper_member_ids": [row.model_id for row in chat.helpers] if chat else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _crowd_context(
|
def _crowd_context(
|
||||||
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
|
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -271,24 +371,39 @@ def _crowd_context(
|
|||||||
|
|
||||||
settings = settings_store.crowd(db)
|
settings = settings_store.crowd(db)
|
||||||
if not settings["enabled"]:
|
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
|
# 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:
|
# 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.
|
# adding it would have it answer twice in a row.
|
||||||
own = chat.model_id if chat is not None else default_model_id
|
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.
|
# The talk rules, evaluated from the main model -- on the new-chat screen the
|
||||||
# On the new-chat screen that is the group of the model the picker shows,
|
# one the picker shows, whose data group the chat will be pinned to. Offered
|
||||||
# which is the one the chat will be pinned to.
|
# 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 = (
|
group = (
|
||||||
data_groups.for_chat(db, chat)
|
data_groups.for_chat(db, chat)
|
||||||
if chat is not None
|
if chat is not None
|
||||||
else (data_groups.for_pair(db, user, own) if own else None)
|
else (data_groups.for_pair(db, user, own) if own else None)
|
||||||
)
|
)
|
||||||
if group is not None:
|
if own and group is not None:
|
||||||
allowed = {m.id for m in chat_service.available_models(db, user, group)}
|
pairs = talk.candidates(db, user, own, group)
|
||||||
models = [model for model in models if model.id in allowed]
|
else:
|
||||||
others = [model for model in models if model.model_id != own]
|
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 = (
|
members = (
|
||||||
[
|
[
|
||||||
row.model_id
|
row.model_id
|
||||||
@@ -297,16 +412,17 @@ def _crowd_context(
|
|||||||
if chat is not None
|
if chat is not None
|
||||||
else []
|
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])
|
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
|
||||||
rounds = int(settings["max_rounds"])
|
rounds = int(settings["max_rounds"])
|
||||||
return {
|
return {
|
||||||
"crowd_available": others,
|
"crowd_available": others,
|
||||||
|
"crowd_held_back": held_back,
|
||||||
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
|
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
|
||||||
"crowd_skipped": (
|
"crowd_skipped": (
|
||||||
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
|
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.
|
# asked whether they disagree, and the main model closes.
|
||||||
"crowd_replies": max(1, speakers * 2 - 1),
|
"crowd_replies": max(1, speakers * 2 - 1),
|
||||||
"crowd_rounds": rounds,
|
"crowd_rounds": rounds,
|
||||||
@@ -839,6 +955,9 @@ async def chat_index(
|
|||||||
context["models"],
|
context["models"],
|
||||||
preselected.model_id if preselected is not None else "",
|
preselected.model_id if preselected is not None else "",
|
||||||
),
|
),
|
||||||
|
**_helper_context(
|
||||||
|
db, user, None, preselected.model_id if preselected is not None else ""
|
||||||
|
),
|
||||||
"starting_temporary": temporary,
|
"starting_temporary": temporary,
|
||||||
"temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"),
|
"temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"),
|
||||||
"model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=",
|
"model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=",
|
||||||
@@ -1034,6 +1153,7 @@ async def settings_page(
|
|||||||
# template shows the two apart rather than printing the raw key.
|
# template shows the two apart rather than printing the raw key.
|
||||||
"split_key": personas_service.split_key,
|
"split_key": personas_service.split_key,
|
||||||
**_data_group_settings(db, user),
|
**_data_group_settings(db, user),
|
||||||
|
**_talk_settings(db, user),
|
||||||
# Sorted rather than left in set order, because a list of six
|
# Sorted rather than left in set order, because a list of six
|
||||||
# hundred zones that is not alphabetical is one nobody can use.
|
# hundred zones that is not alphabetical is one nobody can use.
|
||||||
"languages": i18n.LANGUAGES,
|
"languages": i18n.LANGUAGES,
|
||||||
|
|||||||
@@ -371,3 +371,93 @@ async def delete_personal_group(db: Db, user: RequiredUser, group_id: str) -> Re
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
|
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
|
||||||
return RedirectResponse("/settings?saved=Data+group+deleted.", 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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helper models -------------------------------------------------------------
|
||||||
|
# A person's own designations: for each of their models, others it may send
|
||||||
|
# helpers to. Added to the instance's, for them alone. Needs `helpers.designate`.
|
||||||
|
def _refuse_without_designate(db, user) -> Response | None:
|
||||||
|
from lembas.services import helpers
|
||||||
|
|
||||||
|
if helpers.may_designate(db, user):
|
||||||
|
return None
|
||||||
|
return RedirectResponse(
|
||||||
|
"/settings?error=You+may+not+choose+your+own+helper+models.", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/helpers")
|
||||||
|
async def add_own_helper(
|
||||||
|
db: Db,
|
||||||
|
user: RequiredUser,
|
||||||
|
main_model: str = Form(""),
|
||||||
|
helper_model: str = Form(""),
|
||||||
|
offer: bool = Form(False),
|
||||||
|
) -> Response:
|
||||||
|
from lembas.security import permissions
|
||||||
|
from lembas.services import helpers
|
||||||
|
|
||||||
|
refused = _refuse_without_designate(db, user)
|
||||||
|
if refused is not None:
|
||||||
|
return refused
|
||||||
|
# Only models this person can reach, on both sides: a designation naming one
|
||||||
|
# they cannot use would never be offered and would sit there unexplained.
|
||||||
|
if main_model == helper_model or not (
|
||||||
|
permissions.can_use_model(db, user, main_model)
|
||||||
|
and permissions.can_use_model(db, user, helper_model)
|
||||||
|
):
|
||||||
|
return RedirectResponse("/settings?error=Choose+two+different+models.", status_code=303)
|
||||||
|
helpers.set_designation(db, user, main_model, helper_model, offer=offer)
|
||||||
|
return RedirectResponse("/settings?saved=Helper+models+updated.", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/helpers/{designation_id}/delete")
|
||||||
|
async def delete_own_helper(db: Db, user: RequiredUser, designation_id: str) -> Response:
|
||||||
|
from lembas.services import helpers
|
||||||
|
|
||||||
|
refused = _refuse_without_designate(db, user)
|
||||||
|
if refused is not None:
|
||||||
|
return refused
|
||||||
|
helpers.delete_designation(db, user, designation_id)
|
||||||
|
return RedirectResponse("/settings?saved=Helper+models+updated.", status_code=303)
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ from lembas.db.models.chat import (
|
|||||||
ROLE_TOOL,
|
ROLE_TOOL,
|
||||||
ROLE_USER,
|
ROLE_USER,
|
||||||
Chat,
|
Chat,
|
||||||
|
ChatHelper,
|
||||||
CrowdMember,
|
CrowdMember,
|
||||||
Folder,
|
Folder,
|
||||||
Message,
|
Message,
|
||||||
)
|
)
|
||||||
from lembas.db.models.connection import Connection, Model, model_groups
|
from lembas.db.models.connection import Connection, Model, model_groups
|
||||||
from lembas.db.models.data_group import DEFAULT_GROUP, DataGroup, InDataGroup
|
from lembas.db.models.data_group import DEFAULT_GROUP, DataGroup, InDataGroup
|
||||||
|
from lembas.db.models.helper import HelperDesignation
|
||||||
from lembas.db.models.image import ImageWorkflow
|
from lembas.db.models.image import ImageWorkflow
|
||||||
from lembas.db.models.library import (
|
from lembas.db.models.library import (
|
||||||
AUTHOR_MODEL,
|
AUTHOR_MODEL,
|
||||||
@@ -84,6 +86,7 @@ from lembas.db.models.schedule import (
|
|||||||
)
|
)
|
||||||
from lembas.db.models.setting import Setting
|
from lembas.db.models.setting import Setting
|
||||||
from lembas.db.models.suggestion import Suggestion
|
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 (
|
from lembas.db.models.tool import (
|
||||||
RESPONSE_JSON,
|
RESPONSE_JSON,
|
||||||
RESPONSE_MODES,
|
RESPONSE_MODES,
|
||||||
@@ -165,11 +168,18 @@ __all__ = [
|
|||||||
"Report",
|
"Report",
|
||||||
"Schedule",
|
"Schedule",
|
||||||
"Chat",
|
"Chat",
|
||||||
|
"ChatHelper",
|
||||||
"CrowdMember",
|
"CrowdMember",
|
||||||
|
"HelperDesignation",
|
||||||
"Job",
|
"Job",
|
||||||
"Connection",
|
"Connection",
|
||||||
"DEFAULT_GROUP",
|
"DEFAULT_GROUP",
|
||||||
"DataGroup",
|
"DataGroup",
|
||||||
|
"ANY_MODEL",
|
||||||
|
"EFFECT_ALLOW",
|
||||||
|
"EFFECT_DENY",
|
||||||
|
"EFFECTS",
|
||||||
|
"TalkRule",
|
||||||
"InDataGroup",
|
"InDataGroup",
|
||||||
"CustomTool",
|
"CustomTool",
|
||||||
"CHUNK_DOCUMENT",
|
"CHUNK_DOCUMENT",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@ class Chat(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="CrowdMember.position",
|
order_by="CrowdMember.position",
|
||||||
)
|
)
|
||||||
|
# Helper models somebody added to this chat by hand. See `ChatHelper`.
|
||||||
|
helpers: Mapped[list[ChatHelper]] = relationship(
|
||||||
|
back_populates="chat",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ChatHelper.position",
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Chat {self.title!r}>"
|
return f"<Chat {self.title!r}>"
|
||||||
@@ -372,6 +378,30 @@ class CrowdMember(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
return f"<CrowdMember {self.model_id} at {self.position}>"
|
return f"<CrowdMember {self.model_id} at {self.position}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHelper(UUIDPrimaryKey, Timestamps, Base):
|
||||||
|
"""A model added to this chat by hand, for its model to send helpers to.
|
||||||
|
|
||||||
|
`CrowdMember`'s shape and `CrowdMember`'s reasoning: the model is text with
|
||||||
|
no foreign key, so a "Test & refresh" cannot silently empty the list. A
|
||||||
|
helper that no longer resolves is simply not offered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "chat_helpers"
|
||||||
|
__table_args__ = (UniqueConstraint("chat_id", "model_id"),)
|
||||||
|
|
||||||
|
chat_id: Mapped[str] = mapped_column(
|
||||||
|
String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
model_id: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
connection_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
chat: Mapped[Chat] = relationship(back_populates="helpers")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ChatHelper {self.model_id} at {self.position}>"
|
||||||
|
|
||||||
|
|
||||||
class Message(UUIDPrimaryKey, Timestamps, Base):
|
class Message(UUIDPrimaryKey, Timestamps, Base):
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ class Connection(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
|
|||||||
unload_url: Mapped[str] = mapped_column(String(500), default="")
|
unload_url: Mapped[str] = mapped_column(String(500), default="")
|
||||||
unload_method: Mapped[str] = mapped_column(String(8), default="POST")
|
unload_method: Mapped[str] = mapped_column(String(8), default="POST")
|
||||||
|
|
||||||
|
# Whether this endpoint holds one model at a time -- llama-swap in front of
|
||||||
|
# one GPU, which swaps the model out to serve another. A model here may then
|
||||||
|
# be its own helper, never another model from this connection: the helper
|
||||||
|
# would evict the model whose reply is waiting on it. Named for the
|
||||||
|
# *restrictive* state on purpose: `sync_schema` backfills a NOT NULL boolean
|
||||||
|
# with False, so False has to mean "as before" (any number of models at once).
|
||||||
|
one_model_at_a_time: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Result of the most recent "Test & refresh", surfaced in the admin list.
|
# Result of the most recent "Test & refresh", surfaced in the admin list.
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_error: Mapped[str] = mapped_column(Text, default="")
|
last_error: Mapped[str] = mapped_column(Text, default="")
|
||||||
@@ -119,6 +127,12 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
# Pinned models are offered first, before the full list.
|
# Pinned models are offered first, before the full list.
|
||||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
# Whether this model serves one request at a time, so it cannot be its own
|
||||||
|
# helper: the helper's request would queue behind the reply that is waiting
|
||||||
|
# for it. The restrictive state, for the backfill reason on
|
||||||
|
# `Connection.one_model_at_a_time`. A column and not a `capabilities_json`
|
||||||
|
# key, because that dict is rebuilt from the checkboxes on every save.
|
||||||
|
single_session: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Public models are usable by anyone; otherwise access comes from `groups`.
|
# Public models are usable by anyone; otherwise access comes from `groups`.
|
||||||
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Which models a main model may send helpers to, besides itself.
|
||||||
|
|
||||||
|
Designated **per main model**, on the owner's word: gpt-oss may use qwen35,
|
||||||
|
bonsai may use deepseek-flash, and neither says anything about the other. The
|
||||||
|
instance designates (`owner_id` NULL); a person holding `helpers.designate` may
|
||||||
|
add their own, and theirs are added to the instance's -- a row of theirs for the
|
||||||
|
same pair wins, which is how they change whether it is offered.
|
||||||
|
|
||||||
|
`offer` says whether the main model may choose the helper itself. Off, it can be
|
||||||
|
added to a chat only by hand, and is then the chat's.
|
||||||
|
|
||||||
|
Text model ids and no foreign key, for the reason every such reference here has:
|
||||||
|
"Test & refresh" recreates `Model` rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class HelperDesignation(UUIDPrimaryKey, Timestamps, Base):
|
||||||
|
__tablename__ = "helper_designations"
|
||||||
|
__table_args__ = (UniqueConstraint("owner_id", "main_model", "helper_model"),)
|
||||||
|
|
||||||
|
owner_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
main_model: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
helper_model: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
helper_connection_id: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
offer: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<HelperDesignation {self.main_model} -> {self.helper_model}>"
|
||||||
@@ -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_images,
|
||||||
admin_models,
|
admin_models,
|
||||||
admin_prompts,
|
admin_prompts,
|
||||||
|
admin_rules,
|
||||||
admin_schedules,
|
admin_schedules,
|
||||||
admin_search,
|
admin_search,
|
||||||
admin_suggestions,
|
admin_suggestions,
|
||||||
@@ -231,6 +232,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(admin_agents.router)
|
app.include_router(admin_agents.router)
|
||||||
app.include_router(admin_crowd.router)
|
app.include_router(admin_crowd.router)
|
||||||
app.include_router(admin_data_groups.router)
|
app.include_router(admin_data_groups.router)
|
||||||
|
app.include_router(admin_rules.router)
|
||||||
app.include_router(push.router)
|
app.include_router(push.router)
|
||||||
app.include_router(branding.router)
|
app.include_router(branding.router)
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,31 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
|||||||
# themselves, and move their own records between groups. Off by default,
|
# themselves, and move their own records between groups. Off by default,
|
||||||
# because it moves what a provider can read -- and an instance that never
|
# because it moves what a provider can read -- and an instance that never
|
||||||
# looks should keep the arrangement its administrator made.
|
# 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",
|
||||||
|
),
|
||||||
|
# Helpers on another model. The instance designates which models each main
|
||||||
|
# model may send helpers to; this lets a person add their own designations
|
||||||
|
# for themselves. Off by default: a designation decides where a person's
|
||||||
|
# tasks -- and whatever context a model writes into them -- are sent.
|
||||||
|
PermissionDef(
|
||||||
|
"helpers.designate",
|
||||||
|
"Choose their own helper models",
|
||||||
|
"Let this person designate, for each model, other models it may send "
|
||||||
|
"helpers to -- added to the instance's designations, for them alone.",
|
||||||
|
False,
|
||||||
|
"Chat",
|
||||||
|
),
|
||||||
PermissionDef(
|
PermissionDef(
|
||||||
"data.manage",
|
"data.manage",
|
||||||
"Manage their own data groups",
|
"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
|
would be both a leak and a dead end, since asking it anything is refused by
|
||||||
the same check.
|
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(
|
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
|
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.
|
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
|
And narrowed by the talk rules, evaluated from the main model -- which is
|
||||||
handed this conversation, which is exactly what groups exist to prevent.
|
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 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 = {
|
reachable = {
|
||||||
model.model_id: model
|
model.model_id: model
|
||||||
for model in chat_service.roster_models(
|
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
|
||||||
db, user, exclude="", group=data_groups.for_chat(db, chat)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
|
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
|
||||||
seen = {chat.model_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]:
|
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."""
|
"""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, talk
|
||||||
from lembas.services import data_groups
|
|
||||||
|
|
||||||
reachable = {
|
reachable = {
|
||||||
model.model_id
|
model.model_id
|
||||||
for model in chat_service.roster_models(
|
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
|
||||||
db, user, exclude="", group=data_groups.for_chat(db, chat)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
member.model_id
|
member.model_id
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ def context_variables(
|
|||||||
# opinion. One fragment covering all three would say nothing useful to
|
# opinion. One fragment covering all three would say nothing useful to
|
||||||
# any of them.
|
# any of them.
|
||||||
"friend": "",
|
"friend": "",
|
||||||
|
# Where a helper may run, when that is more than the answering model.
|
||||||
|
# Filled below, from the same candidates the tool's enum was built from.
|
||||||
|
"helper_models": "",
|
||||||
# Who else is here. Filled below, where the chat's own model is known --
|
# Who else is here. Filled below, where the chat's own model is known --
|
||||||
# a model does not need telling that it exists.
|
# a model does not need telling that it exists.
|
||||||
"model_roster": "",
|
"model_roster": "",
|
||||||
@@ -364,6 +367,9 @@ def context_variables(
|
|||||||
db, user, exclude=speaking.model_id, group=group
|
db, user, exclude=speaking.model_id, group=group
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "subagent" in families and speaking.model_id == chat.model_id:
|
||||||
|
values["helper_models"] = _helper_models(db, chat, user)
|
||||||
|
|
||||||
if "persona" in families:
|
if "persona" in families:
|
||||||
# This person's own personality for this model, falling back to the
|
# This person's own personality for this model, falling back to the
|
||||||
# administrator's default until the model has written one with them;
|
# administrator's default until the model has written one with them;
|
||||||
@@ -376,6 +382,37 @@ def context_variables(
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _helper_models(db: DBSession, chat, user) -> str:
|
||||||
|
"""One line per model a helper may run on, or "" when it is only this one.
|
||||||
|
|
||||||
|
The same candidates `tools._subagent_defs` built the `model` enum from, so
|
||||||
|
the list the model reads and the values the call accepts cannot disagree.
|
||||||
|
Roster-shaped and capped the same way.
|
||||||
|
"""
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
|
|
||||||
|
found = helpers_service.candidates(db, chat, user)
|
||||||
|
if len(found) < 2:
|
||||||
|
return ""
|
||||||
|
lines: list[str] = []
|
||||||
|
budget = chat_service.MAX_ROSTER_CHARS
|
||||||
|
for candidate in found[: chat_service.MAX_ROSTER_MODELS]:
|
||||||
|
model = candidate.model
|
||||||
|
parts = ((model.description or "").strip(), (model.notes or "").strip())
|
||||||
|
about = " ".join(" ".join(p for p in parts if p).split())[: chat_service.MAX_ROSTER_ENTRY]
|
||||||
|
line = f"- {model.label} ({model.model_id})"
|
||||||
|
if candidate.source == helpers_service.SOURCE_SELF:
|
||||||
|
line += " — you"
|
||||||
|
elif about:
|
||||||
|
line += f" — {about}"
|
||||||
|
if len(line) > budget:
|
||||||
|
break
|
||||||
|
budget -= len(line)
|
||||||
|
lines.append(line)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
|
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||||
"""What a scheduled task's chat is for, and how often it comes round.
|
"""What a scheduled task's chat is for, and how often it comes round.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Which model a helper runs on: the main model itself, or one designated for it.
|
||||||
|
|
||||||
|
`subagent_run` used to have one answer -- the parent's own model. It has three
|
||||||
|
sources now, in this order, and they are decided here by logic, never by the
|
||||||
|
model:
|
||||||
|
|
||||||
|
1. **The main model itself**, unless it cannot run two requests at once.
|
||||||
|
2. **Helpers somebody added to this chat by hand** (`ChatHelper`).
|
||||||
|
3. **Helpers designated for the main model with `offer` on** -- by the instance,
|
||||||
|
or by a person holding `helpers.designate` -- which the model may choose.
|
||||||
|
|
||||||
|
Each candidate passes two checks before it is offered:
|
||||||
|
|
||||||
|
* **Capacity.** A model marked *serves one request at a time* cannot be its own
|
||||||
|
helper: the helper's request would queue behind the very reply waiting for it.
|
||||||
|
A connection marked *holds one model at a time* (llama-swap in front of one
|
||||||
|
GPU) cannot serve a helper on another of its models: loading it evicts the
|
||||||
|
model whose reply is waiting. The main model may still help itself there.
|
||||||
|
Both flags default off, so an instance that never sets them behaves exactly as
|
||||||
|
before -- the main model is its own helper.
|
||||||
|
* **The talk rules**, read from the main model: a chat's hand-added helpers need
|
||||||
|
to be `addable`, a designation the model chooses itself needs to be `offered`.
|
||||||
|
A different data group is the deny a rule has to open, exactly as for a crowd.
|
||||||
|
|
||||||
|
`ask_friend` and the crowd do not take the capacity check, on the owner's word:
|
||||||
|
both are sequential, and waiting for a model to load is accepted there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
|
from lembas.db.models import Chat, ChatHelper, HelperDesignation, Model, User
|
||||||
|
|
||||||
|
PERMISSION = "helpers.designate"
|
||||||
|
|
||||||
|
SOURCE_SELF = "self"
|
||||||
|
SOURCE_CHAT = "chat"
|
||||||
|
SOURCE_OFFERED = "offered"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
"""A model a helper may run on, and why it is on the list."""
|
||||||
|
|
||||||
|
model: Model
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Choice:
|
||||||
|
"""One designation as the helpers picker shows it.
|
||||||
|
|
||||||
|
`reason` is in English, for a model; `capacity` and `verdict` are what a
|
||||||
|
screen needs to say the same thing in the reader's language.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model: Model
|
||||||
|
offer: bool
|
||||||
|
addable: bool
|
||||||
|
reason: str
|
||||||
|
capacity: bool = False
|
||||||
|
verdict: object = None
|
||||||
|
|
||||||
|
|
||||||
|
def may_designate(db: DBSession, user: User | None) -> bool:
|
||||||
|
from lembas.security import permissions
|
||||||
|
|
||||||
|
return user is not None and permissions.has(db, user, PERMISSION)
|
||||||
|
|
||||||
|
|
||||||
|
def main_row(db: DBSession, chat: Chat) -> Model | None:
|
||||||
|
"""The chat's own model as a row, on the connection it is reached through."""
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
return chat_service.model_row(db, chat_service.speaker_for(db, chat))
|
||||||
|
|
||||||
|
|
||||||
|
def capacity_refusal(main: Model | None, helper: Model) -> str:
|
||||||
|
"""Why this helper cannot run beside the main model's waiting reply, or ""."""
|
||||||
|
if main is None:
|
||||||
|
return ""
|
||||||
|
if helper.model_id == main.model_id and helper.connection_id == main.connection_id:
|
||||||
|
if helper.single_session:
|
||||||
|
return (
|
||||||
|
f"{helper.label} serves one request at a time, so it cannot be its own "
|
||||||
|
f"helper: the helper would wait behind the reply that is waiting for it."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
if helper.connection_id == main.connection_id:
|
||||||
|
connection = helper.connection
|
||||||
|
if connection is not None and connection.one_model_at_a_time:
|
||||||
|
return (
|
||||||
|
f"{connection.name} holds one model at a time, so a helper on "
|
||||||
|
f"{helper.label} would unload {main.label} while its reply waits."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def designations(db: DBSession, user: User | None, main_model: str) -> list[HelperDesignation]:
|
||||||
|
"""The helpers designated for one main model: the instance's, then the person's.
|
||||||
|
|
||||||
|
A person's own row for the same helper replaces the instance's -- that is
|
||||||
|
how they change whether it is offered -- and theirs count only while they
|
||||||
|
hold `helpers.designate`.
|
||||||
|
"""
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(HelperDesignation)
|
||||||
|
.where(
|
||||||
|
HelperDesignation.owner_id.is_(None),
|
||||||
|
HelperDesignation.main_model == main_model,
|
||||||
|
)
|
||||||
|
.order_by(HelperDesignation.helper_model)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if user is not None and may_designate(db, user):
|
||||||
|
own = list(
|
||||||
|
db.scalars(
|
||||||
|
select(HelperDesignation)
|
||||||
|
.where(
|
||||||
|
HelperDesignation.owner_id == user.id,
|
||||||
|
HelperDesignation.main_model == main_model,
|
||||||
|
)
|
||||||
|
.order_by(HelperDesignation.helper_model)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
replaced = {row.helper_model for row in own}
|
||||||
|
rows = [row for row in rows if row.helper_model not in replaced] + own
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(db: DBSession, user: User | None, model_id: str, connection_id: str | None):
|
||||||
|
"""The row this person can reach for a model id, preferring a named connection."""
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
rows = [m for m in chat_service.available_models(db, user) if m.model_id == model_id]
|
||||||
|
rows.sort(key=lambda m: m.connection_id != (connection_id or ""))
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def candidates(db: DBSession, chat: Chat, user: User | None) -> list[Candidate]:
|
||||||
|
"""Every model a helper of this chat may run on, in the order it is chosen."""
|
||||||
|
from lembas.services import data_groups, talk
|
||||||
|
|
||||||
|
main = main_row(db, chat)
|
||||||
|
group = data_groups.for_chat(db, chat)
|
||||||
|
judge = talk.Judge(db, user)
|
||||||
|
found: list[Candidate] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
if main is not None and not capacity_refusal(main, main):
|
||||||
|
found.append(Candidate(main, SOURCE_SELF))
|
||||||
|
seen.add(main.model_id)
|
||||||
|
|
||||||
|
for row in chat.helpers:
|
||||||
|
model = _reachable(db, user, row.model_id, row.connection_id)
|
||||||
|
if model is None or model.model_id in seen:
|
||||||
|
continue
|
||||||
|
if not judge.verdict(chat.model_id, group, model).addable:
|
||||||
|
continue
|
||||||
|
if capacity_refusal(main, model):
|
||||||
|
continue
|
||||||
|
found.append(Candidate(model, SOURCE_CHAT))
|
||||||
|
seen.add(model.model_id)
|
||||||
|
|
||||||
|
for row in designations(db, user, chat.model_id):
|
||||||
|
if not row.offer or row.helper_model in seen:
|
||||||
|
continue
|
||||||
|
model = _reachable(db, user, row.helper_model, row.helper_connection_id)
|
||||||
|
if model is None:
|
||||||
|
continue
|
||||||
|
if not judge.verdict(chat.model_id, group, model).offered:
|
||||||
|
continue
|
||||||
|
if capacity_refusal(main, model):
|
||||||
|
continue
|
||||||
|
found.append(Candidate(model, SOURCE_OFFERED))
|
||||||
|
seen.add(model.model_id)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def choose(
|
||||||
|
db: DBSession, chat: Chat, user: User | None, wanted: str
|
||||||
|
) -> tuple[Model | None, str]:
|
||||||
|
"""The model a helper runs on, or a refusal saying what could have been named.
|
||||||
|
|
||||||
|
`wanted` comes from a tool call, so it is matched against the candidates and
|
||||||
|
nothing else -- by model id, then by label. Empty means the default: the main
|
||||||
|
model itself, or failing that the first helper added to this chat by hand.
|
||||||
|
"""
|
||||||
|
found = candidates(db, chat, user)
|
||||||
|
names = ", ".join(c.model.model_id for c in found)
|
||||||
|
wanted = (wanted or "").strip()
|
||||||
|
if not wanted:
|
||||||
|
for source in (SOURCE_SELF, SOURCE_CHAT):
|
||||||
|
for candidate in found:
|
||||||
|
if candidate.source == source:
|
||||||
|
return candidate.model, ""
|
||||||
|
if found:
|
||||||
|
return None, f"Name the model to send the helper to. You may use: {names}."
|
||||||
|
return None, _why_none(db, chat)
|
||||||
|
|
||||||
|
lowered = wanted.lower()
|
||||||
|
for candidate in found:
|
||||||
|
if lowered in (candidate.model.model_id.lower(), candidate.model.label.lower()):
|
||||||
|
return candidate.model, ""
|
||||||
|
|
||||||
|
main = main_row(db, chat)
|
||||||
|
target = _reachable(db, user, wanted, None)
|
||||||
|
if target is not None and capacity_refusal(main, target):
|
||||||
|
reason = capacity_refusal(main, target)
|
||||||
|
else:
|
||||||
|
reason = f"{wanted!r} is not a helper you may use."
|
||||||
|
return None, f"{reason} You may use: {names}." if names else f"{reason} {_why_none(db, chat)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _why_none(db: DBSession, chat: Chat) -> str:
|
||||||
|
main = main_row(db, chat)
|
||||||
|
if main is not None and capacity_refusal(main, main):
|
||||||
|
return capacity_refusal(main, main) + " No other helper is set up for it."
|
||||||
|
return "No helper model is available here. Do this part yourself."
|
||||||
|
|
||||||
|
|
||||||
|
def picker(db: DBSession, chat_or_main: Chat | str, user: User | None) -> list[Choice]:
|
||||||
|
"""The designations for a main model, as the composer's helpers picker lists them.
|
||||||
|
|
||||||
|
Every designation, offered or not, with whether this person may add it to
|
||||||
|
the chat by hand and, where not, why. On the new-chat screen there is no
|
||||||
|
chat yet, so a main model id is passed instead.
|
||||||
|
"""
|
||||||
|
from lembas.services import data_groups, talk
|
||||||
|
|
||||||
|
if isinstance(chat_or_main, Chat):
|
||||||
|
main_model = chat_or_main.model_id
|
||||||
|
main = main_row(db, chat_or_main)
|
||||||
|
group = data_groups.for_chat(db, chat_or_main)
|
||||||
|
else:
|
||||||
|
main_model = chat_or_main
|
||||||
|
main = _reachable(db, user, main_model, None)
|
||||||
|
group = data_groups.for_pair(db, user, main_model)
|
||||||
|
judge = talk.Judge(db, user)
|
||||||
|
shown: list[Choice] = []
|
||||||
|
for row in designations(db, user, main_model):
|
||||||
|
model = _reachable(db, user, row.helper_model, row.helper_connection_id)
|
||||||
|
if model is None:
|
||||||
|
continue
|
||||||
|
verdict = judge.verdict(main_model, group, model)
|
||||||
|
capacity = capacity_refusal(main, model)
|
||||||
|
reason = capacity or ("" if verdict.addable else verdict.reason)
|
||||||
|
shown.append(
|
||||||
|
Choice(
|
||||||
|
model,
|
||||||
|
row.offer,
|
||||||
|
bool(verdict.addable and not capacity),
|
||||||
|
reason,
|
||||||
|
capacity=bool(capacity),
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return shown
|
||||||
|
|
||||||
|
|
||||||
|
def set_designation(
|
||||||
|
db: DBSession,
|
||||||
|
owner: User | None,
|
||||||
|
main_model: str,
|
||||||
|
helper_model: str,
|
||||||
|
*,
|
||||||
|
offer: bool,
|
||||||
|
connection_id: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Designate a helper for a main model, or change whether it is offered."""
|
||||||
|
main_model = (main_model or "").strip()[:300]
|
||||||
|
helper_model = (helper_model or "").strip()[:300]
|
||||||
|
if not main_model or not helper_model:
|
||||||
|
return
|
||||||
|
existing = db.scalar(
|
||||||
|
select(HelperDesignation).where(
|
||||||
|
(
|
||||||
|
HelperDesignation.owner_id.is_(None)
|
||||||
|
if owner is None
|
||||||
|
else HelperDesignation.owner_id == owner.id
|
||||||
|
),
|
||||||
|
HelperDesignation.main_model == main_model,
|
||||||
|
HelperDesignation.helper_model == helper_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
existing.offer = offer
|
||||||
|
existing.helper_connection_id = connection_id or existing.helper_connection_id
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
HelperDesignation(
|
||||||
|
owner_id=owner.id if owner is not None else None,
|
||||||
|
main_model=main_model,
|
||||||
|
helper_model=helper_model,
|
||||||
|
helper_connection_id=connection_id or "",
|
||||||
|
offer=offer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_designation(db: DBSession, owner: User | None, designation_id: str) -> bool:
|
||||||
|
row = db.get(HelperDesignation, designation_id)
|
||||||
|
if row is None or row.owner_id != (owner.id if owner is not None else None):
|
||||||
|
return False
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def own_designations(db: DBSession, owner: User | None) -> list[HelperDesignation]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(HelperDesignation)
|
||||||
|
.where(
|
||||||
|
HelperDesignation.owner_id.is_(None)
|
||||||
|
if owner is None
|
||||||
|
else HelperDesignation.owner_id == owner.id
|
||||||
|
)
|
||||||
|
.order_by(HelperDesignation.main_model, HelperDesignation.helper_model)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_chat_helpers(db: DBSession, chat: Chat, user: User | None, values: list[str]) -> None:
|
||||||
|
"""Replace a chat's hand-added helpers with the models named.
|
||||||
|
|
||||||
|
Only designations for the chat's model this person may add -- the talk rules
|
||||||
|
and the capacity check, the same as the picker shows -- and never the chat's
|
||||||
|
own model, which is its own helper already.
|
||||||
|
"""
|
||||||
|
allowed = {c.model.model_id: c.model for c in picker(db, chat, user) if c.addable}
|
||||||
|
wanted: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
value = str(value).strip()
|
||||||
|
if value and value in allowed and value != chat.model_id and value not in wanted:
|
||||||
|
wanted.append(value)
|
||||||
|
chat.helpers = [
|
||||||
|
ChatHelper(model_id=model_id, connection_id=allowed[model_id].connection_id, position=i)
|
||||||
|
for i, model_id in enumerate(wanted)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PERMISSION",
|
||||||
|
"Candidate",
|
||||||
|
"Choice",
|
||||||
|
"apply_chat_helpers",
|
||||||
|
"candidates",
|
||||||
|
"capacity_refusal",
|
||||||
|
"choose",
|
||||||
|
"delete_designation",
|
||||||
|
"designations",
|
||||||
|
"may_designate",
|
||||||
|
"own_designations",
|
||||||
|
"picker",
|
||||||
|
"set_designation",
|
||||||
|
]
|
||||||
@@ -163,6 +163,14 @@ VARIABLES: tuple[Variable, ...] = (
|
|||||||
"page, bounded, and empty unless this model may ask one of them a "
|
"page, bounded, and empty unless this model may ask one of them a "
|
||||||
"question — a list of peers it cannot reach is context spent on nothing.",
|
"question — a list of peers it cannot reach is context spent on nothing.",
|
||||||
),
|
),
|
||||||
|
Variable(
|
||||||
|
"helper_models",
|
||||||
|
"The models a helper may run on",
|
||||||
|
"One line per model subagent_run may send a helper to, the answering model "
|
||||||
|
"first when it may be its own helper: its name, the id to pass as model, "
|
||||||
|
"and what it is for. Empty when the only choice is the model itself, so the "
|
||||||
|
"section vanishes and the tool reads as it always did.",
|
||||||
|
),
|
||||||
Variable(
|
Variable(
|
||||||
"persona",
|
"persona",
|
||||||
"Its personality with this person",
|
"Its personality with this person",
|
||||||
@@ -1366,6 +1374,28 @@ BUILTIN: tuple[Fragment, ...] = (
|
|||||||
"because a helper made it."
|
"because a helper made it."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Fragment(
|
||||||
|
key="tool.subagent_models",
|
||||||
|
label="Which model a helper runs on",
|
||||||
|
group=GROUP_TOOLS,
|
||||||
|
order=252,
|
||||||
|
families=("subagent",),
|
||||||
|
variables=("helper_models",),
|
||||||
|
requires=("helper_models",),
|
||||||
|
hint="Appears only when a helper may run on a model other than the one "
|
||||||
|
"answering -- the chat's own helpers added by hand, or ones designated for "
|
||||||
|
"this model and offered to it. The list is built after the capacity check "
|
||||||
|
"and the model rules, so every line is one the call will accept.",
|
||||||
|
default=(
|
||||||
|
"### Where a helper can run\n"
|
||||||
|
"\n"
|
||||||
|
"subagent_run takes a model. Leave it out for the first one below; name "
|
||||||
|
"another, by the id in brackets, when its strengths suit the piece of work "
|
||||||
|
"better. Only these are accepted:\n"
|
||||||
|
"\n"
|
||||||
|
"{{helper_models}}"
|
||||||
|
),
|
||||||
|
),
|
||||||
Fragment(
|
Fragment(
|
||||||
key="tool.subagent_agent",
|
key="tool.subagent_agent",
|
||||||
label="Helpers on a machine",
|
label="Helpers on a machine",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ IMAGES = "images"
|
|||||||
SCHEDULES = "schedules"
|
SCHEDULES = "schedules"
|
||||||
SUBAGENTS = "subagents"
|
SUBAGENTS = "subagents"
|
||||||
CROWD = "crowd"
|
CROWD = "crowd"
|
||||||
|
RULES = "rules"
|
||||||
BRANDING = "branding"
|
BRANDING = "branding"
|
||||||
EXTRACTION = "extraction"
|
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]:
|
def _crowd_defaults() -> dict[str, Any]:
|
||||||
"""Several models answering one turn, in order, then again in reverse.
|
"""Several models answering one turn, in order, then again in reverse.
|
||||||
|
|
||||||
@@ -431,6 +442,7 @@ _DEFAULTS: dict[str, Any] = {
|
|||||||
SCHEDULES: _schedules_defaults,
|
SCHEDULES: _schedules_defaults,
|
||||||
SUBAGENTS: _subagents_defaults,
|
SUBAGENTS: _subagents_defaults,
|
||||||
CROWD: _crowd_defaults,
|
CROWD: _crowd_defaults,
|
||||||
|
RULES: _rules_defaults,
|
||||||
# Whose instance this is. The defaults live in `services/branding.py`
|
# 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
|
# 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
|
# 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
|
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:
|
def images_ready(db: DBSession) -> bool:
|
||||||
"""Whether image generation can actually happen.
|
"""Whether image generation can actually happen.
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ def _create_child(
|
|||||||
title: str,
|
title: str,
|
||||||
write: bool,
|
write: bool,
|
||||||
friend: Model | None = None,
|
friend: Model | None = None,
|
||||||
|
helper: Model | None = None,
|
||||||
) -> Chat:
|
) -> Chat:
|
||||||
"""The hidden chat one helper or one friend runs in.
|
"""The hidden chat one helper or one friend runs in.
|
||||||
|
|
||||||
@@ -225,6 +226,10 @@ def _create_child(
|
|||||||
|
|
||||||
peer = friend is not None
|
peer = friend is not None
|
||||||
owner = db.get(User, parent.user_id) if parent.user_id else None
|
owner = db.get(User, parent.user_id) if parent.user_id else None
|
||||||
|
# A helper on another model runs on that model -- as a pair, for the reason
|
||||||
|
# the friend is -- and reads that model's data group, exactly as a friend
|
||||||
|
# does. It is still a helper: the parent's kind, machine and scope.
|
||||||
|
other = friend or helper
|
||||||
child = Chat(
|
child = Chat(
|
||||||
user_id=parent.user_id,
|
user_id=parent.user_id,
|
||||||
# An ordinary chat for a friend even when the asking one is an agent
|
# An ordinary chat for a friend even when the asking one is an agent
|
||||||
@@ -232,14 +237,14 @@ def _create_child(
|
|||||||
# and a peer being asked a question is not working on one.
|
# and a peer being asked a question is not working on one.
|
||||||
kind=KIND_CHAT if peer else parent.kind,
|
kind=KIND_CHAT if peer else parent.kind,
|
||||||
title=title[:200] or ("Question" if peer else "Helper"),
|
title=title[:200] or ("Question" if peer else "Helper"),
|
||||||
model_id=friend.model_id if peer else parent.model_id,
|
model_id=other.model_id if other is not None else parent.model_id,
|
||||||
connection_id=friend.connection_id if peer else parent.connection_id,
|
connection_id=other.connection_id if other is not None else parent.connection_id,
|
||||||
# The helper is in its parent's group, doing its parent's work. A friend
|
# The helper is in its parent's group, doing its parent's work. A friend
|
||||||
# is in its own model's, so it reads its own group's data and never the
|
# is in its own model's, so it reads its own group's data and never the
|
||||||
# asker's.
|
# asker's.
|
||||||
data_group_id=(
|
data_group_id=(
|
||||||
data_groups.for_pair(db, owner, friend.model_id, friend.connection_id)
|
data_groups.for_pair(db, owner, other.model_id, other.connection_id)
|
||||||
if peer
|
if other is not None
|
||||||
else data_groups.for_chat(db, parent)
|
else data_groups.for_chat(db, parent)
|
||||||
),
|
),
|
||||||
# Never in a listing, and swept a day later even if it is kept.
|
# Never in a listing, and swept a day later even if it is kept.
|
||||||
@@ -254,8 +259,11 @@ def _create_child(
|
|||||||
child.agent_mode = MODE_WRITING if write else MODE_READING
|
child.agent_mode = MODE_WRITING if write else MODE_READING
|
||||||
if peer:
|
if peer:
|
||||||
child.scope_json = {**(child.scope_json or {}), "role": ROLE_FRIEND}
|
child.scope_json = {**(child.scope_json or {}), "role": ROLE_FRIEND}
|
||||||
effort = str((friend.params_json or {}).get("reasoning_effort") or "")
|
if other is not None:
|
||||||
if effort not in chat_service.efforts_for(friend):
|
# The other model's own default, never the parent's: the vocabularies
|
||||||
|
# differ, and `high` handed to a Bonsai raises inside its chat template.
|
||||||
|
effort = str((other.params_json or {}).get("reasoning_effort") or "")
|
||||||
|
if effort not in chat_service.efforts_for(other):
|
||||||
effort = ""
|
effort = ""
|
||||||
else:
|
else:
|
||||||
effort = chat_service.resolved_effort(parent)
|
effort = chat_service.resolved_effort(parent)
|
||||||
@@ -455,6 +463,7 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
|
|||||||
title = str(args.get("title") or "").strip() or task[:60]
|
title = str(args.get("title") or "").strip() or task[:60]
|
||||||
briefing = str(args.get("context") or "")
|
briefing = str(args.get("context") or "")
|
||||||
want_write = bool(args.get("write"))
|
want_write = bool(args.get("write"))
|
||||||
|
wanted_model = str(args.get("model") or "")
|
||||||
|
|
||||||
if not task:
|
if not task:
|
||||||
return _error(
|
return _error(
|
||||||
@@ -493,6 +502,15 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
|
|||||||
if owner is None: # pragma: no cover - a chat outliving its owner
|
if owner is None: # pragma: no cover - a chat outliving its owner
|
||||||
return _error("That account no longer exists.", task=task)
|
return _error("That account no longer exists.", task=task)
|
||||||
|
|
||||||
|
# Which model the helper runs on -- decided by logic, never taken on the
|
||||||
|
# model's word: the name is matched against the candidates the tool was
|
||||||
|
# built from, which already passed the capacity check and the talk rules.
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
|
|
||||||
|
helper, refusal = helpers_service.choose(db, parent, owner, wanted_model)
|
||||||
|
if helper is None:
|
||||||
|
return _error(refusal, task=task)
|
||||||
|
|
||||||
# After the refusals above and before anything is created. The order is
|
# After the refusals above and before anything is created. The order is
|
||||||
# the design: a call that could never have worked should be told *why*
|
# the design: a call that could never have worked should be told *why*
|
||||||
# rather than told it has run out of helpers, and the counter should
|
# rather than told it has run out of helpers, and the counter should
|
||||||
@@ -508,7 +526,13 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
|
|||||||
if refusal:
|
if refusal:
|
||||||
return _error(refusal, task=task)
|
return _error(refusal, task=task)
|
||||||
|
|
||||||
child = _create_child(db, parent, title=title, write=write)
|
own = (
|
||||||
|
helper.model_id == parent.model_id
|
||||||
|
and (not parent.connection_id or helper.connection_id == parent.connection_id)
|
||||||
|
)
|
||||||
|
child = _create_child(
|
||||||
|
db, parent, title=title, write=write, helper=None if own else helper
|
||||||
|
)
|
||||||
child_id = child.id
|
child_id = child.id
|
||||||
|
|
||||||
_LIVE.add(child_id)
|
_LIVE.add(child_id)
|
||||||
@@ -812,10 +836,27 @@ def friend_tool_defs() -> list[ToolDef]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def tool_defs() -> list[ToolDef]:
|
def tool_defs(models: list[str] | None = None) -> list[ToolDef]:
|
||||||
"""The one tool, built here so `services/tools.py` need not know the wording."""
|
"""The one tool, built here so `services/tools.py` need not know the wording.
|
||||||
|
|
||||||
|
`models` is who a helper may run on, from `helpers.candidates`, the main
|
||||||
|
model first when it is its own helper. One of them, or none given, is the
|
||||||
|
tool as it always was; more adds a `model` argument whose enum is exactly
|
||||||
|
that list, so a small model cannot type a name that was never offered.
|
||||||
|
"""
|
||||||
from lembas.services.tools import FAMILY_SUBAGENT, RISK_READ, ToolDef
|
from lembas.services.tools import FAMILY_SUBAGENT, RISK_READ, ToolDef
|
||||||
|
|
||||||
|
properties = _subagent_properties()
|
||||||
|
if models and len(models) > 1:
|
||||||
|
properties["model"] = {
|
||||||
|
"type": "string",
|
||||||
|
"enum": list(models),
|
||||||
|
"description": (
|
||||||
|
"Which model the helper runs on. Leave it out for the default "
|
||||||
|
f"({models[0]}). Choose another when its strengths suit the task "
|
||||||
|
"better -- the list of helpers you were given says what each is."
|
||||||
|
),
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
ToolDef(
|
ToolDef(
|
||||||
name="subagent_run",
|
name="subagent_run",
|
||||||
@@ -836,39 +877,7 @@ def tool_defs() -> list[ToolDef]:
|
|||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": properties,
|
||||||
"task": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"What the helper is to do, written out in full and as "
|
|
||||||
"an instruction. Say what a good answer contains and "
|
|
||||||
"how long it should be. It is read on its own, with "
|
|
||||||
"none of this conversation around it."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "A few words naming this piece of work.",
|
|
||||||
},
|
|
||||||
"context": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"Facts the helper needs that it cannot look up — what "
|
|
||||||
"the reader asked for, decisions already made, names "
|
|
||||||
"and paths. Not a summary of the conversation."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"write": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": (
|
|
||||||
"True if the helper must change something: write a "
|
|
||||||
"file, keep a note, file a report. Leave it out for "
|
|
||||||
"anything that only reads, which is nearly always. A "
|
|
||||||
"writing helper is refused where you would have been "
|
|
||||||
"stopped for approval yourself."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["task"],
|
"required": ["task"],
|
||||||
},
|
},
|
||||||
run=_run_subagent,
|
run=_run_subagent,
|
||||||
@@ -894,3 +903,40 @@ __all__ = [
|
|||||||
"live_count",
|
"live_count",
|
||||||
"tool_defs",
|
"tool_defs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _subagent_properties() -> dict[str, Any]:
|
||||||
|
"""The arguments every `subagent_run` has; `tool_defs` may add `model`."""
|
||||||
|
return {
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"What the helper is to do, written out in full and as "
|
||||||
|
"an instruction. Say what a good answer contains and "
|
||||||
|
"how long it should be. It is read on its own, with "
|
||||||
|
"none of this conversation around it."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A few words naming this piece of work.",
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Facts the helper needs that it cannot look up — what "
|
||||||
|
"the reader asked for, decisions already made, names "
|
||||||
|
"and paths. Not a summary of the conversation."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": (
|
||||||
|
"True if the helper must change something: write a "
|
||||||
|
"file, keep a note, file a report. Leave it out for "
|
||||||
|
"anything that only reads, which is nearly always. A "
|
||||||
|
"writing helper is refused where you would have been "
|
||||||
|
"stopped for approval yourself."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -1750,13 +1750,31 @@ def _schedule_defs() -> list[ToolDef]:
|
|||||||
return schedule_tool.tool_defs()
|
return schedule_tool.tool_defs()
|
||||||
|
|
||||||
|
|
||||||
def _subagent_defs() -> list[ToolDef]:
|
def _subagent_registry_defs() -> list[ToolDef]:
|
||||||
"""The subagent tool. Imported inside the call for the reason above."""
|
|
||||||
from lembas.services import subagent as subagent_service
|
from lembas.services import subagent as subagent_service
|
||||||
|
|
||||||
return subagent_service.tool_defs()
|
return subagent_service.tool_defs()
|
||||||
|
|
||||||
|
|
||||||
|
def _subagent_defs(db: DBSession, chat: Chat, user: User | None) -> list[ToolDef]:
|
||||||
|
"""The subagent tool, shaped by which models a helper may run on.
|
||||||
|
|
||||||
|
Withdrawn when there are none -- a main model that cannot help itself and
|
||||||
|
has no other helper set up would be offered a tool every call of which is
|
||||||
|
refused, and a model finds that out one wasted round at a time. With only
|
||||||
|
itself it is the tool it always was; with more, it gains a `model`
|
||||||
|
argument listing exactly those. Imported inside the call for the reason
|
||||||
|
above.
|
||||||
|
"""
|
||||||
|
from lembas.services import helpers as helpers_service
|
||||||
|
from lembas.services import subagent as subagent_service
|
||||||
|
|
||||||
|
found = helpers_service.candidates(db, chat, user)
|
||||||
|
if not found:
|
||||||
|
return []
|
||||||
|
return subagent_service.tool_defs([c.model.model_id for c in found])
|
||||||
|
|
||||||
|
|
||||||
def _friend_defs() -> list[ToolDef]:
|
def _friend_defs() -> list[ToolDef]:
|
||||||
"""The ask-a-friend tool. Same module, same reason for the late import."""
|
"""The ask-a-friend tool. Same module, same reason for the late import."""
|
||||||
from lembas.services import subagent as subagent_service
|
from lembas.services import subagent as subagent_service
|
||||||
@@ -1824,7 +1842,9 @@ def registry(db: DBSession) -> dict[str, ToolDef]:
|
|||||||
# reaches the model. That omission has cost two features their
|
# reaches the model. That omission has cost two features their
|
||||||
# instructions already.
|
# instructions already.
|
||||||
*_schedule_defs(),
|
*_schedule_defs(),
|
||||||
*_subagent_defs(),
|
# The registry wants the tool's name and family, not this chat's
|
||||||
|
# candidates -- so the bare definition, which needs no chat.
|
||||||
|
*_subagent_registry_defs(),
|
||||||
*_friend_defs(),
|
*_friend_defs(),
|
||||||
*_crowd_defs(),
|
*_crowd_defs(),
|
||||||
]
|
]
|
||||||
@@ -1886,7 +1906,7 @@ def resolve_tools(
|
|||||||
*_agent_defs(db, chat, user),
|
*_agent_defs(db, chat, user),
|
||||||
*(_image_defs(db, image_values) if images_ready else []),
|
*(_image_defs(db, image_values) if images_ready else []),
|
||||||
*(_schedule_defs() if schedules_on else []),
|
*(_schedule_defs() if schedules_on else []),
|
||||||
*(_subagent_defs() if subagents_on else []),
|
*(_subagent_defs(db, chat, user) if subagents_on else []),
|
||||||
*(_friend_defs() if subagents_on else []),
|
*(_friend_defs() if subagents_on else []),
|
||||||
# Only on the closing turn, and only with a round left. Not gated on a
|
# Only on the closing turn, and only with a round left. Not gated on a
|
||||||
# capability or a permission: a tool that exists on exactly one turn of
|
# capability or a permission: a tool that exists on exactly one turn of
|
||||||
|
|||||||
@@ -1859,3 +1859,75 @@ MESSAGES.update(
|
|||||||
'Image review': 'Posudzovanie obrázkov',
|
'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.',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Helpers on another model (1.12.0) -----------------------------------------
|
||||||
|
MESSAGES.update(
|
||||||
|
{
|
||||||
|
'Holds one model at a time': 'Drží naraz jeden model',
|
||||||
|
'Tick this for llama-swap in front of one GPU, or anything else that unloads one model to serve another. A model here may then be its own helper, but never send a helper to another model on this connection: loading it would unload the model whose reply is waiting.': 'Označte pri llama-swap pred jednou GPU alebo čomkoľvek, čo jeden model uvoľní, aby obslúžilo iný. Model tu potom môže byť vlastným pomocníkom, ale nikdy nepošle pomocníka inému modelu na tomto spojení: jeho načítanie by uvoľnilo model, ktorého odpoveď čaká.',
|
||||||
|
'Serves one request at a time': 'Obsluhuje naraz jednu požiadavku',
|
||||||
|
'Tick this for a model with a single slot. It then cannot be its own helper, because the helper would wait behind the reply that is waiting for it. Other models can still be its helpers.': 'Označte pri modeli s jedným slotom. Potom nemôže byť vlastným pomocníkom, lebo pomocník by čakal za odpoveďou, ktorá čaká naňho. Iné modely môžu byť jeho pomocníkmi aj tak.',
|
||||||
|
"Other models this one may send helpers to, for everybody. Offered ones it may choose itself; the rest can only be added to a chat by hand. It is always its own helper too, unless it serves one request at a time. The model rules and the connection's capacity are checked when a helper is sent.": 'Iné modely, ku ktorým môže tento posielať pomocníkov, pre všetkých. Ponúkané si môže vybrať sám; ostatné sa dajú do konverzácie pridať len ručne. Vždy je aj vlastným pomocníkom, pokiaľ neobsluhuje naraz jednu požiadavku. Pravidlá modelov a kapacita spojenia sa kontrolujú pri odoslaní pomocníka.',
|
||||||
|
'offered to the model': 'ponúkané modelu',
|
||||||
|
'by hand only': 'len ručne',
|
||||||
|
'Remove this helper': 'Odstrániť tohto pomocníka',
|
||||||
|
'Helper model': 'Model pomocníka',
|
||||||
|
'Offer it to the model': 'Ponúknuť ho modelu',
|
||||||
|
'Add helper': 'Pridať pomocníka',
|
||||||
|
'Models this one may send helpers to. Tick one to add it to this chat; one offered to the model it may choose anyway.': 'Modely, ku ktorým môže tento posielať pomocníkov. Označením ho pridáte do tejto konverzácie; ponúkaný si model môže vybrať aj tak.',
|
||||||
|
'Offered to the model.': 'Ponúkaný modelu.',
|
||||||
|
'Only when added here.': 'Len keď ho pridáte tu.',
|
||||||
|
'Your helper models': 'Vaše modely pomocníkov',
|
||||||
|
"Models each of your models may send helpers to, for you alone, on top of the instance's. Your row for the same pair replaces the instance's.": 'Modely, ku ktorým môže každý z vašich modelov posielať pomocníkov, len pre vás, navyše k tým od inštancie. Váš riadok pre tú istú dvojicu nahradí riadok inštancie.',
|
||||||
|
"Set by the administrator on each model's page. Adding your own needs the permission to choose your own helper models.": 'Nastavuje správca na stránke každého modelu. Na pridanie vlastných potrebujete oprávnenie vyberať si vlastné modely pomocníkov.',
|
||||||
|
'May send helpers to': 'Smie posielať pomocníkov k',
|
||||||
|
"It cannot run beside this model's reply: one of them serves one request at a time, or their connection holds one model at a time.": 'Nemôže bežať popri odpovedi tohto modelu: jeden z nich obsluhuje naraz jednu požiadavku alebo ich spojenie drží naraz jeden model.',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1954,3 +1954,47 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
|
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
|
||||||
box-shadow 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; }
|
||||||
|
|||||||
@@ -121,6 +121,15 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="one_model_at_a_time" value="true"
|
||||||
|
{{ 'checked' if connection.one_model_at_a_time }}>
|
||||||
|
<span>{{ t("Holds one model at a time") }}</span>
|
||||||
|
</label>
|
||||||
|
<p class="field__hint">{{ t("Tick this for llama-swap in front of one GPU, or anything else that unloads one model to serve another. A model here may then be its own helper, but never send a helper to another model on this connection: loading it would unload the model whose reply is waiting.") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="enabled" value="true"
|
<input type="checkbox" name="enabled" value="true"
|
||||||
|
|||||||
@@ -63,6 +63,10 @@
|
|||||||
{{ icon("users", "icon--sm") }}
|
{{ icon("users", "icon--sm") }}
|
||||||
<span class="nav-item__label">{{ t("A crowd") }}</span>
|
<span class="nav-item__label">{{ t("A crowd") }}</span>
|
||||||
</a>
|
</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">
|
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
|
||||||
{{ icon("speaker", "icon--sm") }}
|
{{ icon("speaker", "icon--sm") }}
|
||||||
<span class="nav-item__label">{{ t("Audio") }}</span>
|
<span class="nav-item__label">{{ t("Audio") }}</span>
|
||||||
|
|||||||
@@ -306,6 +306,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="single_session" value="true"
|
||||||
|
{{ 'checked' if model.single_session }}>
|
||||||
|
<span>{{ t("Serves one request at a time") }}</span>
|
||||||
|
</label>
|
||||||
|
<p class="field__hint">{{ t("Tick this for a model with a single slot. It then cannot be its own helper, because the helper would wait behind the reply that is waiting for it. Other models can still be its helpers.") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
|
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
|
||||||
@@ -343,6 +352,47 @@
|
|||||||
|
|
||||||
{# Outside the form above, and it has to be: two forms cannot nest, and this one
|
{# Outside the form above, and it has to be: two forms cannot nest, and this one
|
||||||
posts somewhere else. See the note beside the Detect button. #}
|
posts somewhere else. See the note beside the Detect button. #}
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card__title">{{ t("Helpers") }}</h2>
|
||||||
|
<p class="card__lede">{{ t("Other models this one may send helpers to, for everybody. Offered ones it may choose itself; the rest can only be added to a chat by hand. It is always its own helper too, unless it serves one request at a time. The model rules and the connection's capacity are checked when a helper is sent.") }}</p>
|
||||||
|
{% if designations %}
|
||||||
|
<ul class="model-list">
|
||||||
|
{% for row in designations %}
|
||||||
|
<li class="model-list__item">
|
||||||
|
<strong class="mono">{{ row.helper_model }}</strong>
|
||||||
|
<div class="btn-row">
|
||||||
|
{% if row.offer %}
|
||||||
|
<span class="badge badge--leaf">{{ t("offered to the model") }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">{{ t("by hand only") }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<form id="remove-helper-{{ row.id }}" method="post"
|
||||||
|
action="/admin/models/{{ model.id }}/helpers/{{ row.id }}/delete"></form>
|
||||||
|
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="remove-helper-{{ row.id }}"
|
||||||
|
aria-label="{{ t('Remove this helper') }}" title="{{ t('Remove this helper') }}">
|
||||||
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% if helper_choices %}
|
||||||
|
<form method="post" action="/admin/models/{{ model.id }}/helpers" class="row"
|
||||||
|
style="gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4)">
|
||||||
|
<label class="visually-hidden" for="helper-model">{{ t("Helper model") }}</label>
|
||||||
|
<select class="select" id="helper-model" name="helper_model" style="flex: 1; min-width: 0">
|
||||||
|
{% for choice in helper_choices %}<option value="{{ choice }}">{{ choice }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="offer" value="true" checked>
|
||||||
|
<span>{{ t("Offer it to the model") }}</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add helper") }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card__title">{{ t("Default personality") }}</h2>
|
<h2 class="card__title">{{ t("Default personality") }}</h2>
|
||||||
<p class="card__lede">
|
<p class="card__lede">
|
||||||
|
|||||||
@@ -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,60 @@
|
|||||||
because a browser submits only the ticked boxes and `start_chat`
|
because a browser submits only the ticked boxes and `start_chat`
|
||||||
needs to know which ones were not.
|
needs to know which ones were not.
|
||||||
#}
|
#}
|
||||||
{% if crowd_available %}
|
{#
|
||||||
|
Helpers: models designated for this chat's model that it may send a
|
||||||
|
helper to. Ticking one adds it to this chat by hand; an offered one
|
||||||
|
the model may use anyway. The crowd picker's shape and its reasons:
|
||||||
|
a sibling empty form reached by `form=`, one hidden field always
|
||||||
|
sent, and the verb on the checkbox.
|
||||||
|
#}
|
||||||
|
{% if helper_choices %}
|
||||||
|
<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"
|
||||||
|
aria-label="{{ t('Helpers') }}" title="{{ t('Helpers') }}">
|
||||||
|
{{ icon("bolt") }}
|
||||||
|
{% if helper_member_ids %}
|
||||||
|
<span class="composer__count">{{ helper_member_ids|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
||||||
|
hidden aria-label="{{ t('Helpers') }}">
|
||||||
|
<p class="picker__lede">
|
||||||
|
{{ t("Models this one may send helpers to. Tick one to add it to this chat; one offered to the model it may choose anyway.") }}
|
||||||
|
</p>
|
||||||
|
{% if chat %}
|
||||||
|
<input type="hidden" name="helper_model_ids" value="" form="helpers-form">
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="helper_model_ids" value="">
|
||||||
|
{% endif %}
|
||||||
|
{% for choice in helper_choices %}
|
||||||
|
<label class="picker__option picker__option--toggle">
|
||||||
|
{% if chat %}
|
||||||
|
<input type="checkbox" name="helper_model_ids" value="{{ choice.model.model_id }}"
|
||||||
|
{{ 'checked' if choice.model.model_id in helper_member_ids }}
|
||||||
|
{{ 'disabled' if not choice.addable }}
|
||||||
|
form="helpers-form"
|
||||||
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" name="helper_model_ids" value="{{ choice.model.model_id }}"
|
||||||
|
{{ 'disabled' if not choice.addable }}>
|
||||||
|
{% endif %}
|
||||||
|
<span class="picker__option-body">
|
||||||
|
<span class="picker__option-name">{{ choice.model.label }}</span>
|
||||||
|
<span class="picker__option-note">
|
||||||
|
{%- if helper_reasons.get(choice.model.model_id) %}{{ helper_reasons[choice.model.model_id] }}
|
||||||
|
{%- elif choice.offer %}{{ t("Offered to the model.") }}
|
||||||
|
{%- else %}{{ t("Only when added here.") }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if crowd_available or crowd_held_back %}
|
||||||
<div class="picker picker--up" data-picker>
|
<div class="picker picker--up" data-picker>
|
||||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||||
aria-haspopup="menu" aria-expanded="false"
|
aria-haspopup="menu" aria-expanded="false"
|
||||||
@@ -366,6 +419,38 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% 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 %}
|
{% if crowd_member_ids %}
|
||||||
{# One sentence and not three, with the numbers as placeholders: a
|
{# One sentence and not three, with the numbers as placeholders: a
|
||||||
translation puts the parts in its own order, and two of these
|
translation puts the parts in its own order, and two of these
|
||||||
@@ -593,7 +678,10 @@
|
|||||||
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
|
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
|
||||||
htmx shows nothing when a request 405s -- which is how these controls
|
htmx shows nothing when a request 405s -- which is how these controls
|
||||||
spent the first half of their lives doing nothing. #}
|
spent the first half of their lives doing nothing. #}
|
||||||
{% if chat and crowd_available %}
|
{% if chat and helper_choices %}
|
||||||
|
<form id="helpers-form"></form>
|
||||||
|
{% endif %}
|
||||||
|
{% if chat and (crowd_available or crowd_held_back) %}
|
||||||
{# Empty, and a sibling of the composer's form rather than inside it. See the
|
{# Empty, and a sibling of the composer's form rather than inside it. See the
|
||||||
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
|
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
|
||||||
of that sentence matter. #}
|
of that sentence matter. #}
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
|
{#
|
||||||
|
A person's own helper designations. Shown to everybody who could
|
||||||
|
send a helper, so the instance's arrangement is visible; editable
|
||||||
|
only with `helpers.designate`, because a designation decides where
|
||||||
|
a person's tasks are sent.
|
||||||
|
#}
|
||||||
|
{% if helper_settings_shown %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="card__title">{{ t("Your helper models") }}</h2>
|
||||||
|
<p class="card__lede">
|
||||||
|
{% if may_designate %}
|
||||||
|
{{ t("Models each of your models may send helpers to, for you alone, on top of the instance's. Your row for the same pair replaces the instance's.") }}
|
||||||
|
{% else %}
|
||||||
|
{{ t("Set by the administrator on each model's page. Adding your own needs the permission to choose your own helper models.") }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if own_designations %}
|
||||||
|
<ul class="model-list">
|
||||||
|
{% for row in own_designations %}
|
||||||
|
<li class="model-list__item">
|
||||||
|
<div style="min-width: 0">
|
||||||
|
<strong class="mono">{{ row.main_model }}</strong> → <strong class="mono">{{ row.helper_model }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<span class="badge {{ 'badge--leaf' if row.offer }}">{{ t("offered to the model") if row.offer else t("by hand only") }}</span>
|
||||||
|
<form id="del-helper-{{ row.id }}" method="post" action="/api/preferences/helpers/{{ row.id }}/delete"></form>
|
||||||
|
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="del-helper-{{ row.id }}"
|
||||||
|
aria-label="{{ t('Remove this helper') }}" title="{{ t('Remove this helper') }}">
|
||||||
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% if may_designate %}
|
||||||
|
<form method="post" action="/api/preferences/helpers" class="talk-rule-form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field__label" for="helper-main">{{ t("This model") }}</label>
|
||||||
|
<select class="select" id="helper-main" name="main_model">
|
||||||
|
{% 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="helper-model">{{ t("May send helpers to") }}</label>
|
||||||
|
<select class="select" id="helper-model" name="helper_model">
|
||||||
|
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="field__hint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="offer" value="true" checked>
|
||||||
|
<span>{{ t("Offer it to the model") }}</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add helper") }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# --- Appearance --- #}
|
{# --- 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 ------------------------------------------
|
# --- Other models reaching the conversation ------------------------------------------
|
||||||
def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
|
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)
|
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
|
||||||
|
owner.role = "user"
|
||||||
|
db.commit()
|
||||||
local, _ = setup
|
local, _ = setup
|
||||||
chat = _chat(db, owner, connection=local)
|
chat = _chat(db, owner, connection=local)
|
||||||
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["local-b", "cloud-model"]})
|
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):
|
def test_a_crowd_member_that_left_the_group_does_not_speak(db, owner, setup):
|
||||||
|
owner.role = "user"
|
||||||
|
db.commit()
|
||||||
local, cloud = setup
|
local, cloud = setup
|
||||||
chat = _chat(db, owner, connection=local)
|
chat = _chat(db, owner, connection=local)
|
||||||
db.add(CrowdMember(chat_id=chat.id, model_id="cloud-model", connection_id=cloud.id))
|
db.add(CrowdMember(chat_id=chat.id, model_id="cloud-model", connection_id=cloud.id))
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""Helpers on another model: capacity, designations, the choice, and the screens.
|
||||||
|
|
||||||
|
`test_subagent.py` covers what a helper is *not* given; this covers which model
|
||||||
|
it runs on. The generation loop is stubbed the way that file stubs it, and the
|
||||||
|
tool is always reached through `resolve_tools`, because what may be run is what
|
||||||
|
was offered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from lembas.db.models import (
|
||||||
|
Chat,
|
||||||
|
ChatHelper,
|
||||||
|
Connection,
|
||||||
|
DataGroup,
|
||||||
|
HelperDesignation,
|
||||||
|
Model,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from lembas.services import helpers, settings_store, talk
|
||||||
|
from lembas.services import subagent as subagent_service
|
||||||
|
from lembas.services import tools as tools_service
|
||||||
|
from lembas.services.crypto import encrypt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(db, registered):
|
||||||
|
"""Helpers on, three models on one local connection and one hosted model."""
|
||||||
|
settings_store.update(db, {"enabled": True}, key=settings_store.SUBAGENTS)
|
||||||
|
settings_store.update(db, {"default_permissions": {"tools.subagent": True}})
|
||||||
|
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(""))
|
||||||
|
db.add_all([local, cloud])
|
||||||
|
db.flush()
|
||||||
|
tools = {"tools": True}
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Model(connection_id=local.id, model_id="gpt-oss", capabilities_json=tools,
|
||||||
|
position=0, params_json={"reasoning_effort": "high"}),
|
||||||
|
Model(connection_id=local.id, model_id="qwen35", capabilities_json=tools,
|
||||||
|
position=1),
|
||||||
|
Model(connection_id=local.id, model_id="bonsai", capabilities_json=tools,
|
||||||
|
position=2, params_json={"reasoning_effort": "xhigh"},
|
||||||
|
reasoning_efforts=["low", "medium", "xhigh"]),
|
||||||
|
Model(connection_id=cloud.id, model_id="deepseek", capabilities_json=tools,
|
||||||
|
position=3),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
subagent_service.clear()
|
||||||
|
yield {"local": local, "cloud": cloud}
|
||||||
|
subagent_service.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _user(db) -> User:
|
||||||
|
return db.scalars(select(User).order_by(User.created_at)).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _chat(db, model_id="gpt-oss", connection=None) -> Chat:
|
||||||
|
connection_id = connection.id if connection else db.scalar(
|
||||||
|
select(Model.connection_id).where(Model.model_id == model_id)
|
||||||
|
)
|
||||||
|
chat = Chat(user_id=_user(db).id, title="t", model_id=model_id, connection_id=connection_id)
|
||||||
|
db.add(chat)
|
||||||
|
db.commit()
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
def _row(db, model_id) -> Model:
|
||||||
|
return db.scalar(select(Model).where(Model.model_id == model_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(found) -> list[str]:
|
||||||
|
return [c.model.model_id for c in found]
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(monkeypatch):
|
||||||
|
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER
|
||||||
|
from lembas.db.session import session_scope
|
||||||
|
from lembas.services import chat as chat_service
|
||||||
|
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def fake_wake(chat_id: str, content: str, *, model_id: str = "") -> str:
|
||||||
|
seen["chat_id"] = chat_id
|
||||||
|
with session_scope() as db:
|
||||||
|
child = db.get(Chat, chat_id)
|
||||||
|
chat_service.create_message(db, child, ROLE_USER, content)
|
||||||
|
reply = chat_service.create_message(db, child, ROLE_ASSISTANT, "Done.")
|
||||||
|
return reply.id
|
||||||
|
|
||||||
|
monkeypatch.setattr("lembas.services.wake.wake_chat", fake_wake)
|
||||||
|
monkeypatch.setattr("lembas.services.generation.running_for", lambda chat_id: None)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(db, chat: Chat, args: dict):
|
||||||
|
from lembas.services import generation as generation_service
|
||||||
|
|
||||||
|
class _Fake:
|
||||||
|
subagents = 0
|
||||||
|
|
||||||
|
user = _user(db)
|
||||||
|
resolved = tools_service.resolve_tools(db, chat, user)
|
||||||
|
context = tools_service.context_for(db, user, chat, tools=resolved)
|
||||||
|
original = generation_service.running_for
|
||||||
|
generation_service.running_for = (
|
||||||
|
lambda chat_id: _Fake() if chat_id == chat.id else original(chat_id)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await tools_service.run_tool(context, "subagent_run", json.dumps(args))
|
||||||
|
finally:
|
||||||
|
generation_service.running_for = original
|
||||||
|
|
||||||
|
|
||||||
|
def _schema(db, chat):
|
||||||
|
resolved = tools_service.resolve_tools(db, chat, _user(db))
|
||||||
|
tool = resolved.by_name.get("subagent_run")
|
||||||
|
return tool.parameters["properties"] if tool else None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Capacity ---------------------------------------------------------------------------
|
||||||
|
def test_by_default_a_model_is_its_own_helper_and_the_tool_is_unchanged(db):
|
||||||
|
"""Both switches off, nothing designated: exactly the 1.11 behaviour."""
|
||||||
|
chat = _chat(db)
|
||||||
|
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss"]
|
||||||
|
assert "model" not in _schema(db, chat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_session_model_cannot_help_itself(db):
|
||||||
|
_row(db, "gpt-oss").single_session = True
|
||||||
|
db.commit()
|
||||||
|
chat = _chat(db)
|
||||||
|
assert helpers.candidates(db, chat, _user(db)) == []
|
||||||
|
# Nothing to send a helper to, so the tool is not offered at all.
|
||||||
|
assert _schema(db, chat) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_model_at_a_time_forbids_another_model_on_that_connection(db, setup):
|
||||||
|
main, other = _row(db, "gpt-oss"), _row(db, "qwen35")
|
||||||
|
assert helpers.capacity_refusal(main, other) == ""
|
||||||
|
setup["local"].one_model_at_a_time = True
|
||||||
|
db.commit()
|
||||||
|
assert "holds one model at a time" in helpers.capacity_refusal(main, other)
|
||||||
|
# ...but itself, and a model on another connection, are fine.
|
||||||
|
assert helpers.capacity_refusal(main, main) == ""
|
||||||
|
assert helpers.capacity_refusal(main, _row(db, "deepseek")) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Designations ------------------------------------------------------------------------
|
||||||
|
def test_an_offered_designation_joins_the_candidates_and_the_enum(db):
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
|
||||||
|
chat = _chat(db)
|
||||||
|
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss", "qwen35"]
|
||||||
|
assert _schema(db, chat)["model"]["enum"] == ["gpt-oss", "qwen35"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_by_hand_designation_is_used_only_once_added_to_the_chat(db):
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
|
||||||
|
chat = _chat(db)
|
||||||
|
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss"]
|
||||||
|
helpers.apply_chat_helpers(db, chat, _user(db), ["qwen35"])
|
||||||
|
db.commit()
|
||||||
|
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss", "qwen35"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_designations_are_per_main_model(db):
|
||||||
|
helpers.set_designation(db, None, "bonsai", "qwen35", offer=True)
|
||||||
|
assert _ids(helpers.candidates(db, _chat(db), _user(db))) == ["gpt-oss"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_persons_designations_need_the_permission_and_replace_the_instances(db):
|
||||||
|
user = _user(db)
|
||||||
|
user.role = "user"
|
||||||
|
db.commit()
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
|
||||||
|
helpers.set_designation(db, user, "gpt-oss", "qwen35", offer=False)
|
||||||
|
assert [d.owner_id for d in helpers.designations(db, user, "gpt-oss")] == [None]
|
||||||
|
settings_store.update(
|
||||||
|
db, {"default_permissions": {"tools.subagent": True, helpers.PERMISSION: True}}
|
||||||
|
)
|
||||||
|
rows = helpers.designations(db, user, "gpt-oss")
|
||||||
|
assert [(d.owner_id, d.offer) for d in rows] == [(user.id, False)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_talk_rules_filter_designations(db):
|
||||||
|
user = _user(db)
|
||||||
|
user.role = "user"
|
||||||
|
db.commit()
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "qwen35", "deny")
|
||||||
|
assert _ids(helpers.candidates(db, _chat(db), user)) == ["gpt-oss"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_data_group_needs_a_rule(db, setup):
|
||||||
|
user = _user(db)
|
||||||
|
user.role = "user"
|
||||||
|
db.add(DataGroup(id="hosted", name="Hosted"))
|
||||||
|
setup["cloud"].data_group_id = "hosted"
|
||||||
|
db.commit()
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "deepseek", offer=True)
|
||||||
|
assert "deepseek" not in _ids(helpers.candidates(db, _chat(db), user))
|
||||||
|
talk.set_rule(db, None, "gpt-oss", "deepseek", "allow")
|
||||||
|
assert "deepseek" in _ids(helpers.candidates(db, _chat(db), user))
|
||||||
|
|
||||||
|
|
||||||
|
# --- The choice, at call time -------------------------------------------------------------
|
||||||
|
async def test_a_named_helper_runs_on_that_model_with_its_own_effort(db, monkeypatch):
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
|
||||||
|
chat = _chat(db)
|
||||||
|
chat.params_json = {"reasoning_effort": "high"}
|
||||||
|
db.commit()
|
||||||
|
seen = _spawn(monkeypatch)
|
||||||
|
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
|
||||||
|
|
||||||
|
outcome = await _run(db, chat, {"task": "Check it.", "model": "bonsai"})
|
||||||
|
|
||||||
|
assert outcome.event["status"] == "ok"
|
||||||
|
child = db.get(Chat, seen["chat_id"])
|
||||||
|
assert child.model_id == "bonsai"
|
||||||
|
# Bonsai's own default, never the parent's `high`, which it would refuse.
|
||||||
|
assert child.params_json["reasoning_effort"] == "xhigh"
|
||||||
|
assert child.parent_chat_id == chat.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_model_named_is_the_main_model_itself(db, monkeypatch):
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
|
||||||
|
seen = _spawn(monkeypatch)
|
||||||
|
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
|
||||||
|
await _run(db, _chat(db), {"task": "Check it."})
|
||||||
|
assert db.get(Chat, seen["chat_id"]).model_id == "gpt-oss"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_model_that_is_not_a_candidate_is_refused_with_the_list(db, monkeypatch):
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
|
||||||
|
_spawn(monkeypatch)
|
||||||
|
outcome = await _run(db, _chat(db), {"task": "Check it.", "model": "deepseek"})
|
||||||
|
assert outcome.event["status"] == "error"
|
||||||
|
assert "bonsai" in outcome.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_itself_the_default_is_the_first_hand_added_helper(db):
|
||||||
|
_row(db, "gpt-oss").single_session = True
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
|
||||||
|
db.commit()
|
||||||
|
chat = _chat(db)
|
||||||
|
helpers.apply_chat_helpers(db, chat, _user(db), ["qwen35"])
|
||||||
|
db.commit()
|
||||||
|
model, refusal = helpers.choose(db, chat, _user(db), "")
|
||||||
|
assert model.model_id == "qwen35" and refusal == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_harness_lists_where_a_helper_can_run(db):
|
||||||
|
from lembas.services import harness
|
||||||
|
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
|
||||||
|
chat = _chat(db)
|
||||||
|
resolved = tools_service.resolve_tools(db, chat, _user(db))
|
||||||
|
text = harness.compose(db, _user(db), resolved.schemas, chat=chat)
|
||||||
|
assert "Where a helper can run" in text
|
||||||
|
assert "qwen35 (qwen35)" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- The screens -----------------------------------------------------------------------------
|
||||||
|
def test_the_model_page_designates_a_helper(client, db):
|
||||||
|
model = _row(db, "gpt-oss")
|
||||||
|
client.post(f"/admin/models/{model.id}/helpers", data={"helper_model": "qwen35",
|
||||||
|
"offer": "true"})
|
||||||
|
row = db.scalar(select(HelperDesignation))
|
||||||
|
assert (row.main_model, row.helper_model, row.offer) == ("gpt-oss", "qwen35", True)
|
||||||
|
assert "offered to the model" in client.get(f"/admin/models/{model.id}/edit").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_model_page_saves_single_session(client, db):
|
||||||
|
model = _row(db, "gpt-oss")
|
||||||
|
page = client.get(f"/admin/models/{model.id}/edit").text
|
||||||
|
assert 'name="single_session"' in page
|
||||||
|
client.post(f"/admin/models/{model.id}", data={"display_name": "", "enabled": "true",
|
||||||
|
"public": "true", "single_session": "true"})
|
||||||
|
db.expire_all()
|
||||||
|
assert _row(db, "gpt-oss").single_session is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_connection_form_saves_one_model_at_a_time(client, db, setup):
|
||||||
|
local = setup["local"]
|
||||||
|
client.post(f"/admin/connections/{local.id}", data={"name": "Local",
|
||||||
|
"base_url": local.base_url, "one_model_at_a_time": "true"})
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Connection, local.id).one_model_at_a_time is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_composer_picker_adds_a_helper_by_hand(client, db):
|
||||||
|
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
|
||||||
|
chat = _chat(db)
|
||||||
|
page = client.get(f"/chat/{chat.id}").text
|
||||||
|
assert 'name="helper_model_ids"' in page and 'id="helpers-form"' in page
|
||||||
|
client.patch(f"/api/chats/{chat.id}", data={"helper_model_ids": ["qwen35"]})
|
||||||
|
assert [h.model_id for h in db.scalars(select(ChatHelper))] == ["qwen35"]
|
||||||
|
client.patch(f"/api/chats/{chat.id}", data={"helper_model_ids": [""]})
|
||||||
|
db.expire_all()
|
||||||
|
assert list(db.scalars(select(ChatHelper))) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_person_adds_their_own_designation_with_the_permission(client, db):
|
||||||
|
user = _user(db)
|
||||||
|
user.role = "user"
|
||||||
|
db.commit()
|
||||||
|
data = {"main_model": "gpt-oss", "helper_model": "qwen35", "offer": "true"}
|
||||||
|
client.post("/api/preferences/helpers", data=data)
|
||||||
|
assert list(db.scalars(select(HelperDesignation))) == []
|
||||||
|
settings_store.update(
|
||||||
|
db, {"default_permissions": {"tools.subagent": True, helpers.PERMISSION: True}}
|
||||||
|
)
|
||||||
|
client.post("/api/preferences/helpers", data=data)
|
||||||
|
assert db.scalar(select(HelperDesignation)).owner_id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
# --- Upgrading ---------------------------------------------------------------------------------
|
||||||
|
def test_an_upgraded_database_reads_both_switches_as_off(db, setup):
|
||||||
|
"""Both are NOT NULL booleans, which `sync_schema` backfills with False -- which
|
||||||
|
is why they name the restrictive state: False has to mean "as before"."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from lembas.db.migrations import sync_schema
|
||||||
|
from lembas.db.session import get_engine
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
db.close()
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS chat_helpers"))
|
||||||
|
connection.execute(text("DROP TABLE IF EXISTS helper_designations"))
|
||||||
|
connection.execute(text("ALTER TABLE models DROP COLUMN single_session"))
|
||||||
|
connection.execute(text("ALTER TABLE connections DROP COLUMN one_model_at_a_time"))
|
||||||
|
sync_schema(engine)
|
||||||
|
assert _row(db, "gpt-oss").single_session is False
|
||||||
|
assert db.get(Connection, setup["local"].id).one_model_at_a_time is False
|
||||||
|
assert _ids(helpers.candidates(db, _chat(db), _user(db))) == ["gpt-oss"]
|
||||||
@@ -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