Files
Aakash BakhleandGitHub 6d38b4d39c fix(captain): improve complex migration instructions (#15002)
Improves Captain V1 → V2 migration for complex legacy instructions so
mandatory triggers, workflows, language rules, and escalation behavior
remain active while query-dependent product knowledge is prepared as
pending FAQ candidates.

## What changed

- Added explicit preservation rules for mandatory triggers, verification
steps, escalation conditions, exceptions, and language behavior.
- Added an auditor that checks the draft and fixes any issues before
manual review.
- Kept the existing migration application contract and schema limits
unchanged
- Added focused regression coverage for the complex-prompt classifier
contract.

## How to reproduce

Generate a migration draft for an assistant with dense legacy
instructions containing mandatory handoff triggers, verification rules,
product facts, and multi-step workflows. The resulting draft should keep
actions active, place query-dependent facts in FAQ candidates, and avoid
silently dropping or reversing source requirements.

Focused Captain migration specs and RuboCop checks pass locally.
2026-07-15 14:51:51 +05:30

217 lines
6.2 KiB
Ruby

class Captain::AssistantMigration::DraftApplier
ASSISTANT_DESCRIPTION_LIMIT = 500
CONFIG_KEY = 'assistant_migration'.freeze
SCENARIO_DESCRIPTION_LIMIT = 500
ORIGINAL_VALUES_KEY = 'original_values'.freeze
pattr_initialize [:assistant!, :draft!, { dry_run: true }]
def perform
changes = build_changes
apply_changes(changes) unless dry_run
{
assistant_id: assistant.id,
dry_run: dry_run,
changes: changes
}
end
private
def build_changes
{
description: description_change,
response_guidelines: array_change(:response_guidelines, response_guidelines),
guardrails: array_change(:guardrails, guardrails),
config: config_change,
faq_responses: faq_responses_change
}.compact
end
def apply_changes(changes)
assistant.transaction do
assistant.update!(assistant_update_attributes(changes)) if assistant_update_attributes(changes).present?
apply_faq_response_changes(changes[:faq_responses]) if changes[:faq_responses].present?
end
end
def assistant_update_attributes(changes)
{}.tap do |attributes|
attributes[:description] = changes.dig(:description, :to) if changes[:description].present?
attributes[:response_guidelines] = changes.dig(:response_guidelines, :to) if changes[:response_guidelines].present?
attributes[:guardrails] = changes.dig(:guardrails, :to) if changes[:guardrails].present?
attributes[:config] = changes.dig(:config, :to) if changes[:config].present?
end
end
def description_change
value = assistant_description_value
return if value.blank? || value == assistant.description
{ from: assistant.description, to: value }
end
def assistant_description_value
value = item_values(:business_product_context).join(' ').presence
return if value.blank?
raise ArgumentError, "Assistant description exceeds #{ASSISTANT_DESCRIPTION_LIMIT} characters" if value.length > ASSISTANT_DESCRIPTION_LIMIT
value
end
def response_guidelines
(Array(assistant.response_guidelines) + item_values(:response_guidelines) + scenario_response_guidelines).uniq
end
def guardrails
(Array(assistant.guardrails) + item_values(:guardrails)).uniq
end
def array_change(field, values)
return if values.blank?
current = Array(assistant.public_send(field)).map(&:to_s)
return if current == values
{ from: current, to: values }
end
def config_change
updated_config = assistant.config.deep_dup
conversation_messages.each do |key, value|
next if value.blank?
next if updated_config[key].present?
updated_config[key] = value
end
updated_config[CONFIG_KEY] = migration_config
return if updated_config == assistant.config
{ from: assistant.config, to: updated_config }
end
def migration_config
existing_migration_config.merge(
ORIGINAL_VALUES_KEY => existing_original_values,
'scenario_candidates' => staged_scenario_candidates,
'faq_document_candidates' => normalized_faq_document_candidates,
'needs_review' => normalized_instruction_items(:needs_review)
)
end
def existing_migration_config
config = assistant.config[CONFIG_KEY]
config.is_a?(Hash) ? config : {}
end
def existing_original_values
existing_migration_config[ORIGINAL_VALUES_KEY].presence || original_values
end
def original_values
{
'name' => assistant.name,
'description' => assistant.description,
'config' => original_config,
'response_guidelines' => Array(assistant.response_guidelines),
'guardrails' => Array(assistant.guardrails)
}
end
def original_config
assistant.config.except(CONFIG_KEY)
end
def conversation_messages
messages = draft_hash.fetch(:conversation_messages, {})
messages = messages.deep_stringify_keys
{
'welcome_message' => messages['welcome_message'].to_s.strip,
'handoff_message' => messages['handoff_message'].to_s.strip,
'resolution_message' => messages['resolution_message'].to_s.strip
}
end
def staged_scenario_candidates
scenario_candidates.map do |candidate|
candidate.transform_keys(&:to_s)
end
end
def scenario_response_guidelines
scenario_candidates.filter_map { |candidate| candidate[:response_guideline].presence }
end
def faq_responses_change
faq_applier.changes
end
def apply_faq_response_changes(changes)
faq_applier.apply(changes)
end
def faq_applier
@faq_applier ||= Captain::AssistantMigration::FaqApplier.new(
assistant: assistant,
candidates: normalized_faq_document_candidates
)
end
def scenario_tool_ids(tool_ids)
Array(tool_ids).filter_map { |tool_id| tool_id.to_s.squish.presence }.uniq
end
def scenario_candidates
Array(draft_hash[:scenario_candidates]).filter_map do |candidate|
normalized_scenario_candidate(candidate)
end
end
def normalized_scenario_candidate(candidate)
return unless candidate.is_a?(Hash)
candidate = candidate.deep_symbolize_keys
normalized_candidate = {
title: candidate[:title].to_s.squish,
description: candidate[:description].to_s.squish.truncate(SCENARIO_DESCRIPTION_LIMIT),
instruction: candidate[:instruction].to_s.squish,
response_guideline: candidate[:response_guideline].to_s.squish,
tool_ids: scenario_tool_ids(candidate[:tool_ids])
}
return if normalized_candidate.values_at(:title, :description, :instruction).any?(&:blank?)
normalized_candidate
end
def item_values(key)
Array(draft_hash[key]).filter_map do |item|
item.to_s.squish.presence
end.uniq
end
def normalized_instruction_items(key)
item_values(key)
end
def normalized_faq_document_candidates
Array(draft_hash[:faq_document_candidates]).map do |candidate|
raise ArgumentError, 'FAQ document candidates must be question and answer objects' unless candidate.is_a?(Hash)
candidate = candidate.deep_symbolize_keys
question = candidate[:question].to_s.squish
answer = candidate[:answer].to_s.strip
raise ArgumentError, 'FAQ document candidates must include a question and answer' if question.blank? || answer.blank?
{ 'question' => question, 'answer' => answer }
end.uniq
end
def draft_hash
@draft_hash ||= draft.deep_symbolize_keys
end
end