fix(captain/mcp): generate_pix prefere assistant.captain_unit + 3 checks runtime

resolve_unit agora prioriza Captain::Assistant.captain_unit_id sobre o
mapping legado CaptainInbox (que falha quando 2 agentes — interno e
Hermes — compartilham a mesma inbox).

Caso real: Juliana Hermes (unit Qnn01) compartilhava inbox 1 com Juliana
captain_interno (unit Recanto), mas o CaptainInbox da inbox 1 estava
mapeado pra unit Dolce Amore (id=4) por contaminação anterior. Tool
resolvia unit errada, generate_pix retornava "categoria não reconhecida"
e o agente travava em " Um momento — vou verificar." sem retomar.

bin/hermes-validate ganha 3 checks novos:
- CaptainInbox.unit == Assistant.unit (FAIL — exatamente o bug acima)
- Pricing dry-run (calcula preço da 1ª categoria sem erro)
- Credenciais Inter completas (WARN se faltar cert/key — cai no fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-02 15:09:04 -03:00
parent 9cfd131dcf
commit 47f32b540b
2 changed files with 37 additions and 3 deletions

View File

@ -59,6 +59,17 @@ DB_DUMP=$(docker exec "$CID" bundle exec rails runner "
hum = asst.config['response_delay']
cats = unit&.pricing_categories&.includes(:amounts)&.to_a || []
galleria = unit&.gallery_items&.count || 0
ci_unit_id = ci&.captain_unit_id
inter_ok = unit && unit.respond_to?(:inter_credentials_present?) ? unit.inter_credentials_present? : false
pricing_dry_run = nil
if unit && cats.any?
first_cat_key = cats.first.key
res = Captain::Mcp::PricingTables.calculate(
unit_id: unit.id, suite_category: first_cat_key,
period: 'pernoite_promo', total_guests: 2
)
pricing_dry_run = res[:error] ? \"ERR: #{res[:error]}\" : \"OK R$ #{res[:amount]} (#{first_cat_key}/pernoite)\"
end
out = {
assistant_id: asst.id,
name: asst.name,
@ -81,7 +92,10 @@ DB_DUMP=$(docker exec "$CID" bundle exec rails runner "
gallery_count: galleria,
enabled_for: inbox ? Captain::Hermes.enabled_for?(inbox) : nil,
webhook_url: inbox ? Captain::Hermes.webhook_url_for(inbox) : nil,
secret_via: inbox ? Captain::Hermes.subscription_signing_secret(inbox)&.first(8) : nil
secret_via: inbox ? Captain::Hermes.subscription_signing_secret(inbox)&.first(8) : nil,
ci_unit_id: ci_unit_id,
inter_ok: inter_ok,
pricing_dry_run: pricing_dry_run
}
puts out.to_json
" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
@ -114,9 +128,16 @@ check "hermes_subscription_secret setado" "$([[ $(echo "$DB_DUMP" | jq -r '.secr
check "hermes_webhook_base_url" "$([[ $(echo "$DB_DUMP" | jq -r '.base_url') =~ ^http ]] && echo PASS || echo FAIL)"
check "parent_assistant_id setado" "$([[ "$PARENT_ID" != 'null' && "$PARENT_ID" != '' ]] && echo PASS || echo WARN)" "parent=$PARENT_ID"
check "captain_unit_id setado" "$([[ $(echo "$DB_DUMP" | jq -r '.unit_id') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.unit_name')"
ASSIST_UNIT=$(echo "$DB_DUMP" | jq -r '.unit_id // ""')
CI_UNIT=$(echo "$DB_DUMP" | jq -r '.ci_unit_id // ""')
check "CaptainInbox.unit == Assistant.unit (sem divergência)" "$([[ -n "$ASSIST_UNIT" && "$ASSIST_UNIT" == "$CI_UNIT" ]] && echo PASS || echo FAIL)" "asst=$ASSIST_UNIT ci=$CI_UNIT"
check "Brand resolvida" "$([[ $(echo "$DB_DUMP" | jq -r '.brand_name') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.brand_name')"
check "Pricing categorias > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.cats_count') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.cats_count') cats: $(echo "$DB_DUMP" | jq -r '.cats_keys | join(",")')"
check "Pricing amounts > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.amounts_total') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.amounts_total') amounts"
PRICING_DRY=$(echo "$DB_DUMP" | jq -r '.pricing_dry_run // ""')
check "Pricing dry-run (calcula sem erro)" "$([[ "$PRICING_DRY" == OK* ]] && echo PASS || echo FAIL)" "$PRICING_DRY"
INTER_OK=$(echo "$DB_DUMP" | jq -r '.inter_ok // false')
check "Credenciais Inter completas (cert+key+client_id)" "$([[ "$INTER_OK" == 'true' ]] && echo PASS || echo WARN)" "Sem isso generate_pix cai no fallback de link"
check "CaptainInbox mapeada" "$([[ "$INBOX_ID" != 'null' && "$INBOX_ID" != '' ]] && echo PASS || echo WARN)" "inbox=$INBOX_ID"
check "Inbox.typing_delay > 0 (debounce)" "$([[ $(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0')s"
check "config.response_delay (typing simulation)" "$([[ $(echo "$DB_DUMP" | jq -r '.response_delay.mode // ""') == 'typing_simulation' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.response_delay.mode // "none"')"

View File

@ -77,7 +77,7 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]) em arguments.') if conversation.blank?
unit = resolve_unit(conversation)
unit = resolve_unit(conversation, context)
return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank?
return error_response('Unidade não tem credenciais Inter configuradas. Avise a gerência.') unless unit.inter_credentials_present?
@ -135,7 +135,20 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def resolve_unit(conversation)
# Resolve unit em 3 níveis (defesa em profundidade contra divergência
# entre Captain::Assistant.captain_unit_id e CaptainInbox.captain_unit_id):
# 1. Assistant.captain_unit (autoritativo — setado por hermes-provision
# e admin UI; não vaza entre agentes que compartilham inbox).
# 2. CaptainInbox legacy (fallback pré-engine column; só funciona se
# a inbox tem 1 agente único).
# 3. Captain::Unit.inbox_id legacy (fallback antigo, antes de CaptainInbox).
def resolve_unit(conversation, context = nil)
asst_id = context && (context[:assistant_id] || context['assistant_id'])
if asst_id
asst = Captain::Assistant.find_by(id: asst_id)
return asst.captain_unit if asst&.captain_unit_id.present?
end
captain_inbox = CaptainInbox.find_by(inbox_id: conversation.inbox_id)
return captain_inbox.captain_unit if captain_inbox&.captain_unit.present?