feat(lifecycle): inject concierge context into Captain orchestrator prompt

Adds concierge.* and reservation.* Liquid variables to agent_instructions
so Sofia's orchestrator_prompt receives unit persona/knowledge/variables
and reservation data resolved from conversation.custom_attributes.current_unit_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-15 09:25:16 -03:00
parent d0d08ed662
commit 7b009cf47f
4 changed files with 138 additions and 1 deletions

View File

@ -21,7 +21,9 @@ module Concerns::Agentable
contact_data = state[:contact] || {}
enhanced_context = enhanced_context.merge(
conversation: conversation_data,
contact: contact_data
contact: contact_data,
concierge: resolve_concierge_context(conversation_data),
reservation: resolve_reservation_context(conversation_data)
)
end
@ -68,4 +70,52 @@ module Concerns::Agentable
def prompt_context
raise NotImplementedError, "#{self.class} must implement prompt_context"
end
def resolve_concierge_context(conversation_data)
unit = resolve_current_unit(conversation_data)
return default_concierge_context if unit.blank?
{
'persona_name' => unit.concierge_persona_name,
'unit_name' => unit.name.to_s,
'knowledge' => unit.concierge_knowledge,
'variables' => unit.concierge_variables
}
end
def resolve_reservation_context(conversation_data)
conv = lookup_conversation(conversation_data)
return {} if conv.blank?
reservation = Captain::Reservation
.where(account_id: conv.account_id, contact_id: conv.contact_id)
.order(created_at: :desc)
.first
return {} if reservation.blank?
Captain::Lifecycle::ContextBuilder.build(reservation).fetch('reservation', {})
end
def resolve_current_unit(conversation_data)
conv = lookup_conversation(conversation_data)
return nil if conv.blank?
unit_id = conv.custom_attributes.to_h['current_unit_id']
return nil if unit_id.blank?
Captain::Unit.find_by(id: unit_id)
end
def lookup_conversation(conversation_data)
return nil if conversation_data.blank?
id = conversation_data.is_a?(Hash) ? (conversation_data[:id] || conversation_data['id']) : nil
return nil if id.blank?
::Conversation.find_by(id: id)
end
def default_concierge_context
{ 'persona_name' => 'Sofia', 'unit_name' => '', 'knowledge' => '', 'variables' => {} }
end
end

View File

@ -0,0 +1,15 @@
Você é {{ concierge.persona_name }}, assistente virtual do {{ concierge.unit_name }}.
{% render 'concierge' %}
## Dados da Estadia Atual
- Suíte: {{ reservation.suite }}
- Check-in: {{ reservation.check_in_at }}
- Check-out: {{ reservation.check_out_at }}
## Como se comportar
- Seja cordial, prestativo e profissional.
- Identifique-se como IA quando questionado diretamente.
- Transfira para um atendente humano se a solicitação exigir ação que não pode ser realizada aqui.
- Não compartilhe informações confidenciais de outros hóspedes.
- Limite respostas ao escopo do hotel e da estadia atual.

View File

@ -0,0 +1,9 @@
# Concierge Context (unit: {{ concierge.unit_name }})
Persona: {{ concierge.persona_name }}
## Base de Conhecimento
{{ concierge.knowledge }}
## Variáveis Disponíveis
{% for pair in concierge.variables %}- {{ pair[0] }}: {{ pair[1] }}
{% endfor %}

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Concerns::Agentable, type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:brand) { create(:captain_brand, account: account) }
let(:unit) do
Captain::Unit.create!(
account: account,
brand: brand,
name: 'Prime Águas Lindas',
concierge_config: {
'persona_name' => 'Sofia',
'knowledge' => 'Hotel com café às 7h',
'variables' => { 'wifi_password' => 'abc123' }
}
)
end
let(:contact) { create(:contact, account: account, name: 'João Silva') }
let(:conversation) do
create(:conversation,
account: account, inbox: inbox, contact: contact,
custom_attributes: { 'current_unit_id' => unit.id })
end
let(:assistant) do
create(:captain_assistant,
account: account,
orchestrator_prompt: '{{ concierge.persona_name }} in {{ concierge.unit_name }} knows: {{ concierge.knowledge }}')
end
def mock_context_for(conv)
state = { conversation: { id: conv.id } }.with_indifferent_access
ctx = instance_double(Agents::RunContext)
allow(ctx).to receive(:context).and_return({ state: state })
ctx
end
describe '#agent_instructions concierge injection' do
it 'resolves concierge context from current_unit_id' do
ctx = mock_context_for(conversation)
rendered = assistant.agent_instructions(ctx)
expect(rendered).to include('Sofia in Prime Águas Lindas')
expect(rendered).to include('Hotel com café às 7h')
end
it 'falls back to empty strings when current_unit_id missing' do
conversation.update!(custom_attributes: {})
ctx = mock_context_for(conversation)
rendered = assistant.agent_instructions(ctx)
expect(rendered).to be_a(String)
end
it 'falls back gracefully when conversation id is nil' do
state = { conversation: {} }.with_indifferent_access
ctx = instance_double(Agents::RunContext)
allow(ctx).to receive(:context).and_return({ state: state })
rendered = assistant.agent_instructions(ctx)
expect(rendered).to be_a(String)
end
end
end