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:
2026-04-23 02:27:09 +01:00
parent ac4e9c6f35
commit 9652805b1e
2 changed files with 97 additions and 56 deletions
+95 -54
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
} }
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')
+2 -2
View File
@@ -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