refactor(observabilidade): pattern detector usa gateway MCP em vez de API Desk directa
Substitui chamadas HTTP directas à API Desk (/api/v1/discussions, /api/v1/tickets) por JSON-RPC 2.0 ao gateway MCP (desk-crm). Helper callMcpTool lida com respostas JSON ou SSE. Substitui DESK_API_TOKEN/DESK_BASE_URL por MCP_GATEWAY_TOKEN/URL. - add_discussion_comment: discussion_id=32, staff_id=25 (Observabilidade) - create_ticket: subject/message/priority(1-4)/department=1 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,14 +9,14 @@
|
|||||||
* 1. Resolver intervalo da semana (segunda 00:00 UTC → domingo 23:59 UTC)
|
* 1. Resolver intervalo da semana (segunda 00:00 UTC → domingo 23:59 UTC)
|
||||||
* 2. Correr detectPatterns (6 detectores heurísticos)
|
* 2. Correr detectPatterns (6 detectores heurísticos)
|
||||||
* 3. Persistir com upsertPattern + consecutive_weeks
|
* 3. Persistir com upsertPattern + consecutive_weeks
|
||||||
* 4. Se --publish: POST comentário HTML para Desk discussion #32,
|
* 4. Se --publish: chamar gateway MCP (desk-crm) para publicar comentário HTML
|
||||||
* e para padrões com consecutive_weeks>=3 e severity∈(warning,action)
|
* na discussão #32, e para padrões com consecutive_weeks>=3 e
|
||||||
* abrir Ticket no Desk.
|
* severity∈(warning,action) abrir Ticket no Desk.
|
||||||
* 5. Output JSON-line final com contagens.
|
* 5. Output JSON-line final com contagens.
|
||||||
*
|
*
|
||||||
* Env obrigatório com --publish:
|
* Env obrigatório com --publish:
|
||||||
* DESK_API_TOKEN Token API Desk CRM
|
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
|
||||||
* DESK_BASE_URL Base URL (default https://desk.descomplicar.pt)
|
* MCP_GATEWAY_URL URL do MCP desk-crm (default https://gateway.descomplicar.pt/v1/desk-crm/mcp)
|
||||||
*/
|
*/
|
||||||
import { openSessionsDb, type PatternRecord } from '../services/sessions/db.js'
|
import { openSessionsDb, type PatternRecord } from '../services/sessions/db.js'
|
||||||
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
detectPatterns,
|
detectPatterns,
|
||||||
toPatternRecord,
|
toPatternRecord,
|
||||||
weekRange,
|
weekRange,
|
||||||
weekIso as computeWeekIso,
|
|
||||||
type Pattern,
|
type Pattern,
|
||||||
} from '../services/sessions/patterns.js'
|
} from '../services/sessions/patterns.js'
|
||||||
|
|
||||||
@@ -34,6 +33,18 @@ interface Args {
|
|||||||
force: boolean
|
force: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MCPToolCallResult {
|
||||||
|
content?: Array<{ type: string; text: string }>
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff ID usado para publicar comentários automáticos (Observabilidade) */
|
||||||
|
const OBSERVABILIDADE_STAFF_ID = 25
|
||||||
|
/** Discussão Desk onde os resumos semanais são publicados */
|
||||||
|
const OBSERVABILIDADE_DISCUSSION_ID = 32
|
||||||
|
/** Departamento "Geral" no Desk CRM (id=1) */
|
||||||
|
const OBSERVABILIDADE_DEPARTMENT_ID = 1
|
||||||
|
|
||||||
function parseArgs(argv: string[]): Args {
|
function parseArgs(argv: string[]): Args {
|
||||||
const a: Args = { publish: false, force: false }
|
const a: Args = { publish: false, force: false }
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
@@ -64,7 +75,7 @@ function escapeHtml(s: string): string {
|
|||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[], baseUrl: string): string {
|
function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[]): string {
|
||||||
const dateStr = new Date().toISOString().slice(0, 10)
|
const dateStr = new Date().toISOString().slice(0, 10)
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`<h4>Semana ${weekIso} — Padrões Detectados Automaticamente</h4>`)
|
lines.push(`<h4>Semana ${weekIso} — Padrões Detectados Automaticamente</h4>`)
|
||||||
@@ -95,25 +106,70 @@ function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[], baseUrl
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postDeskDiscussionComment(baseUrl: string, token: string, html: string): Promise<unknown> {
|
/**
|
||||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/discussions/32/comments`, {
|
* Chama uma ferramenta do gateway MCP (JSON-RPC 2.0 sobre HTTP).
|
||||||
|
* O gateway pode responder em SSE (text/event-stream) ou JSON — tratamos ambos.
|
||||||
|
*/
|
||||||
|
async function callMcpTool(tool: string, args: Record<string, unknown>): Promise<MCPToolCallResult> {
|
||||||
|
const url = process.env.MCP_GATEWAY_URL ?? 'https://gateway.descomplicar.pt/v1/desk-crm/mcp'
|
||||||
|
const token = process.env.MCP_GATEWAY_TOKEN
|
||||||
|
if (!token) throw new Error('MCP_GATEWAY_TOKEN não definido')
|
||||||
|
const body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: Date.now(),
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: tool, arguments: args },
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
authtoken: token,
|
Accept: 'application/json, text/event-stream',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ content: html }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '')
|
const txt = await res.text().catch(() => '')
|
||||||
throw new Error(`Desk discussion POST falhou: ${res.status} ${body.slice(0, 200)}`)
|
throw new Error(`MCP gateway ${res.status}: ${txt.slice(0, 300)}`)
|
||||||
}
|
}
|
||||||
return res.json().catch(() => ({}))
|
const raw = await res.text()
|
||||||
|
// Responde JSON puro ou SSE (linhas "data: {...}")
|
||||||
|
let payload: string | null = null
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
payload = trimmed.slice(6)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
payload = trimmed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error(`MCP resposta sem payload JSON: ${raw.slice(0, 200)}`)
|
||||||
|
const parsed = JSON.parse(payload) as { error?: unknown; result?: MCPToolCallResult }
|
||||||
|
if (parsed.error) throw new Error(`MCP error: ${JSON.stringify(parsed.error)}`)
|
||||||
|
const result = parsed.result as MCPToolCallResult | undefined
|
||||||
|
if (result?.isError) {
|
||||||
|
const txt = result.content?.map((c) => c.text).join('\n') ?? ''
|
||||||
|
throw new Error(`MCP tool ${tool} devolveu isError: ${txt.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
return result ?? {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDeskTicket(baseUrl: string, token: string, p: PatternRecord): Promise<unknown> {
|
async function postDeskDiscussionComment(html: string): Promise<unknown> {
|
||||||
|
return callMcpTool('add_discussion_comment', {
|
||||||
|
discussion_id: OBSERVABILIDADE_DISCUSSION_ID,
|
||||||
|
content: html,
|
||||||
|
staff_id: OBSERVABILIDADE_STAFF_ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDeskTicket(p: PatternRecord): Promise<unknown> {
|
||||||
const priority = p.severity === 'action' ? 4 : 3 // 4=alta, 3=média
|
const priority = p.severity === 'action' ? 4 : 3 // 4=alta, 3=média
|
||||||
const body = [
|
const message = [
|
||||||
`<p><strong>Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.</strong></p>`,
|
`<p><strong>Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.</strong></p>`,
|
||||||
`<p>${escapeHtml(p.description)}</p>`,
|
`<p>${escapeHtml(p.description)}</p>`,
|
||||||
`<ul>`,
|
`<ul>`,
|
||||||
@@ -125,28 +181,20 @@ async function createDeskTicket(baseUrl: string, token: string, p: PatternRecord
|
|||||||
`</ul>`,
|
`</ul>`,
|
||||||
`<p><em>Sample sessions:</em> ${p.sample_session_ids.map((s) => `<code>${escapeHtml(s)}</code>`).join(', ')}</p>`,
|
`<p><em>Sample sessions:</em> ${p.sample_session_ids.map((s) => `<code>${escapeHtml(s)}</code>`).join(', ')}</p>`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/tickets`, {
|
return callMcpTool('create_ticket', {
|
||||||
method: 'POST',
|
subject: `Padrão recorrente: ${p.title}`,
|
||||||
headers: {
|
message,
|
||||||
'Content-Type': 'application/json',
|
priority,
|
||||||
authtoken: token,
|
department: OBSERVABILIDADE_DEPARTMENT_ID,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
subject: `Padrão recorrente: ${p.title}`,
|
|
||||||
body,
|
|
||||||
priority,
|
|
||||||
department: 1,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text().catch(() => '')
|
|
||||||
throw new Error(`Desk ticket POST falhou: ${res.status} ${t.slice(0, 200)}`)
|
|
||||||
}
|
|
||||||
return res.json().catch(() => ({}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2))
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (args.publish && !process.env.MCP_GATEWAY_TOKEN) {
|
||||||
|
console.error('[patterns] --publish requer MCP_GATEWAY_TOKEN. Aborta.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||||
const db = openSessionsDb(dbPath)
|
const db = openSessionsDb(dbPath)
|
||||||
|
|
||||||
@@ -177,35 +225,28 @@ async function main(): Promise<void> {
|
|||||||
let publishError: string | null = null
|
let publishError: string | null = null
|
||||||
|
|
||||||
if (args.publish) {
|
if (args.publish) {
|
||||||
const baseUrl = process.env.DESK_BASE_URL ?? 'https://desk.descomplicar.pt'
|
try {
|
||||||
const token = process.env.DESK_API_TOKEN
|
const html = buildDiscussionHtml(weekIso, records)
|
||||||
if (!token) {
|
await postDeskDiscussionComment(html)
|
||||||
publishError = 'DESK_API_TOKEN ausente'
|
commentPosted = true
|
||||||
} else {
|
for (const rec of records) {
|
||||||
try {
|
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
||||||
const html = buildDiscussionHtml(weekIso, records, baseUrl)
|
try {
|
||||||
await postDeskDiscussionComment(baseUrl, token, html)
|
await createDeskTicket(rec)
|
||||||
commentPosted = true
|
ticketsCreated++
|
||||||
for (const rec of records) {
|
} catch (e) {
|
||||||
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
|
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
|
||||||
try {
|
|
||||||
await createDeskTicket(baseUrl, token, rec)
|
|
||||||
ticketsCreated++
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
publishError = (e as Error).message
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
publishError = (e as Error).message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dry-run: render HTML para stderr para verificação manual
|
// Dry-run: render HTML para stderr para verificação manual
|
||||||
if (!args.publish) {
|
if (!args.publish) {
|
||||||
const baseUrl = process.env.DESK_BASE_URL ?? 'https://desk.descomplicar.pt'
|
const html = buildDiscussionHtml(weekIso, records)
|
||||||
const html = buildDiscussionHtml(weekIso, records, baseUrl)
|
|
||||||
console.error('\n--- HTML (dry-run) ---')
|
console.error('\n--- HTML (dry-run) ---')
|
||||||
console.error(html)
|
console.error(html)
|
||||||
console.error('--- /HTML ---\n')
|
console.error('--- /HTML ---\n')
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
DESK_API_TOKEN=coloca-token-aqui
|
MCP_GATEWAY_TOKEN=coloca-token-aqui
|
||||||
DESK_BASE_URL=https://desk.descomplicar.pt
|
MCP_GATEWAY_URL=https://gateway.descomplicar.pt/v1/desk-crm/mcp
|
||||||
|
|||||||
Reference in New Issue
Block a user