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)
* 2. Correr detectPatterns (6 detectores heurísticos)
* 3. Persistir com upsertPattern + consecutive_weeks
* 4. Se --publish: POST comentário HTML para Desk discussion #32,
* e para padrões com consecutive_weeks>=3 e severity∈(warning,action)
* abrir Ticket no Desk.
* 4. Se --publish: chamar gateway MCP (desk-crm) para publicar comentário HTML
* na discussão #32, e para padrões com consecutive_weeks>=3 e
* severity∈(warning,action) abrir Ticket no Desk.
* 5. Output JSON-line final com contagens.
*
* Env obrigatório com --publish:
* DESK_API_TOKEN Token API Desk CRM
* DESK_BASE_URL Base URL (default https://desk.descomplicar.pt)
* MCP_GATEWAY_TOKEN Bearer token do gateway MCP
* 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 { DEFAULT_DB_PATH } from '../services/sessions/indexer.js'
@@ -24,7 +24,6 @@ import {
detectPatterns,
toPatternRecord,
weekRange,
weekIso as computeWeekIso,
type Pattern,
} from '../services/sessions/patterns.js'
@@ -34,6 +33,18 @@ interface Args {
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 {
const a: Args = { publish: false, force: false }
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;')
}
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 lines: string[] = []
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')
}
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',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
authtoken: token,
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({ content: html }),
body: JSON.stringify(body),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Desk discussion POST falhou: ${res.status} ${body.slice(0, 200)}`)
const txt = await res.text().catch(() => '')
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 body = [
const message = [
`<p><strong>Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.</strong></p>`,
`<p>${escapeHtml(p.description)}</p>`,
`<ul>`,
@@ -125,28 +181,20 @@ async function createDeskTicket(baseUrl: string, token: string, p: PatternRecord
`</ul>`,
`<p><em>Sample sessions:</em> ${p.sample_session_ids.map((s) => `<code>${escapeHtml(s)}</code>`).join(', ')}</p>`,
].join('\n')
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/tickets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authtoken: token,
},
body: JSON.stringify({
subject: `Padrão recorrente: ${p.title}`,
body,
priority,
department: 1,
}),
return callMcpTool('create_ticket', {
subject: `Padrão recorrente: ${p.title}`,
message,
priority,
department: OBSERVABILIDADE_DEPARTMENT_ID,
})
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> {
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 db = openSessionsDb(dbPath)
@@ -177,35 +225,28 @@ async function main(): Promise<void> {
let publishError: string | null = null
if (args.publish) {
const baseUrl = process.env.DESK_BASE_URL ?? 'https://desk.descomplicar.pt'
const token = process.env.DESK_API_TOKEN
if (!token) {
publishError = 'DESK_API_TOKEN ausente'
} else {
try {
const html = buildDiscussionHtml(weekIso, records, baseUrl)
await postDeskDiscussionComment(baseUrl, token, html)
commentPosted = true
for (const rec of records) {
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
try {
await createDeskTicket(baseUrl, token, rec)
ticketsCreated++
} catch (e) {
console.error(`[patterns] falha ao criar ticket para ${rec.pattern_key}:`, (e as Error).message)
}
try {
const html = buildDiscussionHtml(weekIso, records)
await postDeskDiscussionComment(html)
commentPosted = true
for (const rec of records) {
if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) {
try {
await createDeskTicket(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
if (!args.publish) {
const baseUrl = process.env.DESK_BASE_URL ?? 'https://desk.descomplicar.pt'
const html = buildDiscussionHtml(weekIso, records, baseUrl)
const html = buildDiscussionHtml(weekIso, records)
console.error('\n--- HTML (dry-run) ---')
console.error(html)
console.error('--- /HTML ---\n')