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 =
3.minutes
- PROCESSING_JID_TTL =
15.minutes.to_i
- MAX_AUTO_CONTINUATIONS =
5
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 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).
64 65 66 |
# File 'app/models/assistant_conversation.rb', line 64 def current_sender_id @current_sender_id end |
#title ⇒ Object (readonly)
187 |
# File 'app/models/assistant_conversation.rb', line 187 validates :title, presence: true |
Class Method Details
.daily_briefings ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily briefings. Active Record Scope
192 |
# File 'app/models/assistant_conversation.rb', line 192 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
197 198 199 |
# File 'app/models/assistant_conversation.rb', line 197 scope :daily_focus_for, ->(user, date = Date.current) { where(user_id: user.id).where("metadata @> ?", { daily_focus_date: date.iso8601 }.to_json) } |
.daily_focus_pending_review ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus pending review. Active Record Scope
200 201 202 203 |
# File 'app/models/assistant_conversation.rb', line 200 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
204 205 206 207 |
# File 'app/models/assistant_conversation.rb', line 204 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).
210 211 212 213 214 |
# File 'app/models/assistant_conversation.rb', line 210 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
194 195 196 |
# File 'app/models/assistant_conversation.rb', line 194 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
190 |
# File 'app/models/assistant_conversation.rb', line 190 scope :for_user, ->(user) { where(user_id: user.id) } |
.ransackable_associations(_auth_object = nil) ⇒ Object
220 221 222 |
# File 'app/models/assistant_conversation.rb', line 220 def self.ransackable_associations(_auth_object = nil) %w[user] end |
.ransackable_attributes(_auth_object = nil) ⇒ Object
216 217 218 |
# File 'app/models/assistant_conversation.rb', line 216 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
189 |
# File 'app/models/assistant_conversation.rb', line 189 scope :recent, -> { order(updated_at: :desc) } |
.shared_with ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are shared with. Active Record Scope
225 226 227 228 229 230 231 |
# File 'app/models/assistant_conversation.rb', line 225 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
234 235 236 |
# File 'app/models/assistant_conversation.rb', line 234 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
191 |
# File 'app/models/assistant_conversation.rb', line 191 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)
240 241 242 243 244 245 246 247 |
# File 'app/models/assistant_conversation.rb', line 240 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>
70 71 72 73 |
# File 'app/models/assistant_conversation.rb', line 70 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.
79 80 81 |
# File 'app/models/assistant_conversation.rb', line 79 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.
254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'app/models/assistant_conversation.rb', line 254 def notifiable_parties(except_party_id:) parties = [user] shares.for_users.includes(:user).each { |share| parties << share.user } role_ids = shares.for_roles.pluck(:role_id) if role_ids.any? Account.where("inherited_role_ids && ARRAY[?]::integer[]", role_ids) .includes(:party) .each { |admin| parties << admin.party if admin.party } end parties.compact.uniq.reject { |party| party.id == except_party_id } end |
#parent_conversation ⇒ AssistantConversation
68 |
# File 'app/models/assistant_conversation.rb', line 68 belongs_to :parent_conversation, class_name: 'AssistantConversation', optional: true |
#processing_by ⇒ Party
67 |
# File 'app/models/assistant_conversation.rb', line 67 belongs_to :processing_by, class_name: 'Party', optional: true |
#shares ⇒ ActiveRecord::Relation<AssistantConversationShare>
74 |
# File 'app/models/assistant_conversation.rb', line 74 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.
131 132 133 134 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 |
# File 'app/models/assistant_conversation.rb', line 131 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>
69 |
# File 'app/models/assistant_conversation.rb', line 69 has_many :uploads, as: :resource, dependent: :destroy |
#user ⇒ Party
66 |
# File 'app/models/assistant_conversation.rb', line 66 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.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'app/models/assistant_conversation.rb', line 102 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 .where(role: :system).destroy_all 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 |