package main import ( "bufio" "encoding/json" "fmt" "io" "math" "net/http" "os" "regexp" "strings" "time" ) // Config var ( qualitorURL = envOrDefault("QUALITOR_URL", "https://qualitor.think.br.com/ws/services/General/WSGeneral.php") qualitorUser = envOrDefault("QUALITOR_USER", "automation.think") qualitorPass = envOrDefault("QUALITOR_PASS", "rz2FZoeXgXRqyL") qualitorCompany = envOrDefault("QUALITOR_COMPANY", "1") ) func envOrDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // ── SOAP helpers ── func buildLoginSOAP() string { return fmt.Sprintf(` %s %s %s `, qualitorUser, qualitorPass, qualitorCompany) } func buildQuerySOAP(token, sql string) string { return fmt.Sprintf(` %s %s ]]> `, token, sql) } func qualitorLogin() (token, cookie string, err error) { body := strings.NewReader(buildLoginSOAP()) req, _ := http.NewRequest("POST", qualitorURL, body) req.Header.Set("Content-Type", "application/xml") resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", fmt.Errorf("login request failed: %w", err) } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) xml := string(data) cookie = resp.Header.Get("Set-Cookie") re := regexp.MustCompile(`]*>([^<]+)`) m := re.FindStringSubmatch(xml) if m == nil { return "", "", fmt.Errorf("token not found in login response") } return m[1], cookie, nil } func qualitorQuery(token, cookie, sql string) (string, error) { body := strings.NewReader(buildQuerySOAP(token, sql)) req, _ := http.NewRequest("POST", qualitorURL, body) req.Header.Set("Content-Type", "text/xml; charset=utf-8") if cookie != "" { req.Header.Set("Cookie", cookie) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("query request failed: %w", err) } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) return string(data), nil } // ── XML parsing helpers ── func decodeEntities(s string) string { s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, """, `"`) return s } func extractResult(xml string) string { re := regexp.MustCompile(`]*>([\s\S]*?)`) m := re.FindStringSubmatch(xml) if m == nil { return "" } return decodeEntities(m[1]) } func extractDataItems(decoded string) []string { re := regexp.MustCompile(`([\s\S]*?)`) matches := re.FindAllStringSubmatch(decoded, -1) var items []string for _, m := range matches { items = append(items, m[1]) } return items } func extractField(block, field string) string { re := regexp.MustCompile(`(?i)<` + field + `>([\s\S]*?)`) m := re.FindStringSubmatch(block) if m == nil { return "" } return strings.TrimSpace(decodeEntities(m[1])) } func extractAllFields(block string) map[string]string { re := regexp.MustCompile(`<([^/][^>]*)>([^<]*)]+>`) matches := re.FindAllStringSubmatch(block, -1) fields := make(map[string]string) for _, m := range matches { key := strings.ToLower(m[1]) fields[key] = strings.TrimSpace(decodeEntities(m[2])) } return fields } func safeSQLString(s string) string { return strings.ReplaceAll(s, "'", "''") } func formatDateBR(dt string) string { if len(dt) < 10 { return "" } parts := strings.Split(dt[:10], "-") if len(parts) != 3 { return dt } time := "" if len(dt) >= 16 { time = " " + dt[11:16] } return parts[2] + "/" + parts[1] + "/" + parts[0] + time } // ── Tool: get_chamado ── type Atividade struct { Responsavel string `json:"responsavel"` Descricao string `json:"descricao"` Data string `json:"data"` HoraInicio string `json:"hora_inicio"` HoraFim string `json:"hora_fim"` Tempo string `json:"tempo"` } type ChamadoResult struct { Numero string `json:"numero"` Titulo string `json:"titulo"` Descricao string `json:"descricao"` Cliente string `json:"cliente"` Solicitante string `json:"solicitante"` Status string `json:"status"` Prioridade string `json:"prioridade"` Tipo string `json:"tipo"` Equipe string `json:"equipe"` Atendente string `json:"atendente"` Criador string `json:"criador"` Classificacao string `json:"classificacao"` DataAbertura string `json:"data_abertura"` UltimaAtualizacao string `json:"ultima_atualizacao"` DataTermino string `json:"data_termino"` DataLimite string `json:"data_limite"` SLA string `json:"sla"` IC string `json:"ic"` Localidade string `json:"localidade"` ModoAbertura string `json:"modo_abertura"` Solucao string `json:"solucao"` Sintoma string `json:"sintoma"` TotalHoras float64 `json:"total_horas"` TotalAtividades int `json:"total_atividades"` Atividades []Atividade `json:"atividades"` } func getChamado(numero string) (*ChamadoResult, error) { token, cookie, err := qualitorLogin() if err != nil { return nil, err } safe := safeSQLString(numero) sqlChamado := fmt.Sprintf(`SELECT NUMERO, TITULO, DESCRICAO, CLIENTE, SOLICITANTE, STATUS, PRIORIDADE, TIPO, EQUIPE, ATENDENTE, CRIADOR, CLASSIFICACAO_1, CLASSIFICACAO_2, CLASSIFICACAO_3, DATA_ABERTURA, ULTIMA_ATUALIZACAO, DATA_INICIO, DATA_TERMINO, DATA_LIMITE, SLA, EXPIRADO, IC, LOCALIDADE, MODO_ABERTURA, SOLUCAO, SINTOMA FROM lgit_chamados WHERE NUMERO = '%s'`, safe) sqlAtividades := fmt.Sprintf(`SELECT a.dsacompanhamento, a.dtacompanhamento, a.nrduracao, a.dtinicioacompanhamento, a.dtterminoacompanhamento, u.nmusuario AS responsavel FROM QUALITOR.dbo.hd_acompanhamento a LEFT JOIN QUALITOR.dbo.ad_usuario u ON u.cdusuario = a.cdusuario WHERE a.cdchamado = '%s' ORDER BY a.dtacompanhamento DESC`, safe) xmlChamado, err := qualitorQuery(token, cookie, sqlChamado) if err != nil { return nil, err } xmlAtividades, err := qualitorQuery(token, cookie, sqlAtividades) if err != nil { return nil, err } // Parse chamado decoded := extractResult(xmlChamado) items := extractDataItems(decoded) if len(items) == 0 { return nil, fmt.Errorf("chamado %s não encontrado", numero) } f := extractAllFields(items[0]) classificacao := strings.Join(filterEmpty([]string{ f["classificacao_1"], f["classificacao_2"], f["classificacao_3"], }), " > ") result := &ChamadoResult{ Numero: f["numero"], Titulo: f["titulo"], Descricao: f["descricao"], Cliente: f["cliente"], Solicitante: f["solicitante"], Status: f["status"], Prioridade: f["prioridade"], Tipo: f["tipo"], Equipe: f["equipe"], Atendente: f["atendente"], Criador: f["criador"], Classificacao: classificacao, DataAbertura: formatDateBR(f["data_abertura"]), UltimaAtualizacao: formatDateBR(f["ultima_atualizacao"]), DataTermino: formatDateBR(f["data_termino"]), DataLimite: formatDateBR(f["data_limite"]), SLA: f["sla"], IC: f["ic"], Localidade: f["localidade"], ModoAbertura: f["modo_abertura"], Solucao: f["solucao"], Sintoma: f["sintoma"], } // Parse atividades decodedAtiv := extractResult(xmlAtividades) ativItems := extractDataItems(decodedAtiv) var totalMinutos float64 for _, block := range ativItems { responsavel := extractField(block, "responsavel") descricao := extractField(block, "dsacompanhamento") dtAcomp := extractField(block, "dtacompanhamento") duracaoStr := extractField(block, "nrduracao") inicio := extractField(block, "dtinicioacompanhamento") fim := extractField(block, "dtterminoacompanhamento") duracao := parseFloat(duracaoStr) totalMinutos += duracao * 60 dataRef := inicio if dataRef == "" { dataRef = dtAcomp } horaInicio := "-" horaFim := "-" if len(inicio) >= 16 { horaInicio = inicio[11:16] } if len(fim) >= 16 { horaFim = fim[11:16] } tempo := "-" if duracao > 0 { tempo = fmt.Sprintf("%.1fh", duracao) } result.Atividades = append(result.Atividades, Atividade{ Responsavel: responsavel, Descricao: descricao, Data: formatDateBR(dataRef), HoraInicio: horaInicio, HoraFim: horaFim, Tempo: tempo, }) } result.TotalHoras = float64(int(totalMinutos/60*10)) / 10 result.TotalAtividades = len(result.Atividades) return result, nil } // ── Tool: pesquisar_chamados ── type PesquisaChamadosArgs struct { Cliente string `json:"cliente"` Fila string `json:"fila"` Atendente string `json:"atendente"` Tipo string `json:"tipo"` Categoria string `json:"categoria"` Dias int `json:"dias"` Status string `json:"status"` Busca string `json:"busca"` } type ChamadoResumo struct { Numero string `json:"numero"` Titulo string `json:"titulo"` Cliente string `json:"cliente"` Status string `json:"status"` Prioridade string `json:"prioridade"` Tipo string `json:"tipo"` Equipe string `json:"equipe"` Atendente string `json:"atendente"` Categoria string `json:"categoria"` DataAbertura string `json:"data_abertura"` IdadeDias string `json:"idade_dias"` } func pesquisarChamados(args *PesquisaChamadosArgs) ([]ChamadoResumo, error) { token, cookie, err := qualitorLogin() if err != nil { return nil, err } // Defaults if args.Dias == 0 { args.Dias = 7 } sql := fmt.Sprintf(`SELECT TOP 200 NUMERO, DATEDIFF(DAY, DATA_ABERTURA, GETDATE()) AS AGE_DIAS, TITULO, CLIENTE, STATUS, PRIORIDADE, DATA_ABERTURA, CASE WHEN CLASSIFICACAO_1 LIKE '1%%' OR CLASSIFICACAO_1 LIKE '2%%' OR CLASSIFICACAO_1 LIKE '3%%' OR CLASSIFICACAO_1 LIKE '4%%' OR CLASSIFICACAO_1 LIKE '5%%' OR CLASSIFICACAO_1 LIKE '8%%' OR CLASSIFICACAO_1 LIKE '9%%' THEN SUBSTRING(CLASSIFICACAO_1, CHARINDEX(' ', CLASSIFICACAO_1) + 1, LEN(CLASSIFICACAO_1)) ELSE CLASSIFICACAO_1 END AS CATEGORIA, TIPO, ATENDENTE, EQUIPE FROM lgit_chamados WHERE DATA_ABERTURA >= DATEADD(DAY, -%d, GETDATE())`, args.Dias) if args.Cliente != "" { parts := strings.Split(args.Cliente, ",") quoted := make([]string, len(parts)) for i, p := range parts { quoted[i] = "'" + safeSQLString(strings.TrimSpace(p)) + "'" } sql += "\n AND CLIENTE IN (" + strings.Join(quoted, ",") + ")" } if args.Fila != "" { parts := strings.Split(args.Fila, ",") quoted := make([]string, len(parts)) for i, p := range parts { quoted[i] = "'" + safeSQLString(strings.TrimSpace(p)) + "'" } sql += "\n AND EQUIPE IN (" + strings.Join(quoted, ",") + ")" } if args.Atendente != "" { parts := strings.Split(args.Atendente, ",") quoted := make([]string, len(parts)) for i, p := range parts { quoted[i] = "'" + safeSQLString(strings.TrimSpace(p)) + "'" } sql += "\n AND ATENDENTE IN (" + strings.Join(quoted, ",") + ")" } if args.Tipo != "" { parts := strings.Split(args.Tipo, ",") quoted := make([]string, len(parts)) for i, p := range parts { quoted[i] = "'" + safeSQLString(strings.TrimSpace(p)) + "'" } sql += "\n AND TIPO IN (" + strings.Join(quoted, ",") + ")" } if args.Categoria != "" { parts := strings.Split(args.Categoria, ",") conditions := make([]string, len(parts)) for i, p := range parts { conditions[i] = "CLASSIFICACAO_1 LIKE '%" + safeSQLString(strings.TrimSpace(p)) + "%'" } sql += "\n AND (" + strings.Join(conditions, " OR ") + ")" } if args.Status == "" { // Default: exclui Cancelado e Terminado sql += "\n AND STATUS NOT IN ('Cancelado','TERMINADO')" } else { parts := strings.Split(args.Status, ",") quoted := make([]string, len(parts)) for i, p := range parts { quoted[i] = "'" + safeSQLString(strings.TrimSpace(p)) + "'" } sql += "\n AND STATUS IN (" + strings.Join(quoted, ",") + ")" } if args.Busca != "" { safe := safeSQLString(args.Busca) like := "%" + safe + "%" sql += "\n AND (TITULO LIKE '" + like + "' OR DESCRICAO LIKE '" + like + "' OR ID IN (SELECT cdchamado FROM QUALITOR.dbo.hd_acompanhamento WHERE dsacompanhamento LIKE '" + like + "'))" } sql += "\nORDER BY DATA_ABERTURA DESC" xmlResult, err := qualitorQuery(token, cookie, sql) if err != nil { return nil, err } decoded := extractResult(xmlResult) items := extractDataItems(decoded) var chamados []ChamadoResumo for _, block := range items { f := extractAllFields(block) chamados = append(chamados, ChamadoResumo{ Numero: f["numero"], Titulo: f["titulo"], Cliente: f["cliente"], Status: f["status"], Prioridade: f["prioridade"], Tipo: f["tipo"], Equipe: f["equipe"], Atendente: f["atendente"], Categoria: f["categoria"], DataAbertura: formatDateBR(f["data_abertura"]), IdadeDias: f["age_dias"], }) } return chamados, nil } func formatPesquisaForLLM(chamados []ChamadoResumo, args PesquisaChamadosArgs) string { var b strings.Builder b.WriteString(fmt.Sprintf("# Pesquisa de Chamados (%d resultados)\n\n", len(chamados))) // Filtros aplicados b.WriteString("**Filtros:** ") filtros := []string{fmt.Sprintf("Período: %d dias", args.Dias)} if args.Cliente != "" { filtros = append(filtros, "Cliente: "+args.Cliente) } if args.Fila != "" { filtros = append(filtros, "Fila: "+args.Fila) } if args.Atendente != "" { filtros = append(filtros, "Atendente: "+args.Atendente) } if args.Tipo != "" { filtros = append(filtros, "Tipo: "+args.Tipo) } if args.Categoria != "" { filtros = append(filtros, "Categoria: "+args.Categoria) } if args.Status != "" { filtros = append(filtros, "Status: "+args.Status) } if args.Busca != "" { filtros = append(filtros, "Busca: \""+args.Busca+"\"") } b.WriteString(strings.Join(filtros, " | ") + "\n\n") if len(chamados) == 0 { b.WriteString("Nenhum chamado encontrado com os filtros informados.\n") return b.String() } // Resumo por status statusCount := make(map[string]int) for _, c := range chamados { statusCount[c.Status]++ } b.WriteString("**Resumo por status:** ") var statusParts []string for s, count := range statusCount { statusParts = append(statusParts, fmt.Sprintf("%s (%d)", s, count)) } b.WriteString(strings.Join(statusParts, ", ") + "\n\n") // Tabela b.WriteString("| # | Número | Título | Cliente | Status | Prioridade | Atendente | Abertura | Idade |\n") b.WriteString("|---|--------|--------|---------|--------|------------|-----------|----------|-------|\n") for i, c := range chamados { titulo := c.Titulo if len(titulo) > 60 { titulo = titulo[:57] + "..." } b.WriteString(fmt.Sprintf("| %d | %s | %s | %s | %s | %s | %s | %s | %s dias |\n", i+1, c.Numero, titulo, c.Cliente, c.Status, c.Prioridade, c.Atendente, c.DataAbertura, c.IdadeDias)) } return b.String() } // ── Tool: relatorio_chargeability ── var equipeMembros = map[string][]string{ "CLOUD": {"Camila Silva Castelo Branco", "Cassiano Ficagna Barbosa", "Gustavo Oliveira Ramos de Lima", "Murilo Abineder Julieti", "Pedro Miguel Dias Oliveira", "Rafael Broglia Szyszko", "Rafael Matheus Ramos Galiani", "Reginaldo Silva Rocha", "Vinicius Santos Ferreira"}, "LINUX": {"Carlos Roitman Amaral Maceno", "Douglas Michel da Silva Costa", "Edilson Pereira Caldas", "Eliel Borba Vaz", "Fabio Aparecido dos Santos", "João Paulo Santos Araujo", "Julyana Silva da Rocha", "Odair Batista Gonçalves dos Santos", "Vinicius Santos Silva"}, "REDES": {"Abner Mateus Alves Silva", "Fabiano Francisco Martins", "Kevin Ramos Santos", "Lucas Camargo Reichert", "Odilon Pascoal Mendes Silva"}, "VIRTUALIZAÇÃO": {"Pedro Oliveira De Aguiar", "Rafael Duarte Fontenele Liberato"}, "WINDOWS": {"André Luiz Navarro Martos", "Guilherme Ferreira Rocha", "José Luciano Silva Cavalcante", "Julio Cezar Vidal de Oliveira", "Lucas Gabriel De Souza Calado", "Rafael Ferreira dos Santos"}, } type ChargeabilityArgs struct { Equipe string `json:"equipe"` Atendente string `json:"atendente"` Fila string `json:"fila"` Dias int `json:"dias"` } type ProfissionalChargeability struct { Nome string Registros int HorasNormais float64 HorasExtra float64 Total float64 HorasUteis float64 Chargeability float64 } func calcularHorasUteis(dias int) float64 { now := time.Now() var horas float64 for i := 0; i < dias; i++ { d := now.AddDate(0, 0, -i) dow := d.Weekday() if dow >= time.Monday && dow <= time.Friday { horas += 8 } } return horas } func calcNormalExtra(inicio, fim string, totalMin float64) (normal, extra float64) { if inicio == "" || fim == "" { return totalMin, 0 } dtInicio, err1 := time.Parse("2006-01-02 15:04:05.000", inicio) dtFim, err2 := time.Parse("2006-01-02 15:04:05.000", fim) if err1 != nil || err2 != nil || !dtFim.After(dtInicio) { // Try shorter format dtInicio, err1 = time.Parse("2006-01-02 15:04:05", inicio) dtFim, err2 = time.Parse("2006-01-02 15:04:05", fim) if err1 != nil || err2 != nil || !dtFim.After(dtInicio) { return totalMin, 0 } } diffMin := dtFim.Sub(dtInicio).Minutes() if diffMin <= 0 { return 0, totalMin } var minutosNormais float64 cursor := dtInicio for cursor.Before(dtFim) { dow := cursor.Weekday() if dow >= time.Monday && dow <= time.Friday { diaBase := time.Date(cursor.Year(), cursor.Month(), cursor.Day(), 0, 0, 0, 0, cursor.Location()) ns := diaBase.Add(9 * time.Hour) ne := diaBase.Add(18 * time.Hour) minutosNormais += overlapMin(cursor, dtFim, ns, ne) } next := time.Date(cursor.Year(), cursor.Month(), cursor.Day()+1, 0, 0, 0, 0, cursor.Location()) if !next.After(cursor) { break } cursor = next } prop := minutosNormais / diffMin normal = math.Round(totalMin*prop*10) / 10 extra = math.Round((totalMin-normal)*10) / 10 return } func overlapMin(a1, a2, b1, b2 time.Time) float64 { start := a1 if b1.After(start) { start = b1 } end := a2 if b2.Before(end) { end = b2 } diff := end.Sub(start).Minutes() if diff > 0 { return diff } return 0 } func relatorioChargeability(args *ChargeabilityArgs) ([]ProfissionalChargeability, error) { token, cookie, err := qualitorLogin() if err != nil { return nil, err } if args.Dias == 0 { args.Dias = 30 } // Resolve atendentes var atendentes []string if args.Atendente != "" { atendentes = strings.Split(args.Atendente, ",") for i := range atendentes { atendentes[i] = strings.TrimSpace(atendentes[i]) } } else if args.Equipe != "" { for _, eq := range strings.Split(args.Equipe, ",") { eq = strings.TrimSpace(eq) if membros, ok := equipeMembros[eq]; ok { atendentes = append(atendentes, membros...) } } } sql := `SELECT RESPONSAVEL, DATEPART(DW, COALESCE(NULLIF(DATA_INICIO_ACOMPANHAMENTO, ''), DATA_COMPLETA)) AS DW, DATEPART(HOUR, COALESCE(NULLIF(DATA_INICIO_ACOMPANHAMENTO, ''), DATA_COMPLETA)) AS HH, DATA_INICIO_ACOMPANHAMENTO AS DT_INICIO, DATA_FIM_ACOMPANHAMENTO AS DT_FIM, CAST(LEFT(Total_FUNC, 2) AS INT) * 60 + CAST(SUBSTRING(Total_FUNC, 4, 2) AS INT) + CAST(RIGHT(Total_FUNC, 2) AS FLOAT) / 60 AS MINUTOS FROM QUALITOR.dbo.vw_LGIT_HorasPorChamado WHERE Total_FUNC IS NOT NULL AND Total_FUNC != '' AND COALESCE(NULLIF(DATA_INICIO_ACOMPANHAMENTO, ''), DATA_COMPLETA) >= DATEADD(DAY, -` + fmt.Sprintf("%d", args.Dias) + `, GETDATE())` if args.Fila != "" { parts := strings.Split(args.Fila, ",") quoted := make([]string, len(parts)) for i, p := range parts { quoted[i] = "'" + safeSQLString(strings.TrimSpace(p)) + "'" } sql += "\n AND EQUIPE IN (" + strings.Join(quoted, ",") + ")" } if len(atendentes) > 0 { quoted := make([]string, len(atendentes)) for i, a := range atendentes { quoted[i] = "'" + safeSQLString(a) + "'" } sql += "\n AND RESPONSAVEL IN (" + strings.Join(quoted, ",") + ")" } xmlResult, err := qualitorQuery(token, cookie, sql) if err != nil { return nil, err } decoded := extractResult(xmlResult) items := extractDataItems(decoded) profMap := make(map[string]*struct { registros int normais float64 extra float64 }) for _, block := range items { nome := extractField(block, "responsavel") if nome == "" { continue } dtInicio := extractField(block, "dt_inicio") dtFim := extractField(block, "dt_fim") minutos := parseFloat(extractField(block, "minutos")) if _, ok := profMap[nome]; !ok { profMap[nome] = &struct { registros int normais float64 extra float64 }{} } p := profMap[nome] p.registros++ normal, extra := calcNormalExtra(dtInicio, dtFim, minutos) p.normais += normal p.extra += extra } horasUteis := calcularHorasUteis(args.Dias) var result []ProfissionalChargeability for nome, data := range profMap { total := (data.normais + data.extra) / 60 hn := math.Round(data.normais/60*10) / 10 he := math.Round(data.extra/60*10) / 10 totalH := math.Round(total*10) / 10 charge := 0.0 if horasUteis > 0 { charge = math.Round(total/horasUteis*1000) / 10 } result = append(result, ProfissionalChargeability{ Nome: nome, Registros: data.registros, HorasNormais: hn, HorasExtra: he, Total: totalH, HorasUteis: horasUteis, Chargeability: charge, }) } // Sort by total desc for i := 0; i < len(result); i++ { for j := i + 1; j < len(result); j++ { if result[j].Total > result[i].Total { result[i], result[j] = result[j], result[i] } } } return result, nil } func formatChargeabilityForLLM(result []ProfissionalChargeability, args ChargeabilityArgs) string { var b strings.Builder b.WriteString("# Relatório de Chargeability\n\n") filtros := []string{fmt.Sprintf("Período: %d dias", args.Dias)} if args.Equipe != "" { filtros = append(filtros, "Equipe: "+args.Equipe) } if args.Atendente != "" { filtros = append(filtros, "Atendente: "+args.Atendente) } if args.Fila != "" { filtros = append(filtros, "Fila: "+args.Fila) } horasUteis := 0.0 if len(result) > 0 { horasUteis = result[0].HorasUteis } filtros = append(filtros, fmt.Sprintf("Base: %.0fh úteis (8h/dia, seg-sex)", horasUteis)) b.WriteString("**Filtros:** " + strings.Join(filtros, " | ") + "\n\n") if len(result) == 0 { b.WriteString("Nenhum dado encontrado.\n") return b.String() } // Totais var totalHN, totalHE, totalH float64 var totalReg int for _, r := range result { totalHN += r.HorasNormais totalHE += r.HorasExtra totalH += r.Total totalReg += r.Registros } b.WriteString(fmt.Sprintf("**Totais:** %d registros | %.1fh normais | %.1fh extra | **%.1fh total**\n\n", totalReg, totalHN, totalHE, totalH)) // Tabela b.WriteString("| Profissional | Registros | Horas Normais | Horas Extra | Total | Chargeability |\n") b.WriteString("|---|---|---|---|---|---|\n") for _, r := range result { chargeIcon := "🔴" if r.Chargeability >= 100 { chargeIcon = "🔵" } else if r.Chargeability >= 80 { chargeIcon = "🟢" } else if r.Chargeability >= 50 { chargeIcon = "🟡" } b.WriteString(fmt.Sprintf("| %s | %d | %.1fh | %.1fh | **%.1fh** | %s %.1f%% |\n", r.Nome, r.Registros, r.HorasNormais, r.HorasExtra, r.Total, chargeIcon, r.Chargeability)) } return b.String() } // ── Tool: pesquisar_usuarios / get_usuario ── type UsuarioResumo struct { Nome string `json:"nome"` Email string `json:"email"` Cargo string `json:"cargo"` Ativo string `json:"ativo"` Disponivel string `json:"disponivel"` } func pesquisarUsuarios(busca string) ([]UsuarioResumo, error) { token, cookie, err := qualitorLogin() if err != nil { return nil, err } safe := safeSQLString(busca) like := "%" + safe + "%" sql := `SELECT u.nmusuario, u.dsemail, u.idativo, u.iddisponivel, c.nmcargo FROM QUALITOR.dbo.ad_usuario u LEFT JOIN QUALITOR.dbo.ad_cargo c ON c.cdcargo = u.cdcargo WHERE (u.nmusuario LIKE '` + like + `' OR u.dsemail LIKE '` + like + `' OR u.nmusuariorede LIKE '` + like + `') AND u.idativo = 'Y' ORDER BY u.nmusuario` xmlResult, err := qualitorQuery(token, cookie, sql) if err != nil { return nil, err } decoded := extractResult(xmlResult) items := extractDataItems(decoded) var usuarios []UsuarioResumo for _, block := range items { f := extractAllFields(block) usuarios = append(usuarios, UsuarioResumo{ Nome: f["nmusuario"], Email: f["dsemail"], Cargo: f["nmcargo"], Ativo: f["idativo"], Disponivel: f["iddisponivel"], }) } return usuarios, nil } type UsuarioDetalhado struct { Nome string `json:"nome"` Email string `json:"email"` Login string `json:"login"` Cargo string `json:"cargo"` Superior string `json:"superior"` Ativo string `json:"ativo"` Disponivel string `json:"disponivel"` Bloqueado string `json:"bloqueado"` Equipes []string `json:"equipes"` Total30d string `json:"total_chamados_30d"` Terminados string `json:"terminados_30d"` Abertos string `json:"abertos_30d"` } func getUsuario(nome string) (*UsuarioDetalhado, error) { token, cookie, err := qualitorLogin() if err != nil { return nil, err } safe := safeSQLString(nome) // Dados do usuario sqlUser := `SELECT u.nmusuario, u.dsemail, u.nmusuariorede, u.idativo, u.iddisponivel, u.idbloqueado, c.nmcargo, s.nmusuario AS superior FROM QUALITOR.dbo.ad_usuario u LEFT JOIN QUALITOR.dbo.ad_cargo c ON c.cdcargo = u.cdcargo LEFT JOIN QUALITOR.dbo.ad_usuario s ON s.cdusuario = u.cdusuariosuperior WHERE u.nmusuario = '` + safe + `'` // Equipes sqlEquipes := `SELECT e.nmequipe FROM QUALITOR.dbo.hd_equipe e JOIN QUALITOR.dbo.hd_equipeusuario eu ON eu.cdequipe = e.cdequipe JOIN QUALITOR.dbo.ad_usuario u ON u.cdusuario = eu.cdusuario WHERE u.nmusuario = '` + safe + `' ORDER BY e.nmequipe` // Stats 30 dias sqlStats := `SELECT COUNT(*) AS total, SUM(CASE WHEN STATUS = 'TERMINADO' THEN 1 ELSE 0 END) AS terminados, SUM(CASE WHEN STATUS NOT IN ('TERMINADO','Cancelado') THEN 1 ELSE 0 END) AS abertos FROM lgit_chamados WHERE ATENDENTE = '` + safe + `' AND DATA_ABERTURA >= DATEADD(DAY, -30, GETDATE())` xmlUser, err := qualitorQuery(token, cookie, sqlUser) if err != nil { return nil, err } xmlEquipes, err := qualitorQuery(token, cookie, sqlEquipes) if err != nil { return nil, err } xmlStats, err := qualitorQuery(token, cookie, sqlStats) if err != nil { return nil, err } // Parse user decoded := extractResult(xmlUser) items := extractDataItems(decoded) if len(items) == 0 { return nil, fmt.Errorf("usuário '%s' não encontrado", nome) } f := extractAllFields(items[0]) result := &UsuarioDetalhado{ Nome: f["nmusuario"], Email: f["dsemail"], Login: f["nmusuariorede"], Cargo: f["nmcargo"], Superior: f["superior"], Ativo: f["idativo"], Disponivel: f["iddisponivel"], Bloqueado: f["idbloqueado"], } // Parse equipes decodedEq := extractResult(xmlEquipes) eqItems := extractDataItems(decodedEq) for _, block := range eqItems { eq := extractField(block, "nmequipe") if eq != "" { result.Equipes = append(result.Equipes, eq) } } // Parse stats decodedSt := extractResult(xmlStats) stItems := extractDataItems(decodedSt) if len(stItems) > 0 { sf := extractAllFields(stItems[0]) result.Total30d = sf["total"] result.Terminados = sf["terminados"] result.Abertos = sf["abertos"] } return result, nil } func formatUsuarioForLLM(u *UsuarioDetalhado) string { var b strings.Builder b.WriteString(fmt.Sprintf("# Usuário: %s\n\n", u.Nome)) b.WriteString(fmt.Sprintf("**E-mail:** %s\n", u.Email)) b.WriteString(fmt.Sprintf("**Login:** %s\n", u.Login)) b.WriteString(fmt.Sprintf("**Cargo:** %s\n", u.Cargo)) b.WriteString(fmt.Sprintf("**Superior:** %s\n", u.Superior)) status := "Ativo" if u.Ativo != "Y" { status = "Inativo" } if u.Bloqueado == "S" { status += " (Bloqueado)" } b.WriteString(fmt.Sprintf("**Status:** %s | **Disponível:** %s\n", status, u.Disponivel)) if len(u.Equipes) > 0 { b.WriteString(fmt.Sprintf("**Equipes:** %s\n", strings.Join(u.Equipes, ", "))) } b.WriteString(fmt.Sprintf("\n## Chamados (últimos 30 dias)\n")) b.WriteString(fmt.Sprintf("- Total: %s\n", u.Total30d)) b.WriteString(fmt.Sprintf("- Terminados: %s\n", u.Terminados)) b.WriteString(fmt.Sprintf("- Em aberto: %s\n", u.Abertos)) return b.String() } func formatPesquisaUsuariosForLLM(usuarios []UsuarioResumo, busca string) string { var b strings.Builder b.WriteString(fmt.Sprintf("# Pesquisa de Usuários (%d resultados)\n\n", len(usuarios))) b.WriteString(fmt.Sprintf("**Busca:** \"%s\"\n\n", busca)) if len(usuarios) == 0 { b.WriteString("Nenhum usuário encontrado.\n") return b.String() } b.WriteString("| Nome | E-mail | Cargo | Disponível |\n") b.WriteString("|------|--------|-------|------------|\n") for _, u := range usuarios { disp := "Sim" if u.Disponivel != "Y" { disp = "Não" } b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", u.Nome, u.Email, u.Cargo, disp)) } return b.String() } func filterEmpty(ss []string) []string { var out []string for _, s := range ss { if s != "" { out = append(out, s) } } return out } func parseFloat(s string) float64 { var f float64 fmt.Sscanf(s, "%f", &f) return f } // ── MCP SSE Protocol (JSON-RPC over stdio) ── type JSONRPCRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } type JSONRPCResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Result interface{} `json:"result,omitempty"` Error *RPCError `json:"error,omitempty"` } type RPCError struct { Code int `json:"code"` Message string `json:"message"` } func sendResponse(w io.Writer, id json.RawMessage, result interface{}) { resp := JSONRPCResponse{JSONRPC: "2.0", ID: id, Result: result} data, _ := json.Marshal(resp) fmt.Fprintf(w, "%s\n", data) } func sendError(w io.Writer, id json.RawMessage, code int, msg string) { resp := JSONRPCResponse{JSONRPC: "2.0", ID: id, Error: &RPCError{Code: code, Message: msg}} data, _ := json.Marshal(resp) fmt.Fprintf(w, "%s\n", data) } func handleInitialize(id json.RawMessage, w io.Writer) { sendResponse(w, id, map[string]interface{}{ "protocolVersion": "2024-11-05", "capabilities": map[string]interface{}{ "tools": map[string]interface{}{}, }, "serverInfo": map[string]interface{}{ "name": "qualitor-mcp", "version": "1.0.0", }, }) } func handleToolsList(id json.RawMessage, w io.Writer) { sendResponse(w, id, map[string]interface{}{ "tools": []map[string]interface{}{ { "name": "get_chamado", "description": "Busca informações detalhadas de um chamado no Qualitor pelo número. Retorna dados do chamado (título, cliente, status, prioridade, equipe, atendente, SLA, datas, classificação, descrição, solução) e todas as atividades/acompanhamentos registrados com responsável, data, horários e descrição do que foi feito. Inclui o total de horas de esforço.", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "numero": map[string]interface{}{ "type": "string", "description": "Número do chamado no Qualitor (ex: 1081120)", }, }, "required": []string{"numero"}, }, }, { "name": "pesquisar_chamados", "description": "Pesquisa chamados no Qualitor com filtros opcionais. Retorna lista de chamados com número, título, cliente, status, prioridade, tipo, equipe, atendente, data de abertura e idade em dias. Todos os filtros são opcionais — se não informados, usam valores padrão (todas as filas, todos os clientes, todos os atendentes, últimos 7 dias).", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "cliente": map[string]interface{}{ "type": "string", "description": "Nome do cliente para filtrar. Pode ser múltiplos separados por vírgula (ex: 'HINODE' ou 'HINODE,LEO MADEIRAS'). Padrão: todos os clientes.", }, "fila": map[string]interface{}{ "type": "string", "description": "Fila/equipe do Qualitor (ex: 'ADVANCED - REDES', 'ADVANCED - CLOUD'). Múltiplos separados por vírgula. Padrão: todas as filas.", }, "atendente": map[string]interface{}{ "type": "string", "description": "Nome do atendente para filtrar (ex: 'Odilon Pascoal Mendes Silva'). Múltiplos separados por vírgula. Padrão: todos os atendentes.", }, "tipo": map[string]interface{}{ "type": "string", "description": "Tipo do chamado (ex: '5. Solicitação', 'INCIDENTE', 'Projeto', 'REQ - Cliente'). Múltiplos separados por vírgula. Padrão: todos os tipos.", }, "categoria": map[string]interface{}{ "type": "string", "description": "Categoria/classificação do chamado (ex: 'INC - Network', 'Rede WAN/LAN', 'Banco de Dados'). Múltiplos separados por vírgula. Padrão: todas as categorias.", }, "status": map[string]interface{}{ "type": "string", "description": "Status do chamado (ex: 'Em atendimento', 'Aguardando atendimento', 'TERMINADO', 'Suspenso'). Múltiplos separados por vírgula. Padrão: todos os status.", }, "dias": map[string]interface{}{ "type": "integer", "description": "Quantidade de dias para buscar a partir de hoje (ex: 7, 30, 90, 120). Padrão: 7 dias.", }, "busca": map[string]interface{}{ "type": "string", "description": "Texto livre para buscar no título, descrição e atividades/acompanhamentos do chamado (ex: '10.101.14.25', 'VPN', 'backup falhou'). Se o texto aparecer em qualquer atividade registrada, o chamado será retornado.", }, }, }, }, { "name": "relatorio_chargeability", "description": "Gera relatório de chargeability (aproveitamento de horas) por profissional. Mostra horas normais (09:00-18:00 seg-sex), horas extra (fora do horário ou fins de semana), total e percentual de chargeability baseado nas horas úteis do período. Pode filtrar por equipe (CLOUD, LINUX, REDES, VIRTUALIZAÇÃO, WINDOWS), por atendente específico ou por fila do Qualitor.", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "equipe": map[string]interface{}{ "type": "string", "description": "Nome da equipe para filtrar (CLOUD, LINUX, REDES, VIRTUALIZAÇÃO, WINDOWS). Múltiplas separadas por vírgula. Filtra pelos membros da equipe. Padrão: todos.", }, "atendente": map[string]interface{}{ "type": "string", "description": "Nome do profissional específico (ex: 'Odilon Pascoal Mendes Silva'). Múltiplos separados por vírgula. Tem prioridade sobre equipe. Padrão: todos.", }, "fila": map[string]interface{}{ "type": "string", "description": "Fila do Qualitor para filtrar os apontamentos (ex: 'ADVANCED - REDES', 'ADVANCED - CLOUD'). Múltiplas separadas por vírgula. Padrão: todas.", }, "dias": map[string]interface{}{ "type": "integer", "description": "Quantidade de dias para o relatório (ex: 7, 15, 30, 60, 90). Padrão: 30 dias.", }, }, }, }, { "name": "pesquisar_usuarios", "description": "Pesquisa usuários ativos no Qualitor por nome, e-mail ou login de rede. Retorna nome, e-mail, cargo e disponibilidade.", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "busca": map[string]interface{}{ "type": "string", "description": "Texto para buscar no nome, e-mail ou login do usuário (ex: 'Odilon', 'odilon.silva', 'grupothink')", }, }, "required": []string{"busca"}, }, }, { "name": "get_usuario", "description": "Busca informações detalhadas de um usuário no Qualitor pelo nome completo. Retorna dados pessoais (e-mail, login, cargo, superior hierárquico), equipes que participa, status (ativo/bloqueado/disponível) e estatísticas de chamados dos últimos 30 dias.", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "nome": map[string]interface{}{ "type": "string", "description": "Nome completo do usuário no Qualitor (ex: 'Odilon Pascoal Mendes Silva')", }, }, "required": []string{"nome"}, }, }, }, }) } func handleToolCall(id json.RawMessage, params json.RawMessage, w io.Writer) { var p struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } json.Unmarshal(params, &p) switch p.Name { case "get_chamado": var args struct { Numero string `json:"numero"` } json.Unmarshal(p.Arguments, &args) if args.Numero == "" { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": "Erro: número do chamado é obrigatório"}, }, "isError": true, }) return } result, err := getChamado(args.Numero) if err != nil { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": fmt.Sprintf("Erro ao buscar chamado: %s", err.Error())}, }, "isError": true, }) return } // Formata saída estruturada para LLM text := formatChamadoForLLM(result) sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": text}, }, }) case "pesquisar_chamados": var args PesquisaChamadosArgs json.Unmarshal(p.Arguments, &args) chamados, err := pesquisarChamados(&args) if err != nil { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": fmt.Sprintf("Erro ao pesquisar chamados: %s", err.Error())}, }, "isError": true, }) return } text := formatPesquisaForLLM(chamados, args) sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": text}, }, }) case "relatorio_chargeability": var args ChargeabilityArgs json.Unmarshal(p.Arguments, &args) result, err := relatorioChargeability(&args) if err != nil { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": fmt.Sprintf("Erro ao gerar relatório: %s", err.Error())}, }, "isError": true, }) return } text := formatChargeabilityForLLM(result, args) sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": text}, }, }) case "pesquisar_usuarios": var args struct { Busca string `json:"busca"` } json.Unmarshal(p.Arguments, &args) if args.Busca == "" { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": "Erro: campo 'busca' é obrigatório"}, }, "isError": true, }) return } usuarios, err := pesquisarUsuarios(args.Busca) if err != nil { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": fmt.Sprintf("Erro: %s", err.Error())}, }, "isError": true, }) return } text := formatPesquisaUsuariosForLLM(usuarios, args.Busca) sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": text}, }, }) case "get_usuario": var args struct { Nome string `json:"nome"` } json.Unmarshal(p.Arguments, &args) if args.Nome == "" { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": "Erro: campo 'nome' é obrigatório"}, }, "isError": true, }) return } usuario, err := getUsuario(args.Nome) if err != nil { sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": fmt.Sprintf("Erro: %s", err.Error())}, }, "isError": true, }) return } text := formatUsuarioForLLM(usuario) sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": text}, }, }) default: sendResponse(w, id, map[string]interface{}{ "content": []map[string]interface{}{ {"type": "text", "text": fmt.Sprintf("Tool '%s' não encontrada", p.Name)}, }, "isError": true, }) } } func formatChamadoForLLM(c *ChamadoResult) string { var b strings.Builder b.WriteString(fmt.Sprintf("# Chamado #%s\n\n", c.Numero)) b.WriteString(fmt.Sprintf("**Título:** %s\n", c.Titulo)) b.WriteString(fmt.Sprintf("**Status:** %s | **Prioridade:** %s | **SLA:** %s\n", c.Status, c.Prioridade, c.SLA)) b.WriteString(fmt.Sprintf("**Cliente:** %s | **Solicitante:** %s\n", c.Cliente, c.Solicitante)) b.WriteString(fmt.Sprintf("**Equipe:** %s | **Atendente:** %s\n", c.Equipe, c.Atendente)) b.WriteString(fmt.Sprintf("**Tipo:** %s | **Classificação:** %s\n", c.Tipo, c.Classificacao)) b.WriteString(fmt.Sprintf("**Abertura:** %s | **Última Atualização:** %s\n", c.DataAbertura, c.UltimaAtualizacao)) b.WriteString(fmt.Sprintf("**Data Limite:** %s | **Data Término:** %s\n", c.DataLimite, c.DataTermino)) b.WriteString(fmt.Sprintf("**IC:** %s | **Localidade:** %s | **Modo Abertura:** %s\n", c.IC, c.Localidade, c.ModoAbertura)) b.WriteString(fmt.Sprintf("**Esforço Total:** %.1fh (%d atividades)\n", c.TotalHoras, c.TotalAtividades)) if c.Descricao != "" { b.WriteString(fmt.Sprintf("\n## Descrição\n%s\n", c.Descricao)) } if c.Sintoma != "" && c.Sintoma != c.Descricao { b.WriteString(fmt.Sprintf("\n## Sintoma\n%s\n", c.Sintoma)) } if c.Solucao != "" { b.WriteString(fmt.Sprintf("\n## Solução\n%s\n", c.Solucao)) } if len(c.Atividades) > 0 { b.WriteString("\n## Atividades / Acompanhamentos\n\n") for i, a := range c.Atividades { horario := "" if a.HoraInicio != "-" { horario = fmt.Sprintf(" (%s - %s)", a.HoraInicio, a.HoraFim) } tempo := "" if a.Tempo != "-" { tempo = fmt.Sprintf(" [%s]", a.Tempo) } b.WriteString(fmt.Sprintf("### %d. %s — %s%s%s\n", i+1, a.Data, a.Responsavel, horario, tempo)) if a.Descricao != "" { b.WriteString(a.Descricao + "\n") } b.WriteString("\n") } } return b.String() } // ── Main ── func main() { reader := bufio.NewReader(os.Stdin) writer := os.Stdout for { line, err := reader.ReadBytes('\n') if err != nil { break } line = []byte(strings.TrimSpace(string(line))) if len(line) == 0 { continue } var req JSONRPCRequest if err := json.Unmarshal(line, &req); err != nil { continue } switch req.Method { case "initialize": handleInitialize(req.ID, writer) case "notifications/initialized": // no response needed case "tools/list": handleToolsList(req.ID, writer) case "tools/call": handleToolCall(req.ID, req.Params, writer) case "ping": sendResponse(writer, req.ID, map[string]interface{}{}) default: if req.ID != nil { sendError(writer, req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method)) } } } }