Class: AssistantConversation
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- AssistantConversation
- Includes:
- AssistantConversationMessageReplayable, AssistantConversationPlannable, AssistantConversationProcessingLockable, AssistantConversationTokenTrackable, PgSearch::Model
- Defined in:
- app/models/assistant_conversation.rb
Overview
== Schema Information
Table name: assistant_conversations
Database name: primary
id :bigint not null, primary key
metadata :jsonb not null
processing_since :datetime
title :string default("New Conversation"), not null
created_at :datetime not null
updated_at :datetime not null
llm_model_id :bigint
parent_conversation_id :bigint
processing_by_id :bigint
user_id :bigint not null
Indexes
index_assistant_conversations_on_llm_model_id (llm_model_id)
index_assistant_conversations_on_metadata (metadata) USING gin
index_assistant_conversations_on_parent_conversation_id (parent_conversation_id)
index_assistant_conversations_on_user_id_and_updated_at (user_id,updated_at)
Foreign Keys
fk_rails_... (llm_model_id => llm_models.id)
fk_rails_... (parent_conversation_id => assistant_conversations.id)
fk_rails_... (user_id => parties.id)
Constant Summary collapse
- LOCK_STALE_AFTER =
Processing lock / plan limits (referenced by workers and initializers)
5.minutes
- HEARTBEAT_STALE_AFTER =
Heartbeat stale after.
3.minutes
- PROCESSING_JID_TTL =
Processing jid ttl.
15.minutes.to_i
- MAX_AUTO_CONTINUATIONS =
Maximum auto continuations.
5
Constants included from Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#current_sender_id ⇒ Object
Set by ChatService before ask() so AssistantMessage.stamp_sender_id can attribute user messages to the actual sender (not the conversation owner).
- #title ⇒ Object readonly
Belongs to collapse
Has many collapse
- #continuations ⇒ ActiveRecord::Relation<AssistantConversation>
- #shares ⇒ ActiveRecord::Relation<AssistantConversationShare>
- #uploads ⇒ ActiveRecord::Relation<Upload>
Class Method Summary collapse
-
.daily_briefings ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily briefings.
-
.daily_focus_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus for.
-
.daily_focus_pending_review ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus pending review.
-
.daily_focus_processing ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus processing.
-
.earliest_daily_focus_briefing_date ⇒ Object
Earliest calendar day we have a stored daily briefing (for review date picker range).
-
.for_daily_focus_date ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for daily focus date.
-
.for_user ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for user.
- .ransackable_associations(_auth_object = nil) ⇒ Object
- .ransackable_attributes(_auth_object = nil) ⇒ Object
-
.recent ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are recent.
-
.shared_with ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are shared with.
-
.viewable_by ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are viewable by.
-
.with_errors ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are with errors.
Instance Method Summary collapse
-
#access_level_for(party, account) ⇒ Object
Determine access level for a given party Returns "owner", "collaborator", or "viewer" (nil if no access).
-
#model ⇒ Object
RubyLLM's with_model() internally calls
modelas a bare method, but we named the association :llm_model to avoid ActiveRecord conflicts. -
#notifiable_parties(except_party_id:) ⇒ Object
Returns all parties who have access to this conversation (owner + all directly shared parties + all parties with a matching role share), excluding the party identified by +except_party_id+.
-
#to_llm ⇒ Object
── Context management ────────────────────────────────────────── Override to_llm to eager-load tool_call associations and avoid N+1 queries when replaying conversation history.
-
#with_instructions(instructions, append: false, replace: nil) ⇒ Object
── System prompt persistence fix ────────────────────────────── RubyLLM v1.11.0 has a bug: ChatMethods#with_instructions directly does
messages_association.create!(role: :system, content: instructions).
Methods included from AssistantConversationMessageReplayable
Methods included from AssistantConversationProcessingLockable
#acquire_processing_lock!, #force_processing_lock!, #heartbeat_stale?, #processing?, #processing_job_id, #processing_lock_key, #refresh_processing_ttl!, #release_processing_lock!
Methods included from AssistantConversationTokenTrackable
#computed_token_totals, #computed_total_cost, #sync_token_totals!, #total_tokens, #track_error!, #track_query!
Methods included from AssistantConversationPlannable
#can_auto_continue?, #increment_continuation_count!, #plan_active?, #plan_completed_steps, #plan_continuation_count, #plan_pending_steps, #plan_progress_summary
Methods inherited from ApplicationRecord
ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#current_sender_id ⇒ Object
Set by ChatService before ask() so AssistantMessage.stamp_sender_id
can attribute user messages to the actual sender (not the conversation owner).
68 69 70 |
# File 'app/models/assistant_conversation.rb', line 68 def current_sender_id @current_sender_id end |
#title ⇒ Object (readonly)
193 |
# File 'app/models/assistant_conversation.rb', line 193 validates :title, presence: true |
Class Method Details
.daily_briefings ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily briefings. Active Record Scope
198 |
# File 'app/models/assistant_conversation.rb', line 198 scope :daily_briefings, -> { where("metadata @> ?", { conversation_type: 'daily_briefing' }.to_json) } |
.daily_focus_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus for. Active Record Scope
201 202 203 |
# File 'app/models/assistant_conversation.rb', line 201 scope :daily_focus_for, ->(user, date = Date.current) { where(user_id: user.id).where.contains(metadata: { daily_focus_date: date.iso8601 }) } |
.daily_focus_pending_review ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus pending review. Active Record Scope
204 205 206 207 |
# File 'app/models/assistant_conversation.rb', line 204 scope :daily_focus_pending_review, ->(date = Date.current) { where("metadata @> ?", { daily_focus_date: date.iso8601, daily_focus_status: 'ready' }.to_json) .where("metadata->>'daily_focus_target_employee_id' IS NOT NULL") } |
.daily_focus_processing ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus processing. Active Record Scope
208 209 210 211 |
# File 'app/models/assistant_conversation.rb', line 208 scope :daily_focus_processing, ->(date = Date.current) { where("metadata @> ?", { daily_focus_date: date.iso8601, daily_focus_status: 'processing' }.to_json) .where("metadata->>'daily_focus_target_employee_id' IS NOT NULL") } |
.earliest_daily_focus_briefing_date ⇒ Object
Earliest calendar day we have a stored daily briefing (for review date picker range).
214 215 216 217 218 |
# File 'app/models/assistant_conversation.rb', line 214 def self.earliest_daily_focus_briefing_date daily_briefings .where("metadata->>'daily_focus_date' IS NOT NULL") .minimum(Arel.sql("(metadata->>'daily_focus_date')::date")) end |
.for_daily_focus_date ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for daily focus date. Active Record Scope
200 |
# File 'app/models/assistant_conversation.rb', line 200 scope :for_daily_focus_date, ->(date) { where("metadata @> ?", { daily_focus_date: date.iso8601 }.to_json) } |
.for_user ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for user. Active Record Scope
196 |
# File 'app/models/assistant_conversation.rb', line 196 scope :for_user, ->(user) { where(user_id: user.id) } |
.ransackable_associations(_auth_object = nil) ⇒ Object
224 225 226 |
# File 'app/models/assistant_conversation.rb', line 224 def self.ransackable_associations(_auth_object = nil) %w[user] end |
.ransackable_attributes(_auth_object = nil) ⇒ Object
220 221 222 |
# File 'app/models/assistant_conversation.rb', line 220 def self.ransackable_attributes(_auth_object = nil) %w[title created_at updated_at user_id] end |
.recent ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are recent. Active Record Scope
195 |
# File 'app/models/assistant_conversation.rb', line 195 scope :recent, -> { order(updated_at: :desc) } |
.shared_with ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are shared with. Active Record Scope
229 230 231 232 233 234 235 |
# File 'app/models/assistant_conversation.rb', line 229 scope :shared_with, ->(party, account) { where( id: AssistantConversationShare .granting_access_to(party, account) .select(:assistant_conversation_id) ) } |
.viewable_by ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are viewable by. Active Record Scope
238 |
# File 'app/models/assistant_conversation.rb', line 238 scope :viewable_by, ->(party, account) { for_user(party).or(shared_with(party, account)) } |
.with_errors ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are with errors. Active Record Scope
197 |
# File 'app/models/assistant_conversation.rb', line 197 scope :with_errors, -> { where('(metadata->>\'error_count\')::int > 0') } |
Instance Method Details
#access_level_for(party, account) ⇒ Object
Determine access level for a given party
Returns "owner", "collaborator", or "viewer" (nil if no access)
242 243 244 245 246 247 248 249 |
# File 'app/models/assistant_conversation.rb', line 242 def access_level_for(party, account) return 'owner' if user_id == party.id share = shares.granting_access_to(party, account).order( Arel.sql("CASE access_level WHEN 'collaborator' THEN 0 ELSE 1 END") ).first share&.access_level end |
#continuations ⇒ ActiveRecord::Relation<AssistantConversation>
74 75 76 77 |
# File 'app/models/assistant_conversation.rb', line 74 has_many :continuations, class_name: 'AssistantConversation', foreign_key: :parent_conversation_id, dependent: :nullify, inverse_of: :parent_conversation |
#model ⇒ Object
RubyLLM's with_model() internally calls model as a bare method,
but we named the association :llm_model to avoid ActiveRecord conflicts.
Provide a reader so the gem can access it.
83 84 85 |
# File 'app/models/assistant_conversation.rb', line 83 def model llm_model end |
#notifiable_parties(except_party_id:) ⇒ Object
Returns all parties who have access to this conversation (owner + all
directly shared parties + all parties with a matching role share),
excluding the party identified by +except_party_id+.
Used by AssistantMessageNotifier to determine notification recipients.
256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'app/models/assistant_conversation.rb', line 256 def notifiable_parties(except_party_id:) parties = [user] shares.for_users.includes(:user).find_each { |share| parties << share.user } role_ids = shares.for_roles.pluck(:role_id) if role_ids.any? Account.where.overlap(inherited_role_ids: role_ids) .includes(:party) .find_each { |admin| parties << admin.party if admin.party } end parties.compact.uniq.reject { |party| party.id == except_party_id } end |
#parent_conversation ⇒ AssistantConversation
72 |
# File 'app/models/assistant_conversation.rb', line 72 belongs_to :parent_conversation, class_name: 'AssistantConversation', optional: true |
#processing_by ⇒ Party
71 |
# File 'app/models/assistant_conversation.rb', line 71 belongs_to :processing_by, class_name: 'Party', optional: true |
#shares ⇒ ActiveRecord::Relation<AssistantConversationShare>
78 |
# File 'app/models/assistant_conversation.rb', line 78 has_many :shares, class_name: 'AssistantConversationShare', dependent: :destroy |
#to_llm ⇒ Object
── Context management ──────────────────────────────────────────
Override to_llm to eager-load tool_call associations and avoid N+1
queries when replaying conversation history. RubyLLM's default
implementation iterates messages and lazily loads assistant_tool_calls
and parent_tool_call for each one, causing O(n) extra queries.
Also integrates sliding-window compaction: when context exceeds the token
threshold, older messages are replaced with a cached summary and only
recent messages are sent verbatim.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'app/models/assistant_conversation.rb', line 135 def to_llm model_record = model_association # No model assigned yet — return nil @chat without crashing. # Hit by RubyLLM::Agent.apply_configuration during create!/find before # ChatService assigns a model via with_model(). Safe because apply_configuration # ignores the return value when no macros are declared on SunnyAgent. return @chat if model_record.nil? # assume_model_exists: true lets RubyLLM call the API with any model ID # that is stored in llm_models, even if RubyLLM's bundled models.json # registry hasn't caught up with a newly-released Anthropic model yet. # configure_conversation already uses assume_exists: true for the same reason. @chat ||= (context || RubyLLM).chat( model: model_record.model_id, provider: model_record.provider.to_sym, assume_model_exists: true ) @chat. compaction_summary = Assistant::ContextCompactor.ensure_context_summary!(self) cutoff_id = compaction_summary ? : nil msgs = (cutoff_id) orphaned_ids = detect_orphaned_tool_call_ids(msgs) (msgs, compaction_summary, orphaned_ids) send(:setup_persistence_callbacks) end |
#uploads ⇒ ActiveRecord::Relation<Upload>
73 |
# File 'app/models/assistant_conversation.rb', line 73 has_many :uploads, as: :resource, dependent: :destroy |
#user ⇒ Party
70 |
# File 'app/models/assistant_conversation.rb', line 70 belongs_to :user, class_name: 'Party', inverse_of: :assistant_conversations |
#with_instructions(instructions, append: false, replace: nil) ⇒ Object
── System prompt persistence fix ──────────────────────────────
RubyLLM v1.11.0 has a bug: ChatMethods#with_instructions directly
does messages_association.create!(role: :system, content: instructions).
When instructions is a Content::Raw object (e.g. Anthropic cache-control
wrapped prompt), the text column stores "#RubyLLM::Content::Raw:0x..."
instead of the actual content. The content_raw JSON column is never set.
Every subsequent to_llm call (from with_thinking, with_tools, etc.)
resets in-memory messages and replays only from DB, so the correct
Content::Raw is permanently lost. This breaks both:
- The system prompt (garbled text sent to the API)
- Prompt caching (no cache_control header)
This override uses prepare_content_for_storage (which correctly splits
Content::Raw into content_raw + nil content) before persisting.
Signature mirrors the gem's ChatMethods#with_instructions so callers can use
the same keyword arguments (append:, replace:) they would use with a plain chat.
Replace semantics: replace=true clears existing system messages; replace=false or
append=true adds to them. Default (no kwargs) replaces, matching the gem's behaviour.
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'app/models/assistant_conversation.rb', line 106 def with_instructions(instructions, append: false, replace: nil) content_text, , content_raw = send(:prepare_content_for_storage, instructions) should_replace = !append && replace != false transaction do .destroy_by(role: :system) if should_replace msg = .build(role: :system) msg.content = content_text msg.content_raw = content_raw if msg.respond_to?(:content_raw=) && content_raw.present? msg.save! end # Rebuild @chat from DB so it picks up the correctly persisted system # message (with Content::Raw and cache_control intact). Do NOT call # @chat.with_instructions again — the DB replay already includes it. to_llm self end |