From 7b009cf47fa2cc9685982ce6b41f38b026e99a12 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 15 Apr 2026 09:25:16 -0300 Subject: [PATCH] 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 --- enterprise/app/models/concerns/agentable.rb | 52 ++++++++++++++- .../lib/captain/prompts/concierge.liquid | 15 +++++ .../captain/prompts/snippets/concierge.liquid | 9 +++ .../concerns/agentable_concierge_spec.rb | 63 +++++++++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 enterprise/lib/captain/prompts/concierge.liquid create mode 100644 enterprise/lib/captain/prompts/snippets/concierge.liquid create mode 100644 spec/enterprise/models/concerns/agentable_concierge_spec.rb diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb index 2ee0f3ad9..a8824dd9f 100644 --- a/enterprise/app/models/concerns/agentable.rb +++ b/enterprise/app/models/concerns/agentable.rb @@ -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 diff --git a/enterprise/lib/captain/prompts/concierge.liquid b/enterprise/lib/captain/prompts/concierge.liquid new file mode 100644 index 000000000..0b8f5cd96 --- /dev/null +++ b/enterprise/lib/captain/prompts/concierge.liquid @@ -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. diff --git a/enterprise/lib/captain/prompts/snippets/concierge.liquid b/enterprise/lib/captain/prompts/snippets/concierge.liquid new file mode 100644 index 000000000..11745acc8 --- /dev/null +++ b/enterprise/lib/captain/prompts/snippets/concierge.liquid @@ -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 %} diff --git a/spec/enterprise/models/concerns/agentable_concierge_spec.rb b/spec/enterprise/models/concerns/agentable_concierge_spec.rb new file mode 100644 index 000000000..9ee804a29 --- /dev/null +++ b/spec/enterprise/models/concerns/agentable_concierge_spec.rb @@ -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