Files
chatwoot/enterprise/app/services/captain/assistant_migration/instruction_classifier.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

149 lines
4.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
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