DEV: Add summarization logic from core (#658)

This commit is contained in:
Keegan George
2024-07-02 08:51:59 -07:00
committed by GitHub
parent c352cc5ba3
commit 1b0ba9197c
36 changed files with 1987 additions and 206 deletions
@@ -0,0 +1,49 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
class ChatSummaryController < ::Chat::ApiController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_plugin ::Chat::PLUGIN_NAME
VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
def show
since = params[:since].to_i
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
channel = ::Chat::Channel.find(params[:channel_id])
guardian.ensure_can_join_chat_channel!(channel)
strategy = DiscourseAi::Summarization::Models::Base.selected_strategy
raise Discourse::NotFound.new unless strategy
unless DiscourseAi::Summarization::Models::Base.can_request_summary_for?(current_user)
raise Discourse::InvalidAccess
end
RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed!
hijack do
content = { content_title: channel.name }
content[:contents] = channel
.chat_messages
.where("chat_messages.created_at > ?", since.hours.ago)
.includes(:user)
.order(created_at: :asc)
.pluck(:id, :username_lower, :message)
.map { { id: _1, poster: _2, text: _3 } }
summarized_text =
if content[:contents].empty?
I18n.t("discourse_ai.summarization.chat.no_targets")
else
strategy.summarize(content, current_user).dig(:summary)
end
render json: { summary: summarized_text }
end
end
end
end
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
class SummaryController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
def show
topic = Topic.find(params[:topic_id])
guardian.ensure_can_see!(topic)
strategy = DiscourseAi::Summarization::Models::Base.selected_strategy
if strategy.nil? ||
!DiscourseAi::Summarization::Models::Base.can_see_summary?(topic, current_user)
raise Discourse::NotFound
end
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user
opts = params.permit(:skip_age_check)
if params[:stream] && current_user
Jobs.enqueue(
:stream_topic_ai_summary,
topic_id: topic.id,
user_id: current_user.id,
opts: opts.as_json,
)
render json: success_json
else
hijack do
summary =
DiscourseAi::TopicSummarization.new(strategy).summarize(topic, current_user, opts)
render_serialized(summary, AiTopicSummarySerializer)
end
end
end
end
end
end
@@ -0,0 +1,52 @@
# frozen_string_literal: true
module Jobs
class StreamTopicAiSummary < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return unless topic = Topic.find_by(id: args[:topic_id])
return unless user = User.find_by(id: args[:user_id])
strategy = DiscourseAi::Summarization::Models::Base.selected_strategy
if strategy.nil? || !DiscourseAi::Summarization::Models::Base.can_see_summary?(topic, user)
return
end
guardian = Guardian.new(user)
return unless guardian.can_see?(topic)
opts = args[:opts] || {}
streamed_summary = +""
start = Time.now
summary =
DiscourseAi::TopicSummarization
.new(strategy)
.summarize(topic, user, opts) do |partial_summary|
streamed_summary << partial_summary
# Throttle updates.
if (Time.now - start > 0.5) || Rails.env.test?
payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } }
publish_update(topic, user, payload)
start = Time.now
end
end
publish_update(
topic,
user,
AiTopicSummarySerializer.new(summary, { scope: guardian }).as_json.merge(done: true),
)
end
private
def publish_update(topic, user, payload)
MessageBus.publish("/discourse-ai/summaries/topic/#{topic.id}", payload, user_ids: [user.id])
end
end
end
+28
View File
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class AiSummary < ActiveRecord::Base
belongs_to :target, polymorphic: true
def mark_as_outdated
@outdated = true
end
def outdated
@outdated || false
end
end
# == Schema Information
#
# Table name: ai_summaries
#
# id :bigint not null, primary key
# target_id :integer not null
# target_type :string not null
# content_range :int4range
# summarized_text :string not null
# original_content_sha :string not null
# algorithm :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class AiTopicSummarySerializer < ApplicationSerializer
attributes :summarized_text, :algorithm, :outdated, :can_regenerate, :new_posts_since_summary
def can_regenerate
DiscourseAi::Summarization::Models::Base.can_request_summary_for?(scope.current_user)
end
def new_posts_since_summary
# Postgres uses discrete range types for int4range, which means
# (1..2) is stored as (1...3).
#
# We use Range#max to work around this, which in the case above always returns 2.
# Be careful with using Range#end here, it could lead to unexpected results as:
#
# (1..2).end => 2
# (1...3).end => 3
object.target.highest_post_number.to_i - object.content_range&.max.to_i
end
end
@@ -0,0 +1,120 @@
# frozen_string_literal: true
module DiscourseAi
class TopicSummarization
def initialize(strategy)
@strategy = strategy
end
def summarize(topic, user, opts = {}, &on_partial_blk)
existing_summary = AiSummary.find_by(target: topic)
# Existing summary shouldn't be nil in this scenario because the controller checks its existence.
return if !user && !existing_summary
targets_data = summary_targets(topic).pluck(:post_number, :raw, :username)
current_topic_sha = build_sha(targets_data.map(&:first))
can_summarize = DiscourseAi::Summarization::Models::Base.can_request_summary_for?(user)
if use_cached?(existing_summary, can_summarize, current_topic_sha, !!opts[:skip_age_check])
# It's important that we signal a cached summary is outdated
existing_summary.mark_as_outdated if new_targets?(existing_summary, current_topic_sha)
return existing_summary
end
delete_cached_summaries_of(topic) if existing_summary
content = {
resource_path: "#{Discourse.base_path}/t/-/#{topic.id}",
content_title: topic.title,
contents: [],
}
targets_data.map do |(pn, raw, username)|
raw_text = raw
if pn == 1 && topic.topic_embed&.embed_content_cache.present?
raw_text = topic.topic_embed&.embed_content_cache
end
content[:contents] << { poster: username, id: pn, text: raw_text }
end
summarization_result = strategy.summarize(content, user, &on_partial_blk)
cache_summary(summarization_result, targets_data.map(&:first), topic)
end
def summary_targets(topic)
topic.has_summary? ? best_replies(topic) : pick_selection(topic)
end
private
attr_reader :strategy
def best_replies(topic)
Post
.summary(topic.id)
.where("post_type = ?", Post.types[:regular])
.where("NOT hidden")
.joins(:user)
.order(:post_number)
end
def pick_selection(topic)
posts =
Post
.where(topic_id: topic.id)
.where("post_type = ?", Post.types[:regular])
.where("NOT hidden")
.order(:post_number)
post_numbers = posts.limit(5).pluck(:post_number)
post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number)
post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number)
Post
.where(topic_id: topic.id)
.joins(:user)
.where("post_number in (?)", post_numbers)
.order(:post_number)
end
def delete_cached_summaries_of(topic)
AiSummary.where(target: topic).destroy_all
end
# For users without permissions to generate a summary or fresh summaries, we return what we have cached.
def use_cached?(existing_summary, can_summarize, current_sha, skip_age_check)
existing_summary &&
!(
can_summarize && new_targets?(existing_summary, current_sha) &&
(skip_age_check || existing_summary.created_at < 1.hour.ago)
)
end
def new_targets?(summary, current_sha)
summary.original_content_sha != current_sha
end
def cache_summary(result, post_numbers, topic)
cached_summary =
AiSummary.create!(
target: topic,
algorithm: strategy.display_name,
content_range: (post_numbers.first..post_numbers.last),
summarized_text: result[:summary],
original_content_sha: build_sha(post_numbers),
)
cached_summary
end
def build_sha(ids)
Digest::SHA256.hexdigest(ids.join)
end
end
end
@@ -0,0 +1,129 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel } from "@ember/runloop";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
import discourseLater from "discourse-common/lib/later";
class Block {
@tracked show = false;
@tracked shown = false;
@tracked blinking = false;
constructor(args = {}) {
this.show = args.show ?? false;
this.shown = args.shown ?? false;
}
}
const BLOCKS_SIZE = 20; // changing this requires to change css accordingly
export default class AiSummarySkeleton extends Component {
blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())];
#nextBlockBlinkingTimer;
#blockBlinkingTimer;
#blockShownTimer;
@action
setupAnimation() {
this.blocks.firstObject.show = true;
this.blocks.firstObject.shown = true;
}
@action
onBlinking(block) {
if (!block.blinking) {
return;
}
block.show = false;
this.#nextBlockBlinkingTimer = discourseLater(
this,
() => {
this.#nextBlock(block).blinking = true;
},
250
);
this.#blockBlinkingTimer = discourseLater(
this,
() => {
block.blinking = false;
},
500
);
}
@action
onShowing(block) {
if (!block.show) {
return;
}
this.#blockShownTimer = discourseLater(
this,
() => {
this.#nextBlock(block).show = true;
this.#nextBlock(block).shown = true;
if (this.blocks.lastObject === block) {
this.blocks.firstObject.blinking = true;
}
},
250
);
}
@action
teardownAnimation() {
cancel(this.#blockShownTimer);
cancel(this.#nextBlockBlinkingTimer);
cancel(this.#blockBlinkingTimer);
}
#nextBlock(currentBlock) {
if (currentBlock === this.blocks.lastObject) {
return this.blocks.firstObject;
} else {
return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1);
}
}
<template>
<div class="ai-summary__container">
<ul class="ai-summary__list" {{didInsert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concatClass
"ai-summary__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{didUpdate (fn this.onBlinking block) block.blinking}}
{{didUpdate (fn this.onShowing block) block.show}}
{{willDestroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<span class="ai-summary__indicator-wave">
<span class="ai-summary__indicator-dot">.</span>
<span class="ai-summary__indicator-dot">.</span>
<span class="ai-summary__indicator-dot">.</span>
</span>
</span>
</div>
</template>
}
@@ -0,0 +1,83 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
import DModal from "discourse/components/d-modal";
import DModalCancel from "discourse/components/d-modal-cancel";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
export default class ChatModalChannelSummary extends Component {
@service chatApi;
@tracked sinceHours = null;
@tracked loading = false;
@tracked summary = null;
availableSummaries = {};
sinceOptions = [1, 3, 6, 12, 24, 72, 168].map((hours) => {
return {
name: I18n.t("discourse_ai.summarization.chat.since", { count: hours }),
value: hours,
};
});
get channelId() {
return this.args.model.channelId;
}
@action
summarize(since) {
this.sinceHours = since;
this.loading = true;
if (this.availableSummaries[since]) {
this.summary = this.availableSummaries[since];
this.loading = false;
return;
}
return ajax(`/discourse-ai/summarization/channels/${this.channelId}.json`, {
type: "GET",
data: {
since,
},
})
.then((data) => {
this.availableSummaries[this.sinceHours] = data.summary;
this.summary = this.availableSummaries[this.sinceHours];
})
.catch(popupAjaxError)
.finally(() => (this.loading = false));
}
<template>
<DModal
@closeModal={{@closeModal}}
class="chat-modal-channel-summary"
@title={{i18n "discourse_ai.summarization.chat.title"}}
>
<:body>
<span>{{i18n "discourse_ai.summarization.chat.description"}}</span>
<ComboBox
@value={{this.sinceHours}}
@content={{this.sinceOptions}}
@onChange={{this.summarize}}
@valueProperty="value"
class="summarization-since"
/>
<ConditionalLoadingSection @isLoading={{this.loading}}>
<p class="summary-area">{{this.summary}}</p>
</ConditionalLoadingSection>
</:body>
<:footer>
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>
</template>
}
@@ -0,0 +1,207 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { array } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { shortDateNoYear } from "discourse/lib/formatter";
import { cook } from "discourse/lib/text";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
import and from "truth-helpers/helpers/and";
import not from "truth-helpers/helpers/not";
import or from "truth-helpers/helpers/or";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
export default class AiSummaryBox extends Component {
@service siteSettings;
@service messageBus;
@service currentUser;
@tracked summary = "";
@tracked text = "";
@tracked summarizedOn = null;
@tracked summarizedBy = null;
@tracked newPostsSinceSummary = null;
@tracked outdated = false;
@tracked canRegenerate = false;
@tracked regenerated = false;
@tracked showSummaryBox = false;
@tracked canCollapseSummary = false;
@tracked loading = false;
get generateSummaryTitle() {
const title = this.canRegenerate
? "summary.buttons.regenerate"
: "summary.buttons.generate";
return I18n.t(title);
}
get generateSummaryIcon() {
return this.canRegenerate ? "sync" : "discourse-sparkles";
}
get outdatedSummaryWarningText() {
let outdatedText = I18n.t("summary.outdated");
if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) {
outdatedText += " ";
outdatedText += I18n.t("summary.outdated_posts", {
count: this.newPostsSinceSummary,
});
}
return outdatedText;
}
get topRepliesSummaryEnabled() {
return this.args.outletArgs.postStream.summary;
}
@action
collapse() {
this.showSummaryBox = false;
this.canCollapseSummary = false;
}
@action
generateSummary() {
const topicId = this.args.outletArgs.topic.id;
this.showSummaryBox = true;
if (this.text && !this.canRegenerate) {
this.canCollapseSummary = false;
return;
}
let fetchURL = `/discourse-ai/summarization/t/${topicId}?`;
if (this.currentUser) {
fetchURL += `stream=true`;
if (this.canRegenerate) {
fetchURL += "&skip_age_check=true";
}
}
this.loading = true;
return ajax(fetchURL).then((data) => {
if (!this.currentUser) {
data.done = true;
this._updateSummary(data);
}
});
}
@bind
subscribe() {
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
this.messageBus.subscribe(channel, this._updateSummary);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/summaries/topic/*",
this._updateSummary
);
}
@bind
_updateSummary(update) {
const topicSummary = update.ai_topic_summary;
return cook(topicSummary.summarized_text)
.then((cooked) => {
this.text = cooked;
this.loading = false;
})
.then(() => {
if (update.done) {
this.summarizedOn = shortDateNoYear(topicSummary.summarized_on);
this.summarizedBy = topicSummary.algorithm;
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
this.outdated = topicSummary.outdated;
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
this.canRegenerate =
topicSummary.outdated && topicSummary.can_regenerate;
}
});
}
<template>
{{#if (or @outletArgs.topic.has_summary @outletArgs.topic.summarizable)}}
<div class="summarization-buttons">
{{#if @outletArgs.topic.summarizable}}
{{#if this.showSummaryBox}}
<DButton
@action={{this.collapse}}
@title="summary.buttons.hide"
@label="summary.buttons.hide"
@icon="chevron-up"
class="btn-primary ai-topic-summarization"
/>
{{else}}
<DButton
@action={{this.generateSummary}}
@translatedLabel={{this.generateSummaryTitle}}
@translatedTitle={{this.generateSummaryTitle}}
@icon={{this.generateSummaryIcon}}
@disabled={{this.loading}}
class="btn-primary ai-topic-summarization"
/>
{{/if}}
{{/if}}
{{yield}}
</div>
<div
class="summary-box__container"
{{didInsert this.subscribe}}
{{willDestroy this.unsubscribe}}
>
{{#if this.showSummaryBox}}
<article class="ai-summary-box">
{{#if (and this.loading (not this.text))}}
<AiSummarySkeleton />
{{else}}
<div class="generated-summary">{{this.text}}</div>
{{#if this.summarizedOn}}
<div class="summarized-on">
<p>
{{i18n "summary.summarized_on" date=this.summarizedOn}}
<DTooltip @placements={{array "top-end"}}>
<:trigger>
{{dIcon "info-circle"}}
</:trigger>
<:content>
{{i18n "summary.model_used" model=this.summarizedBy}}
</:content>
</DTooltip>
</p>
{{#if this.outdated}}
<p class="outdated-summary">
{{this.outdatedSummaryWarningText}}
</p>
{{/if}}
</div>
{{/if}}
{{/if}}
</article>
{{/if}}
</div>
{{/if}}
</template>
}
@@ -0,0 +1,72 @@
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import { shortDateNoYear } from "discourse/lib/formatter";
import { cook } from "discourse/lib/text";
export default class AiTopicSummary {
@tracked text = "";
@tracked summarizedOn = null;
@tracked summarizedBy = null;
@tracked newPostsSinceSummary = null;
@tracked outdated = false;
@tracked canRegenerate = false;
@tracked regenerated = false;
@tracked showSummaryBox = false;
@tracked canCollapseSummary = false;
@tracked loadingSummary = false;
processUpdate(update) {
const topicSummary = update.ai_topic_summary;
return cook(topicSummary.summarized_text)
.then((cooked) => {
this.text = cooked;
this.loading = false;
})
.then(() => {
if (update.done) {
this.summarizedOn = shortDateNoYear(topicSummary.summarized_on);
this.summarizedBy = topicSummary.algorithm;
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
this.outdated = topicSummary.outdated;
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
this.canRegenerate =
topicSummary.outdated && topicSummary.can_regenerate;
}
});
}
collapse() {
this.showSummaryBox = false;
this.canCollapseSummary = false;
}
generateSummary(currentUser, topicId) {
this.showSummaryBox = true;
if (this.text && !this.canRegenerate) {
this.canCollapseSummary = false;
return;
}
let fetchURL = `/discourse-ai/summarization/t/${topicId}?`;
if (currentUser) {
fetchURL += `stream=true`;
if (this.canRegenerate) {
fetchURL += "&skip_age_check=true";
}
}
this.loading = true;
return ajax(fetchURL).then((data) => {
if (!currentUser) {
data.done = true;
this.processUpdate(data);
}
});
}
}
@@ -0,0 +1,29 @@
import { apiInitializer } from "discourse/lib/api";
import ChatModalChannelSummary from "../discourse/components/modal/chat-modal-channel-summary";
export default apiInitializer("1.34.0", (api) => {
const siteSettings = api.container.lookup("service:site-settings");
const currentUser = api.getCurrentUser();
const chatService = api.container.lookup("service:chat");
const modal = api.container.lookup("service:modal");
const canSummarize =
siteSettings.ai_summarization_strategy &&
currentUser &&
currentUser.can_summarize;
if (!chatService.userCanChat || !siteSettings.chat_enabled || !canSummarize) {
return;
}
api.registerChatComposerButton({
translatedLabel: "discourse_ai.summarization.chat.title",
id: "channel-summary",
icon: "discourse-sparkles",
position: "dropdown",
action: () => {
modal.show(ChatModalChannelSummary, {
model: { channelId: chatService.activeChannel?.id },
});
},
});
});
@@ -0,0 +1,193 @@
.topic-map .toggle-summary {
.summarization-buttons {
display: flex;
gap: 0.5em;
}
.ai-summary {
&__list {
list-style: none;
display: flex;
flex-wrap: wrap;
padding: 0;
margin: 0;
}
&__list-item {
background: var(--primary-300);
border-radius: var(--d-border-radius);
margin-right: 8px;
margin-bottom: 8px;
height: 18px;
opacity: 0;
display: block;
&:nth-child(1) {
width: 10%;
}
&:nth-child(2) {
width: 12%;
}
&:nth-child(3) {
width: 18%;
}
&:nth-child(4) {
width: 14%;
}
&:nth-child(5) {
width: 18%;
}
&:nth-child(6) {
width: 14%;
}
&:nth-child(7) {
width: 22%;
}
&:nth-child(8) {
width: 05%;
}
&:nth-child(9) {
width: 25%;
}
&:nth-child(10) {
width: 14%;
}
&:nth-child(11) {
width: 18%;
}
&:nth-child(12) {
width: 12%;
}
&:nth-child(13) {
width: 22%;
}
&:nth-child(14) {
width: 18%;
}
&:nth-child(15) {
width: 13%;
}
&:nth-child(16) {
width: 22%;
}
&:nth-child(17) {
width: 19%;
}
&:nth-child(18) {
width: 13%;
}
&:nth-child(19) {
width: 22%;
}
&:nth-child(20) {
width: 25%;
}
&.is-shown {
opacity: 1;
}
&.show {
animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards;
@media (prefers-reduced-motion) {
animation-duration: 0s;
}
}
@media (prefers-reduced-motion: no-preference) {
&.blink {
animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both;
}
}
}
&__generating-text {
display: inline-block;
margin-left: 3px;
}
&__indicator-wave {
flex: 0 0 auto;
display: inline-flex;
}
&__indicator-dot {
display: inline-block;
@media (prefers-reduced-motion: no-preference) {
animation: ai-summary__indicator-wave 1.8s linear infinite;
}
&:nth-child(2) {
animation-delay: -1.6s;
}
&:nth-child(3) {
animation-delay: -1.4s;
}
}
}
.placeholder-summary {
padding-top: 0.5em;
}
.placeholder-summary-text {
display: inline-block;
height: 1em;
margin-top: 0.6em;
width: 100%;
}
.summarized-on {
text-align: right;
.info-icon {
margin-left: 3px;
}
}
.outdated-summary {
color: var(--primary-medium);
}
}
@keyframes ai-summary__indicator-wave {
0%,
60%,
100% {
transform: initial;
}
30% {
transform: translateY(-0.2em);
}
}
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
+9
View File
@@ -391,6 +391,15 @@ en:
sentiments:
dashboard:
title: "Sentiment"
summarization:
chat:
title: "Summarize messages"
description: "Select an option below to summarize the conversation sent during the desired timeframe."
summarize: "Summarize"
since:
one: "Last hour"
other: "Last %{count} hours"
review:
types:
reviewable_ai_post:
+4
View File
@@ -95,6 +95,8 @@ en:
ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running."
ai_summarization_discourse_service_api_key: "API key for the Discourse summarization API."
ai_summarization_strategy: "Additional ways to summarize content registered by plugins"
ai_custom_summarization_allowed_groups: "Groups allowed to summarize contents using the `summarization_strategy`."
ai_bot_enabled: "Enable the AI Bot module."
ai_bot_enable_chat_warning: "Display a warning when PM chat is initiated. Can be overriden by editing the translation string: discourse_ai.ai_bot.pm_warning"
@@ -312,6 +314,8 @@ en:
configuration_hint:
one: "Configure the `%{setting}` setting first."
other: "Configure these settings first: %{settings}"
chat:
no_targets: "There were no messages during the selected period."
sentiment:
reports:
+5
View File
@@ -27,6 +27,11 @@ DiscourseAi::Engine.routes.draw do
get "/:share_key" => "shared_ai_conversations#show"
get "/preview/:topic_id" => "shared_ai_conversations#preview"
end
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
get "/channels/:channel_id" => "chat_summary#show"
end
end
Discourse::Application.routes.draw do
+9
View File
@@ -327,6 +327,15 @@ discourse_ai:
ai_summarization_discourse_service_api_key:
default: ""
secret: true
ai_summarization_strategy:
client: true
default: ""
enum: "DiscourseAi::Configuration::SummarizationEnumerator"
validator: "DiscourseAi::Configuration::SummarizationValidator"
ai_custom_summarization_allowed_groups:
type: group_list
list_type: compact
default: "3|13" # 3: @staff, 13: @trust_level_3
ai_bot_enabled:
default: false
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateAiSummariesTable < ActiveRecord::Migration[7.0]
def change
create_table :ai_summaries do |t|
t.integer :target_id, null: false
t.string :target_type, null: false
t.int4range :content_range
t.string :summarized_text, null: false
t.string :original_content_sha, null: false
t.string :algorithm, null: false
t.timestamps
end
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CopySummarySectionsToAiSummaries < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
INSERT INTO ai_summaries (id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at)
SELECT id, target_id, target_type, content_range, summarized_text, original_content_sha, algorithm, created_at, updated_at
FROM summary_sections
WHERE meta_section_id IS NULL
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CopySummarizationStrategyToAiSummarizationStrategy < ActiveRecord::Migration[7.0]
def up
execute <<-SQL
UPDATE site_settings
SET data_type = (SELECT data_type FROM site_settings WHERE name = 'summarization_strategy'),
value = (SELECT value FROM site_settings WHERE name = 'summarization_strategy')
WHERE name = 'ai_summarization_strategy'
AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'summarization_strategy');
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class CopyCustomSummarizationAllowedGroupsToAiCustomSummarizationAllowedGroups < ActiveRecord::Migration[
7.0
]
def up
execute <<-SQL
UPDATE site_settings
SET data_type = (SELECT data_type FROM site_settings WHERE name = 'custom_summarization_allowed_groups'),
value = (SELECT value FROM site_settings WHERE name = 'custom_summarization_allowed_groups')
WHERE name = 'ai_custom_summarization_allowed_groups'
AND EXISTS (SELECT 1 FROM site_settings WHERE name = 'custom_summarization_allowed_groups');
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
+8 -4
View File
@@ -13,7 +13,7 @@ module DiscourseAi
def initialize(responses)
@responses = responses
@completions = 0
@prompt = nil
@dialect = nil
end
def normalize_model_params(model_params)
@@ -21,10 +21,14 @@ module DiscourseAi
model_params
end
attr_reader :responses, :completions, :prompt
attr_reader :responses, :completions, :dialect
def perform_completion!(prompt, _user, _model_params, feature_name: nil)
@prompt = prompt
def prompt_messages
dialect.prompt.messages
end
def perform_completion!(dialect, _user, _model_params, feature_name: nil)
@dialect = dialect
response = responses[completions]
if response.nil?
raise CANNED_RESPONSE_ERROR,
@@ -0,0 +1,20 @@
# frozen_string_literal: true
require "enum_site_setting"
module DiscourseAi
module Configuration
class SummarizationEnumerator < ::EnumSiteSetting
def self.valid_value?(val)
true
end
def self.values
@values ||=
DiscourseAi::Summarization::Models::Base.available_strategies.map do |strategy|
{ name: strategy.display_name, value: strategy.model }
end
end
end
end
end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
module DiscourseAi
module Configuration
class SummarizationValidator
def initialize(opts = {})
@opts = opts
end
def valid_value?(val)
strategy = DiscourseAi::Summarization::Models::Base.find_strategy(val)
return true unless strategy
strategy.correctly_configured?.tap { |is_valid| @strategy = strategy unless is_valid }
end
def error_message
@strategy.configuration_hint
end
end
end
end
+6 -68
View File
@@ -4,79 +4,17 @@ module DiscourseAi
module Summarization
class EntryPoint
def inject_into(plugin)
foldable_models = [
Models::OpenAi.new("open_ai:gpt-4", max_tokens: 8192),
Models::OpenAi.new("open_ai:gpt-4-32k", max_tokens: 32_768),
Models::OpenAi.new("open_ai:gpt-4-turbo", max_tokens: 100_000),
Models::OpenAi.new("open_ai:gpt-4o", max_tokens: 100_000),
Models::OpenAi.new("open_ai:gpt-3.5-turbo", max_tokens: 4096),
Models::OpenAi.new("open_ai:gpt-3.5-turbo-16k", max_tokens: 16_384),
Models::Gemini.new("google:gemini-pro", max_tokens: 32_768),
Models::Gemini.new("google:gemini-1.5-pro", max_tokens: 800_000),
Models::Gemini.new("google:gemini-1.5-flash", max_tokens: 800_000),
]
claude_prov = "anthropic"
if DiscourseAi::Completions::Endpoints::AwsBedrock.correctly_configured?("claude-2")
claude_prov = "aws_bedrock"
plugin.add_to_serializer(:current_user, :can_summarize) do
scope.user.in_any_groups?(SiteSetting.ai_custom_summarization_allowed_groups_map)
end
foldable_models << Models::Anthropic.new("#{claude_prov}:claude-2", max_tokens: 200_000)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-instant-1",
max_tokens: 100_000,
)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-3-haiku",
max_tokens: 200_000,
)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-3-sonnet",
max_tokens: 200_000,
)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-3-opus",
max_tokens: 200_000,
)
mixtral_prov = "hugging_face"
if DiscourseAi::Completions::Endpoints::Vllm.correctly_configured?(
"mistralai/Mixtral-8x7B-Instruct-v0.1",
)
mixtral_prov = "vllm"
plugin.add_to_serializer(:topic_view, :summarizable) do
DiscourseAi::Summarization::Models::Base.can_see_summary?(object.topic, scope.user)
end
foldable_models << Models::Mixtral.new(
"#{mixtral_prov}:mistralai/Mixtral-8x7B-Instruct-v0.1",
max_tokens: 32_000,
)
# TODO: Roman, we need to de-register custom LLMs on destroy from summarization
# strategy and clear cache
# it may be better to pull all of this code into Discourse AI cause as it stands
# the coupling is making it really hard to reason about summarization
#
# Auto registration and de-registration needs to be tested
#LlmModel.all.each do |model|
# foldable_models << Models::CustomLlm.new(
# "custom:#{model.id}",
# max_tokens: model.max_prompt_tokens,
# )
#end
foldable_models.each do |model|
plugin.register_summarization_strategy(Strategies::FoldContent.new(model))
plugin.add_to_serializer(:web_hook_topic_view, :summarizable) do
DiscourseAi::Summarization::Models::Base.can_see_summary?(object.topic, scope.user)
end
#plugin.add_model_callback(LlmModel, :after_create) do
# new_model = Models::CustomLlm.new("custom:#{self.id}", max_tokens: self.max_prompt_tokens)
# if ::Summarization::Base.find_strategy("custom:#{self.id}").nil?
# plugin.register_summarization_strategy(Strategies::FoldContent.new(new_model))
# end
#end
end
end
end
+127
View File
@@ -1,30 +1,157 @@
# frozen_string_literal: true
# Base class that defines the interface that every summarization
# strategy must implement.
# Above each method, you'll find an explanation of what
# it does and what it should return.
module DiscourseAi
module Summarization
module Models
class Base
class << self
def available_strategies
foldable_models = [
Models::OpenAi.new("open_ai:gpt-4", max_tokens: 8192),
Models::OpenAi.new("open_ai:gpt-4-32k", max_tokens: 32_768),
Models::OpenAi.new("open_ai:gpt-4-turbo", max_tokens: 100_000),
Models::OpenAi.new("open_ai:gpt-4o", max_tokens: 100_000),
Models::OpenAi.new("open_ai:gpt-3.5-turbo", max_tokens: 4096),
Models::OpenAi.new("open_ai:gpt-3.5-turbo-16k", max_tokens: 16_384),
Models::Gemini.new("google:gemini-pro", max_tokens: 32_768),
Models::Gemini.new("google:gemini-1.5-pro", max_tokens: 800_000),
Models::Gemini.new("google:gemini-1.5-flash", max_tokens: 800_000),
]
claude_prov = "anthropic"
if DiscourseAi::Completions::Endpoints::AwsBedrock.correctly_configured?("claude-2")
claude_prov = "aws_bedrock"
end
foldable_models << Models::Anthropic.new("#{claude_prov}:claude-2", max_tokens: 200_000)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-instant-1",
max_tokens: 100_000,
)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-3-haiku",
max_tokens: 200_000,
)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-3-sonnet",
max_tokens: 200_000,
)
foldable_models << Models::Anthropic.new(
"#{claude_prov}:claude-3-opus",
max_tokens: 200_000,
)
mixtral_prov = "hugging_face"
if DiscourseAi::Completions::Endpoints::Vllm.correctly_configured?(
"mistralai/Mixtral-8x7B-Instruct-v0.1",
)
mixtral_prov = "vllm"
end
foldable_models << Models::Mixtral.new(
"#{mixtral_prov}:mistralai/Mixtral-8x7B-Instruct-v0.1",
max_tokens: 32_000,
)
unless Rails.env.production?
foldable_models << Models::Fake.new("fake:fake", max_tokens: 8192)
end
folded_models = foldable_models.map { |model| Strategies::FoldContent.new(model) }
folded_models
end
def find_strategy(strategy_model)
available_strategies.detect { |s| s.model == strategy_model }
end
def selected_strategy
return if SiteSetting.ai_summarization_strategy.blank?
find_strategy(SiteSetting.ai_summarization_strategy)
end
def can_see_summary?(target, user)
return false if SiteSetting.ai_summarization_strategy.blank?
return false if target.class == Topic && target.private_message?
has_cached_summary = AiSummary.exists?(target: target)
return has_cached_summary if user.nil?
has_cached_summary || can_request_summary_for?(user)
end
def can_request_summary_for?(user)
return false unless user
user_group_ids = user.group_ids
SiteSetting.ai_custom_summarization_allowed_groups_map.any? do |group_id|
user_group_ids.include?(group_id)
end
end
end
def initialize(model_name, max_tokens:)
@model_name = model_name
@max_tokens = max_tokens
end
# Some strategies could require other conditions to work correctly,
# like site settings.
# This method gets called when admins attempt to select it,
# checking if we met those conditions.
def correctly_configured?
raise NotImplemented
end
# Strategy name to display to admins in the available strategies dropdown.
def display_name
raise NotImplemented
end
# If we don't meet the conditions to enable this strategy,
# we'll display this hint as an error to admins.
def configuration_hint
raise NotImplemented
end
# The idea behind this method is "give me a collection of texts,
# and I'll handle the summarization to the best of my capabilities.".
# It's important to emphasize the "collection of texts" part, which implies
# it's not tied to any model and expects the "content" to be a hash instead.
#
# @param content { Hash } - Includes the content to summarize, plus additional
# context to help the strategy produce a better result. Keys present in the content hash:
# - resource_path (optional): Helps the strategy build links to the content in the summary (e.g. "/t/-/:topic_id/POST_NUMBER")
# - content_title (optional): Provides guidance about what the content is about.
# - contents (required): Array of hashes with content to summarize (e.g. [{ poster: "asd", id: 1, text: "This is a text" }])
# All keys are required.
# @param &on_partial_blk { Block - Optional } - If the strategy supports it, the passed block
# will get called with partial summarized text as its generated.
#
# @param current_user { User } - User requesting the summary.
#
# @returns { Hash } - The summarized content. Example:
# {
# summary: "This is the final summary",
# }
def summarize(content, current_user)
raise NotImplemented
end
def available_tokens
max_tokens - reserved_tokens
end
# Returns the string we'll store in the selected strategy site setting.
def model
model_name.split(":").last
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
module Models
class Fake < Base
def display_name
"fake"
end
def correctly_configured?
true
end
def configuration_hint
""
end
def model
"fake"
end
end
end
end
end
+7 -102
View File
@@ -3,7 +3,7 @@
module DiscourseAi
module Summarization
module Strategies
class FoldContent < ::Summarization::Base
class FoldContent < DiscourseAi::Summarization::Models::Base
def initialize(completion_model)
@completion_model = completion_model
end
@@ -21,122 +21,27 @@ module DiscourseAi
llm = DiscourseAi::Completions::Llm.proxy(completion_model.model_name)
initial_chunks =
rebalance_chunks(
llm.tokenizer,
content[:contents].map { |c| { ids: [c[:id]], summary: format_content_item(c) } },
)
summary_content =
content[:contents].map { |c| { ids: [c[:id]], summary: format_content_item(c) } }
# Special case where we can do all the summarization in one pass.
if initial_chunks.length == 1
{
summary:
summarize_single(llm, initial_chunks.first[:summary], user, opts, &on_partial_blk),
chunks: [],
}
else
summarize_chunks(llm, initial_chunks, user, opts, &on_partial_blk)
end
{
summary:
summarize_single(llm, summary_content.first[:summary], user, opts, &on_partial_blk),
}
end
private
def summarize_chunks(llm, chunks, user, opts, &on_partial_blk)
# Safely assume we always have more than one chunk.
summarized_chunks = summarize_in_chunks(llm, chunks, user, opts)
total_summaries_size =
llm.tokenizer.size(summarized_chunks.map { |s| s[:summary].to_s }.join)
if total_summaries_size < completion_model.available_tokens
# Chunks are small enough, we can concatenate them.
{
summary:
concatenate_summaries(
llm,
summarized_chunks.map { |s| s[:summary] },
user,
&on_partial_blk
),
chunks: summarized_chunks,
}
else
# We have summarized chunks but we can't concatenate them yet. Split them into smaller summaries and summarize again.
rebalanced_chunks = rebalance_chunks(llm.tokenizer, summarized_chunks)
summarize_chunks(llm, rebalanced_chunks, user, opts, &on_partial_blk)
end
end
def format_content_item(item)
"(#{item[:id]} #{item[:poster]} said: #{item[:text]} "
end
def rebalance_chunks(tokenizer, chunks)
section = { ids: [], summary: "" }
chunks =
chunks.reduce([]) do |sections, chunk|
if tokenizer.can_expand_tokens?(
section[:summary],
chunk[:summary],
completion_model.available_tokens,
)
section[:summary] += chunk[:summary]
section[:ids] = section[:ids].concat(chunk[:ids])
else
sections << section
section = chunk
end
sections
end
chunks << section if section[:summary].present?
chunks
end
def summarize_single(llm, text, user, opts, &on_partial_blk)
prompt = summarization_prompt(text, opts)
llm.generate(prompt, user: user, feature_name: "summarize", &on_partial_blk)
end
def summarize_in_chunks(llm, chunks, user, opts)
chunks.map do |chunk|
prompt = summarization_prompt(chunk[:summary], opts)
chunk[:summary] = llm.generate(
prompt,
user: user,
max_tokens: 300,
feature_name: "summarize",
)
chunk
end
end
def concatenate_summaries(llm, summaries, user, &on_partial_blk)
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip)
You are a summarization bot that effectively concatenates disjoint summaries, creating a cohesive narrative.
The narrative you create is in the form of one or multiple paragraphs.
Your reply MUST BE a single concatenated summary using the summaries I'll provide to you.
I'm NOT interested in anything other than the concatenated summary, don't include additional text or comments.
You understand and generate Discourse forum Markdown.
You format the response, including links, using Markdown.
TEXT
prompt.push(type: :user, content: <<~TEXT.strip)
THESE are the summaries, each one separated by a newline, all of them inside <input></input> XML tags:
<input>
#{summaries.join("\n")}
</input>
TEXT
llm.generate(prompt, user: user, &on_partial_blk)
end
def summarization_prompt(input, opts)
insts = +<<~TEXT
You are an advanced summarization bot that generates concise, coherent summaries of provided text.
+2
View File
@@ -15,6 +15,8 @@ enabled_site_setting :discourse_ai_enabled
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
register_asset "stylesheets/modules/summarization/common/ai-summary.scss"
register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss"
register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss"
register_asset "stylesheets/modules/ai-bot/mobile/ai-persona.scss", :mobile
@@ -0,0 +1,81 @@
# frozen_string_literal: true
RSpec.describe Jobs::StreamTopicAiSummary do
subject(:job) { described_class.new }
describe "#execute" do
fab!(:topic) { Fabricate(:topic, highest_post_number: 2) }
fab!(:post_1) { Fabricate(:post, topic: topic, post_number: 1) }
fab!(:post_2) { Fabricate(:post, topic: topic, post_number: 2) }
fab!(:user) { Fabricate(:leader) }
before { Group.find(Group::AUTO_GROUPS[:trust_level_3]).add(user) }
before { SiteSetting.ai_summarization_strategy = "fake" }
def with_responses(responses)
DiscourseAi::Completions::Llm.with_prepared_responses(responses) { yield }
end
describe "validates params" do
it "does nothing if there is no topic" do
messages =
MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do
job.execute(topic_id: nil, user_id: user.id)
end
expect(messages).to be_empty
end
it "does nothing if there is no user" do
messages =
MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do
job.execute(topic_id: topic.id, user_id: nil)
end
expect(messages).to be_empty
end
it "does nothing if the user is not allowed to see the topic" do
private_topic = Fabricate(:private_message_topic)
messages =
MessageBus.track_publish("/discourse-ai/summaries/topic/#{private_topic.id}") do
job.execute(topic_id: private_topic.id, user_id: user.id)
end
expect(messages).to be_empty
end
end
it "publishes updates with a partial summary" do
with_responses(["dummy"]) do
messages =
MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do
job.execute(topic_id: topic.id, user_id: user.id)
end
partial_summary_update = messages.first.data
expect(partial_summary_update[:done]).to eq(false)
expect(partial_summary_update.dig(:ai_topic_summary, :summarized_text)).to eq("dummy")
end
end
it "publishes a final update to signal we're done and provide metadata" do
with_responses(["dummy"]) do
messages =
MessageBus.track_publish("/discourse-ai/summaries/topic/#{topic.id}") do
job.execute(topic_id: topic.id, user_id: user.id)
end
final_update = messages.last.data
expect(final_update[:done]).to eq(true)
expect(final_update.dig(:ai_topic_summary, :algorithm)).to eq("fake")
expect(final_update.dig(:ai_topic_summary, :outdated)).to eq(false)
expect(final_update.dig(:ai_topic_summary, :can_regenerate)).to eq(true)
expect(final_update.dig(:ai_topic_summary, :new_posts_since_summary)).to be_zero
end
end
end
end
@@ -0,0 +1,70 @@
# frozen_string_literal: true
describe DiscourseAi::Summarization::Models::Base do
fab!(:user)
fab!(:group)
fab!(:topic)
before do
group.add(user)
SiteSetting.ai_summarization_strategy = "fake"
end
describe "#can_see_summary?" do
context "when the user cannot generate a summary" do
before { SiteSetting.ai_custom_summarization_allowed_groups = "" }
it "returns false" do
SiteSetting.ai_custom_summarization_allowed_groups = ""
expect(described_class.can_see_summary?(topic, user)).to eq(false)
end
it "returns true if there is a cached summary" do
AiSummary.create!(
target: topic,
summarized_text: "test",
original_content_sha: "123",
algorithm: "test",
)
expect(described_class.can_see_summary?(topic, user)).to eq(true)
end
end
context "when the user can generate a summary" do
before { SiteSetting.ai_custom_summarization_allowed_groups = group.id }
it "returns true if the user group is present in the ai_custom_summarization_allowed_groups_map setting" do
expect(described_class.can_see_summary?(topic, user)).to eq(true)
end
end
context "when there is no user" do
it "returns false for anons" do
expect(described_class.can_see_summary?(topic, nil)).to eq(false)
end
it "returns true for anons when there is a cached summary" do
AiSummary.create!(
target: topic,
summarized_text: "test",
original_content_sha: "123",
algorithm: "test",
)
expect(described_class.can_see_summary?(topic, nil)).to eq(true)
end
end
context "when the topic is a PM" do
before { SiteSetting.ai_custom_summarization_allowed_groups = group.id }
let(:pm) { Fabricate(:private_message_topic) }
it "returns false" do
expect(described_class.can_see_summary?(pm, user)).to eq(false)
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More