fix(captain): generate_pix asks nome+CPF together, hydrates bare name

Root cause of the staging test failure:
- Tool asked for CPF then name separately, two back-and-forth turns.
- When the user replied with just "Rodrigo Borba Machado" (no "nome:"
  prefix), NAME_WITH_LABEL_REGEX didn't match, so the contact.name
  stayed as the emoji "😅‼️". The tool kept returning missing_name and
  the LLM eventually hallucinated success without another generate_pix
  call.

Changes:
- missing_identity_response combines nome + CPF into one prompt when
  both are missing.
- extract_name_from_qa_pattern finds the last outgoing message asking
  for "nome completo" and takes the next incoming message as the name
  candidate.
- extract_name_run_from_text pulls the leading alphabetic run from the
  message so "Rodrigo Borba Machado, 00251938131" parses the name
  correctly alongside the CPF.
This commit is contained in:
Rodribm10 2026-04-21 18:35:44 -03:00
parent cfffea9c16
commit ee2aae3958

View File

@ -70,8 +70,11 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
contact = @conversation.contact
hydrate_contact_identity_from_conversation!(contact, @conversation)
return missing_cpf_response if contact_cpf(contact).blank?
return missing_name_response if valid_contact_name?(contact.name).blank?
missing = []
missing << 'name' if valid_contact_name?(contact.name).blank?
missing << 'cpf' if contact_cpf(contact).blank?
return missing_identity_response(missing) if missing.any?
# Verifica se já existe reserva pendente de pagamento
pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last
@ -147,6 +150,11 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
break if cpf.present? && name.present? && email.present?
end
# Fallback: quando o cliente responde só com o nome cru logo após a IA
# perguntar "qual seu nome completo?", a regex com prefixo `nome:` não
# pega. Procuramos pelo pattern pergunta->resposta nas mensagens.
name ||= extract_name_from_qa_pattern(conversation)
{
cpf: cpf,
name: name,
@ -154,6 +162,56 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
}.compact
end
# Lê as mensagens na ordem em que aconteceram, acha a última vez em que o
# agente perguntou pelo nome completo, e pega a próxima mensagem do cliente
# como candidata a nome. Aceita só se o texto parecer de fato um nome
# (2+ palavras alfabéticas de 3+ letras, sem dígitos).
def extract_name_from_qa_pattern(conversation)
all_msgs = conversation.messages
.where(private: false)
.reorder(created_at: :asc)
.to_a
last_ask = nil
all_msgs.each do |m|
next unless m.message_type.to_s == 'outgoing'
content = normalize_text(m.content)
next if content.blank?
next unless content.match?(/nome\s+completo|confirmar.*nome|seu\s+nome/i)
last_ask = m
end
return if last_ask.nil?
answer = all_msgs.find do |m|
m.message_type.to_s == 'incoming' &&
m.sender_type.to_s == 'Contact' &&
m.created_at > last_ask.created_at
end
return if answer.nil?
raw = normalize_text(answer.content)
extract_name_run_from_text(raw)
end
# Extrai um "run" alfabético de 2-6 palavras (3+ letras cada) de dentro
# de um texto que pode conter CPF, vírgulas, etc.
# Ex: "Rodrigo Borba Machado, 00251938131" → "Rodrigo Borba Machado"
# Ex: "Ta ok" → nil (primeira palavra tem 2 letras)
# Ex: "00251938131" → nil (só dígitos)
def extract_name_run_from_text(text)
normalized = normalize_text(text).gsub(/[,;:]/, ' ').squish
return if normalized.blank?
tokens = normalized.split(/\s+/)
alpha_tokens = tokens.take_while { |w| w.match?(/\A[\p{L}'\-]+\z/u) && w.length >= 3 }
return if alpha_tokens.length < 2
return if alpha_tokens.length > 6
alpha_tokens.join(' ').titleize
end
def extract_email_from_text(text)
text = normalize_text(text)
candidate = text[EMAIL_REGEX]
@ -196,24 +254,36 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
end
def missing_cpf_response
normalize_payload(
{
formatted_message: 'Para gerar o Pix e seguir com sua reserva, preciso do seu CPF com 11 dígitos. ' \
'Pode me enviar agora? Se preferir, pode mandar só os números.',
success: true,
requires_input: true,
missing_field: 'cpf'
}
)
missing_identity_response(['cpf'])
end
def missing_name_response
missing_identity_response(['name'])
end
# Quando CPF e nome faltam juntos, pede os dois em UMA única mensagem
# pra evitar aquele vai-e-vem de "preciso do CPF" / "preciso do nome".
def missing_identity_response(missing_fields)
has_name = missing_fields.include?('name')
has_cpf = missing_fields.include?('cpf')
msg = if has_name && has_cpf
'Perfeito! Pra fechar a reserva e gerar o Pix, me manda numa única mensagem: ' \
'seu nome completo e seu CPF (11 dígitos). Exemplo: "João da Silva, 12345678900".'
elsif has_cpf
'Para gerar o Pix e seguir com sua reserva, preciso do seu CPF com 11 dígitos. ' \
'Pode me enviar agora? Se preferir, pode mandar só os números.'
else
'Perfeito. Para gerar o Pix, preciso confirmar seu nome completo. Pode me informar?'
end
normalize_payload(
{
formatted_message: 'Perfeito. Para gerar o Pix, preciso confirmar seu nome completo. Pode me informar?',
formatted_message: msg,
success: true,
requires_input: true,
missing_field: 'name'
missing_fields: missing_fields,
missing_field: missing_fields.first
}
)
end