Compare commits

..
Author SHA1 Message Date
Shivam Mishra 87d4eb46fb fix: normalize captain usage bucket timestamps to whole seconds 2026-06-30 16:26:52 +05:30
Shivam Mishra f2df41299c feat: log captain assistant and copilot usage 2026-06-30 16:14:50 +05:30
Shivam Mishra e9789ca9c4 feat: add captain usage model 2026-06-30 16:14:50 +05:30
Shivam Mishra 1b8a4a8594 feat: add captain usage table 2026-06-30 16:14:50 +05:30
Aakash BakhleandGitHub 926a9d8a69 fix(captain): default temperature to 0.5 and remove UI control (#14879)
# Pull Request Template

## Description

- Default temperature to 0.5 and remove UI control
- No migrations needed for existing accounts, their current settings are
preserved

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and spec

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-30 15:14:03 +05:30
Aakash BakhleandGitHub 9caceea858 fix(deps): update msgpack for CVE-2026-54522 (#14898)
# Pull Request Template

## Description

This updates the locked `msgpack` gem from `1.8.0` to `1.8.3` so the
bundle-audit check no longer flags CVE-2026-54522. The upgrade stays
within the existing transitive dependency constraints used by `bootsnap`
and `datadog`.

Fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114757/workflows/f8c7b37f-27d5-45d4-9f1b-1d1782ebc4e3/jobs/162250

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec bundle audit update && bundle
exec bundle audit check -v`
- `eval "$(rbenv init -)" && bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `eval "$(rbenv init -)" && RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle
exec rubocop --no-server Gemfile`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-30 14:56:34 +05:30
13 changed files with 141 additions and 32 deletions
@@ -29,7 +29,6 @@ const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
@@ -131,26 +128,6 @@ watch(
class="z-0"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@@ -494,10 +494,6 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -0,0 +1,20 @@
class CreateCaptainUsages < ActiveRecord::Migration[7.1]
def change
create_table :captain_usages do |t|
t.references :account, null: false
t.references :assistant, null: false
t.integer :usage_type, null: false
# Usage is binned into 15-minute UTC buckets so it can be re-sliced into
# any caller timezone (including :45 offset zones). Do not widen to hourly.
t.datetime :bucket_started_at, null: false
t.integer :credits_used, null: false, default: 0
t.timestamps
end
add_index :captain_usages,
[:account_id, :assistant_id, :usage_type, :bucket_started_at],
unique: true,
name: 'index_captain_usages_unique_bucket'
end
end
+14 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_30_102817) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -431,6 +431,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["enabled"], name: "index_captain_scenarios_on_enabled"
end
create_table "captain_usages", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "assistant_id", null: false
t.integer "usage_type", null: false
t.datetime "bucket_started_at", null: false
t.integer "credits_used", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "assistant_id", "usage_type", "bucket_started_at"], name: "index_captain_usages_unique_bucket", unique: true
t.index ["account_id"], name: "index_captain_usages_on_account_id"
t.index ["assistant_id"], name: "index_captain_usages_on_assistant_id"
end
create_table "categories", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "portal_id", null: false
@@ -96,7 +96,7 @@ module Captain::ChatHelper
end
def temperature
@assistant&.config&.[]('temperature').to_f || 1
@assistant&.config&.[]('temperature').presence&.to_f || 0.5
end
def resolved_account_id
@@ -77,6 +77,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
# Granular usage reporting. Only assistant + copilot responses are logged for now;
# the other increment_response_usage call sites (task services, audio transcription)
# map to usage_types that are intentionally not recorded yet.
Captain::Usage.log(account: account, assistant: @assistant, usage_type: :assistant_response)
end
end
end
@@ -35,6 +35,7 @@ class Captain::Assistant < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
has_many :usages, class_name: 'Captain::Usage', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
+73
View File
@@ -0,0 +1,73 @@
# == Schema Information
#
# Table name: captain_usages
#
# id :bigint not null, primary key
# bucket_started_at :datetime not null
# credits_used :integer default(0), not null
# usage_type :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
# Indexes
#
# index_captain_usages_on_account_id (account_id)
# index_captain_usages_on_assistant_id (assistant_id)
# index_captain_usages_unique_bucket (account_id,assistant_id,usage_type,bucket_started_at) UNIQUE
#
class Captain::Usage < ApplicationRecord
self.table_name = 'captain_usages'
# Width of each reporting bucket. Usage is binned into 15-minute UTC buckets so
# it can be re-sliced into any caller timezone, including :45 offset zones
# (e.g. Nepal +5:45). Do not widen this without revisiting timezone slicing.
BUCKET_SIZE = 15.minutes
# Only :assistant_response and :copilot_response are logged today. The
# remaining values are reserved for future usage sources:
# editor_actions, label_suggestion, audio_transcription
enum usage_type: {
assistant_response: 0,
copilot_response: 1
}
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :account
# log is the only sanctioned write path; it uses upsert, which skips model
# validations. Column invariants are enforced via NOT NULL at the DB level.
#
# Records credit usage against the 15-minute bucket that contains +occurred_at+.
# Concurrent calls are safe: the row is created or its counter incremented in a
# single atomic statement keyed on the unique (account, assistant, type, bucket).
def self.log(account:, assistant:, usage_type:, credits: 1, occurred_at: Time.current)
now = Time.current
# rubocop:disable Rails/SkipsModelValidations
upsert(
{
account_id: account.id,
assistant_id: assistant.id,
usage_type: usage_types.fetch(usage_type.to_s),
bucket_started_at: bucket_for(occurred_at),
credits_used: credits,
created_at: now,
updated_at: now
},
unique_by: 'index_captain_usages_unique_bucket',
on_duplicate: Arel.sql(
'credits_used = captain_usages.credits_used + EXCLUDED.credits_used, updated_at = EXCLUDED.updated_at'
)
)
# rubocop:enable Rails/SkipsModelValidations
end
# Floors +time+ to the start of its 15-minute UTC bucket. Subseconds are
# dropped so the bucket timestamp is stable across calls within the same
# bucket; otherwise distinct microseconds break the unique-key upsert.
def self.bucket_for(time)
epoch = time.to_i
Time.at(epoch - (epoch % BUCKET_SIZE.to_i)).utc
end
end
+3 -1
View File
@@ -1,13 +1,15 @@
module Concerns::Agentable
extend ActiveSupport::Concern
DEFAULT_TEMPERATURE = 0.5
def agent
Agents::Agent.new(
name: agent_name,
instructions: ->(context) { agent_instructions(context) },
tools: agent_tools,
model: agent_model,
temperature: temperature.to_f || 0.7,
temperature: temperature.presence&.to_f || DEFAULT_TEMPERATURE,
response_schema: agent_response_schema
)
end
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :captain_usages, dependent: :destroy_async, class_name: 'Captain::Usage'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
@@ -29,6 +29,10 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
# Granular usage reporting. Only assistant + copilot responses are logged for now;
# the other increment_response_usage call sites (task services, audio transcription)
# map to usage_types that are intentionally not recorded yet.
Captain::Usage.log(account: @account, assistant: @assistant, usage_type: :copilot_response)
response
end
@@ -56,11 +56,11 @@ RSpec.describe Concerns::Agentable do
dummy_instance.agent
end
it 'converts nil temperature to 0.0' do
it 'uses default temperature when temperature is nil' do
dummy_instance.temperature = nil
expect(Agents::Agent).to receive(:new).with(
hash_including(temperature: 0.0)
hash_including(temperature: 0.5)
)
dummy_instance.agent
@@ -39,6 +39,24 @@ RSpec.describe Captain::Llm::AssistantChatService do
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
it 'uses default temperature when assistant config does not include temperature' do
expect(mock_chat).to receive(:with_temperature).with(0.5).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
it 'preserves explicit assistant config temperature' do
assistant.update!(config: assistant.config.merge('temperature' => 1.0))
expect(mock_chat).to receive(:with_temperature).with(1.0).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
service = described_class.new(assistant: assistant, conversation: conversation)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
it 'passes channel_type to the agent session instrumentation' do
service = described_class.new(assistant: assistant, conversation: conversation)