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.
217 lines
6.2 KiB
Ruby
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
|