Compare commits

..

34 Commits

Author SHA1 Message Date
d48f2b86aa feat: add monthly repport 2025-11-05 11:37:24 -03:00
2975dad9cd fix: send groupped report on email 2025-11-05 10:34:03 -03:00
ca227ee45f chore: calculate excelence percentual for chats 2025-11-05 10:32:47 -03:00
1ba0510a3e refactor: extract zip and mail sending to other module 2025-11-05 09:35:17 -03:00
4d82b51796 fix: filename extension 2025-11-05 09:01:51 -03:00
49ddb9a238 Merge branch 'feat/groupped_report' 2025-11-05 08:45:55 -03:00
7d2ae90eb3 style: format code 2025-11-05 08:33:39 -03:00
6037643a28 chore: update gitignore 2025-11-05 08:16:27 -03:00
de9dd7a49a feat: group repports in a week 2025-11-05 08:15:49 -03:00
47901d4a9f chore: update gitignore 2025-11-05 08:15:15 -03:00
90b30b0d51 fix: read chats from api when is only one page 2025-11-03 11:39:10 -03:00
14bf25e83a style: apply cargo fmt 2025-11-03 10:56:00 -03:00
5477f91bb9 chore: gather user name and evaluation as a dataframe 2025-10-30 12:03:23 -03:00
5bb0ad8b1a fix: remove empty string from filters 2025-10-30 08:56:25 -03:00
ca406b4601 chore: parse csv and talk id for evaluation files 2025-10-29 12:03:00 -03:00
197642727c chore: add vscode folder to gitignore 2025-10-29 11:56:23 -03:00
99a4c81a58 chore: update data sanitization prompt 2025-10-29 11:54:44 -03:00
178219c4df chore: add csv dependencies and bump libraries version 2025-10-29 11:54:30 -03:00
7fb6021e64 chore: sanitize data with ai model 2025-10-25 12:09:28 -03:00
1edb92af50 chore: add cargo.lock file 2025-10-20 11:10:10 -03:00
6b12637d0a chore: update system prompt 2025-10-20 10:47:53 -03:00
829436b570 chore: update system prompt 2025-10-20 07:51:19 -03:00
28c9d810e9 chore: update system prompt 2025-10-17 10:42:51 -03:00
cd44d58f69 chore: obtain previoous week dates 2025-10-16 12:05:07 -03:00
9fdaf35e28 chore: add Cargo.lock file 2025-10-16 07:22:54 -03:00
ff2cee12ae chore: remove evaluation limit 2025-10-16 07:21:30 -03:00
3af0eab693 chore: started report grouping 2025-10-15 12:10:02 -03:00
94218b4477 chore: update system prompt 2025-10-10 07:23:03 -03:00
a968b80e0f chore: update system prompt 2025-10-09 09:47:04 -03:00
227ca2af5b chore: update filters 2025-10-07 11:22:47 -03:00
4db874bcf6 chore: update system prompt 2025-10-07 11:16:49 -03:00
8d5bec6f06 chore: update system prompt 2025-10-06 10:45:13 -03:00
b523e9b7e1 chore: add .dockerignore file 2025-10-06 09:32:30 -03:00
05bf884aa1 fix: set main branch for image build 2025-10-06 09:14:44 -03:00
14 changed files with 3120 additions and 353 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
/target
src/.env
.env
log/
.zip
evaluations

2
.gitignore vendored
View File

@@ -3,7 +3,9 @@ src/.env
.env
log/
.zip
.vscode
evaluations
groupped
# Added by cargo
#
# already existing elements were commented out

18
.vscode/launch.json vendored
View File

@@ -41,6 +41,24 @@
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'groupper_report'",
"cargo": {
"args": [
"build",
"--bin=groupped_repport",
"--package=piperun-bot"
],
"filter": {
"name": "groupped_repport",
"kind": "bin"
}
},
"args": ["cargo", "run", "--bin=groupped_repport"],
"cwd": "${workspaceFolder}"
},
]
}

1795
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,18 @@ name = "piperun-bot"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "groupped-repport-weekly"
path = "src/groupped_repport_weekly.rs"
[[bin]]
name = "groupped-repport-monthly"
path = "src/groupped_repport_monthly.rs"
[[bin]]
name = "piperun-bot"
path = "src/main.rs"
[dependencies]
http = {version = "1.3.1"}
dotenv = {version = "0.15.0"}
@@ -11,7 +23,11 @@ reqwest = { version = "0.12.23", features = ["json", "cookies", "blocking"] }
chrono = { version = "0.4.42" }
itertools = {version = "0.14.0"}
ipaddress = {version = "0.1.3"}
zip = { version = "5.1.1"}
zip = { version = "6.0.0"}
walkdir = { version = "2.5.0"}
lettre = {version = "0.11.18", features = ["builder"]}
lettre = {version = "0.11.19", features = ["builder"]}
anyhow = { version = "1.0.100"}
polars = { version = "0.52.0"}
serde = { version = "1.0.228" }
csv = {version = "1.4.0"}
regex = { version = "1.12.2" }

View File

@@ -1,6 +1,7 @@
FROM docker.io/rust:1.90 AS build
COPY . /app
WORKDIR /app
RUN git checkout main
RUN cargo build --release
# FROM docker.io/alpine:3.22.1 AS production

View File

@@ -11,3 +11,8 @@ Transbordo automático para a fila [NovaNet -> Atendimento -> Suporte], pois nã
comprovante de pagamento
restrição de velocidade
*Basta desligar e ligar teus equipamentos da tomada para a conexão normalizar
ativar conta
link de ativação
paramount
https://play.watch.tv.br/ative
watch

View File

@@ -4,23 +4,25 @@ SEGUINDO OS CRITÉRIOS QUE VÃO ESTAR ABAIXO, AVALIE ATENDIMENTOS DE SUPORTE PRE
02 (CONFIRMAÇÃO DE E-MAIL) - O AGENTE DEVE SOLICITAR OU CONFIRMAR O E-MAIL DO CLIENTE DURANTE A CONVERSA.
03 (PROTOCOLO) - O AGENTE DEVE INFORMAR O PROTOCOLO DE ATENDIMENTO DURANTE A CONVERSA.
03 (CONFIRMAÇÃO DE TELEFONE) - O AGENTE DEVE SOLICITAR OU CONFIRMAR O TELEFONE DO TITULAR DURANTE A CONVERSA.
04 (USO DO PORTUGUÊS) - O AGENTE DEVE UTILIZAR CORRETAMENTE O PORTUGUÊS..
04 (PROTOCOLO) - O AGENTE DEVE INFORMAR O PROTOCOLO DE ATENDIMENTO DURANTE A CONVERSA.
05 (USO DO PORTUGUÊS) - O AGENTE DEVE UTILIZAR CORRETAMENTE O PORTUGUÊS..
NÃO ESTÁ ERRADO SE O AGENTE UTILIZAR 'TU', 'TEU', 'TUA'. SOMOS UMA EMPRESA REGIONAL.
05 (PACIÊNCIA E EDUCAÇÃO) - O AGENTE DEVE SER PACIENTE E EDUCADO, UTILIZANDO O USO DE AGRADECIMENTOS, SAUDAÇÕES, PEDIDOS DE DESCULPAS E LINGUAGEM RESPEITOSA, COMO POR EXEMPLO "COMO POSSO TE AJUDAR?", "PERFEITO", "POR GENTILEZA", "OBRIGADO PELAS INFORMAÇÕES", "VOU VERIFICAR, AGUARDE UM MOMENTO POR GENTILEZA", "DESCULPE, MAS NÃO TE COMPREENDI", "PODERIA EXPLICAR DE NOVO?".
06 (PACIÊNCIA E EDUCAÇÃO) - O AGENTE DEVE SER PACIENTE E EDUCADO, UTILIZANDO O USO DE AGRADECIMENTOS, SAUDAÇÕES, PEDIDOS DE DESCULPAS E LINGUAGEM RESPEITOSA, COMO POR EXEMPLO "COMO POSSO TE AJUDAR?", "PERFEITO", "POR GENTILEZA", "OBRIGADO PELAS INFORMAÇÕES", "VOU VERIFICAR, AGUARDE UM MOMENTO POR GENTILEZA", "DESCULPE, MAS NÃO TE COMPREENDI", "PODERIA EXPLICAR DE NOVO?".
06 (DISPONIBILIDADE) - O AGENTE DEVE SE COLOCAR À DISPOSIÇÃO DO CLIENTE, DEIXANDO CLARO QUE A EMPRESA ESTÁ SEMPRE DISPONÍVEL PARA AJUDAR.
07 (DISPONIBILIDADE) - O AGENTE DEVE SE COLOCAR À DISPOSIÇÃO DO CLIENTE, DEIXANDO CLARO QUE A EMPRESA ESTÁ SEMPRE DISPONÍVEL PARA AJUDAR.
07 (CONHECIMENTO TÉCNICO) - O AGENTE DEVE INFORMAR DE FORMA CLARA SE IDENTIFICOU ALGUM PROBLEMA NA CONEXÃO OU SE ESTÁ TUDO NORMAL. COMO POR EXEMPLO:
08 (CONHECIMENTO TÉCNICO) - O AGENTE DEVE INFORMAR DE FORMA CLARA SE IDENTIFICOU ALGUM PROBLEMA NA CONEXÃO OU SE ESTÁ TUDO NORMAL. COMO POR EXEMPLO:
"VERIFICANDO ATRAVÉS DOS EQUIPAMENTOS QUE ESTÃO NO LOCAL, NÃO ENCONTREI NENHUM PROBLEMA QUE PUDESSE OCASIONAR O MAU FUNCIONAMENTO DA TUA CONEXÃO."
"ATÉ A TUA CONEXÃO ESTA TUDO CERTO, OS EQUIPAMENTOS ESTÃO PADRONIZADOS E O VELOCIDADE ESTÁ SENDO ENTREGUE."
"DE FORMA REMOTA, IDENTIFIQUEI QUE O EQUIPAMENTO DE FIBRA, O MENOR SEM ANTENAS, NÃO ESTÁ RECEBENDO ENERGIA."
"DE FORMA REMOTA EU IDENTIFIQUEI QUE O EQUIPAMENTO DA FIBRA NÃO ESTÁ RECEBENDO ENERGIA"
"ATÉ TUA ANTENA ESTA TUDO CERTO, O SINAL QUE CHEGA ATÉ ELA É BOM, ESTÁ DENTRO DO PADRÃO"
08 (DIDATISMO) O AGENTE DEVE SER DIDÁTICO, EXPLICANDO O MOTIVO DE CADA SOLICITAÇÃO OU VERIFICAÇÃO.
09 (DIDATISMO) O AGENTE DEVE SER DIDÁTICO, EXPLICANDO O MOTIVO DE CADA SOLICITAÇÃO OU VERIFICAÇÃO.
A EXPLICAÇÃO PODE VIR ANTES OU DEPOIS DA SOLICITAÇÃO.
NÃO É NECESSÁRIO USAR AS MESMAS PALAVRAS DOS EXEMPLOS, BASTA DEIXAR CLARO O PORQUÊ.
✅ EXEMPLOS CORRETOS:
@@ -37,9 +39,12 @@ NÃO É NECESSÁRIO USAR AS MESMAS PALAVRAS DOS EXEMPLOS, BASTA DEIXAR CLARO O P
❌ NÃO CONSIDERAR DIDÁTICO:
QUANDO O AGENTE PEDE ALGO SEM DIZER O MOTIVO.
FRASES VAGAS COMO: "RECOLOQUE OS CABOS", "DESLIGA E LIGA DE NOVO", "PODE VERIFICAR SE VOLTOU?"
NÃO CONSIDERAR A SOLICITAÇÃO DE E-MAIL COMO ALGO DIDATICO.
NÃO CONSIDERAR A CONFIRMAÇÃO DE E-MAIL COMO ALGO DIDATICO.
NÃO CONSIDERAR A SOLICITAÇÃO DE TELEFONE COMO ALGO DIDATICO.
NÃO CONSIDERAR A CONFIRMAÇÃO DE TELEFONE COMO ALGO DIDATICO.
09 (ECLARECIMENTO) - DURANTE A CONVERSA, O AGENTE DEVE FECHAR UM DIAGNOSTICO.
10 (ECLARECIMENTO) - DURANTE A CONVERSA, O AGENTE DEVE FECHAR UM DIAGNOSTICO.
NÃO É NECESSÁRIO USAR AS MESMAS PALAVRAS DOS EXEMPLOS, BASTA DEIXAR O CLIENTE CIENTE DA CONCLUSÃO DO ATENDIMENTO.
✅ EXEMPLOS CORRETOS:
"COMO O NOME DA TUA REDE NÃO APARECE, ISSO INDICA QUE O ROTEADOR FOI RESETADO E VOLTOU ÀS CONFIGURAÇÕES DE FÁBRICA. SERÁ NECESSÁRIO ABRIR UMA ORDEM DE SERVIÇO PARA RECONFIGURAR O EQUIPAMENTO"
@@ -49,11 +54,6 @@ NÃO É NECESSÁRIO USAR AS MESMAS PALAVRAS DOS EXEMPLOS, BASTA DEIXAR O CLIENTE
"SE A CONEXÃO ESTÁ SENDO USADA POR MUITAS PESSOAS AO MESMO TEMPO E ISSO FAZER COM QUE O CONSUMO SUPERE A VELOCIDADE CONTRATADA, A CONEXÃO PODERÁ APRESENTAR LENTIDÃO"
"EM ESPECÍFICO, QUANDO SE TRATA DE DISPOSITIVOS BOX, RECEPTORES DE CANAIS E APLICATIVOS IPTV, INFELIZMENTE NÃO CONSEGUIMOS GARANTIR ESTABILIDADE PARA ESSES TIPOS DE SERVIÇOS, JÁ QUE ELES NÃO DEPENDEM APENAS DE UMA BOA QUALIDADE DE INTERNET PARA FUNCIONAR CORRETAMENTE. VOU TE INDICAR A VERIFICAR A RESPEITO COM A PESSOA QUE TE FORNECEU O SERVIÇO, SE POSSÍVEL"
"O IDEAL SERIA CONECTAR TUA TV AO ROTEADOR POR UM CABO DE REDE, POIS ASSIM, O SINAL SERÁ TRANSMITIDO DIRETO, SEM SOFRER INTERFERÊNCIA.
10 (TEMPO DE ESPERA) SEMPRE QUE O CLIENTE ENVIAR UMA MENSAGEM, O AGENTE DEVE DAR ALGUM RETORNO EM ATÉ 5 MINUTOS.
O RETORNO PODE SER UMA RESPOSTA, UMA SOLICITAÇÃO OU UM AVISO DE QUE ESTÁ VERIFICANDO.
CASO O AGENTE DEMORE MAIS DE 5 MINUTOS PARA RESPONDER EM 3 OU MAIS MOMENTOS DIFERENTES, ELE PERDE ESTE CRITÉRIO.
É PERMITIDO ULTRAPASSAR OS 5 MINUTOS ATÉ 2 VEZES DURANTE A CONVERSA.
-----------------------------------
As mensagens do chat estão estruturadas no formato JSON com os campos:

View File

@@ -0,0 +1,111 @@
Abaixo está a avaliação de um atendimento que foi realizado. Eu preciso que a formatação fique consistente e padronizada.
Padronize o arquivo CSV da seguinte forma, deixando apenas as colunas listadas.
Título: CATEGORIA;PONTOS
A sua resposta deve ser apenas o CSV com a formatação corrigida, nada mais deve ser incluído na sua resposta, nem mesmo notas sobre a resposta.
Se não for possível padronizar o arquivo de entrada de acordo com as instruções fornecidas a resposta deve ser o CSV com o campo de pontuação vazio.
As categorias são: APRESENTAÇÃO, CONFIRMAÇÃO DE E-MAIL, CONFIRMAÇÃO DE TELEFONE, PROTOCOLO, USO DO PORTUGUÊS, PACIÊNCIA E EDUCAÇÃO, DISPONIBILIDADE, CONHECIMENTO TÉCNICO, DIDATISMO
A coluna pontos deve ter apenas os valores 0, 1 ou vazio, se no arquivo de entrada não houver a avaliação da categoria, a columa de pontos deve ser vazia.
Aqui estão alguns exemplos de formatação de como deve ser a sua resposta:
Exemplo 01:
Dado o seguinte arquivo de entrada:
APRESENTAÇÃO;1;O agente se apresentou ao cliente.;Boa noite, me chamo Ander.;Certo, um bom final de semana! 😊
CONFIRMAÇÃO DE E-MAIL;1;O agente pediu confirmação do e-mail.;Para manter o cadastro atualizado, poderia me confirmar se o e-mail continua sendo janainads.sls@gmail.com e se o telefone do titular do cadastro permanece (53) 98446-2208?;Obrigado pela confirmação.
CONFIRMAÇÃO DE TELEFONE;1;O agente pediu confirmação do telefone.;Para manter o cadastro atualizado, poderia me confirmar se o e-mail continua sendo janainads.sls@gmail.com e se o telefone do titular do cadastro permanece (53) 98446-2208?;Obrigado pela confirmação.
PROTOCOLO;1;O agente informou o protocolo.;Aqui está o protocolo do teu atendimento: 2510.3624;Boa noite, me chamo Ander.
USO DO PORTUGUÊS;1;O agente utilizou português correto.;Aqui está o protocolo do teu atendimento: 2510.3624;Para manter o cadastro atualizado, poderia me confirmar se o e-mail continua sendo janainads.sls@gmail.com e se o telefone do titular do cadastro permanece (53) 98446-2208?
PACIÊNCIA E EDUCAÇÃO;1;O agente foi paciente e educado.;Obrigado pela confirmação.;Certo, um bom final de semana! 😊
DISPONIBILIDADE;1;O agente demonstrou disponibilidade.;Caso tenha alguma dúvida, nos contate. A equipe da NovaNet está sempre aqui para te ajudar.😊;Certo, um bom final de semana! 😊
CONHECIMENTO TÉCNICO;1;O agente identificou que não havia problemas nos equipamentos.;Verifiquei aqui os equipamentos e não identifiquei nenhum problema com eles.;A potência recebida pelo equipamento está normal e as configurações do roteador estão todas corretas.
DIDATISMO;1;O agente foi didático ao orientar o cliente.;Certo, tu poderia se conectar na rede do primeiro roteador e verificar se ocorre algum problema?;Entendi, pode ser algum problema neste segundo roteador, tu pode estar reiniciando ele na tomada para caso seja algum travamento.
100%
A resposta sua deve ser:
```csv
CATEGORIA;PONTOS
APRESENTAÇÃO;1
CONFIRMAÇÃO DE E-MAIL;1
CONFIRMAÇÃO DE TELEFONE;1
PROTOCOLO;1
USO DO PORTUGUÊS;1
PACIÊNCIA E EDUCAÇÃO;1
DISPONIBILIDADE;1
CONHECIMENTO TÉCNICO;1
DIDATISMO;1
```
Exemplo 02:
Dado o seguinte arquivo de entrada:
01,1,Apresentação,"Boa tarde, me chamo Ander. (12:10:05)"
02,1,Confirmou email,"Para manter o cadastro atualizado, poderia me confirmar se o email continua sendo mmaicomvoss@gmail.com? (13:01:40)"
03,1,Confirmou telefone,"para manter o cadastro atualizado, poderia me confirmar se o telefone continua (53) 984143027? (13:01:40)"
04,1,Informa protocolo,"Aqui está o protocolo do teu atendimento: 2510.2749 (12:10:06)"
05,1,Uso correto do português,"Todas as mensagens foram escritas em português formal e correto, inclusive com 'tu' permitido. (12:10:0513:03:08)"
06,1,Uso de linguagem paciente e educada,"Aguarde meu retorno, por gentileza; me informe, por gentileza; obrigado pela confirmação. (12:10:1313:03:07)"
07,1,Disponibilidade expressa,"Caso tenha alguma dúvida, nos contate. A equipe da NovaNet está sempre aqui para te ajudar. (13:03:08)"
08,1,Conhecimento técnico,"Identificou mau contato no cabo, instruiu reconexões, avaliou ordem de serviço, cotou peças. (12:25:4412:57:10)"
09,1,Didático,"Desconecte o cabo LAN/WAN passo a passo e me informe quando terminar. (12:25:5612:26:06)"
10,1,Eclaração diagnóstica,"Fez diagnóstico de mau contato no cabo e ofereceu ordem de serviço sem custo. (12:55:1713:01:15)"
11,2,Tempo de espera excedido, 2 ocorrências,"Intervalos superiores a 5min: 12:55:1713:01:15 (5min58s) e 12:27:5112:54:11 (26min20s)"
A resposta sua deve ser:
```csv
CATEGORIA;PONTOS
APRESENTAÇÃO;1
CONFIRMAÇÃO DE E-MAIL;1
CONFIRMAÇÃO DE TELEFONE;1
PROTOCOLO;1
USO DO PORTUGUÊS;1
PACIÊNCIA E EDUCAÇÃO;1
DISPONIBILIDADE;1
CONHECIMENTO TÉCNICO;1
DIDATISMO;1
```
Exemplo 03:
Dado o seguinte arquivo de entrada:
Identificação e abertura do atendimento,1,
Confirmação de dados do cliente,1,
Verificação do histórico e do plano do cliente,1,
Análise e diagnóstico da falha na conectividade,1,
Verificação e teste de equipamentos,1,
Sugestão de solução de conectividade,1,
Escala de serviço,1,
Encerramento de atendimento,1,
Follow-up,1,
Comunicação com o cliente,1,
Tempo de Resposta,1
A sua resposta deve ser vazia neste caso, pois a entrada não fornece a pontuação adequada para os critérios.
Ou seja o retorno deve ser o seguinte
```csv
CATEGORIA;PONTOS
APRESENTAÇÃO;
CONFIRMAÇÃO DE E-MAIL;
CONFIRMAÇÃO DE TELEFONE;
PROTOCOLO;
USO DO PORTUGUÊS;
PACIÊNCIA E EDUCAÇÃO;
DISPONIBILIDADE;
CONHECIMENTO TÉCNICO;
DIDATISMO;
```
Aqui um exemplo de formatação de como não deve ser sua resposta
Erro 01: Não utilizar o formato estritamente como fornecido nas instruções e copiar da entrada que está sendo avaliada
```csv
CATEGORIA;PONTOS
APRESENTAÇÃO;1
Confirmação de e-mail;1
CONFIRMAÇÃO DE TELEFONE;1
PROTOCOLO;1
USO DO PORTUGUÊS;1
PACIÊNCIA E EDUCAÇÃO;1
DISPONIBILIDADE;1
CONHECIMENTO TÉCNICO;1
DIDATISMO;1
```
Abaixo está a avaliação que deve ser processada
--------------------------------

View File

@@ -0,0 +1,386 @@
use std::fmt::Debug;
use chrono::{Datelike, NaiveDate};
use itertools::Itertools;
use polars::prelude::*;
use reqwest;
use std::env;
use std::time::Duration;
use csv;
pub mod send_mail_util;
pub mod zip_directory_util;
#[derive(Debug, serde::Deserialize)]
struct CsvHeader {
CATEGORIA: String,
PONTOS: Option<u8>,
}
#[derive(Debug, serde::Deserialize)]
struct CsvEvaluation {
APRESENTAÇÃO: u8,
CONFIRMAÇÃO_DE_EMAIL: u8,
CONFIRMAÇÃO_DE_TELEFONE: u8,
PROTOCOLO: u8,
USO_DO_PORTUGUÊS: u8,
PACIÊNCIA_E_EDUCAÇÃO: u8,
DISPONIBILIDADE: u8,
CONHECIMENTO_TÉCNICO: u8,
DIDATISMO: u8,
ID_TALK: String,
}
fn main() {
match dotenv::dotenv().ok() {
Some(_) => println!("Environment variables loaded from .env file"),
None => eprintln!("Failed to load .env file, using defaults"),
}
// Read environment variables
let OLLAMA_URL = env::var("OLLAMA_URL").unwrap_or("localhost".to_string());
let OLLAMA_PORT = env::var("OLLAMA_PORT")
.unwrap_or("11432".to_string())
.parse::<u16>()
.unwrap_or(11432);
let OLLAMA_AI_MODEL_DATA_SANITIZATION = env::var("OLLAMA_AI_MODEL_DATA_SANITIZATION")
.expect("Missing environment variable OLLAMA_AI_MODEL_DATA_SANITIZATION");
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!");
let ip_address = ipaddress::IPAddress::parse(OLLAMA_URL.to_string());
let OLLAMA_SANITIZED_IP = match ip_address {
Ok(ip) => {
if ip.is_ipv4() {
OLLAMA_URL.clone()
} else {
format!("[{}]", OLLAMA_URL.clone())
}
}
Err(e) => OLLAMA_URL.clone(),
};
// Get the current day in the format YYYY-MM-DD
let current_date = chrono::Local::now();
let formatted_date = current_date.format("%Y-%m-%d").to_string();
let current_date = chrono::Local::now();
// let first_day_of_current_month = NaiveDate::fro
let current_day_of_last_month = current_date
.checked_sub_months(chrono::Months::new(1))
.expect("Failed to subtract one month");
let first_day_of_last_month = NaiveDate::from_ymd_opt(
current_day_of_last_month.year(),
current_day_of_last_month.month(),
1,
)
.expect("Failed to obtain date");
let last_day_of_last_month = NaiveDate::from_ymd_opt(
current_day_of_last_month.year(),
current_day_of_last_month.month(),
current_day_of_last_month.num_days_in_month() as u32,
)
.expect("Failed to obtain date");
let previous_month_folder_names = std::fs::read_dir(std::path::Path::new("./evaluations"))
.expect("Failed to read directory ./evaluations")
.filter_map_ok(|entry| {
if entry.metadata().unwrap().is_dir() {
Some(entry.file_name())
} else {
None
}
})
.filter_map_ok(|entry_string_name| {
let regex_match_date =
regex::Regex::new(r"(\d{4}-\d{2}-\d{2})").expect("Failed to build regex");
let filename = entry_string_name.to_str().unwrap();
let matches_find = regex_match_date.find(filename);
match matches_find {
Some(found) => {
let date = chrono::NaiveDate::parse_from_str(found.as_str(), "%Y-%m-%d");
return Some((date.unwrap(), entry_string_name));
}
None => {
return None;
}
};
})
.filter_map_ok(|(folder_name_date, directory_string)| {
if folder_name_date.year() == first_day_of_last_month.year()
&& folder_name_date.month() == first_day_of_last_month.month()
{
return Some(directory_string);
}
return None;
})
.filter_map(|value| {
if value.is_ok() {
return Some(value.unwrap());
} else {
return None;
}
})
.sorted()
.collect_vec();
println!("{:?}", previous_month_folder_names);
let prompt_data_sanitization = std::fs::read_to_string("./PROMPT_DATA_SANITIZATION.txt")
.expect("Failed to read PROMPT_DATA_SANITIZATION.txt");
let client = reqwest::blocking::Client::new();
let groupped_values = previous_month_folder_names
.iter()
.map(|folder_name| {
let folder_base_path = std::path::Path::new("./evaluations");
let folder_date_path = folder_base_path.join(folder_name);
std::fs::read_dir(folder_date_path)
})
.filter_map_ok(|files_inside_folder_on_date| {
let groupped_by_user_on_day = files_inside_folder_on_date
.filter_ok(|entry| {
let entry_file_name_as_str = entry
.file_name()
.into_string()
.expect("Failed to get filename as a String");
entry_file_name_as_str.ends_with(".csv")
&& !entry_file_name_as_str.contains("response_time.csv")
})
.filter_map(|value| {
if value.is_ok() {
return Some(value.unwrap());
}
None
})
.map(|file_name_csv| {
println!("{:?}", file_name_csv.path());
let file_contents = std::fs::read_to_string(file_name_csv.path())
.expect("Failed to read CSV file");
let ollama_api_request = client
.post(format!(
"http://{OLLAMA_SANITIZED_IP}:{OLLAMA_PORT}/api/generate"
))
.body(
serde_json::json!({
"model": OLLAMA_AI_MODEL_DATA_SANITIZATION,
"prompt": format!("{prompt_data_sanitization} \n{file_contents}"),
"temperature": 0.0, // Get predictable and reproducible output
"stream": false,
})
.to_string(),
);
let result = ollama_api_request.timeout(Duration::from_secs(3600)).send();
match result {
Ok(response) => {
println!("Response: {:?}", response);
let response_json = response
.json::<serde_json::Value>()
.expect("Failed to deserialize response to JSON");
let ai_response = response_json["response"]
.as_str()
.expect("Failed to get AI response as string");
let ai_response = ai_response.to_string();
let ai_response = if let Some(resp) = ai_response
.strip_prefix(" ")
.unwrap_or(&ai_response)
.strip_prefix("```csv\n")
{
resp.to_string()
} else {
ai_response
};
let ai_response = if let Some(resp) = ai_response
.strip_suffix(" ")
.unwrap_or(&ai_response)
.strip_suffix("```")
{
resp.to_string()
} else {
ai_response
};
return Ok((ai_response, file_name_csv));
}
Err(error) => {
println!("Error {error}");
return Err(error);
}
};
})
.filter_map_ok(|(ai_repsonse, file_path_csv)| {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.delimiter(b';')
.from_reader(ai_repsonse.as_bytes());
let mut deserialized_iter = reader.deserialize::<CsvHeader>();
let mut columns = deserialized_iter
.filter_ok(|value| value.PONTOS.is_some())
.map_ok(|value| {
let col =
Column::new(value.CATEGORIA.into(), [value.PONTOS.unwrap() as u32]);
col
})
.filter_map(|value| {
if value.is_ok() {
return Some(value.unwrap());
}
None
})
.collect_vec();
if columns.len() != 9 {
return None;
}
// Parse id talk from file_path
// filename example is: CC - Erraoander Quintana - 515578 - 20251020515578.csv
// id talk is the last information, so in the example is: 20251020515578
let regex_filename =
regex::Regex::new(r"(CC - )((\w+\s*)+) - (\d+) - (\d+).csv").unwrap();
let filename = file_path_csv
.file_name()
.into_string()
.expect("Failed to convert file name as Rust &str");
let found_regex_groups_in_filename = regex_filename
.captures(filename.as_str())
.expect("Failed to do regex capture");
let user_name = found_regex_groups_in_filename
.get(2)
.expect("Failed to get the id from regex maches");
let talk_id = found_regex_groups_in_filename
.get(5)
.expect("Failed to get the id from regex maches");
let excelence_percentual = columns
.iter()
.map(|col| col.as_materialized_series().u32().unwrap().sum().unwrap())
.sum::<u32>() as f32
/ columns.iter().len() as f32
* 100.0;
columns.push(Column::new(
"PERCENTUAL DE EXELENCIA".into(),
[format!("{excelence_percentual:.2}")],
));
columns.push(Column::new("ID_TALK".into(), [talk_id.clone().as_str()]));
let df = polars::frame::DataFrame::new(columns)
.expect("Failed to concatenate into a dataframe");
// return a tuple with the dataframe and the user name, so it can be correctly merged after
return Some((user_name.as_str().to_owned(), df));
})
.filter_map(|res| {
if res.is_ok() {
return Some(res.unwrap());
}
return None;
})
.into_group_map()
.into_iter()
.map(|(name, eval_dataframe_vec)| {
let groupped_df = eval_dataframe_vec
.iter()
.cloned()
.reduce(|acc, e| acc.vstack(&e).unwrap())
.expect("Failed to concatenate dataframes");
(name, groupped_df)
})
.into_group_map();
dbg!(&groupped_by_user_on_day);
return Some(groupped_by_user_on_day);
})
.filter_map(|res| {
if res.is_ok() {
return Some(res.unwrap());
}
return None;
})
.reduce(|mut acc, mut e| {
e.iter_mut().for_each(|(key, val)| {
if acc.contains_key(key) {
acc.get_mut(key)
.expect("Failed to obtain key that should already be present")
.append(val);
} else {
acc.insert(key.to_owned(), val.to_owned());
}
});
acc
})
.and_then(|groupped_hashmap_df| {
let result = groupped_hashmap_df
.iter()
.map(|(key, val)| {
let dfs = val
.iter()
.cloned()
.reduce(|acc, e| acc.vstack(&e).unwrap())
.expect("Failed to concatenate dataframes");
(key.clone(), dfs)
})
.collect_vec();
return Some(result);
});
// Setup groupped folder
if !std::fs::exists(format!("./groupped/")).unwrap() {
std::fs::create_dir(format!("./groupped")).expect("Failed to create directory")
}
// Setup previous week folder
if !std::fs::exists(format!("./groupped/{first_day_of_last_month}")).unwrap() {
std::fs::create_dir(format!("./groupped/{first_day_of_last_month}"))
.expect("Failed to create directory")
}
match groupped_values {
Some(mut val) => {
val.iter_mut().for_each(|(agent, groupped_evaluations)| {
let mut save_file_csv = std::fs::File::create(format!(
"./groupped/{first_day_of_last_month}/{agent}.csv"
))
.expect("Could not create csv file for saving");
CsvWriter::new(&mut save_file_csv)
.include_header(true)
.with_separator(b';')
.finish(groupped_evaluations)
.expect("Failed to save Groupped DataFrame to CSV File");
});
}
None => {}
}
zip_directory_util::zip_directory_util::zip_source_dir_to_dst_file(
std::path::Path::new(&format!("./groupped/{first_day_of_last_month}")),
std::path::Path::new(&format!("./groupped/{first_day_of_last_month}.zip")),
);
let recipients = "Wilson da Conceição Oliveira <wilson.oliveira@nova.net.br>, Isadora G. Moura de Moura <isadora.moura@nova.net.br>";
println!("Trying to send mail... {recipients}");
send_mail_util::send_mail_util::send_email(
&format!("Relatório agrupado dos atendimentos do mês {first_day_of_last_month}"),
&BOT_EMAIL,
&BOT_EMAIL_PASSWORD,
recipients,
&format!("./groupped/{first_day_of_last_month}.zip"),
);
}

View File

@@ -0,0 +1,392 @@
use std::fmt::Debug;
use itertools::Itertools;
use polars::prelude::*;
use reqwest;
use std::env;
use std::time::Duration;
use csv;
pub mod send_mail_util;
pub mod zip_directory_util;
#[derive(Debug, serde::Deserialize)]
struct CsvHeader {
CATEGORIA: String,
PONTOS: Option<u8>,
}
#[derive(Debug, serde::Deserialize)]
struct CsvEvaluation {
APRESENTAÇÃO: u8,
CONFIRMAÇÃO_DE_EMAIL: u8,
CONFIRMAÇÃO_DE_TELEFONE: u8,
PROTOCOLO: u8,
USO_DO_PORTUGUÊS: u8,
PACIÊNCIA_E_EDUCAÇÃO: u8,
DISPONIBILIDADE: u8,
CONHECIMENTO_TÉCNICO: u8,
DIDATISMO: u8,
ID_TALK: String,
}
fn main() {
match dotenv::dotenv().ok() {
Some(_) => println!("Environment variables loaded from .env file"),
None => eprintln!("Failed to load .env file, using defaults"),
}
// Read environment variables
let OLLAMA_URL = env::var("OLLAMA_URL").unwrap_or("localhost".to_string());
let OLLAMA_PORT = env::var("OLLAMA_PORT")
.unwrap_or("11432".to_string())
.parse::<u16>()
.unwrap_or(11432);
let OLLAMA_AI_MODEL_DATA_SANITIZATION = env::var("OLLAMA_AI_MODEL_DATA_SANITIZATION")
.expect("Missing environment variable OLLAMA_AI_MODEL_DATA_SANITIZATION");
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!");
let ip_address = ipaddress::IPAddress::parse(OLLAMA_URL.to_string());
let OLLAMA_SANITIZED_IP = match ip_address {
Ok(ip) => {
if ip.is_ipv4() {
OLLAMA_URL.clone()
} else {
format!("[{}]", OLLAMA_URL.clone())
}
}
Err(e) => OLLAMA_URL.clone(),
};
// Get the current day in the format YYYY-MM-DD
let current_date = chrono::Local::now();
let formatted_date = current_date.format("%Y-%m-%d").to_string();
let current_date = chrono::Local::now();
let first_day_of_current_week = current_date
.date_naive()
.week(chrono::Weekday::Sun)
.first_day();
let current_date_minus_one_week = first_day_of_current_week
.checked_sub_days(chrono::Days::new(1))
.expect("Failed to subtract one day");
let first_day_of_last_week = current_date_minus_one_week
.week(chrono::Weekday::Sun)
.first_day();
let last_day_of_last_week = current_date_minus_one_week
.week(chrono::Weekday::Sun)
.last_day();
let previous_week_folder_names = std::fs::read_dir(std::path::Path::new("./evaluations"))
.expect("Failed to read directory ./evaluations")
.filter_map_ok(|entry| {
if entry.metadata().unwrap().is_dir() {
Some(entry.file_name())
} else {
None
}
})
.filter_map_ok(|entry_string_name| {
let regex_match_date =
regex::Regex::new(r"(\d{4}-\d{2}-\d{2})").expect("Failed to build regex");
let filename = entry_string_name.to_str().unwrap();
let matches_find = regex_match_date.find(filename);
match matches_find {
Some(found) => {
let date = chrono::NaiveDate::parse_from_str(found.as_str(), "%Y-%m-%d");
return Some((date.unwrap().week(chrono::Weekday::Sun), entry_string_name));
}
None => {
return None;
}
};
})
.filter_map_ok(|(week, directory_string)| {
let first_day_of_week_in_folder_name = week.first_day();
if first_day_of_last_week == first_day_of_week_in_folder_name {
return Some(directory_string);
}
return None;
})
.filter_map(|value| {
if value.is_ok() {
return Some(value.unwrap());
} else {
return None;
}
})
.sorted()
.collect_vec();
println!("{:?}", previous_week_folder_names);
let prompt_data_sanitization = std::fs::read_to_string("./PROMPT_DATA_SANITIZATION.txt")
.expect("Failed to read PROMPT_DATA_SANITIZATION.txt");
let client = reqwest::blocking::Client::new();
let groupped_values = previous_week_folder_names
.iter()
.map(|folder_name| {
let folder_base_path = std::path::Path::new("./evaluations");
let folder_date_path = folder_base_path.join(folder_name);
std::fs::read_dir(folder_date_path)
})
.filter_map_ok(|files_inside_folder_on_date| {
let groupped_by_user_on_day = files_inside_folder_on_date
.filter_ok(|entry| {
let entry_file_name_as_str = entry
.file_name()
.into_string()
.expect("Failed to get filename as a String");
entry_file_name_as_str.ends_with(".csv")
&& !entry_file_name_as_str.contains("response_time.csv")
})
.filter_map(|value| {
if value.is_ok() {
return Some(value.unwrap());
}
None
})
.map(|file_name_csv| {
println!("{:?}", file_name_csv.path());
let file_contents = std::fs::read_to_string(file_name_csv.path())
.expect("Failed to read CSV file");
let ollama_api_request = client
.post(format!(
"http://{OLLAMA_SANITIZED_IP}:{OLLAMA_PORT}/api/generate"
))
.body(
serde_json::json!({
"model": OLLAMA_AI_MODEL_DATA_SANITIZATION,
"prompt": format!("{prompt_data_sanitization} \n{file_contents}"),
"temperature": 0.0, // Get predictable and reproducible output
"stream": false,
})
.to_string(),
);
let result = ollama_api_request.timeout(Duration::from_secs(3600)).send();
match result {
Ok(response) => {
println!("Response: {:?}", response);
let response_json = response
.json::<serde_json::Value>()
.expect("Failed to deserialize response to JSON");
let ai_response = response_json["response"]
.as_str()
.expect("Failed to get AI response as string");
let ai_response = ai_response.to_string();
let ai_response = if let Some(resp) = ai_response
.strip_prefix(" ")
.unwrap_or(&ai_response)
.strip_prefix("```csv\n")
{
resp.to_string()
} else {
ai_response
};
let ai_response = if let Some(resp) = ai_response
.strip_suffix(" ")
.unwrap_or(&ai_response)
.strip_suffix("```")
{
resp.to_string()
} else {
ai_response
};
return Ok((ai_response, file_name_csv));
}
Err(error) => {
println!("Error {error}");
return Err(error);
}
};
})
.filter_map_ok(|(ai_repsonse, file_path_csv)| {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.delimiter(b';')
.from_reader(ai_repsonse.as_bytes());
let mut deserialized_iter = reader.deserialize::<CsvHeader>();
let mut columns = deserialized_iter
.filter_ok(|value| value.PONTOS.is_some())
.map_ok(|value| {
let col =
Column::new(value.CATEGORIA.into(), [value.PONTOS.unwrap() as u32]);
col
})
.filter_map(|value| {
if value.is_ok() {
return Some(value.unwrap());
}
None
})
.collect_vec();
if columns.len() != 9 {
return None;
}
// Parse id talk from file_path
// filename example is: CC - Erraoander Quintana - 515578 - 20251020515578.csv
// id talk is the last information, so in the example is: 20251020515578
let regex_filename =
regex::Regex::new(r"(CC - )((\w+\s*)+) - (\d+) - (\d+).csv").unwrap();
let filename = file_path_csv
.file_name()
.into_string()
.expect("Failed to convert file name as Rust &str");
let found_regex_groups_in_filename = regex_filename
.captures(filename.as_str())
.expect("Failed to do regex capture");
let user_name = found_regex_groups_in_filename
.get(2)
.expect("Failed to get the id from regex maches");
let talk_id = found_regex_groups_in_filename
.get(5)
.expect("Failed to get the id from regex maches");
let excelence_percentual = columns
.iter()
.map(|col| col.as_materialized_series().u32().unwrap().sum().unwrap())
.sum::<u32>() as f32
/ columns.iter().len() as f32
* 100.0;
columns.push(Column::new(
"PERCENTUAL DE EXELENCIA".into(),
[format!("{excelence_percentual:.2}")],
));
columns.push(Column::new("ID_TALK".into(), [talk_id.clone().as_str()]));
let df = polars::frame::DataFrame::new(columns)
.expect("Failed to concatenate into a dataframe");
// return a tuple with the dataframe and the user name, so it can be correctly merged after
return Some((user_name.as_str().to_owned(), df));
})
.filter_map(|res| {
if res.is_ok() {
return Some(res.unwrap());
}
return None;
})
.into_group_map()
.into_iter()
.map(|(name, eval_dataframe_vec)| {
let groupped_df = eval_dataframe_vec
.iter()
.cloned()
.reduce(|acc, e| acc.vstack(&e).unwrap())
.expect("Failed to concatenate dataframes");
(name, groupped_df)
})
.into_group_map();
dbg!(&groupped_by_user_on_day);
return Some(groupped_by_user_on_day);
})
.filter_map(|res| {
if res.is_ok() {
return Some(res.unwrap());
}
return None;
})
.reduce(|mut acc, mut e| {
e.iter_mut().for_each(|(key, val)| {
if acc.contains_key(key) {
acc.get_mut(key)
.expect("Failed to obtain key that should already be present")
.append(val);
} else {
acc.insert(key.to_owned(), val.to_owned());
}
});
acc
})
.and_then(|groupped_hashmap_df| {
let result = groupped_hashmap_df
.iter()
.map(|(key, val)| {
let dfs = val
.iter()
.cloned()
.reduce(|acc, e| acc.vstack(&e).unwrap())
.expect("Failed to concatenate dataframes");
(key.clone(), dfs)
})
.collect_vec();
return Some(result);
});
// Setup groupped folder
if !std::fs::exists(format!("./groupped/")).unwrap() {
std::fs::create_dir(format!("./groupped")).expect("Failed to create directory")
}
// Setup previous week folder
if !std::fs::exists(format!(
"./groupped/{first_day_of_last_week} - {last_day_of_last_week}"
))
.unwrap()
{
std::fs::create_dir(format!(
"./groupped/{first_day_of_last_week} - {last_day_of_last_week}"
))
.expect("Failed to create directory")
}
match groupped_values {
Some(mut val) => {
val.iter_mut().for_each(|(agent, groupped_evaluations)| {
let mut save_file_csv = std::fs::File::create(format!(
"./groupped/{first_day_of_last_week} - {last_day_of_last_week}/{agent}.csv"
))
.expect("Could not create csv file for saving");
CsvWriter::new(&mut save_file_csv)
.include_header(true)
.with_separator(b';')
.finish(groupped_evaluations)
.expect("Failed to save Groupped DataFrame to CSV File");
});
}
None => {}
}
zip_directory_util::zip_directory_util::zip_source_dir_to_dst_file(
std::path::Path::new(&format!(
"./groupped/{first_day_of_last_week} - {last_day_of_last_week}"
)),
std::path::Path::new(&format!(
"./groupped/{first_day_of_last_week} - {last_day_of_last_week}.zip"
)),
);
let recipients = "Wilson da Conceição Oliveira <wilson.oliveira@nova.net.br>, Isadora G. Moura de Moura <isadora.moura@nova.net.br>";
println!("Trying to send mail... {recipients}");
send_mail_util::send_mail_util::send_email(
&format!(
"Relatório agrupado dos atendimentos semana {first_day_of_last_week} - {last_day_of_last_week}"
),
&BOT_EMAIL,
&BOT_EMAIL_PASSWORD,
recipients,
&format!("./groupped/{first_day_of_last_week} - {last_day_of_last_week}.zip"),
);
}

View File

@@ -1,15 +1,17 @@
use std::ffi::OsStr;
use std::{any::Any, env, fmt::format, iter, time::Duration};
use chrono::{self, Timelike};
use dotenv;
use ipaddress;
use itertools::{self, Itertools};
use lettre::message::Mailboxes;
use lettre::{self, message};
use reqwest;
use serde_json::{self, json};
use zip;
use std::io::prelude::*;
pub mod send_mail_util;
pub mod zip_directory_util;
fn main() -> anyhow::Result<()> {
match dotenv::dotenv().ok() {
@@ -166,20 +168,37 @@ fn main() -> anyhow::Result<()> {
// Create a folder named with the day_before
if !std::fs::exists(format!("./evaluations/{formatted_day_before}")).unwrap() {
std::fs::create_dir(format!("./evaluations/{formatted_day_before}")).expect("Failed to create directory")
std::fs::create_dir(format!("./evaluations/{formatted_day_before}"))
.expect("Failed to create directory")
}
// Create the response time folder
if !std::fs::exists(format!("./evaluations/{formatted_day_before}/response_time.csv")).unwrap() {
let mut response_time_file = std::fs::File::create_new(format!("./evaluations/{formatted_day_before}/response_time.csv")).expect("Failed to response_time.csv");
if !std::fs::exists(format!(
"./evaluations/{formatted_day_before}/response_time.csv"
))
.unwrap()
{
let mut response_time_file = std::fs::File::create_new(format!(
"./evaluations/{formatted_day_before}/response_time.csv"
))
.expect("Failed to response_time.csv");
}
// Read system prompt
let prompt = std::fs::read_to_string("PROMPT.txt").unwrap();
let filter_file_contents = std::fs::read_to_string("FILTER.txt").unwrap_or(String::new());
let filter_keywords = filter_file_contents.split("\n").collect::<Vec<&str>>();
let filter_keywords = filter_file_contents
.split("\n")
.filter(|keyword| !keyword.is_empty())
.collect::<Vec<&str>>();
let talks_array = get_piperun_chats_on_date(&PIPERUN_API_URL, &client, &access_token, formatted_day_before_at_midnight, formatted_day_before_at_23_59_59);
let talks_array = get_piperun_chats_on_date(
&PIPERUN_API_URL,
&client,
&access_token,
formatted_day_before_at_midnight,
formatted_day_before_at_23_59_59,
);
println!("Number of consolidated talks: {}", talks_array.len());
@@ -215,41 +234,82 @@ fn main() -> anyhow::Result<()> {
let talk_id_get_result = talk_id_get_request.send();
return talk_id_get_result;
}).filter_map_ok(|result| {
let json = result.json::<serde_json::Value>().expect("Failed to deserialize response to JSON").to_owned();
})
.filter_map_ok(|result| {
let json = result
.json::<serde_json::Value>()
.expect("Failed to deserialize response to JSON")
.to_owned();
let talk_histories = &json["talk_histories"];
let data = &talk_histories["data"];
// Filter chats that have very few messages
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 None;}
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 None;
}
// Filter chats that have less that specified ammount of talks with support agent form the last queue 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]");
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 None;},
None => {
return None;
}
Some(pos) => {
let pos_found = pos.0;
if pos_found < MINIMUM_NUMBER_OF_MESSAGES_WITH_AGENT_TO_EVALUATE {return None;}
if pos_found < MINIMUM_NUMBER_OF_MESSAGES_WITH_AGENT_TO_EVALUATE {
return None;
}
}
};
// Filter Bot finished chats
if json["agent"]["user"]["name"].as_str().unwrap_or("unknown_user") == "PipeBot" {return None;}
if json["agent"]["user"]["name"]
.as_str()
.unwrap_or("unknown_user")
== "PipeBot"
{
return None;
}
// Apply keyword based filtering
let filter_keywords_found = talk_histories.as_array().expect("Wrong message type received from talk histories").into_iter().any(|message_object|{
let message = message_object["message"].as_str().expect("Failed to decode message as string");
let found = filter_keywords.iter().any(|keyword|{message.to_uppercase().find(&keyword.to_uppercase()).is_some()});
let filter_keywords_found = talk_histories
.as_array()
.expect("Wrong message type received from talk histories")
.into_iter()
.any(|message_object| {
let message = message_object["message"]
.as_str()
.expect("Failed to decode message as string");
let found = filter_keywords.iter().any(|keyword| {
message
.to_uppercase()
.find(&keyword.to_uppercase())
.is_some()
});
found
});
if filter_keywords_found {return None;}
if filter_keywords_found {
return None;
}
return Some(json);
});
@@ -267,28 +327,42 @@ fn main() -> anyhow::Result<()> {
// find the bot transfer message
let bot_transfer_message = talk_histories
.as_array().expect("Wrong message type received from talk histories").into_iter()
.as_array()
.expect("Wrong message type received from talk histories")
.into_iter()
.enumerate()
.filter(|(pos, message_object)| {
let user_name = message_object["user"]["name"].as_str().expect("Failed to decode message as string");
let user_name = message_object["user"]["name"]
.as_str()
.expect("Failed to decode message as string");
user_name == "PipeBot".to_string()
}).find(|(pos, message_object)|{
let message = message_object["message"].as_str().expect("Failed to decode message as string");
})
.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]");
let found = message.find("Atendimento entregue da fila de espera para o agente");
let found =
message.find("Atendimento entregue da fila de espera para o agente");
found.is_some()
});
// Find first agent message sent after the last bot message
let (pos, transfer_message) = bot_transfer_message.expect("Failed to get the transfer bot message position");
let (pos, transfer_message) =
bot_transfer_message.expect("Failed to get the transfer bot message position");
let msg = talk_histories.as_array().expect("Wrong message type received from talk histories").into_iter()
let msg = talk_histories
.as_array()
.expect("Wrong message type received from talk histories")
.into_iter()
.take(pos)
.rev()
.filter(|message| {
message["type"] == "out".to_string() && message["user"]["name"] != "PipeBot".to_string()
message["type"] == "out".to_string()
&& message["user"]["name"] != "PipeBot".to_string()
})
.take(1).collect_vec();
.take(1)
.collect_vec();
let agent_first_message = msg[0];
@@ -297,47 +371,75 @@ fn main() -> anyhow::Result<()> {
let format = "%Y-%m-%d %H:%M:%S";
let date_user_message_sent_parsed = match chrono::NaiveDateTime::parse_from_str(date_user_message_sent, format) {
let date_user_message_sent_parsed =
match chrono::NaiveDateTime::parse_from_str(date_user_message_sent, format) {
Ok(dt) => dt,
Err(e) => {println!("Error parsing DateTime: {}", e); panic!("Failed parsing date")},
Err(e) => {
println!("Error parsing DateTime: {}", e);
panic!("Failed parsing date")
}
};
let date_transfer_message_sent_parsed = match chrono::NaiveDateTime::parse_from_str(transfer_message["sent_at"].as_str().unwrap(), format) {
let date_transfer_message_sent_parsed = match chrono::NaiveDateTime::parse_from_str(
transfer_message["sent_at"].as_str().unwrap(),
format,
) {
Ok(dt) => dt,
Err(e) => {println!("Error parsing DateTime: {}", e); panic!("Failed parsing date")},
Err(e) => {
println!("Error parsing DateTime: {}", e);
panic!("Failed parsing date")
}
};
let response_time = (date_user_message_sent_parsed - date_transfer_message_sent_parsed).as_seconds_f32();
let name = agent_first_message["user"]["name"].as_str().unwrap().to_owned();
let response_time = (date_user_message_sent_parsed - date_transfer_message_sent_parsed)
.as_seconds_f32();
let name = agent_first_message["user"]["name"]
.as_str()
.unwrap()
.to_owned();
let id = json["tracking_number"].as_str().unwrap_or("").to_owned();
let bot_transfer_date = date_transfer_message_sent_parsed.to_owned();
let user_response_date = date_user_message_sent.to_owned();
println!("response_time: {}s", (date_user_message_sent_parsed - date_transfer_message_sent_parsed).as_seconds_f32());
println!(
"response_time: {}s",
(date_user_message_sent_parsed - date_transfer_message_sent_parsed)
.as_seconds_f32()
);
format!("{};{};{};{};{}", name, id, response_time, bot_transfer_date, user_response_date)
}).reduce(|acc, e|{format!("{}\n{}",acc,e)})
format!(
"{};{};{};{};{}",
name, id, response_time, bot_transfer_date, user_response_date
)
})
.reduce(|acc, e| format!("{}\n{}", acc, e))
.unwrap_or("".to_string());
// return Ok(());
// Open file and write to it
let header = "NOME;ID_TALK;TEMPO DE RESPOSTA;TRANFERENCIA PELO BOT;PRIMEIRA RESPOSTA DO AGENTE";
let mut response_time_file = std::fs::OpenOptions::new().write(true).open(format!("./evaluations/{formatted_day_before}/response_time.csv")).expect("Failed to open response time file for write");
response_time_file.write_all(format!("{header}\n{response_time}").as_bytes()).expect("Failed to write header to file");
let mut response_time_file = std::fs::OpenOptions::new()
.write(true)
.open(format!(
"./evaluations/{formatted_day_before}/response_time.csv"
))
.expect("Failed to open response time file for write");
response_time_file
.write_all(format!("{header}\n{response_time}").as_bytes())
.expect("Failed to write header to file");
filtered_chats
.clone()
.skip(0)
.take(10)
.for_each(|result| {
filtered_chats.clone().skip(0).for_each(|result| {
let json = result.unwrap();
let talk_histories = &json["talk_histories"];
let data = &talk_histories["data"];
let talk = talk_histories.as_array().expect("Wrong message type received from talk histories").iter().rev().map(|message_object|
{
let talk = talk_histories
.as_array()
.expect("Wrong message type received from talk histories")
.iter()
.rev()
.map(|message_object| {
let new_json_filtered = format!(
"{{
"{{
message: {},
sent_at: {},
type: {},
@@ -350,25 +452,34 @@ fn main() -> anyhow::Result<()> {
);
// println!("{}", new_json_filtered);
new_json_filtered
}).reduce(|acc, e| {format!("{acc}\n{e}")}).expect("Error extracting talk");
})
.reduce(|acc, e| format!("{acc}\n{e}"))
.expect("Error extracting talk");
println!("{prompt}\n {talk}");
let ollama_api_request = client.post(format!("http://{OLLAMA_SANITIZED_IP}:{OLLAMA_PORT}/api/generate"))
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!("{prompt} \n{talk}"),
// "options": serde_json::json!({"temperature": 0.1}),
"stream": false,
}).to_string()
})
.to_string(),
);
let result = ollama_api_request.timeout(Duration::from_secs(3600)).send();
match result {
Ok(response) => {println!("Response: {:?}", response);
let response_json = response.json::<serde_json::Value>().expect("Failed to deserialize response to JSON");
Ok(response) => {
println!("Response: {:?}", response);
let response_json = response
.json::<serde_json::Value>()
.expect("Failed to deserialize response to JSON");
println!("{}", response_json);
let ai_response = response_json["response"]
.as_str()
@@ -379,38 +490,63 @@ fn main() -> anyhow::Result<()> {
// Save the CSV response to a file
let user_name = &json["agent"]["user"]["name"].as_str().unwrap_or("unknown_user");
let user_name = &json["agent"]["user"]["name"]
.as_str()
.unwrap_or("unknown_user");
let talk_id = &json["id"].as_u64().unwrap_or(0);
let tracking_number = &json["tracking_number"].as_str().unwrap_or("");
std::fs::write(format!("./evaluations/{}/{} - {} - {}.csv", formatted_day_before, user_name, talk_id, tracking_number), csv_response).expect("Unable to write file");
std::fs::write(format!("./evaluations/{}/{} - {} - {} - prompt.txt", formatted_day_before, user_name, talk_id, tracking_number), format!("{prompt} \n{talk}")).expect("Unable to write file");
},
Err(error) => {println!("Error {error}");}
std::fs::write(
format!(
"./evaluations/{}/{} - {} - {}.csv",
formatted_day_before, user_name, talk_id, tracking_number
),
csv_response,
)
.expect("Unable to write file");
std::fs::write(
format!(
"./evaluations/{}/{} - {} - {} - prompt.txt",
formatted_day_before, user_name, talk_id, tracking_number
),
format!("{prompt} \n{talk}"),
)
.expect("Unable to write file");
}
Err(error) => {
println!("Error {error}");
}
};
});
// Compress folder into zip
let source_dir_str = format!("./evaluations/{formatted_day_before}");
let output_zip_file_str = format!("./evaluations/{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);
let source_dir = std::path::Path::new(source_dir_str.as_str());
let output_zip_file = std::path::Path::new(output_zip_file_str.as_str());
zip_directory_util::zip_directory_util::zip_source_dir_to_dst_file(source_dir, output_zip_file);
// Send folder to email
let recipients = "Wilson da Conceição Oliveira <wilson.oliveira@nova.net.br>, Isadora G. Moura de Moura <isadora.moura@nova.net.br>";
println!("Trying to send email... Recipients {recipients}");
send_email(
&formatted_day_before,
send_mail_util::send_mail_util::send_email(
&format!("Avaliacao atendimentos {formatted_day_before}"),
&BOT_EMAIL,
&BOT_EMAIL_PASSWORD,
recipients,
&output_zip_file_str,
);
return Ok(());
}
fn get_piperun_chats_on_date(PIPERUN_API_URL: &String, client: &reqwest::blocking::Client, access_token: &String, formatted_day_before_at_midnight: String, formatted_day_before_at_23_59_59: String) -> Vec<serde_json::Value> {
fn get_piperun_chats_on_date(
PIPERUN_API_URL: &String,
client: &reqwest::blocking::Client,
access_token: &String,
formatted_day_before_at_midnight: String,
formatted_day_before_at_23_59_59: String,
) -> Vec<serde_json::Value> {
let start_of_talk_code: String = "talk_start".to_string();
let support_queue_id: String = "13".to_string();
@@ -455,12 +591,24 @@ fn get_piperun_chats_on_date(PIPERUN_API_URL: &String, client: &reqwest::blockin
}
};
let mut aggregated_talks = json_response["data"].as_array().expect("Failed to parse messages as array").to_owned();
let mut aggregated_talks = json_response["data"]
.as_array()
.expect("Failed to parse messages as array")
.to_owned();
let current_page = json_response["current_page"].as_i64().expect("Failed to obtain current page number");
let last_page = json_response["last_page"].as_i64().expect("Failed to obtain current page number");
let current_page = json_response["current_page"]
.as_i64()
.expect("Failed to obtain current page number");
let last_page = json_response["last_page"]
.as_i64()
.expect("Failed to obtain current page number");
let mut all_other_messages = (current_page..last_page).into_iter()
if current_page == last_page {
return aggregated_talks;
}
let mut all_other_messages = (current_page..last_page)
.into_iter()
.map(|page| {
let page_to_request = page + 1;
let talks_request = client
@@ -499,129 +647,19 @@ fn get_piperun_chats_on_date(PIPERUN_API_URL: &String, client: &reqwest::blockin
}
};
let aggregated_talks = json_response["data"].as_array().expect("Failed to parse messages as array").to_owned();
let aggregated_talks = json_response["data"]
.as_array()
.expect("Failed to parse messages as array")
.to_owned();
return aggregated_talks;
})
.reduce(|mut this, mut acc| {acc.append(&mut this); acc})
.reduce(|mut this, mut acc| {
acc.append(&mut this);
acc
})
.expect("Failed to concatenate all vectors of messages");
aggregated_talks.append(&mut all_other_messages);
aggregated_talks
}
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<T>(
it: &mut dyn Iterator<Item = DirEntry>,
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, SmtpTransport, Transport,
message::Attachment,
message::MultiPart,
message::SinglePart,
message::header::ContentType,
transport::smtp::authentication::{Credentials, Mechanism},
};
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 mailboxes : Mailboxes = to.parse().unwrap();
let to_header: message::header::To = mailboxes.into();
let email = Message::builder()
.from(format!("PipeRUN bot <{bot_email}>").parse().unwrap())
.reply_to(format!("PipeRUN bot <{bot_email}>").parse().unwrap())
.mailbox(to_header)
.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();
}

46
src/send_mail_util.rs Normal file
View File

@@ -0,0 +1,46 @@
pub mod send_mail_util {
use lettre::{
Message, SmtpTransport, Transport,
message::{self, Attachment, Mailboxes, MultiPart, SinglePart, header::ContentType},
};
pub fn send_email(
subject_of_email: &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 mailboxes: Mailboxes = to.parse().unwrap();
let to_header: message::header::To = mailboxes.into();
let email = Message::builder()
.from(format!("PipeRUN bot <{bot_email}>").parse().unwrap())
.reply_to(format!("PipeRUN bot <{bot_email}>").parse().unwrap())
.mailbox(to_header)
.subject(format!("{subject_of_email}"))
.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();
}
}

69
src/zip_directory_util.rs Normal file
View File

@@ -0,0 +1,69 @@
pub mod zip_directory_util {
use std::io::prelude::*;
use zip::write::SimpleFileOptions;
use std::fs::File;
use std::path::Path;
use walkdir::{DirEntry, WalkDir};
fn zip_dir<T>(
it: &mut dyn Iterator<Item = DirEntry>,
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");
}
pub fn zip_source_dir_to_dst_file(src_dir: &Path, dst_file: &Path) {
if !Path::new(src_dir).is_dir() {
panic!("src_dir must be a directory");
}
let method = zip::CompressionMethod::Stored;
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);
}
}