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

194 lines
6.0 KiB
Ruby

class Captain::AssistantMigration::InstructionClassifier < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::AssistantMigration::InstructionClassifierSchema
CLASSIFIER_MODEL = 'gpt-5.2'.freeze
MAX_INSTRUCTIONS_LENGTH = 20_000
pattr_initialize [:assistant!]
def perform
classifier_response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
return error_response(classifier_response) if classifier_response[:error]
generated_draft = normalized_payload(classifier_response[:message])
auditor_response = Captain::AssistantMigration::InstructionAuditor.new(
assistant: assistant,
source_payload: assistant_payload,
draft: generated_draft,
available_additions: available_additions(generated_draft)
).perform
return error_response(auditor_response) if auditor_response[:error]
{
assistant: assistant_metadata,
draft: audited_payload(generated_draft, auditor_response[:message]),
usage: combined_usage(classifier_response, auditor_response),
request_messages: classifier_response[:request_messages]
}
rescue ArgumentError => e
error_response(error: e.message, request_messages: auditor_response&.dig(:request_messages))
end
private
def account
assistant.account
end
def messages
[
{ role: 'system', content: system_prompt },
{ role: 'user', content: user_prompt }
]
end
def system_prompt
Captain::PromptRenderer.render('instruction_classifier')
end
def user_prompt
JSON.pretty_generate(assistant_payload)
end
def assistant_payload # rubocop:disable Metrics/AbcSize
{
assistant_id: assistant.id,
account_id: assistant.account_id,
account_name: assistant.account.name,
name: assistant.name,
description: assistant.description,
product_name: assistant.config['product_name'],
instructions: truncated_instructions,
welcome_message: assistant.config['welcome_message'],
handoff_message: assistant.config['handoff_message'],
resolution_message: assistant.config['resolution_message'],
existing_response_guidelines: assistant.response_guidelines || [],
existing_guardrails: assistant.guardrails || [],
existing_scenarios: existing_scenarios,
available_agent_tools: available_agent_tools,
feature_settings: feature_settings
}
end
def truncated_instructions
instructions = assistant.config['instructions'].to_s
return instructions if instructions.length <= MAX_INSTRUCTIONS_LENGTH
"#{instructions.first(MAX_INSTRUCTIONS_LENGTH)}\n\n[TRUNCATED]"
end
def existing_scenarios
assistant.scenarios.map do |scenario|
{
id: scenario.id,
title: scenario.title,
description: scenario.description,
instruction: scenario.instruction,
enabled: scenario.enabled
}
end
end
def available_agent_tools
tools = assistant.respond_to?(:available_agent_tools) ? assistant.available_agent_tools : Captain::Assistant.built_in_agent_tools
tools.map { |tool| tool.slice(:id, :title, :description) }
end
def feature_settings
assistant.config.slice(
'feature_faq',
'feature_memory',
'feature_citation',
'feature_contact_attributes',
'temperature'
)
end
def normalized_payload(message)
payload = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
payload.reverse_merge(
business_product_context: [],
response_guidelines: [],
guardrails: [],
scenario_candidates: [],
conversation_messages: {},
faq_document_candidates: [],
needs_review: []
)
end
def combined_usage(*responses)
%w[prompt_tokens completion_tokens total_tokens].index_with do |key|
responses.sum { |response| response.dig(:usage, key).to_i }
end
end
def available_additions(draft)
{
response_guidelines: 20 - draft[:response_guidelines].length,
guardrails: 20 - draft[:guardrails].length,
scenario_candidates: 15 - draft[:scenario_candidates].length,
faq_document_candidates: 25 - draft[:faq_document_candidates].length,
needs_review: 20 - draft[:needs_review].length
}
end
def audited_payload(generated_draft, audit_message)
audit = audit_message.is_a?(Hash) ? audit_message.deep_symbolize_keys : {}
generated_draft.merge(
response_guidelines: merged_items(generated_draft, audit, :response_guidelines, 20),
guardrails: merged_items(generated_draft, audit, :guardrails, 20),
scenario_candidates: merged_items(generated_draft, audit, :scenario_candidates, 15),
faq_document_candidates: merged_items(generated_draft, audit, :faq_document_candidates, 25),
needs_review: merged_items(generated_draft, audit, :needs_review, 20)
)
end
def merged_items(generated_draft, audit, key, limit)
items = (Array(generated_draft[key]) + Array(audit[key])).uniq
raise ArgumentError, "Audited #{key} exceeds #{limit} items" if items.length > limit
items
end
def assistant_metadata # rubocop:disable Metrics/AbcSize
{
id: assistant.id,
name: assistant.name,
description: assistant.description.to_s,
account_id: assistant.account_id,
account_name: assistant.account.name,
inbox_count: assistant.captain_inboxes.size,
instruction_length: assistant.config['instructions'].to_s.length,
original_instructions: assistant.config['instructions'].to_s,
welcome_message: assistant.config['welcome_message'].to_s,
handoff_message: assistant.config['handoff_message'].to_s,
resolution_message: assistant.config['resolution_message'].to_s
}
end
def error_response(response)
{
assistant: assistant_metadata,
error: response[:error],
error_code: response[:error_code],
request_messages: response[:request_messages]
}
end
def event_name
'assistant_migration_instruction_classifier'
end
def captain_tasks_enabled?
true
end
def counts_toward_usage?
false
end
def build_follow_up_context?
false
end
end