diff --git a/Cargo.toml b/Cargo.toml index 59c7091..58559ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,7 @@ dotenv = {version = "0.15.0"} serde_json = "1.0.140" reqwest = { version = "0.12.20", features = ["json", "cookies", "blocking"] } chrono = { version = "0.4.41" } -ipaddress = {version = "0.1.3"} \ No newline at end of file +ipaddress = {version = "0.1.3"} +zip = { version = "4.2.0"} +walkdir = { version = "2.5.0"} +lettre = {version = "0.11.17", features = ["builder"]} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0f5992e..cf68ad4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use std::ffi::OsStr; use std::{any::Any, env, fmt::format, iter, time::Duration}; // use http::{self, response}; @@ -7,6 +8,9 @@ use dotenv; use reqwest; use serde_json::{self, json}; use ipaddress; +use zip; +use lettre; + fn main() { match dotenv::dotenv().ok() { @@ -33,6 +37,14 @@ fn main() { env::var("PIPERUN_BOT_PASSWORD").expect("PIPERUN_BOT_PASSWORD has not been set!"); let OLLAMA_AI_MODEL = env::var("OLLAMA_AI_MODEL").expect("OLLAMA_AI_MODEL has not been set!"); + let MINIMUM_NUMBER_OF_MESSAGES_TO_EVALUATE = env::var("MINIMUM_NUMBER_OF_MESSAGES_TO_EVALUATE").expect("MINIMUM_NUMBER_OF_MESSAGES_TO_EVALUATE has not been set!") + .parse::() + .unwrap_or(10); + let MINIMUM_NUMBER_OF_MESSAGES_WITH_AGENT_TO_EVALUATE = env::var("MINIMUM_NUMBER_OF_MESSAGES_WITH_AGENT_TO_EVALUATE").expect("MINIMUM_NUMBER_OF_MESSAGES_WITH_AGENT_TO_EVALUATE has not been set!") + .parse::() + .unwrap_or(12); + let BOT_EMAIL = env::var("BOT_EMAIL").expect("BOT_EMAIL has not been set!"); + let BOT_EMAIL_PASSWORD = env::var("BOT_EMAIL_PASSWORD").expect("BOT_EMAIL_PASSWORD has not been set!"); // Print the configuration @@ -149,9 +161,35 @@ fn main() { formatted_day_before_at_midnight, formatted_day_before_at_23_59_59 ); + let formatted_day_before = day_before_at_midnight.format("%Y-%m-%d").to_string(); + + // Create a folder named with the day_before + if !std::fs::exists(format!("{formatted_day_before}")).unwrap() { + std::fs::create_dir(format!("{formatted_day_before}")).expect("Failed to create directory") + } + let start_of_talk_code: String = "1".to_string(); let support_queue_id: String = "13".to_string(); + // API V2 + let report_type = "consolidated".to_string(); + let talks_request = client + .get(format!( + "https://{}/api/v2/reports/talks", + PIPERUN_API_URL + )) + .bearer_auth(&access_token) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .query(&[ + ("report_type", report_type), + ("start_date", formatted_day_before_at_midnight), + ("end_date", formatted_day_before_at_23_59_59), + ("date_range_type", start_of_talk_code), + ("queue_id[]", support_queue_id), + ]); + + /* // Get the list of consolidated talks from the day before let talks_request = client .get(format!( @@ -167,7 +205,7 @@ fn main() { ("date_range_type", start_of_talk_code), ("queue_id[]", support_queue_id), ]); - + */ println!("Sending request for consolidated talks... {talks_request:?}"); let talks_response = talks_request.send(); let talks = match talks_response { @@ -190,7 +228,8 @@ fn main() { } }; - let talks_array = talks + // TODO: Read paginated output until the end + let talks_array = talks["data"] .as_array() .expect("Failed to parse talks response as array"); @@ -230,99 +269,99 @@ fn main() { return talk_id_get_result; }) - .skip(9) - .take(4) + .skip(0) + .take(15) .for_each(|result| { let json = result.unwrap().json::().expect("Failed to deserialize response to JSON"); let talk_histories = &json["talk_histories"]; let data = &talk_histories["data"]; + // Further filtering + let talk_lenght = talk_histories.as_array().expect("Wrong message type received from talk histories").len(); + if talk_lenght < MINIMUM_NUMBER_OF_MESSAGES_TO_EVALUATE {return;} + + // Find the last transfer + let found = talk_histories.as_array().expect("Wrong message type received from talk histories").into_iter().enumerate().find(|(pos, message_object)|{ + let message = message_object["message"].as_str().expect("Failed to decode message as string"); + let found = message.find("Atendimento transferido para a fila [NovaNet -> Atendimento -> Suporte]"); + found.is_some() + }); + + match found { + None => {return;}, + Some(pos) => { + let pos_found = pos.0; + if pos_found < MINIMUM_NUMBER_OF_MESSAGES_WITH_AGENT_TO_EVALUATE {return;} + } + }; + + if json["agent"]["user"]["name"].as_str().unwrap_or("unknown_user") == "PipeBot" {return;} + let talk = talk_histories.as_array().expect("Wrong message type received from talk histories").iter().rev().map(|message_object| { // println!("Message obj: {:?}", message_object) + // println!("{:?}", message_object["user"]["name"]); let new_json_filtered = format!( "{{ - message: {}, - sent_at: {}, - type: {} + message: {}, + sent_at: {}, + type: {}, + user_name: {} }}", message_object["message"], message_object["sent_at"], - message_object["type"] + message_object["type"], + message_object["user"]["name"] ); // println!("{}", new_json_filtered); new_json_filtered }).reduce(|acc, e| {format!("{acc}\n{e}")}).expect("Error extracting talk"); - let system = " -Por favor haja como se você fosse o **supervisor de atendimentos via chat** e deve avaliar o desempenho de cada agente segundo os critérios. +let system = +" +SEGUINDO OS CRITÉRIOS QUE VÃO ESTAR ABAIXO, AVALIE ATENDIMENTOS DE SUPORTE PRESTADOS VIA CHAT. -### Critérios de Avaliação -1. **Apresentação inicial** - - Se o agente se apresentar no início, ganha 1 ponto. +01 (APRESENTAÇÃO) - O AGENTE DEVE SE APRESENTAR NO INÍCIO DO ATENDIMENTO. -2. **Entendimento da necessidade** - - Se o cliente informar ‘estou sem conexão’ ou ‘conexão está lenta’ e o agente não perguntar “Como posso te ajudar?”, ganha 1 ponto. +02 (CONFIRMAÇÃO DE E-MAIL) - O AGENTE DEVE SOLICITAR OU CONFIRMAR O E-MAIL DO CLIENTE DURANTE A CONVERSA. -3. **Coleta de e-mail** - - Se o agente solicitar ou confirmar o e-mail do cliente, ganha 1 ponto. +03 (PROTOCOLO) - O AGENTE DEVE INFORMOU O PROTOCOLO DE ATENDIMENTO DURANTE A CONVERSA. -4. **Protocolo de atendimento** - - Se o agente informar o protocolo, ganha 1 ponto. +04 (USO DO PORTUGUÊS) - O AGENTE DEVE UTILIZAR CORRETAMENTE O PORTUGUÊS. LEMBRANDO QUE SOMOS UMA EMPRESA REGIONAL, UTILIZAMOS UMA LINGUAGEM MAIS INFORMAL. NÃO ESTÁ ERRADO SE O AGENTE UTILIZAR 'TU', 'TEU', 'TUA'. -5. **Uso correto do português** - - Se o agente usar corretamente o português (inclusive “tu”, “teu”, “tua”), ganha 1 ponto. +05 (PACIÊNCIA E EDUCAÇÃO) - O AGENTE DEVE SER PACIENTE E EDUCADO, INCLUIMDO O USO DE AGRADECIMENTOS, SAUDAÇÕES E LINGUAGEM RESPEITOSA. -6. **Tempo de resposta** - - Se o agente não deixar o cliente sem retorno por mais de 5 minutos, ganha 1 ponto. +06 (DISPONIBILIDADE) - O AGENTE DEVE SE COLOCAR À DISPOSIÇÃO DO CLIENTE, DEIXANDO CLARO QUE A EMPRESA ESTÁ SEMPRE DISPONÍVEL PARA AJUDAR. -7. **Paciência** - - Se o agente for paciente, mesmo com clientes impacientes, ganha 1 ponto. +---------- -8. **Educação e cordialidade** - - Se o agente usar agradecimentos, saudações e linguagem respeitosa, ganha 1 ponto. - -9. **Cobertura de todas as dúvidas** - - Se o agente não ignorar nenhuma pergunta (direta ou indireta), ganha 1 ponto. - -10. **Disponibilidade** - - Se o agente demonstrar que a empresa está sempre disponível para ajudar, ganha 1 ponto. - -11. **Conhecimento técnico** - - Se o agente demonstrar embasamento técnico, ganha 1 ponto. - - **Desconsidere**: respostas vagamente técnicas ou suposições sem explicação (ex.: “aconteceu do nada”, alterações em Wi-Fi quando o cliente reporta dispositivo cabeado etc.). - -12. **Didática** - - Se o agente explicar claramente o que fez, por que fez e indicar exatamente quais cabos ou procedimentos, ganha 1 ponto. - - **Desconsidere**: instruções vagas (“recoloque os cabos”, “poderia mexer nos cabos?”) ou sem justificativa. - ---- - -As mensagens do chat estão estruturadas em JSON com os campos: +As mensagens do chat estão estruturadas no formato JSON com os campos: - **message**: conteúdo da mensagem - **sent_at**: horário de envio -- **type**: tipo da mensagem (`IN`, `OUT` ou `SYSTEM`). As mensagens do agente são sempre do tipo `OUT`. +- **type**: tipo da mensagem ('IN', 'OUT' ou 'SYSTEM'). As mensagens do agente são sempre do tipo 'OUT'. +- **user_name**: nome do usuário que enviou a mensagem. Não considere respostas do PipeBot como do agente -Caso não seja identificado um critério, atribua 0 pontos, quando identificado atribua 1 ponto. +O fluxo de mensagens inica-se com o cliente interagindo com o BOT, e então a mensagem é transferida para o atendente. -A sua resposta deve ser uma apenas uma **tabela CSV** com as colunas: CATEGORIA,PONTOS,MOTIVO -onde cada linha contém a avaliação de um dos critérios acima +Em cada categoria, atribua 0 quando o agente não tiver atendido o critétio e 1 caso tenha atendido. -**A seguir estão as mensagens do atendimento**, em JSON, avalie e retorne um CSV. +A sua resposta deve ser uma apenas uma tabela CSV e nada mais, utilizando como separador o caracter ';' com as colunas: CATEGORIA;PONTOS;MOTIVO;EVIDENCIA onde cada linha contém a avaliação de um dos critérios acima. + +A seguir estão as mensagens do atendimento, em JSON, avalie e retorne apenas um CSV. "; - - // println!("{system}"); - + // println!("{system}\n {talk}"); + let ollama_api_request = client.post(format!("http://{OLLAMA_SANITIZED_IP}:{OLLAMA_PORT}/api/generate")) .body( serde_json::json!({ "model": OLLAMA_AI_MODEL, "prompt": format!("{system} \n{talk}"), + "options": serde_json::json!({"temperature": 0.1}), "stream": false, }).to_string() ); - + let result = ollama_api_request.timeout(Duration::from_secs(3600)).send(); match result { @@ -340,20 +379,125 @@ onde cada linha contém a avaliação de um dos critérios acima let user_name = &json["agent"]["user"]["name"].as_str().unwrap_or("unknown_user"); let talk_id = &json["id"].as_u64().unwrap_or(0); - std::fs::write(format!("{} - {}.csv", user_name, talk_id), csv_response).expect("Unable to write file"); - std::fs::write(format!("{} - {} - prompt.txt", user_name, talk_id), format!("{system} \n{talk}")).expect("Unable to write file"); + let tracking_number = &json["tracking_number"].as_str().unwrap_or(""); + std::fs::write(format!("./{}/{} - {} - {}.csv", formatted_day_before, user_name, talk_id, tracking_number), csv_response).expect("Unable to write file"); + std::fs::write(format!("./{}/{} - {} - {} - prompt.txt", formatted_day_before, user_name, talk_id, tracking_number), format!("{system} \n{talk}")).expect("Unable to write file"); }, Err(error) => {println!("Error {error}");} }; - // println!("Whole talk Histories \n\n{:?}", talk_histories); - // println!("\n\nData Only\n\n{:?}", data); - // println!( - // "{:?}", - // result - // .unwrap() - // .json::() - // .expect("Failed to deserialize response to JSON")["talk_histories"] - // ) }); + + // Compress folder into zip + let source_dir_str = format!("./{formatted_day_before}"); + let output_zip_file_str = format!("./{formatted_day_before}.zip"); + let source_dir = Path::new(source_dir_str.as_str()); + let output_zip_file = Path::new(output_zip_file_str.as_str()); + doit(source_dir, output_zip_file, zip::CompressionMethod::Stored); + + // Send folder to email + println!("Trying to send email..."); + send_email(&formatted_day_before, &BOT_EMAIL, &BOT_EMAIL_PASSWORD, "Wilson da Conceição Oliveira ", &output_zip_file_str); } + +use std::io::prelude::*; +use zip::{result::ZipError, write::SimpleFileOptions}; + +use std::fs::File; +use std::path::{Path, PathBuf}; +use walkdir::{DirEntry, WalkDir}; + +fn zip_dir( + it: &mut dyn Iterator, + prefix: &Path, + writer: T, + method: zip::CompressionMethod, +) +where + T: Write + Seek, +{ + let mut zip = zip::ZipWriter::new(writer); + let options = SimpleFileOptions::default() + .compression_method(method) + .unix_permissions(0o755); + + let prefix = Path::new(prefix); + let mut buffer = Vec::new(); + for entry in it { + let path = entry.path(); + let name = path.strip_prefix(prefix).unwrap(); + let path_as_string = name + .to_str() + .map(str::to_owned).expect("Failed to parse path"); + + // Write file or directory explicitly + // Some unzip tools unzip files with directory paths correctly, some do not! + if path.is_file() { + println!("adding file {path:?} as {name:?} ..."); + zip.start_file(path_as_string, options).expect("Failed to add file"); + let mut f = File::open(path).unwrap(); + + f.read_to_end(&mut buffer).expect("Failed to read file"); + zip.write_all(&buffer).expect("Failed to write file"); + buffer.clear(); + } else if !name.as_os_str().is_empty() { + // Only if not root! Avoids path spec / warning + // and mapname conversion failed error on unzip + println!("adding dir {path_as_string:?} as {name:?} ..."); + zip.add_directory(path_as_string, options).expect("Failed to add directory"); + } + } + zip.finish().expect("Failed to ZIP"); +} + +fn doit(src_dir: &Path, dst_file: &Path, method: zip::CompressionMethod) { + if !Path::new(src_dir).is_dir() { + panic!("src_dir must be a directory"); + } + + let path = Path::new(dst_file); + let file = File::create(path).unwrap(); + + let walkdir = WalkDir::new(src_dir); + let it = walkdir.into_iter(); + + zip_dir(&mut it.filter_map(|e| e.ok()), src_dir, file, method); +} + + + +use lettre::{ + message::header::ContentType, + message::Attachment, + message::MultiPart, + message::SinglePart, + transport::smtp::authentication::{Credentials, Mechanism}, + Message, SmtpTransport, Transport, +}; + +fn send_email(day_before:&str, bot_email:&str, bot_email_password:&str, to:&str, zip_file_name:&str) { + let filebody = std::fs::read(zip_file_name).unwrap(); + let content_type = ContentType::parse("application/zip").unwrap(); + let attachment = Attachment::new(zip_file_name.to_string()).body(filebody, content_type); + + let email = Message::builder() + .from(format!("PipeRUN bot <{bot_email}>").parse().unwrap()) + .reply_to(format!("PipeRUN bot <{bot_email}>").parse().unwrap()) + .to(format!("{to}").parse().unwrap()) + .subject(format!("Avaliacao atendimentos {day_before}")) + .multipart(MultiPart::mixed() + .singlepart(SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(String::from("Avaliacao dos atendimentos")) + ) + .singlepart( + attachment + ) + ).unwrap() ; + + // Create the SMTPS transport + let sender = SmtpTransport::from_url(&format!("smtps://{bot_email}:{bot_email_password}@mail.nova.net.br")).unwrap().build(); + + // Send the email via remote relay + sender.send(&email).unwrap(); +} \ No newline at end of file