Files
chatwoot/enterprise/app/services/captain/assistant_migration/draft_applier.rb
8fc5c7a5c8 feat: add captain general guidelines migration helpers (#14909)
This PR adds internal tooling and planning docs for migrating existing
Captain assistant instructions into the new General Guidelines
structure.


**Summary**

This PR adds a controlled migration path for moving existing Captain V1
assistant instructions into the structured Captain architecture.

It introduces a classifier that reads the current `config.instructions`
and produces reviewed migration drafts with separate sections for:

- assistant description / business context
- response guidelines
- guardrails
- scenario candidates
- conversation messages
- FAQ/document candidates
- needs-review items

The migration is intentionally staged. It only targets V1-style
assistants that still have custom instructions, are connected to
inboxes, and do not already have structured response guidelines,
guardrails, or scenario records.

When applied, the task writes the extracted business context to the
assistant description, response guidelines to `response_guidelines`,
guardrails to `guardrails`, and stores scenario candidates / FAQ
candidates / review notes under `config["assistant_migration"]`.
Scenario candidates are also flattened into response guidelines for now
so customer behavior is preserved before we create real
`Captain::Scenario` records in a later rollout.

The applier stores the original assistant values under migration
metadata so conversation message config can be restored if needed. It
does not create scenario records yet.

**How to generate drafts**

For specific assistant IDs:

```bash
bundle exec rake captain:assistant_migration:generate \
  IDS=546,636,819 \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For the first 50 eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

For all eligible assistants:

```bash
bundle exec rake captain:assistant_migration:generate \
  LIMIT=0 \
  OUTPUT=tmp/captain_migration_drafts.jsonl
```

**How to apply drafts**

Dry run first:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=true
```

Apply changes:

```bash
bundle exec rake captain:assistant_migration:apply \
  INPUT=tmp/captain_migration_drafts.jsonl \
  DRY_RUN=false
```

**How to restore conversation messages**

If extracted `welcome_message`, `handoff_message`, or
`resolution_message` need to be reverted to their pre-migration values:

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=true
```

```bash
bundle exec rake captain:assistant_migration:restore_messages \
  IDS=546,636,819 \
  DRY_RUN=false
```

**Notes**

- `LIMIT=0` means no limit.
- `generate` overwrites the output file.
- The apply task skips assistants that are no longer V1 migration
candidates.
- This PR does not create `Captain::Scenario` records; scenario
candidates are staged in assistant config for a future migration.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-07-13 14:56:09 +05:30

200 lines
5.7 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
}.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