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.
194 lines
6.0 KiB
Ruby
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
|