Resolve duas camadas de problema identificadas em teste end-to-end: 1. Embeddings falhavam com HTTP 404 (/codex/v1/embeddings não existe). Solução: Captain::Llm::EmbeddingService sempre usa OpenAI tradicional via Llm::Config.with_api_key(legacy_settings). ProviderConfig expõe legacy_openai_settings pra isso. 2. Servidor Codex ocasionalmente responde com response.failed + code=server_error (instabilidade transitória). Client agora retenta até 2x com backoff exponencial (0.5s, 1.5s) em erros retryable: HTTP 5xx, server_error no response.failed, ou stream inacabado. Outras correções nesta etapa: - Scenario#agent_model: em modo Codex, ignora CAPTAIN_OPEN_AI_MODEL_SCENARIO (que pode ter gpt-4o legado) e usa ProviderConfig.model. - ExtractionService/ContradictionCheckerService/TranslateQueryService: trocam constantes hardcoded gpt-4o-mini/gpt-4.1-nano por ProviderConfig.light_model (respeitando o provider ativo). - ProviderConfig.DEFAULT_CODEX_MODEL agora é gpt-5.2 (reconhecido pelo RubyLLM; gpt-5.4 não está no catalog do gem). Validado ponta-a-ponta: WhatsApp → Chatwoot → Jasmine → handoff Daniela → faq_lookup com embedding OK → resposta com preços corretos. Docs em docs/captain-codex-oauth.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
5.4 KiB
Ruby
167 lines
5.4 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: captain_scenarios
|
|
#
|
|
# id :bigint not null, primary key
|
|
# description :text
|
|
# enabled :boolean default(TRUE), not null
|
|
# fallback_message :text
|
|
# instruction :text
|
|
# title :string
|
|
# tools :jsonb
|
|
# trigger_keywords :text
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :bigint not null
|
|
# assistant_id :bigint not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_captain_scenarios_on_account_id (account_id)
|
|
# index_captain_scenarios_on_assistant_id (assistant_id)
|
|
# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled)
|
|
# index_captain_scenarios_on_enabled (enabled)
|
|
#
|
|
class Captain::Scenario < ApplicationRecord
|
|
include Concerns::CaptainToolsHelpers
|
|
include Concerns::Agentable
|
|
|
|
DEFAULT_TOOL_IDS = %w[faq_lookup].freeze
|
|
|
|
self.table_name = 'captain_scenarios'
|
|
|
|
belongs_to :assistant, class_name: 'Captain::Assistant'
|
|
belongs_to :account
|
|
|
|
validates :title, presence: true
|
|
validates :description, presence: true
|
|
validates :instruction, presence: true
|
|
validates :assistant_id, presence: true
|
|
validates :account_id, presence: true
|
|
validate :validate_instruction_tools
|
|
|
|
scope :enabled, -> { where(enabled: true) }
|
|
|
|
delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant
|
|
|
|
before_save :resolve_tool_references
|
|
|
|
def prompt_context
|
|
{
|
|
title: title,
|
|
instructions: resolved_instructions,
|
|
tools: resolved_tools,
|
|
assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
|
|
current_date: Time.current.in_time_zone('Brasilia').strftime('%d/%m/%Y'),
|
|
current_time: Time.current.in_time_zone('Brasilia').strftime('%H:%M'),
|
|
current_timezone: 'Horário de Brasília (BRT/BRST)',
|
|
response_guidelines: response_guidelines || [],
|
|
guardrails: guardrails || []
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def agent_name
|
|
"#{title} Agent".parameterize(separator: '_')
|
|
end
|
|
|
|
# Scenarios can use a different model than the orchestrator (Assistant).
|
|
# Rationale: orchestrator does simple routing (cheap model suffices),
|
|
# scenarios handle complex flows (tool calling, strict rules) and benefit
|
|
# from a stronger model. Falls back to the global CAPTAIN_OPEN_AI_MODEL
|
|
# (used by the orchestrator) when SCENARIO-specific override is unset.
|
|
def agent_model
|
|
# Em modo Codex OAuth, ignora CAPTAIN_OPEN_AI_MODEL_SCENARIO (pode ter modelo
|
|
# legado como gpt-4o que o Codex rejeita) e usa o modelo padrão do provider.
|
|
return Captain::Llm::ProviderConfig.model if Captain::Llm::ProviderConfig.codex_oauth?
|
|
|
|
scenario_model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL_SCENARIO')&.value.presence
|
|
scenario_model || super
|
|
end
|
|
|
|
def agent_tools
|
|
resolved_tools.map { |tool| resolve_tool_instance(tool) }
|
|
end
|
|
|
|
def resolved_instructions
|
|
instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool')
|
|
end
|
|
|
|
def resolved_tools
|
|
available_tools = assistant.available_agent_tools
|
|
tool_ids = (Array(tools) + default_tool_ids).uniq
|
|
|
|
tool_ids.filter_map do |tool_id|
|
|
available_tools.find { |tool| tool[:id] == tool_id }
|
|
end
|
|
end
|
|
|
|
def default_tool_ids
|
|
return [] unless ActiveModel::Type::Boolean.new.cast(feature_faq)
|
|
|
|
DEFAULT_TOOL_IDS
|
|
end
|
|
|
|
def resolve_tool_instance(tool_metadata)
|
|
tool_id = tool_metadata[:id]
|
|
|
|
if tool_metadata[:custom]
|
|
custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
|
|
custom_tool&.tool(assistant)
|
|
else
|
|
tool_class = self.class.resolve_tool_class(tool_id)
|
|
tool_class&.new(assistant)
|
|
end
|
|
end
|
|
|
|
# Validates that all tool references in the instruction are valid.
|
|
# Parses the instruction for tool references and checks if they exist
|
|
# in the available tools configuration.
|
|
#
|
|
# @return [void]
|
|
# @api private
|
|
# @example Valid instruction
|
|
# scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document"
|
|
# scenario.valid? # => true
|
|
#
|
|
# @example Invalid instruction
|
|
# scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process"
|
|
# scenario.valid? # => false
|
|
# scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"]
|
|
def validate_instruction_tools
|
|
return if instruction.blank?
|
|
|
|
tool_ids = extract_tool_ids_from_text(instruction)
|
|
return if tool_ids.empty?
|
|
|
|
all_available_tool_ids = assistant.available_tool_ids
|
|
invalid_tools = tool_ids - all_available_tool_ids
|
|
|
|
return unless invalid_tools.any?
|
|
|
|
errors.add(:instruction, "contains invalid tools: #{invalid_tools.join(', ')}")
|
|
end
|
|
|
|
# Resolves tool references from the instruction text into the tools field.
|
|
# Parses the instruction for tool references and materializes them as
|
|
# tool IDs stored in the tools JSONB field.
|
|
#
|
|
# @return [void]
|
|
# @api private
|
|
# @example
|
|
# scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)"
|
|
# scenario.save!
|
|
# scenario.tools # => ["add_private_note", "update_priority"]
|
|
#
|
|
# scenario.instruction = "No tools mentioned here"
|
|
# scenario.save!
|
|
# scenario.tools # => nil
|
|
def resolve_tool_references
|
|
return if instruction.blank?
|
|
|
|
tool_ids = extract_tool_ids_from_text(instruction)
|
|
self.tools = tool_ids.presence
|
|
end
|
|
end
|