Class: AssistantConversation

Inherits:
ApplicationRecord show all
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

Belongs to collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from AssistantConversationMessageReplayable

#has_thinking_history?

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

#publish_event

Instance Attribute Details

#current_sender_idObject

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

#titleObject (readonly)



187
# File 'app/models/assistant_conversation.rb', line 187

validates :title, presence: true

Class Method Details

.daily_briefingsActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily briefings. Active Record Scope

Returns:

See Also:



192
# File 'app/models/assistant_conversation.rb', line 192

scope :daily_briefings, -> { where("metadata @> ?", { conversation_type: 'daily_briefing' }.to_json) }

.daily_focus_forActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily focus for. Active Record Scope

Returns:

See Also:



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_reviewActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily focus pending review. Active Record Scope

Returns:

See Also:



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_processingActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily focus processing. Active Record Scope

Returns:

See Also:



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_dateObject

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_dateActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are for daily focus date. Active Record Scope

Returns:

See Also:



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_userActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are for user. Active Record Scope

Returns:

See Also:



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

.recentActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are recent. Active Record Scope

Returns:

See Also:



189
# File 'app/models/assistant_conversation.rb', line 189

scope :recent, -> { order(updated_at: :desc) }

.shared_withActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are shared with. Active Record Scope

Returns:

See Also:



225
226
227
228
229
230
231
# File 'app/models/assistant_conversation.rb', line 225

scope :shared_with, ->(party, ) {
  where(
    id: AssistantConversationShare
          .granting_access_to(party, )
          .select(:assistant_conversation_id)
  )
}

.viewable_byActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are viewable by. Active Record Scope

Returns:

See Also:



234
235
236
# File 'app/models/assistant_conversation.rb', line 234

scope :viewable_by, ->(party, ) {
  for_user(party).or(shared_with(party, ))
}

.with_errorsActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are with errors. Active Record Scope

Returns:

See Also:



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, )
  return 'owner' if user_id == party.id

  share = shares.granting_access_to(party, ).order(
    Arel.sql("CASE access_level WHEN 'collaborator' THEN 0 ELSE 1 END")
  ).first
  share&.access_level
end

#continuationsActiveRecord::Relation<AssistantConversation>

Returns:

See Also:



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

#modelObject

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_conversationAssistantConversation



68
# File 'app/models/assistant_conversation.rb', line 68

belongs_to :parent_conversation, class_name: 'AssistantConversation', optional: true

#processing_byParty

Returns:

See Also:



67
# File 'app/models/assistant_conversation.rb', line 67

belongs_to :processing_by, class_name: 'Party', optional: true

#sharesActiveRecord::Relation<AssistantConversationShare>

Returns:

See Also:



74
# File 'app/models/assistant_conversation.rb', line 74

has_many :shares, class_name: 'AssistantConversationShare', dependent: :destroy

#to_llmObject

── 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.reset_messages!

  compaction_summary = Assistant::ContextCompactor.ensure_context_summary!(self)
  cutoff_id          = compaction_summary ? compaction_through_message_id : nil
  msgs               = load_messages_for_replay(cutoff_id)
  orphaned_ids       = detect_orphaned_tool_call_ids(msgs)

  replay_messages_onto_chat(msgs, compaction_summary, orphaned_ids)

  send(:setup_persistence_callbacks)
end

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



69
# File 'app/models/assistant_conversation.rb', line 69

has_many :uploads, as: :resource, dependent: :destroy

#userParty

Returns:

See Also:



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:

  1. The system prompt (garbled text sent to the API)
  2. 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, _attachments, content_raw = send(:prepare_content_for_storage, instructions)
  should_replace = !append && replace != false

  transaction do
    assistant_messages.where(role: :system).destroy_all if should_replace

    msg = assistant_messages.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