This PR adds internal tooling and planning docs for migrating existing Captain assistant instructions into the new General Guidelines structure. **Summary** This PR adds a controlled migration path for moving existing Captain V1 assistant instructions into the structured Captain architecture. It introduces a classifier that reads the current `config.instructions` and produces reviewed migration drafts with separate sections for: - assistant description / business context - response guidelines - guardrails - scenario candidates - conversation messages - FAQ/document candidates - needs-review items The migration is intentionally staged. It only targets V1-style assistants that still have custom instructions, are connected to inboxes, and do not already have structured response guidelines, guardrails, or scenario records. When applied, the task writes the extracted business context to the assistant description, response guidelines to `response_guidelines`, guardrails to `guardrails`, and stores scenario candidates / FAQ candidates / review notes under `config["assistant_migration"]`. Scenario candidates are also flattened into response guidelines for now so customer behavior is preserved before we create real `Captain::Scenario` records in a later rollout. The applier stores the original assistant values under migration metadata so conversation message config can be restored if needed. It does not create scenario records yet. **How to generate drafts** For specific assistant IDs: ```bash bundle exec rake captain:assistant_migration:generate \ IDS=546,636,819 \ LIMIT=0 \ OUTPUT=tmp/captain_migration_drafts.jsonl ``` For the first 50 eligible assistants: ```bash bundle exec rake captain:assistant_migration:generate \ OUTPUT=tmp/captain_migration_drafts.jsonl ``` For all eligible assistants: ```bash bundle exec rake captain:assistant_migration:generate \ LIMIT=0 \ OUTPUT=tmp/captain_migration_drafts.jsonl ``` **How to apply drafts** Dry run first: ```bash bundle exec rake captain:assistant_migration:apply \ INPUT=tmp/captain_migration_drafts.jsonl \ DRY_RUN=true ``` Apply changes: ```bash bundle exec rake captain:assistant_migration:apply \ INPUT=tmp/captain_migration_drafts.jsonl \ DRY_RUN=false ``` **How to restore conversation messages** If extracted `welcome_message`, `handoff_message`, or `resolution_message` need to be reverted to their pre-migration values: ```bash bundle exec rake captain:assistant_migration:restore_messages \ IDS=546,636,819 \ DRY_RUN=true ``` ```bash bundle exec rake captain:assistant_migration:restore_messages \ IDS=546,636,819 \ DRY_RUN=false ``` **Notes** - `LIMIT=0` means no limit. - `generate` overwrites the output file. - The apply task skips assistants that are no longer V1 migration candidates. - This PR does not create `Captain::Scenario` records; scenario candidates are staged in assistant config for a future migration. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: aakashb95 <aakashbakhle@gmail.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
279 lines
9.3 KiB
Ruby
279 lines
9.3 KiB
Ruby
require 'json'
|
|
require 'fileutils'
|
|
require 'csv'
|
|
|
|
# rubocop:disable Metrics/BlockLength
|
|
namespace :captain do
|
|
namespace :assistant_migration do
|
|
desc 'Generate structured migration drafts. Usage: rake captain:assistant_migration:generate IDS=1,2,3 LIMIT=50 ' \
|
|
'OUTPUT=tmp/captain_migration.jsonl'
|
|
task generate: :environment do
|
|
assistants = CaptainAssistantMigrationTask.assistants
|
|
output_path = ENV.fetch('OUTPUT', Rails.root.join('tmp/captain_assistant_migration_drafts.jsonl').to_s)
|
|
|
|
FileUtils.mkdir_p(File.dirname(output_path))
|
|
processed = 0
|
|
|
|
File.open(output_path, 'w') do |file|
|
|
CaptainAssistantMigrationTask.each_assistant(assistants) do |assistant|
|
|
result = Captain::AssistantMigration::InstructionClassifier.new(assistant: assistant).perform
|
|
file.puts(JSON.generate(result))
|
|
processed += 1
|
|
puts "Generated migration draft for assistant #{assistant.id} (#{processed}/#{CaptainAssistantMigrationTask.assistant_count(assistants)})"
|
|
end
|
|
end
|
|
|
|
puts "Wrote #{processed} migration drafts to #{output_path}"
|
|
end
|
|
|
|
desc 'Apply reviewed migration drafts. Usage: rake captain:assistant_migration:apply INPUT=tmp/reviewed.jsonl DRY_RUN=true'
|
|
task apply: :environment do
|
|
input_path = ENV.fetch('INPUT')
|
|
dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true)
|
|
|
|
results = CaptainAssistantMigrationTask.apply_drafts(
|
|
input_path: input_path,
|
|
dry_run: dry_run
|
|
)
|
|
|
|
results.each { |result| puts(JSON.generate(result)) }
|
|
puts "Processed #{results.size} migration drafts from #{input_path}"
|
|
puts 'Dry run only. Re-run with DRY_RUN=false to write changes.' if dry_run
|
|
end
|
|
|
|
desc 'Restore conversation message config from migration backup. Usage: rake captain:assistant_migration:restore_messages IDS=1,2 DRY_RUN=true'
|
|
task restore_messages: :environment do
|
|
dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true)
|
|
results = CaptainAssistantMigrationTask.restore_conversation_messages(dry_run: dry_run)
|
|
|
|
results.each { |result| puts(JSON.generate(result)) }
|
|
puts "Processed #{results.size} assistant message restores"
|
|
puts 'Dry run only. Re-run with DRY_RUN=false to restore conversation messages.' if dry_run
|
|
end
|
|
end
|
|
end
|
|
# rubocop:enable Metrics/BlockLength
|
|
|
|
# rubocop:disable Style/OneClassPerFile
|
|
class CaptainAssistantMigrationTask
|
|
CsvAccount = Struct.new(:id, :name, keyword_init: true) do
|
|
def captain_models
|
|
{}
|
|
end
|
|
|
|
def conversations
|
|
CsvRelation.new
|
|
end
|
|
end
|
|
|
|
CsvAssociation = Struct.new(:inbox_count, keyword_init: true) do
|
|
def size
|
|
inbox_count
|
|
end
|
|
end
|
|
|
|
class CsvRelation
|
|
def find_by(*)
|
|
nil
|
|
end
|
|
|
|
def exists?
|
|
false
|
|
end
|
|
end
|
|
|
|
CsvAssistant = Struct.new(
|
|
:id,
|
|
:name,
|
|
:account_id,
|
|
:account,
|
|
:description,
|
|
:config,
|
|
:response_guidelines,
|
|
:guardrails,
|
|
:captain_inboxes,
|
|
:scenarios,
|
|
keyword_init: true
|
|
)
|
|
|
|
class << self
|
|
def assistants
|
|
return csv_assistants if ENV['CSV_INPUT'].present?
|
|
|
|
scope = Captain::Assistant.includes(:account, :captain_inboxes, :scenarios)
|
|
|
|
ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence }
|
|
scope = scope.where(id: ids) if ids.any?
|
|
|
|
scope = migration_eligible_scope(scope).order(:id)
|
|
|
|
limit = ENV.fetch('LIMIT', 50).to_i
|
|
limit.positive? ? scope.limit(limit) : scope
|
|
end
|
|
|
|
def each_assistant(assistants, &)
|
|
return assistants.find_each(&) if assistants.respond_to?(:find_each)
|
|
|
|
assistants.each(&)
|
|
end
|
|
|
|
def assistant_count(assistants)
|
|
assistants.respond_to?(:size) ? assistants.size : assistants.count
|
|
end
|
|
|
|
def restore_conversation_messages(dry_run:)
|
|
ENV.fetch('IDS').split(',').filter_map { |id| id.strip.presence }.map do |assistant_id|
|
|
assistant = Captain::Assistant.find(assistant_id)
|
|
restore_conversation_messages_for(assistant, dry_run: dry_run)
|
|
rescue ActiveRecord::RecordNotFound
|
|
{ assistant_id: assistant_id, error: 'Assistant not found' }
|
|
end
|
|
end
|
|
|
|
def apply_drafts(input_path:, dry_run:)
|
|
File.readlines(input_path, chomp: true).filter_map.with_index(1) do |line, line_number|
|
|
next if line.blank?
|
|
|
|
apply_draft(JSON.parse(line), line_number: line_number, dry_run: dry_run)
|
|
rescue JSON::ParserError => e
|
|
{ line_number: line_number, error: "Invalid JSON: #{e.message}" }
|
|
end
|
|
end
|
|
|
|
def apply_draft(payload, line_number:, dry_run:)
|
|
return { line_number: line_number, skipped: true, reason: payload['error'] } if payload['error'].present?
|
|
|
|
assistant_id = payload.dig('assistant', 'id') || payload['assistant_id']
|
|
assistant = Captain::Assistant.find(assistant_id)
|
|
return skipped_result(line_number, assistant_id, 'Assistant is not a V1 migration candidate') unless migration_candidate?(assistant)
|
|
|
|
draft = payload['draft'] || payload
|
|
|
|
Captain::AssistantMigration::DraftApplier.new(
|
|
assistant: assistant,
|
|
draft: draft,
|
|
dry_run: dry_run
|
|
).perform.merge(line_number: line_number)
|
|
rescue ActiveRecord::RecordNotFound
|
|
{ line_number: line_number, assistant_id: assistant_id, error: 'Assistant not found' }
|
|
end
|
|
|
|
def truthy?(key, default:)
|
|
value = ENV.fetch(key, nil)
|
|
return default if value.nil?
|
|
|
|
value.to_s.downcase.in?(%w[1 true yes y])
|
|
end
|
|
|
|
private
|
|
|
|
def restore_conversation_messages_for(assistant, dry_run:)
|
|
original_config = assistant.config.dig(
|
|
Captain::AssistantMigration::DraftApplier::CONFIG_KEY,
|
|
Captain::AssistantMigration::DraftApplier::ORIGINAL_VALUES_KEY,
|
|
'config'
|
|
)
|
|
return skipped_result(nil, assistant.id, 'No stored migration original config found') if original_config.nil?
|
|
|
|
config, changes = restored_message_config(assistant.config.deep_dup, original_config)
|
|
assistant.update!(config: config) if !dry_run && changes.present?
|
|
|
|
{ assistant_id: assistant.id, dry_run: dry_run, changes: changes }
|
|
end
|
|
|
|
def restored_message_config(config, original_config)
|
|
changes = {}
|
|
%w[welcome_message handoff_message resolution_message].each do |key|
|
|
original_present = original_config.key?(key)
|
|
next if config[key] == original_config[key] && config.key?(key) == original_present
|
|
|
|
changes[key] = { from: config[key], to: original_config[key] }
|
|
original_present ? config[key] = original_config[key] : config.delete(key)
|
|
end
|
|
[config, changes]
|
|
end
|
|
|
|
def skipped_result(line_number, assistant_id, reason)
|
|
{
|
|
line_number: line_number,
|
|
assistant_id: assistant_id,
|
|
skipped: true,
|
|
reason: reason
|
|
}
|
|
end
|
|
|
|
def migration_eligible_scope(scope)
|
|
scope.left_outer_joins(:scenarios)
|
|
.joins(:captain_inboxes)
|
|
.where("NULLIF(captain_assistants.config->>'instructions', '') IS NOT NULL")
|
|
.where("captain_assistants.response_guidelines IS NULL OR captain_assistants.response_guidelines = '[]'::jsonb")
|
|
.where("captain_assistants.guardrails IS NULL OR captain_assistants.guardrails = '[]'::jsonb")
|
|
.where(captain_scenarios: { id: nil })
|
|
.distinct
|
|
end
|
|
|
|
def migration_candidate?(assistant)
|
|
assistant.config['instructions'].present? &&
|
|
assistant.captain_inboxes.size.positive? &&
|
|
Array(assistant.response_guidelines).blank? &&
|
|
Array(assistant.guardrails).blank? &&
|
|
!scenarios_exist?(assistant)
|
|
end
|
|
|
|
def scenarios_exist?(assistant)
|
|
scenarios = assistant.scenarios
|
|
return scenarios.exists? if scenarios.respond_to?(:exists?)
|
|
|
|
scenarios.present?
|
|
end
|
|
|
|
def csv_assistants # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
rows = CSV.read(ENV.fetch('CSV_INPUT'), headers: true)
|
|
ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence }
|
|
status = ENV.fetch('STATUS', '').presence
|
|
|
|
assistants = rows.filter_map do |row|
|
|
next if ids.any? && ids.exclude?(row['id'].to_s)
|
|
next if status.present? && row['status'].to_s != status
|
|
|
|
assistant = csv_assistant(row)
|
|
next unless migration_candidate?(assistant)
|
|
|
|
assistant
|
|
end
|
|
|
|
limit = ENV.fetch('LIMIT', 50).to_i
|
|
limit.positive? ? assistants.first(limit) : assistants
|
|
end
|
|
|
|
def csv_assistant(row)
|
|
config = parse_json(row['config'], fallback: {})
|
|
CsvAssistant.new(
|
|
id: normalize_integer(row['id']),
|
|
name: row['name'].to_s,
|
|
account_id: normalize_integer(row['account_id']),
|
|
account: CsvAccount.new(id: normalize_integer(row['account_id']), name: row['account_name'].to_s),
|
|
description: row['description'].to_s,
|
|
config: config,
|
|
response_guidelines: parse_json(row['response_guidelines'], fallback: []),
|
|
guardrails: parse_json(row['guardrails'], fallback: []),
|
|
captain_inboxes: CsvAssociation.new(inbox_count: normalize_integer(row['inbox_count'])),
|
|
scenarios: []
|
|
)
|
|
end
|
|
|
|
def parse_json(value, fallback:)
|
|
return fallback if value.blank?
|
|
|
|
JSON.parse(value)
|
|
rescue JSON::ParserError
|
|
fallback
|
|
end
|
|
|
|
def normalize_integer(value)
|
|
value.to_s.delete(',').to_i
|
|
end
|
|
end
|
|
end
|
|
# rubocop:enable Style/OneClassPerFile
|