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 =

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

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 Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

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).



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

def current_sender_id
  @current_sender_id
end

#titleObject (readonly)



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

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:



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

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:



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

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

Returns:

See Also:



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

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

Returns:

See Also:



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_dateObject

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

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

Returns:

See Also:



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

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

Returns:

See Also:



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

.recentActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are recent. Active Record Scope

Returns:

See Also:



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

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

.shared_withActiveRecord::Relation<AssistantConversation>

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

Returns:

See Also:



229
230
231
232
233
234
235
# File 'app/models/assistant_conversation.rb', line 229

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:



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

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:



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, )
  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:



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

#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.



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_conversationAssistantConversation



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

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

#processing_byParty

Returns:

See Also:



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

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

#sharesActiveRecord::Relation<AssistantConversationShare>

Returns:

See Also:



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

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.



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.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:



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

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

#userParty

Returns:

See Also:



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:

  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.



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, _attachments, content_raw = send(:prepare_content_for_storage, instructions)
  should_replace = !append && replace != false

  transaction do
    assistant_messages.destroy_by(role: :system) 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