Files
mcp-claude/main.go
2026-04-01 13:31:06 -03:00

1182 lines
36 KiB
Go

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(`<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:main">
<soapenv:Header/>
<soapenv:Body>
<urn:login soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<login xsi:type="xsd:string">%s</login>
<passwd xsi:type="xsd:string">%s</passwd>
<company xsi:type="xsd:string">%s</company>
</urn:login>
</soapenv:Body>
</soapenv:Envelope>`, qualitorUser, qualitorPass, qualitorCompany)
}
func buildQuerySOAP(token, sql string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="urn:main" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<SOAP-ENV:Body>
<ns1:getSqlQueryResult>
<auth xsi:type="xsd:string">%s</auth>
<xmlValue xsi:type="xsd:string">
<![CDATA[
<wsqualitor>
<contents>
<data>
<dsquery>%s</dsquery>
</data>
</contents>
</wsqualitor>
]]>
</xmlValue>
</ns1:getSqlQueryResult>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`, 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(`<result[^>]*>([^<]+)</result>`)
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, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&quot;", `"`)
return s
}
func extractResult(xml string) string {
re := regexp.MustCompile(`<result[^>]*>([\s\S]*?)</result>`)
m := re.FindStringSubmatch(xml)
if m == nil {
return ""
}
return decodeEntities(m[1])
}
func extractDataItems(decoded string) []string {
re := regexp.MustCompile(`<dataitem>([\s\S]*?)</dataitem>`)
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]*?)</` + field + `>`)
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()
}
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.",
},
},
},
},
},
})
}
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},
},
})
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))
}
}
}
}