diff --git a/app/controllers/events/activity_summaries_controller.rb b/app/controllers/events/activity_summaries_controller.rb deleted file mode 100644 index 46e3c78f5..000000000 --- a/app/controllers/events/activity_summaries_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Events::ActivitySummariesController < ApplicationController - include DayTimelinesScoped - - def create - @day_timeline.summarize_later - end -end diff --git a/app/jobs/user/day_timeline/summarize_job.rb b/app/jobs/user/day_timeline/summarize_job.rb deleted file mode 100644 index f83347585..000000000 --- a/app/jobs/user/day_timeline/summarize_job.rb +++ /dev/null @@ -1,5 +0,0 @@ -class User::DayTimeline::SummarizeJob < ApplicationJob - def perform(day_timeline) - day_timeline.summarize - end -end diff --git a/app/jobs/user/highlights/generate_all_job.rb b/app/jobs/user/highlights/generate_all_job.rb new file mode 100644 index 000000000..82ce8d9a5 --- /dev/null +++ b/app/jobs/user/highlights/generate_all_job.rb @@ -0,0 +1,7 @@ +class User::Highlights::GenerateAllJob < ApplicationJob + queue_as :backend + + def perform(user) + User.generate_all_weekly_highlights + end +end diff --git a/app/models/event/activity_summary.rb b/app/models/event/activity_summary.rb deleted file mode 100644 index 4a68f2dc9..000000000 --- a/app/models/event/activity_summary.rb +++ /dev/null @@ -1,38 +0,0 @@ -class Event::ActivitySummary < ApplicationRecord - validates :key, :content, presence: true - - after_create_commit :broadcast_activity_summarized - - class << self - def create_for(events) - key = key_for(events) - - # Outside to avoid holding the transaction during the LLM request - summarizer = Event::Summarizer.new(events) - - create_or_find_by!(key: key) do |record| - record.content = summarizer.summarized_content - record.cost_in_microcents = summarizer.cost.in_microcents - end - end - - def for(events) - find_by key: key_for(events) - end - - def key_for(events) - Digest::SHA256.hexdigest(events.ids.sort.join("-")) - end - end - - def to_html - renderer = Redcarpet::Render::HTML.new - markdowner = Redcarpet::Markdown.new(renderer, autolink: true, tables: true, fenced_code_blocks: true, strikethrough: true, superscript: true,) - markdowner.render(content).html_safe - end - - private - def broadcast_activity_summarized - broadcast_replace_later_to :activity_summaries, target: key, partial: "events/day_timeline/activity_summary", locals: { summary: self } - end -end diff --git a/app/models/period_highlights.rb b/app/models/period_highlights.rb new file mode 100644 index 000000000..357e5380d --- /dev/null +++ b/app/models/period_highlights.rb @@ -0,0 +1,36 @@ +class PeriodHighlights < ApplicationRecord + class << self + def create_for(collections, starts_at:, duration: 1.week) + starts_at = normalize_anchor_date(starts_at) + key = key_for(collections) + events = Event.where(collection: collections).where(created_at: starts_at..starts_at + duration) + + create_or_find_by!(key:, starts_at:, duration:) do |record| + summarizer = Event::Summarizer.new(events) + record.content = summarizer.summarized_content + record.cost_in_microcents = summarizer.cost.in_microcents + end + end + + def for(collections, starts_at:, duration: 1.week) + starts_at = normalize_anchor_date(starts_at) + key = key_for(collections) + find_by(key:, starts_at:, duration:) + end + + private + def key_for(collections) + Digest::SHA256.hexdigest(collections.ids.sort.join("-")) + end + + def normalize_anchor_date(date) + date.utc.beginning_of_day + end + end + + def to_html + renderer = Redcarpet::Render::HTML.new + markdowner = Redcarpet::Markdown.new(renderer, autolink: true, tables: true, fenced_code_blocks: true, strikethrough: true, superscript: true,) + markdowner.render(content).html_safe + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 39475882a..64a5d1769 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,6 @@ class User < ApplicationRecord - include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Mentionable, Named, - Notifiable, Role, Searcher, SignalUser, Staff, Transferable + include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Highlights, + Mentionable, Named, Notifiable, Role, Searcher, SignalUser, Staff, Transferable include Timelined # Depends on Accessor has_one_attached :avatar diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb index 6050eaebe..71b520c6d 100644 --- a/app/models/user/day_timeline.rb +++ b/app/models/user/day_timeline.rb @@ -1,5 +1,5 @@ class User::DayTimeline - include Serializable, Summarizable + include Serializable attr_reader :user, :day, :filter diff --git a/app/models/user/day_timeline/summarizable.rb b/app/models/user/day_timeline/summarizable.rb deleted file mode 100644 index ecbc30a6a..000000000 --- a/app/models/user/day_timeline/summarizable.rb +++ /dev/null @@ -1,36 +0,0 @@ -module User::DayTimeline::Summarizable - extend ActiveSupport::Concern - - def summarized? - summary.present? - end - - def summary - @summary ||= Event::ActivitySummary.for(events) - end - - def summarizable? - day.past? || summarizable_today? - end - - def summarize - Event::ActivitySummary.create_for(events) - end - - def summary_key - Event::ActivitySummary.key_for(events) - end - - def summarizable_content - Event::Summarizer.new(events).summarizable_content - end - - def summarize_later - User::DayTimeline::SummarizeJob.perform_later(self) - end - - private - def summarizable_today? - day.today? && events.count >= 10 - end -end diff --git a/app/models/user/highlights.rb b/app/models/user/highlights.rb new file mode 100644 index 000000000..d40abafdd --- /dev/null +++ b/app/models/user/highlights.rb @@ -0,0 +1,28 @@ +module User::Highlights + extend ActiveSupport::Concern + + class_methods do + def generate_all_weekly_highlights_later + User::Highlights::GenerateAllJob.perform_later + end + + def generate_all_weekly_highlights + # We're not interested in parallelizing individual generation. Better for AI quota limits and, also, + # most summaries will be reused for users accessing the same collections. + find_each(&:generate_weekly_highlights) + end + end + + def generate_weekly_highlights + PeriodHighlights.create_for collections, starts_at: current_highlights_starts_at, duration: 1.week + end + + def current_weekly_highlights + PeriodHighlights.for collections, starts_at: current_highlights_starts_at, duration: 1.week + end + + private + def current_highlights_starts_at + Time.current.utc.beginning_of_week(:sunday) + end +end diff --git a/app/models/user/role.rb b/app/models/user/role.rb index 4a90410b5..4bc09b518 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -3,6 +3,7 @@ module User::Role included do enum :role, %i[ admin member system ].index_by(&:itself), scopes: false + scope :regular, -> { where(role: %i[ admin member ]) } scope :member, -> { where(role: :member) } scope :active, -> { where(active: true, role: %i[ admin member ]) } diff --git a/app/views/events/_day.html.erb b/app/views/events/_day.html.erb index b296404ee..6fbcc8b50 100644 --- a/app/views/events/_day.html.erb +++ b/app/views/events/_day.html.erb @@ -10,7 +10,7 @@ - <%= render "events/day_timeline/summary", day_timeline: day_timeline if day_timeline.summarizable?%> + <%= render "events/weekly_summary" %>
<%= render "events/day_timeline_column", event_type: "added", day_timeline: day_timeline %> diff --git a/app/views/events/_weekly_summary.html.erb b/app/views/events/_weekly_summary.html.erb new file mode 100644 index 000000000..646669e27 --- /dev/null +++ b/app/views/events/_weekly_summary.html.erb @@ -0,0 +1,5 @@ +<% if weekly_highlights = Current.user.current_weekly_highlights %> +
+ <%= weekly_highlights.to_html %> +
+<% end %> diff --git a/app/views/events/day_timeline/_activity_summary.html.erb b/app/views/events/day_timeline/_activity_summary.html.erb deleted file mode 100644 index d4ab7c977..000000000 --- a/app/views/events/day_timeline/_activity_summary.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= summary.to_html %> -
diff --git a/app/views/events/day_timeline/_summary.html.erb b/app/views/events/day_timeline/_summary.html.erb deleted file mode 100644 index 58979c873..000000000 --- a/app/views/events/day_timeline/_summary.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -
- <% if day_timeline.summarized? %> - <%= render "events/day_timeline/activity_summary", summary: day_timeline.summary %> - - <% if Current.user.staff? %> - <%= link_to admin_prompt_sandbox_path(day: day_timeline.day), class: "btn txt-xx-small events__activity-prompt-edit" do %> - <%= icon_tag "bolt" %> - Prompt sandbox - <% end %> - <% end %> - <% else %> -
- <%= auto_submit_form_with url: events_activity_summaries_path(day: day_timeline.day, **day_timeline.filter.as_params), method: :post %> - Writing summary… -
- <% end %> -
diff --git a/config/recurring.yml b/config/recurring.yml index 66188ec7a..8e365b85f 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -20,6 +20,9 @@ production: &production deliver_bundled_notifications: command: "Notification::Bundle.deliver_all_later" schedule: every 30 minutes + generate_weekly_highlights: + command: "User.generate_all_weekly_highlights_later" + schedule: 0 12 * * sun # every Sunday at noon beta: *production staging: *production diff --git a/config/routes.rb b/config/routes.rb index 4b6b4d073..348e3254f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,7 +75,6 @@ Rails.application.routes.draw do resources :events, only: :index namespace :events do - resources :activity_summaries resources :days end diff --git a/db/migrate/20250901114009_create_period_highlights.rb b/db/migrate/20250901114009_create_period_highlights.rb new file mode 100644 index 000000000..e082cdf6a --- /dev/null +++ b/db/migrate/20250901114009_create_period_highlights.rb @@ -0,0 +1,15 @@ +class CreatePeriodHighlights < ActiveRecord::Migration[8.1] + def change + create_table :period_highlights do |t| + t.datetime :starts_at, null: false + t.bigint :duration, null: false, default: 1.week.to_i + t.string :key, null: false + t.text :content, null: false + t.bigint :cost_in_microcents + + t.index %i[ key starts_at duration ] , unique: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index afd2ae05d..e3598e0c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do +ActiveRecord::Schema[8.1].define(version: 2025_09_01_114009) do create_table "accesses", force: :cascade do |t| t.datetime "accessed_at" t.integer "collection_id", null: false @@ -333,6 +333,17 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "period_highlights", force: :cascade do |t| + t.text "content", null: false + t.bigint "cost_in_microcents" + t.datetime "created_at", null: false + t.bigint "duration", default: 604800, null: false + t.string "key", null: false + t.datetime "starts_at", null: false + t.datetime "updated_at", null: false + t.index ["key", "starts_at", "duration"], name: "index_period_highlights_on_key_and_starts_at_and_duration", unique: true + end + create_table "pins", force: :cascade do |t| t.integer "card_id", null: false t.datetime "created_at", null: false diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 77d7d292a..35e38d49e 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -496,7 +496,7 @@ columns: default_function: collation: comment: - - &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: title cast_type: *7 @@ -577,11 +577,11 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: all_access - cast_type: &31 !ruby/object:ActiveModel::Type::Boolean + cast_type: &34 !ruby/object:ActiveModel::Type::Boolean precision: scale: limit: - sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &35 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: boolean type: :boolean limit: @@ -637,7 +637,7 @@ columns: default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: cost_in_microcents cast_type: *11 @@ -783,7 +783,7 @@ columns: - *6 - *9 event_activity_summaries: - - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: content cast_type: *15 @@ -900,7 +900,7 @@ columns: comment: filters_tags: - *20 - - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: tag_id cast_type: *3 @@ -967,7 +967,7 @@ columns: collation: comment: - *6 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: starts_at cast_type: *1 @@ -1016,6 +1016,24 @@ columns: - *29 - *9 - *18 + period_highlights: + - *30 + - *31 + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: duration + cast_type: *11 + sql_type_metadata: *12 + 'null': false + default: 604800 + default_function: + collation: + comment: + - *6 + - *19 + - *32 + - *9 pins: - *23 - *5 @@ -1056,7 +1074,7 @@ columns: collation: comment: - *9 - - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: user_agent cast_type: *7 @@ -1202,21 +1220,21 @@ columns: collation: comment: - *9 - - *30 + - *33 - *18 steps: - *23 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: completed - cast_type: *31 - sql_type_metadata: *32 + cast_type: *34 + sql_type_metadata: *35 'null': false default: false default_function: collation: comment: - - *33 + - *30 - *5 - *6 - *9 @@ -1224,12 +1242,12 @@ columns: - *23 - *5 - *6 - - *34 + - *36 - *9 tags: - *5 - *6 - - *35 + - *37 - *9 user_settings: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -1250,8 +1268,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active - cast_type: *31 - sql_type_metadata: *32 + cast_type: *34 + sql_type_metadata: *35 'null': false default: true default_function: @@ -1310,8 +1328,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: watching - cast_type: *31 - sql_type_metadata: *32 + cast_type: *34 + sql_type_metadata: *35 'null': false default: true default_function: @@ -1382,6 +1400,7 @@ primary_keys: mentions: id notification_bundles: id notifications: id + period_highlights: id pins: id push_subscriptions: id reactions: id @@ -1433,6 +1452,7 @@ data_sources: mentions: true notification_bundles: true notifications: true + period_highlights: true pins: true push_subscriptions: true reactions: true @@ -2551,6 +2571,25 @@ indexes: nulls_not_distinct: comment: valid: true + period_highlights: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: period_highlights + name: index_period_highlights_on_key_and_starts_at_and_duration + unique: true + columns: + - key + - starts_at + - duration + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true pins: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: pins @@ -2992,4 +3031,4 @@ indexes: comment: valid: true workflows: [] -version: 20250828092106 +version: 20250901114009 diff --git a/test/controllers/events/activity_summaries_controller_test.rb b/test/controllers/events/activity_summaries_controller_test.rb deleted file mode 100644 index 264946bf8..000000000 --- a/test/controllers/events/activity_summaries_controller_test.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "test_helper" - -class Events::ActivitySummariesControllerTest < ActionDispatch::IntegrationTest - include VcrTestHelper - - setup do - sign_in_as :kevin - - freeze_timestamps - end - - test "create" do - assert_difference -> { Event::ActivitySummary.count }, +1 do - perform_enqueued_jobs only: User::DayTimeline::SummarizeJob do - post events_activity_summaries_path(day: Date.current) - end - end - end -end diff --git a/test/models/event/activity_summary_test.rb b/test/models/event/activity_summary_test.rb deleted file mode 100644 index eb4326582..000000000 --- a/test/models/event/activity_summary_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -require "test_helper" - -class Event::ActivitySummaryTest < ActiveSupport::TestCase - include VcrTestHelper - - setup do - @events = Event.limit(3) - freeze_timestamps - end - - test "create summaries only once for a given set of events" do - summary = assert_difference -> { Event::ActivitySummary.count }, +1 do - Event::ActivitySummary.create_for(@events) - end - - assert_no_difference -> { Event::ActivitySummary.count } do - assert_equal summary, Event::ActivitySummary.create_for(@events) - assert_equal summary, Event::ActivitySummary.create_for(@events.order("action desc").where(id: @events.ids)) # order does not matter - end - end - - test "fetching a existing summary" do - assert_nil Event::ActivitySummary.for(@events) - - summary = Event::ActivitySummary.create_for(@events) - assert_equal summary, Event::ActivitySummary.for(@events) - end - - test "getting an HTML summary for a set of events" do - summary = Event::ActivitySummary.create_for(@events) - assert_match /layout/i, summary.to_html - end -end diff --git a/test/models/period_highlights_test.rb b/test/models/period_highlights_test.rb new file mode 100644 index 000000000..b1c942a91 --- /dev/null +++ b/test/models/period_highlights_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class PeriodHighlightTest < ActiveSupport::TestCase + include VcrTestHelper + + setup do + @user = users(:david) + end + + test "generate period highlights" do + period_highlights = assert_difference -> { PeriodHighlights.count }, 1 do + PeriodHighlights.create_for(@user.collections, starts_at: 1.month.ago, duration: 2.months) + end + + assert_match /logo/i, period_highlights.to_html + end + + test "don't generate highlights for existing periods" do + new_period_highlights = PeriodHighlights.create_for(@user.collections, starts_at: 1.month.ago, duration: 2.months) + + existing_period_highlights = assert_no_difference -> { PeriodHighlights.count } do + PeriodHighlights.create_for(@user.collections, starts_at: 1.month.ago, duration: 2.months) + end + + assert_equal new_period_highlights, existing_period_highlights + end +end diff --git a/test/models/user/day_timeline/summarizable_test.rb b/test/models/user/day_timeline/summarizable_test.rb deleted file mode 100644 index 760e2d353..000000000 --- a/test/models/user/day_timeline/summarizable_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "test_helper" - -class User::DayTimeline::SummarizableTest < ActiveSupport::TestCase - include VcrTestHelper - - setup do - @user = users(:david) - - freeze_timestamps - - Current.session = sessions(:david) - @day = events(:logo_published).reload.created_at - @day_timeline = @user.timeline_for(@day, filter: @user.filters.new) - end - - test "summarize" do - assert_not @day_timeline.summarized? - assert @day_timeline.events.any? - - summary = assert_difference -> { Event::ActivitySummary.count }, +1 do - @day_timeline.summarize - end - - assert @day_timeline.summarized? - - assert_equal summary, @day_timeline.summary - - assert_no_difference -> { Event::ActivitySummary.count } do - @day_timeline.summarize - end - end - - test "past events are summarizable" do - unfreeze_time - - # Not summarizable in the future - travel_to @day - 2.weeks - assert_not @day_timeline.summarizable? - - # Summarizable in the past - travel_to @day + 1.day - assert @day_timeline.summarizable? - end - - test "today events are summarizable" do - unfreeze_time - - travel_to @day - assert_not @day_timeline.summarizable? - - 10.times.each do |index| - cards(:logo).update! title: "Title change #{index} to track event" - end - - # Summarizable in the past - assert @day_timeline.summarizable? - end -end diff --git a/test/models/user/highlights_test.rb b/test/models/user/highlights_test.rb new file mode 100644 index 000000000..e28ad3078 --- /dev/null +++ b/test/models/user/highlights_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class User::HighlightsTest < ActiveSupport::TestCase + include VcrTestHelper + + setup do + @user = users(:david) + travel_to 1.week.ago + 2.days + end + + test "generate weekly highlights" do + period_highlights = assert_difference -> { PeriodHighlights.count }, 1 do + @user.generate_weekly_highlights + end + + assert_equal Time.current.beginning_of_week(:sunday).utc, period_highlights.starts_at + assert_match /logo/i, period_highlights.to_html + end + + test "don't generate highlights for existing periods" do + new_period_highlights = @user.generate_weekly_highlights + + existing_period_highlights = assert_no_difference -> { PeriodHighlights.count } do + @user.generate_weekly_highlights + end + + assert_equal new_period_highlights, existing_period_highlights + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index d0c6b8710..4d4906d97 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -20,6 +20,23 @@ VCR.configure do |config| config.default_cassette_options = { match_requests_on: [ :method, :uri, :body ] } + + # Ignore timestamps in request bodies + config.before_record do |i| + if i.request&.body + i.request.body.gsub!(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "