diff --git a/enterprise/app/services/captain/assistant_migration/draft_applier.rb b/enterprise/app/services/captain/assistant_migration/draft_applier.rb new file mode 100644 index 000000000..df03624b4 --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/draft_applier.rb @@ -0,0 +1,199 @@ +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 + }.compact + end + + def apply_changes(changes) + assistant.transaction do + assistant.update!(assistant_update_attributes(changes)) if assistant_update_attributes(changes).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 + (item_values(:response_guidelines) + scenario_response_guidelines).uniq + end + + def guardrails + item_values(:guardrails) + 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 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.squish + 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 diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb new file mode 100644 index 000000000..989989855 --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb @@ -0,0 +1,148 @@ +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 + response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA) + return error_response(response) if response[:error] + + { + assistant: assistant_metadata, + draft: normalized_payload(response[:message]), + usage: response[:usage], + request_messages: response[: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: [], + classification_notes: [] + ) + end + + def assistant_metadata # rubocop:disable Metrics/AbcSize + { + id: assistant.id, + name: assistant.name, + 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 diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb new file mode 100644 index 000000000..3e42bb49d --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb @@ -0,0 +1,91 @@ +class Captain::AssistantMigration::InstructionClassifierSchema < RubyLLM::Schema + DESCRIPTION_LENGTH_LIMIT = 500 + + def self.instruction_items(field_name, description:, max_items: 20) + array field_name, + description: "#{description} Return plain standalone sentences without numbering, bullets, or section labels.", + max_items: max_items, + of: :string + end + + array :business_product_context, + description: "Single compact root assistant description for the root orchestrator prompt, maximum #{DESCRIPTION_LENGTH_LIMIT} characters: " \ + 'preserve the existing assistant description and enrich it only with relevant business/product context from the ' \ + 'custom instructions. Include assistant identity, product scope, high-level mission, and high-level source/routing ' \ + 'priorities only. Do not include workflows, procedures, attribute glossaries, policy details, or long inventories. ' \ + 'Return complete plain prose without numbering, bullets, section labels, or a truncated final sentence.', + min_items: 1, + max_items: 1 do + string max_length: DESCRIPTION_LENGTH_LIMIT + end + + instruction_items :response_guidelines, + description: 'Tone, language, answer length, formatting, and clarification behavior.', + max_items: 20 + + instruction_items :guardrails, + description: 'Refusal rules, escalation boundaries, source boundaries, safety limits, and things the assistant must not do.', + max_items: 20 + + array :scenario_candidates, + description: 'Review-stage specialized-agent candidates. These are also temporarily flattened into response guidelines.', + max_items: 15 do + object do + string :title, + description: 'Short scenario agent title for a distinct user-intent workflow.', + max_length: 80 + string :description, + description: 'When this specialized scenario should be used. This is shown to the orchestrator for routing.', + max_length: 500 + string :instruction, + description: 'How the specialized agent should handle the workflow. Include only evidence-backed markdown tool links. ' \ + 'Do not include confidence labels or review notes.', + max_length: 2000 + string :response_guideline, + description: 'Same-language, customer-visible response guideline that preserves this scenario behavior when flattened. ' \ + 'Do not include tool syntax, tool names, labels, private-note instructions, or internal implementation details.', + max_length: 1000 + array :tool_ids, + description: 'Available tool IDs explicitly referenced in instruction using markdown links. Empty when no tools are required.', + max_items: 10, + of: :string + end + end + + object :conversation_messages, description: 'Exact globally reusable customer-facing message copy found in instructions. ' \ + 'Leave empty for conditional, placeholder, or workflow-specific copy.' do + string :welcome_message, description: 'Exact globally reusable initial greeting copy from instructions, or empty string. ' \ + 'Do not convert an instruction about greeting into message copy.', + max_length: 1000 + string :handoff_message, + description: 'Exact globally reusable human-handoff message copy from instructions, or empty string. ' \ + 'Do not use scenario-specific, team-specific, placeholder, or conditional handoff copy.', + max_length: 1000 + string :resolution_message, + description: 'Exact globally reusable resolution/closing message copy from instructions, or empty string. ' \ + 'Do not use conditional or placeholder closing copy.', + max_length: 1000 + end + + array :faq_document_candidates, + description: 'Pending FAQ candidates for factual or product-specific knowledge such as pricing, policy, setup, troubleshooting, ' \ + 'or operational details. These candidates remain inactive until reviewed and approved.', + max_items: 25 do + object do + string :question, + description: 'Natural, standalone customer question about factual product or business knowledge.', + max_length: 255 + string :answer, + description: 'Self-contained factual answer using only the existing instructions. Do not include assistant behavior, ' \ + 'tool use, or message copy. Preserve exact values, conditions, and exceptions.', + max_length: 2000 + end + end + + instruction_items :needs_review, + description: 'Unclear, conflicting, risky, duplicated, or uncertain content that needs human review. ' \ + 'Include the reason in the item text.', + max_items: 20 + + array :classification_notes, description: 'Short notes about important migration decisions or risks.', max_items: 10, of: :string +end diff --git a/enterprise/lib/captain/prompts/instruction_classifier.liquid b/enterprise/lib/captain/prompts/instruction_classifier.liquid new file mode 100644 index 000000000..abc58ff60 --- /dev/null +++ b/enterprise/lib/captain/prompts/instruction_classifier.liquid @@ -0,0 +1,137 @@ +You are migrating Captain assistant instructions into a structured configuration. + +Classify the existing assistant instructions into these sections: +1. Business/Product Context +2. Response Guidelines +3. Guardrails +4. Scenario Candidates +5. Conversation Messages +6. FAQs/Documents Candidates +7. Needs Review + +## General Rules + +- Preserve behavior as closely as possible. +- Do not duplicate the same content across sections. +- Return clean migrated values only. Do not include source excerpts, source labels, citations, or "Source:" text in any migrated field. +- Do not rewrite customer-facing message copy unless necessary to classify an exact copy from instructions. +- Do not include confidence labels, review labels, bracketed reviewer comments, or schema labels inside migrated values. +- For Business/Product Context, Response Guidelines, and Guardrails, return each item as a plain standalone sentence. + Do not prefix items with numbers, bullets, section labels, or list markers such as "1.", "-", or "*". +- When several instructions share the same trigger, condition, or subject, combine them into one concise item instead + of repeating the same trigger across multiple items. Preserve every required action, prohibition, and routing + outcome from the source instruction when combining. +- If unsure, place content in Needs Review and include the reason in that item. +- Return data that matches the provided schema. + +## Business/Product Context + +- Business/Product Context maps to the root assistant description and is injected into the root orchestrator prompt. +- Return exactly one Business/Product Context item. +- Start with the existing assistant description and preserve its meaning. +- Enrich it only with relevant business or product context found in the custom instructions. +- Produce one coherent description rather than appending a second context block or repeating the existing description. +- Keep it at most 500 characters because that is the assistant description limit in the UI and model. +- Prefer roughly 300-450 characters when the source needs detail, leaving room below the hard limit. +- Finish the description cleanly. Never end mid-word, mid-clause, after an opening bracket, or with a dangling separator. +- Make it a compact summary of assistant identity, product scope, high-level mission, and high-level source or routing priorities. +- Do not include detailed workflows, step-by-step procedures, long support-scope inventories, attribute glossaries, + policy details, scenario-specific handling, tool instructions, or customer-facing message copy. + +## Conversation Messages + +- Existing welcome_message, handoff_message, and resolution_message config values are provided separately. +- Treat welcome_message, handoff_message, and resolution_message as conversation message config fields. +- Extract exact welcome, handoff, or resolution message copy from instructions into conversation_messages when present. +- Only classify handoff copy as conversation_messages.handoff_message when it is generic enough to reuse for any human handoff. +- If handoff copy is scenario-specific, keep it inside that scenario instruction; if it is only a rule about when or how to hand off, classify it as a Response Guideline or Guardrail. +- Do not extract a conversation message from an instruction about what to say, from a placeholder template, + from conditional copy, from role/team-specific copy, or from text that only applies inside one workflow. +- If a message contains placeholders such as a blank name, team name, bracketed variable, business-hours state, + or dynamic runtime condition, do not place it in conversation_messages. Keep it in the relevant workflow or + Needs Review. +- Do not copy message values from existing config into conversation_messages. +- Do not decide whether existing config values should be overwritten. Migration code handles applying extracted + conversation_messages only when the corresponding config value is blank. + +## Scenario Candidates + +- In the current architecture, a scenario becomes a specialized sub-agent with its own title, description, + instructions, and optional tools. +- During this migration, scenario candidates are also temporarily flattened into response guidelines so existing + assistant behavior is preserved before scenario records are created. +- For every scenario candidate, write a response_guideline that is the flattened version of that scenario for + the root assistant's response guidelines. +- The response_guideline must be in the same language as the original scenario or source instruction. +- The response_guideline must preserve the intended customer-visible behavior, trigger, information to collect, + and routing/escalation outcome. +- The response_guideline must not include tool syntax, tool:// links, markdown tool links, tool names, label + updates, priority updates, private-note instructions, custom-tool instructions, or internal implementation details. +- If the scenario uses internal tools such as labels, priorities, private notes, or custom tools, describe only + the customer-visible behavior and expected routing/escalation outcome in response_guideline. +- If human handoff is needed, describe it in natural language such as route/escalate/transfer to a human; do not + mention the handoff tool in response_guideline. +- Keep scenario titles, descriptions, instructions, and response_guidelines clear, self-contained, and reviewable. +- Only create scenario candidates for distinct user-intent workflows that should be routed to a specialized agent. + A candidate must be narrow enough to become a named specialist assistant with domain-specific handling instructions. +- Good scenario candidates include multi-step intake workflows, qualification flows, specialized troubleshooting + workflows, booking flows, lead-capture flows, recommendation flows, fulfillment workflows, or tool-use procedures + for a specific user intent. +- A scenario candidate should answer "yes" to this test: would a named specialist sub-agent improve handling + beyond the base assistant's global FAQ, guardrail, response-guideline, and human-handoff behavior? +- Do not create scenario candidates for global escalation rules, generic handoff policy, missing-information + behavior, source-boundary rules, refusal rules, tone, formatting, answer length, or one-step fallback behavior. +- Do not create scenario candidates whose main purpose is to escalate or hand off. "Identify the trigger, avoid + guessing, tell the user support will review, and hand off" is a guardrail/handoff boundary, not a scenario, + even though it contains multiple statements. +- Do create scenario candidates when the instructions define a concrete intake, qualification, troubleshooting, + booking, lead-capture, recommendation, or fulfillment workflow, even when the workflow eventually hands off + to a human. +- Do not create scenario candidates for simple routing triggers such as "user asks for a human", "immediately + hand off this category", or "route sales questions to the sales team" when there is no concrete workflow to run. +- Handoff behavior is a scenario candidate only when part of a larger intake, qualification, or specialized handling workflow. +- Global rules like "if not in docs, escalate", "ask one clarifying question", "do not answer account-specific + questions", or "tell the user support will review" belong in Guardrails or Response Guidelines, not Scenario Candidates. +- Broad buckets like "account-specific issue escalation", "unknown question escalation", "contact support", + "fallback to human", or "documentation unavailable" are not scenario candidates. + +## Tool Use + +- If a scenario candidate requires tools, reference the available tool explicitly inside the scenario instruction + using markdown tool links such as [Handoff to Human](tool://handoff). +- Use only tool IDs listed in available_agent_tools. If a needed tool is unavailable or the workflow depends on + unavailable runtime data such as FAQ relevance scores or business-hours status, place it in Needs Review instead. +- Do not map an unavailable named tool to a different available tool. For example, do not treat FAQ Lookup as + Product Search, Order Status, website browsing, pricing lookup, agent availability, business-hours detection, + ticket creation, or custom-attribute assignment unless the instructions explicitly say that the available + tool provides that behavior. +- If a workflow cannot run without an unavailable tool or runtime signal, do not create a tool-backed scenario + for it. Preserve the instruction in Needs Review with the missing capability named. + +## FAQs/Documents Candidates + +- Convert factual or product-specific knowledge into pending FAQ candidates with a natural customer question and a self-contained answer. +- FAQ candidates are review-stage data only. They are not active assistant knowledge until a human reviews and approves them. +- Use only facts stated in the existing instructions. Do not invent, generalize, update, or fill in missing details. +- Preserve exact prices, limits, dates, time zones, conditions, exceptions, product names, and operational details in the answer. +- Write each question as a standalone question a customer might naturally ask. Make it specific enough to retrieve the corresponding answer. +- Write each answer so it fully answers its question without relying on another FAQ candidate or surrounding context. +- Split unrelated facts into separate candidates. Keep related conditions and exceptions together when separating them would make an answer incomplete. +- Do not create FAQ candidates about what the assistant should say or do, how it should use sources or tools, when it should route or escalate, + or which exact message it should send. Classify those as Response Guidelines, Guardrails, Scenario Candidates, Conversation Messages, + or Needs Review as appropriate. +- FAQ questions must ask about the product or business, not about the assistant. Do not write questions such as "What should the assistant answer?", + "What should I say?", "Which source should the assistant use?", or "Which tool should be called?". +- FAQ answers must contain customer-facing knowledge, not instructions to call tools, inspect internal data, update records, transfer conversations, + or follow internal workflows. +- When factual sources conflict and the instructions do not explicitly establish which fact overrides the others, put the conflict in Needs Review + instead of creating an FAQ candidate. Use an explicitly stated override or superseding fact when one is present. +- Only factual or product-specific knowledge should become FAQs/Documents candidates. +- Generic capability statements such as "answer product questions", "help with billing", + "troubleshoot common issues", or "direct to documentation" are not FAQ/document candidates. + Put them in Business/Product Context or Response Guidelines when useful. +- Product facts, pricing, policies, setup steps, troubleshooting facts, support hours, emergency contacts, + and operational details should become pending FAQ candidates, not Response Guidelines or trusted approved knowledge. +- Do not create FAQ/document candidates for topic labels or unsupported capabilities when the factual content is + missing. Put "pricing details are needed", "same-day delivery schedule details are needed", or similar gaps in + Needs Review instead. diff --git a/lib/tasks/captain_assistant_migration.rake b/lib/tasks/captain_assistant_migration.rake new file mode 100644 index 000000000..7cb8c2e1c --- /dev/null +++ b/lib/tasks/captain_assistant_migration.rake @@ -0,0 +1,278 @@ +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 diff --git a/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb new file mode 100644 index 000000000..0e2f420ac --- /dev/null +++ b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe Captain::AssistantMigration::DraftApplier do + let(:account) { create(:account) } + let(:assistant) do + create( + :captain_assistant, + account: account, + config: { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' }, + response_guidelines: [], + guardrails: [] + ) + end + let(:scenario_candidate) do + { + 'title' => 'Billing Investigation', + 'description' => 'Use when a customer reports an account-specific billing issue.', + 'instruction' => 'Collect the invoice number and summarize the issue before escalating.', + 'response_guideline' => 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.', + 'tool_ids' => [] + } + end + let(:faq_document_candidate) do + { + 'question' => 'When is support available?', + 'answer' => 'Support is available Monday to Friday.' + } + end + let(:draft) do + { + business_product_context: ['Support assistant for Test Product.'], + response_guidelines: ['Be concise.'], + guardrails: ['Do not guess.'], + conversation_messages: {}, + scenario_candidates: [scenario_candidate], + faq_document_candidates: [faq_document_candidate], + needs_review: ['Pricing details are missing because factual details are absent from the source instructions.'] + } + end + + describe '#perform' do + it 'reports staged scenario candidates in dry run without writing to the assistant' do + result = described_class.new(assistant: assistant, draft: draft, dry_run: true).perform + + expect(result.dig(:changes, :config, :to, 'assistant_migration', 'scenario_candidates')).to eq([scenario_candidate]) + expect(result.dig(:changes, :response_guidelines, :to)).to include( + 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' + ) + expect(assistant.reload.config).not_to have_key('assistant_migration') + expect(assistant.scenarios.count).to eq(0) + end + + it 'stores scenario candidates in assistant config and flattens them into response guidelines' do + described_class.new(assistant: assistant, draft: draft, dry_run: false).perform + + assistant.reload + expect(assistant.config.dig('assistant_migration', 'scenario_candidates')).to eq([scenario_candidate]) + expect(assistant.config.dig('assistant_migration', 'faq_document_candidates')).to contain_exactly(faq_document_candidate) + expect(assistant.config.dig('assistant_migration', 'needs_review')).to contain_exactly( + 'Pricing details are missing because factual details are absent from the source instructions.' + ) + expect(assistant.response_guidelines).to include( + 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' + ) + expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer']) + expect(assistant.scenarios.count).to eq(0) + end + + it 'rejects stale drafts whose FAQ candidates use the old string format' do + stale_draft = draft.merge(faq_document_candidates: ['Support is available Monday to Friday.']) + + expect do + described_class.new(assistant: assistant, draft: stale_draft, dry_run: false).perform + end.to raise_error(ArgumentError, 'FAQ document candidates must be question and answer objects') + + expect(assistant.reload.config).not_to have_key('assistant_migration') + end + + it 'preserves original values in migration config before applying classifier output' do + assistant.update!( + description: 'Existing assistant description.', + response_guidelines: ['Use plain language.'], + guardrails: ['Do not disclose internal notes.'] + ) + + described_class.new(assistant: assistant, draft: draft, dry_run: false).perform + + assistant.reload + expect(assistant.description).to eq('Support assistant for Test Product.') + expect(assistant.response_guidelines).to include('Be concise.') + expect(assistant.guardrails).to eq(['Do not guess.']) + expect(assistant.config.dig('assistant_migration', 'original_values')).to include( + 'name' => assistant.name, + 'description' => 'Existing assistant description.', + 'config' => { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' }, + 'response_guidelines' => ['Use plain language.'], + 'guardrails' => ['Do not disclose internal notes.'] + ) + end + + it 'rejects an oversized assistant description from a stale draft' do + long_context = 'This assistant supports a very broad product surface with many long details. ' * 10 + original_description = assistant.description + + expect do + described_class.new( + assistant: assistant, + draft: draft.merge(business_product_context: [long_context]), + dry_run: false + ).perform + end.to raise_error(ArgumentError, 'Assistant description exceeds 500 characters') + + expect(assistant.reload.description).to eq(original_description) + end + end +end