settings: Add setting to control permission for topic summarization.

This commit is contained in:
Sahil Batra
2025-02-10 16:46:37 +05:30
committed by Tim Abbott
parent 9b38444e42
commit 4ca28bb850
25 changed files with 247 additions and 6 deletions

View File

@@ -24,6 +24,10 @@ format used by the Zulip server that they are interacting with.
* [`POST /register`](/api/register-queue): Added
`server_can_summarize_topics` to the response.
* [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events),
`PATCH /realm`: Added `can_summarize_topics_group` realm setting which is
a [group-setting value](/api/group-setting-values) describing the set of
users with permission to use AI summarization.
**Feature level 349**

View File

@@ -256,6 +256,7 @@ export function build_page(): void {
giphy_help_link,
...get_realm_level_notification_settings(),
group_setting_labels: settings_config.all_group_setting_labels.realm,
server_can_summarize_topics: realm.server_can_summarize_topics,
};
const rendered_admin_tab = render_admin_tab(options);

View File

@@ -56,6 +56,7 @@ export const realm_group_setting_name_schema = z.enum([
"can_manage_all_groups",
"can_move_messages_between_channels_group",
"can_move_messages_between_topics_group",
"can_summarize_topics_group",
"create_multiuse_invite_group",
"direct_message_initiator_group",
"direct_message_permission_group",

View File

@@ -56,7 +56,6 @@ type TopicPopoverContext = {
topic_unmuted: boolean;
is_spectator: boolean;
is_moderator: boolean;
is_development_environment: boolean;
is_topic_empty: boolean;
can_move_topic: boolean;
can_rename_topic: boolean;
@@ -67,6 +66,7 @@ type TopicPopoverContext = {
url: string;
visibility_policy: number | false;
all_visibility_policies: AllVisibilityPolicies;
can_summarize_topics: boolean;
};
type VisibilityChangePopoverContext = {
@@ -267,8 +267,6 @@ export function get_topic_popover_content_context({
can_move_topic,
can_rename_topic,
is_moderator: current_user.is_moderator,
// Temporary, as we're using this to control whether we show the summarize popover.
is_development_environment: page_params.development_environment,
is_realm_admin: current_user.is_admin,
topic_is_resolved: resolved_topic.is_resolved(topic_name),
has_starred_messages,
@@ -276,6 +274,8 @@ export function get_topic_popover_content_context({
url,
visibility_policy,
all_visibility_policies,
can_summarize_topics:
realm.server_can_summarize_topics && settings_data.user_can_summarize_topics(),
};
}

View File

@@ -225,6 +225,7 @@ export function dispatch_normal_event(event) {
can_manage_all_groups: noop,
can_move_messages_between_channels_group: noop,
can_move_messages_between_topics_group: noop,
can_summarize_topics_group: noop,
create_multiuse_invite_group: noop,
default_code_block_language: noop,
default_language: noop,

View File

@@ -814,6 +814,7 @@ export function check_realm_settings_property_changed(elem: HTMLElement): boolea
case "realm_can_manage_all_groups":
case "realm_can_move_messages_between_channels_group":
case "realm_can_move_messages_between_topics_group":
case "realm_can_summarize_topics_group":
case "realm_create_multiuse_invite_group":
case "realm_direct_message_initiator_group":
case "realm_direct_message_permission_group": {
@@ -1064,6 +1065,7 @@ export function populate_data_for_realm_settings_request(
"can_invite_users_group",
"can_move_messages_between_channels_group",
"can_move_messages_between_topics_group",
"can_summarize_topics_group",
"create_multiuse_invite_group",
"direct_message_initiator_group",
"direct_message_permission_group",
@@ -1534,6 +1536,7 @@ export const group_setting_widget_map = new Map<string, GroupSettingPillContaine
["realm_can_manage_all_groups", null],
["realm_can_move_messages_between_channels_group", null],
["realm_can_move_messages_between_topics_group", null],
["realm_can_summarize_topics_group", null],
["realm_create_multiuse_invite_group", null],
["realm_direct_message_initiator_group", null],
["realm_direct_message_permission_group", null],

View File

@@ -687,6 +687,7 @@ export const all_group_setting_labels = {
can_access_all_users_group: $t({
defaultMessage: "Who can view all other users in the organization",
}),
can_summarize_topics_group: $t({defaultMessage: "Who can use AI summaries"}),
can_create_write_only_bots_group: $t({
defaultMessage: "Who can create bots that send messages into Zulip",
}),
@@ -766,6 +767,7 @@ export const realm_group_permission_settings: {
subsection_heading: $t({defaultMessage: "Other permissions"}),
subsection_key: "org-other-permissions",
settings: [
"can_summarize_topics_group",
"can_create_write_only_bots_group",
"can_create_bots_group",
"can_add_custom_emoji_group",

View File

@@ -90,6 +90,14 @@ export function user_can_create_multiuse_invite(): boolean {
);
}
export function user_can_summarize_topics(): boolean {
return user_has_permission_for_group_setting(
realm.realm_can_summarize_topics_group,
"can_summarize_topics_group",
"realm",
);
}
export function can_subscribe_others_to_all_accessible_streams(): boolean {
return user_has_permission_for_group_setting(
realm.realm_can_add_subscribers_group,

View File

@@ -533,6 +533,7 @@ export function discard_realm_property_element_changes(elem: HTMLElement): void
case "realm_can_manage_all_groups":
case "realm_can_move_messages_between_channels_group":
case "realm_can_move_messages_between_topics_group":
case "realm_can_summarize_topics_group":
case "realm_create_multiuse_invite_group":
case "realm_direct_message_initiator_group":
case "realm_direct_message_permission_group": {

View File

@@ -307,6 +307,7 @@ export const realm_schema = z.object({
realm_can_manage_all_groups: group_setting_value_schema,
realm_can_move_messages_between_channels_group: group_setting_value_schema,
realm_can_move_messages_between_topics_group: group_setting_value_schema,
realm_can_summarize_topics_group: group_setting_value_schema,
realm_create_multiuse_invite_group: group_setting_value_schema,
realm_date_created: z.number(),
realm_default_code_block_language: z.string(),

View File

@@ -506,6 +506,10 @@ input[type="checkbox"] {
padding-top: 10px;
}
#org-other-permissions .tip {
margin-top: 0;
}
.language_selection_widget .language_selection_button {
text-decoration: none;
min-width: 0;

View File

@@ -38,7 +38,7 @@
{{else}}
{{!-- Group 2 --}}
<li role="separator" class="popover-menu-separator"></li>
{{#if (and is_development_environment (or is_moderator is_realm_admin))}}
{{#if can_summarize_topics}}
<li role="none" class="link-item popover-menu-list-item">
<a role="menuitem" class="sidebar-popover-summarize-topic popover-menu-link" tabindex="0">
<i class="popover-menu-icon fa fa-magic" aria-hidden="true"></i>

View File

@@ -341,6 +341,15 @@
{{> settings_save_discard_widget section_name="other-permissions" }}
</div>
<div class="m-10 inline-block organization-permissions-parent">
{{#unless server_can_summarize_topics}}
<div class="tip">
{{t "AI summaries are not enabled on this server."}}
</div>
{{/unless}}
{{> group_setting_value_pill_input
setting_name="realm_can_summarize_topics_group"
label=group_setting_labels.can_summarize_topics_group}}
{{> group_setting_value_pill_input
setting_name="realm_can_create_write_only_bots_group"
label=group_setting_labels.can_create_write_only_bots_group}}

View File

@@ -632,3 +632,10 @@ run_test("guests_can_access_all_other_users", () => {
realm.realm_can_access_all_users_group = everyone.id;
assert.ok(settings_data.guests_can_access_all_other_users());
});
run_test("user_can_summarize_topics", () => {
test_realm_group_settings(
"realm_can_summarize_topics_group",
settings_data.user_can_summarize_topics,
);
});

View File

@@ -524,6 +524,7 @@ class GroupSettingUpdateData(GroupSettingUpdateDataCore):
can_manage_all_groups: int | AnonymousSettingGroupDict | None = None
can_move_messages_between_channels_group: int | AnonymousSettingGroupDict | None = None
can_move_messages_between_topics_group: int | AnonymousSettingGroupDict | None = None
can_summarize_topics_group: int | AnonymousSettingGroupDict | None = None
direct_message_initiator_group: int | AnonymousSettingGroupDict | None = None
direct_message_permission_group: int | AnonymousSettingGroupDict | None = None

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.10 on 2025-02-10 10:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0663_realm_enable_guest_user_dm_warning"),
]
operations = [
migrations.AddField(
model_name="realm",
name="can_summarize_topics_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.0.10 on 2025-02-10 10:31
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import OuterRef
def set_default_for_can_summarize_topics_group(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
Realm = apps.get_model("zerver", "Realm")
NamedUserGroup = apps.get_model("zerver", "NamedUserGroup")
Realm.objects.filter(can_summarize_topics_group=None).update(
can_summarize_topics_group=NamedUserGroup.objects.filter(
name="role:everyone", realm=OuterRef("id"), is_system_group=True
).values("pk")
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("zerver", "0664_realm_can_summarize_topics_group"),
]
operations = [
migrations.RunPython(
set_default_for_can_summarize_topics_group,
elidable=True,
reverse_code=migrations.RunPython.noop,
)
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0.10 on 2025-02-10 10:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0665_set_default_for_can_summarize_topics_group"),
]
operations = [
migrations.AlterField(
model_name="realm",
name="can_summarize_topics_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
]

View File

@@ -293,6 +293,11 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
"UserGroup", on_delete=models.RESTRICT, related_name="+"
)
# UserGroup whose members are allowed to summarize topics.
can_summarize_topics_group = models.ForeignKey(
"UserGroup", on_delete=models.RESTRICT, related_name="+"
)
# UserGroup whose members are allowed to create invite link.
create_multiuse_invite_group = models.ForeignKey(
"UserGroup", on_delete=models.RESTRICT, related_name="+"
@@ -790,6 +795,13 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
allow_everyone_group=True,
default_group_name=SystemGroups.EVERYONE,
),
can_summarize_topics_group=GroupPermissionSetting(
require_system_group=False,
allow_internet_group=False,
allow_nobody_group=True,
allow_everyone_group=True,
default_group_name=SystemGroups.EVERYONE,
),
direct_message_initiator_group=GroupPermissionSetting(
require_system_group=False,
allow_internet_group=False,
@@ -1203,6 +1215,8 @@ def get_realm_with_settings(realm_id: int) -> Realm:
"can_move_messages_between_channels_group__named_user_group",
"can_move_messages_between_topics_group",
"can_move_messages_between_topics_group__named_user_group",
"can_summarize_topics_group",
"can_summarize_topics_group__named_user_group",
"direct_message_initiator_group",
"direct_message_initiator_group__named_user_group",
"direct_message_permission_group",

View File

@@ -873,6 +873,9 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings):
def can_delete_own_message(self) -> bool:
return self.has_permission("can_delete_own_message_group")
def can_summarize_topics(self) -> bool:
return self.has_permission("can_summarize_topics_group")
def can_access_public_streams(self) -> bool:
return not (self.is_guest or self.realm.is_zephyr_mirror_realm)

View File

@@ -4647,6 +4647,14 @@ paths:
create and edit user groups.
[calc-full-member]: /api/roles-and-permissions#determining-if-a-user-is-a-full-member
can_summarize_topics_group:
allOf:
- $ref: "#/components/schemas/GroupSettingValue"
- description: |
A [group-setting value](/api/group-setting-values) defining the
set of users who are allowed to use AI summarization.
**Changes**: New in Zulip 10.0 (feature level 350).
create_multiuse_invite_group:
allOf:
- $ref: "#/components/schemas/GroupSettingValue"
@@ -17662,6 +17670,14 @@ paths:
to be of type integer and did not accept anonymous user groups.
New in Zulip 8.0 (feature level 225).
realm_can_summarize_topics_group:
allOf:
- $ref: "#/components/schemas/GroupSettingValue"
- description: |
A [group-setting value](/api/group-setting-values) defining the
set of users who are allowed to use AI summarization.
**Changes**: New in Zulip 10.0 (feature level 350).
zulip_plan_is_not_limited:
type: boolean
description: |

View File

@@ -142,6 +142,7 @@ class HomeTest(ZulipTestCase):
"realm_can_manage_all_groups",
"realm_can_move_messages_between_channels_group",
"realm_can_move_messages_between_topics_group",
"realm_can_summarize_topics_group",
"realm_create_multiuse_invite_group",
"realm_create_private_stream_policy",
"realm_create_public_stream_policy",

View File

@@ -7,7 +7,11 @@ from django.conf import settings
from typing_extensions import override
from analytics.models import UserCount
from zerver.actions.realm_settings import do_change_realm_permission_group_setting
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import NamedUserGroup
from zerver.models.groups import SystemGroups
from zerver.models.realms import get_realm
warnings.filterwarnings("ignore", category=UserWarning, module="pydantic")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pydantic")
@@ -119,3 +123,82 @@ class MessagesSummaryTestCase(ZulipTestCase):
):
response = self.client_get("/json/messages/summary")
self.assert_json_error_contains(response, "Reached monthly limit for AI credits.")
def test_permission_to_summarize_message_in_topics(self) -> None:
narrow = orjson.dumps([["channel", self.channel_name], ["topic", self.topic_name]]).decode()
realm = get_realm("zulip")
moderators_group = NamedUserGroup.objects.get(
name=SystemGroups.MODERATORS, realm=realm, is_system_group=True
)
do_change_realm_permission_group_setting(
realm,
"can_summarize_topics_group",
moderators_group,
acting_user=None,
)
# In this code path, we test using the fixtures.
with open(LLM_FIXTURES_FILE, "rb") as f:
fixture_data = orjson.loads(f.read())
def check_message_summary_permission(user: str, expect_fail: bool = False) -> None:
self.login(user)
with (
self.settings(
TOPIC_SUMMARIZATION_MODEL="groq/llama-3.3-70b-versatile",
TOPIC_SUMMARIZATION_API_KEY="test",
),
mock.patch("litellm.completion", return_value=fixture_data["response"]),
):
result = self.client_get("/json/messages/summary", dict(narrow=narrow))
if expect_fail:
self.assert_json_error(result, "Insufficient permission")
else:
self.assert_json_success(result)
check_message_summary_permission("hamlet", expect_fail=True)
check_message_summary_permission("shiva")
nobody_group = NamedUserGroup.objects.get(
name=SystemGroups.NOBODY, realm=realm, is_system_group=True
)
do_change_realm_permission_group_setting(
realm,
"can_summarize_topics_group",
nobody_group,
acting_user=None,
)
check_message_summary_permission("desdemona", expect_fail=True)
hamletcharacters_group = NamedUserGroup.objects.get(name="hamletcharacters", realm=realm)
do_change_realm_permission_group_setting(
realm,
"can_summarize_topics_group",
hamletcharacters_group,
acting_user=None,
)
check_message_summary_permission("desdemona", expect_fail=True)
check_message_summary_permission("othello", expect_fail=True)
check_message_summary_permission("hamlet")
check_message_summary_permission("cordelia")
setting_group = self.create_or_update_anonymous_group_for_setting(
[self.example_user("othello")], [moderators_group]
)
do_change_realm_permission_group_setting(
realm,
"can_summarize_topics_group",
setting_group,
acting_user=None,
)
check_message_summary_permission("cordelia", expect_fail=True)
check_message_summary_permission("hamlet", expect_fail=True)
check_message_summary_permission("othello")
check_message_summary_permission("shiva")
check_message_summary_permission("desdemona")

View File

@@ -27,8 +27,8 @@ def get_messages_summary(
if settings.TOPIC_SUMMARIZATION_MODEL is None: # nocoverage
raise JsonableError(_("AI features are not enabled on this server."))
if not (user_profile.is_moderator or user_profile.is_realm_admin): # nocoverage
return json_success(request, {"summary": "Feature limited to moderators for now."})
if not user_profile.can_summarize_topics():
raise JsonableError(_("Insufficient permission"))
if settings.MAX_PER_USER_MONTHLY_AI_COST is not None:
used_credits = COUNT_STATS["ai_credit_usage::day"].current_month_accumulated_count_for_user(

View File

@@ -136,6 +136,7 @@ def update_realm(
can_manage_all_groups: Json[GroupSettingChangeRequest] | None = None,
can_move_messages_between_channels_group: Json[GroupSettingChangeRequest] | None = None,
can_move_messages_between_topics_group: Json[GroupSettingChangeRequest] | None = None,
can_summarize_topics_group: Json[GroupSettingChangeRequest] | None = None,
direct_message_initiator_group: Json[GroupSettingChangeRequest] | None = None,
direct_message_permission_group: Json[GroupSettingChangeRequest] | None = None,
wildcard_mention_policy: Json[WildcardMentionPolicyEnum] | None = None,