module CrmInsights class GenerateService < Llm::BaseAiService DEFAULT_MODEL = 'gpt-4o-mini' def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil) super() @conversation = conversation @insight = insight @sessions_count = sessions_count @last_contact_at = last_contact_at @from_message_id = from_message_id @to_message_id = to_message_id @model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL) end def generate chat = RubyLLM.chat(model: @model) .with_temperature(0.2) .with_params(response_format: { type: 'json_object' }) response = chat.ask(prompt) parsed = parse_response(response) return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank? { data: parsed, error: nil } rescue StandardError => e Rails.logger.error "[CRM Insights] Generation failed: #{e.message}" { data: nil, error: e.message } end private def prompt <<~PROMPT Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente. Regras: - Idioma: PT-BR sempre. - Nao resuma a conversa; gere um perfil do cliente. - Frases curtas, estilo CRM humano. - Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes. - Atualize o resumo existente sem perder informacoes relevantes. - Priorize padroes recorrentes sobre eventos isolados. - Se dados forem insuficientes, diga que faltam sinais claros. - So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo. - Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio. - Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio. - Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio. - Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos. Saida OBRIGATORIA (JSON valido): { "summary_text": "texto humano completo para UI", "structured_data": { "summary_text": "...", "preferences": [], "contact_pattern": { "time_range": "", "days": [] }, "intent": "", "price_sensitivity": "", "urgency": "", "frictions": [], "commercial_status": "", "customer_potential": "", "agent_tip": "", "funnel": { "stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost "confidence": 0.0, // float 0-1 "reason": "justificativa curta", "evidence_message_ids": [], // IDs das mensagens que justificam o estagio "updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior } } } REGRAS FUNIL DE VENDAS (CRITICO): 1. Analise APENAS o historico fornecido abaixo para definir o estagio. 2. Estagios: - info: pede informacoes gerais. (Confianca minima: qualquer) - price: discute valores. (Confianca minima: 0.6) - availability: pergunta sobre datas/vagas. (Confianca minima: 0.6) - confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75) - closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85) - closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85) 3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio. 4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda. 5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info". Contexto: - Canal: #{channel_name} - Conversa ID: #{@conversation.id} - Contatos (24h): #{@sessions_count} - Ultimo contato valido: #{format_time(@last_contact_at)} - Intervalo de mensagens: #{message_range_label} Resumo anterior (se existir): #{@insight&.summary_text || 'Sem resumo anterior.'} JSON anterior (se existir): #{(@insight&.structured_data || {}).to_json} Historico recente (ate 50 mensagens): #{history_block} Formato do texto humano (exemplo de estilo): Cliente recorrente. Demonstra preferencia por suites com hidro. Costuma entrar em contato a noite (principalmente entre 19h e 23h). Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in. Perfil objetivo, poucas mensagens. Intencao predominante: reserva rapida Sensibilidade a preco: media Urgencia: alta Padrao de contato: • Horario: entre 19h e 23h • Dias mais comuns: sexta e sabado Pontos de atencao: • Duvidas recorrentes sobre formas de pagamento • Questionamentos frequentes sobre horario de check-in Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade) Potencial do cliente: • Perfil recorrente • Compativel com suites premium • Bom candidato a fidelizacao Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro. PROMPT end def history_block messages = @conversation.messages .where(message_type: %i[incoming outgoing], private: false) messages = messages.where('id >= ?', @from_message_id) if @from_message_id messages = messages.where('id <= ?', @to_message_id) if @to_message_id messages = messages.order(created_at: :desc).limit(50).reverse messages.map do |message| role = message.incoming? ? 'Cliente' : 'Atendente' time = message.created_at&.strftime('%d/%m/%Y %H:%M') "#{time} - #{role}: #{message.content}" end.join("\n") end def channel_name @conversation.inbox&.channel_type.to_s end def format_time(value) return 'Desconhecido' if value.blank? value.strftime('%d/%m/%Y %H:%M') end def parse_response(response) content = response.respond_to?(:content) ? response.content : response.to_s JSON.parse(content) rescue JSON::ParserError => e Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}" nil end def message_range_label return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank? return "A partir de #{@from_message_id}" if @to_message_id.blank? return "Ate #{@to_message_id}" if @from_message_id.blank? "#{@from_message_id} ate #{@to_message_id}" end end end