From 5bd1459c7de3192b6f43ecbf581881316b0947df Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Thu, 23 Apr 2026 02:17:31 +0100 Subject: [PATCH] feat(observabilidade): CLI patterns com dry-run e publish Desk #32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Script CLI api/scripts/sessions-patterns.ts com args --week, --publish, --force. Default: semana actual, dry-run (render HTML stderr + JSON summary stdout). Com --publish: - POST html comentário para /api/v1/discussions/32/comments (Desk) - Para padrões com consecutive_weeks>=3 e severity warning|action: auto-abre TICKET via /api/v1/tickets (priority 3|4 conforme severity) Pipeline interno: detectPatterns -> upsertPattern placeholder -> computar consecutive_weeks -> upsert final. Escape HTML defensivo; 5 sample ids por padrão. Auth via DESK_API_TOKEN (env file), NUNCA hardcoded. Refs Fase 6A Co-Authored-By: Claude Opus 4.7 (1M context) --- api/scripts/sessions-patterns.ts | 240 +++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 api/scripts/sessions-patterns.ts diff --git a/api/scripts/sessions-patterns.ts b/api/scripts/sessions-patterns.ts new file mode 100644 index 0000000..4f36b59 --- /dev/null +++ b/api/scripts/sessions-patterns.ts @@ -0,0 +1,240 @@ +#!/usr/bin/env tsx +/** + * Detector semanal de padrões sobre a BD Observabilidade (Fase 6A). + * + * Uso: + * sessions-patterns.ts [--week YYYY-Www] [--publish] [--force] + * + * Fluxo: + * 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. + * 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) + */ +import { openSessionsDb, type PatternRecord } from '../services/sessions/db.js' +import { DEFAULT_DB_PATH } from '../services/sessions/indexer.js' +import { + detectPatterns, + toPatternRecord, + weekRange, + weekIso as computeWeekIso, + type Pattern, +} from '../services/sessions/patterns.js' + +interface Args { + week?: string + publish: boolean + force: boolean +} + +function parseArgs(argv: string[]): Args { + const a: Args = { publish: false, force: false } + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--week') a.week = argv[++i] + else if (argv[i] === '--publish') a.publish = true + else if (argv[i] === '--force') a.force = true + } + return a +} + +/** Converte YYYY-Www em intervalo {start,end} UTC. */ +function weekIsoToRange(weekIsoStr: string): { start: Date; end: Date; iso: string } { + const m = weekIsoStr.match(/^(\d{4})-W(\d{2})$/) + if (!m) throw new Error(`Formato --week inválido: ${weekIsoStr}`) + const year = parseInt(m[1], 10) + const week = parseInt(m[2], 10) + // ISO week: quinta da semana 1 está sempre em 4 de Janeiro + const jan4 = new Date(Date.UTC(year, 0, 4)) + const jan4Dow = jan4.getUTCDay() || 7 // 1..7 (seg..dom) + const mondayWeek1 = new Date(jan4) + mondayWeek1.setUTCDate(jan4.getUTCDate() - (jan4Dow - 1)) + const monday = new Date(mondayWeek1) + monday.setUTCDate(mondayWeek1.getUTCDate() + (week - 1) * 7) + return weekRange(monday) +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function buildDiscussionHtml(weekIso: string, patterns: PatternRecord[], baseUrl: string): string { + const dateStr = new Date().toISOString().slice(0, 10) + const lines: string[] = [] + lines.push(`

Semana ${weekIso} — Padrões Detectados Automaticamente

`) + lines.push(`

Detector automático Observabilidade — ${patterns.length} padrões analisados.

`) + if (patterns.length === 0) { + lines.push('

Nenhum padrão accionável detectado esta semana.

') + } else { + lines.push('

Padrões (severity)

') + lines.push('') + } + lines.push('
') + lines.push(`

Skill: observabilidade-patterns | Data: ${dateStr}

`) + return lines.join('\n') +} + +async function postDeskDiscussionComment(baseUrl: string, token: string, html: string): Promise { + const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/v1/discussions/32/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authtoken: token, + }, + body: JSON.stringify({ content: html }), + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Desk discussion POST falhou: ${res.status} ${body.slice(0, 200)}`) + } + return res.json().catch(() => ({})) +} + +async function createDeskTicket(baseUrl: string, token: string, p: PatternRecord): Promise { + const priority = p.severity === 'action' ? 4 : 3 // 4=alta, 3=média + const body = [ + `

Padrão recorrente detectado automaticamente — ${p.consecutive_weeks} semanas consecutivas.

`, + `

${escapeHtml(p.description)}

`, + `
    `, + `
  • Pattern key: ${escapeHtml(p.pattern_key)}
  • `, + `
  • Severity: ${p.severity}
  • `, + `
  • Affected: ${p.affected_count} sessões
  • `, + `
  • Metric: ${p.metric_value ?? 'n/a'}
  • `, + `
  • Week: ${p.week_iso}
  • `, + `
`, + `

Sample sessions: ${p.sample_session_ids.map((s) => `${escapeHtml(s)}`).join(', ')}

`, + ].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, + }), + }) + 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 { + const args = parseArgs(process.argv.slice(2)) + const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH + const db = openSessionsDb(dbPath) + + const range = args.week ? weekIsoToRange(args.week) : weekRange(new Date()) + const weekIso = args.week ?? range.iso + console.error(`[patterns] semana ${weekIso} range=${range.start.toISOString()}..${range.end.toISOString()} db=${dbPath} publish=${args.publish}`) + + const detected: Pattern[] = detectPatterns(db, range.start, range.end) + console.error(`[patterns] detectados ${detected.length} padrões`) + + const records: PatternRecord[] = [] + for (const p of detected) { + // Primeiro upsert com consecutive=1 (placeholder); depois recalcula. + const tmpRec = toPatternRecord(p, weekIso, 1) + db.upsertPattern(tmpRec) + const consecutive = db.getConsecutiveWeeks(p.pattern_key, weekIso) + const rec = toPatternRecord(p, weekIso, consecutive) + db.upsertPattern(rec) + records.push(rec) + } + + // Ordenar por severity (action > warning > info) depois affected_count + const sevRank: Record = { action: 3, warning: 2, info: 1 } + records.sort((a, b) => (sevRank[b.severity] - sevRank[a.severity]) || (b.affected_count - a.affected_count)) + + let commentPosted = false + let ticketsCreated = 0 + 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) + } + } + } + } 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) + console.error('\n--- HTML (dry-run) ---') + console.error(html) + console.error('--- /HTML ---\n') + for (const rec of records) { + if (rec.consecutive_weeks >= 3 && (rec.severity === 'warning' || rec.severity === 'action')) { + console.error(`[patterns] (dry-run) Ticket seria criado: ${rec.title} — ${rec.consecutive_weeks} sem.`) + } + } + } + + const summary = { + week_iso: weekIso, + range: { start: range.start.toISOString(), end: range.end.toISOString() }, + detected: records.length, + by_severity: { + action: records.filter((r) => r.severity === 'action').length, + warning: records.filter((r) => r.severity === 'warning').length, + info: records.filter((r) => r.severity === 'info').length, + }, + published: commentPosted, + tickets_created: ticketsCreated, + publish_error: publishError, + dry_run: !args.publish, + } + console.log(JSON.stringify(summary)) + db.close() +} + +main().catch((err) => { + console.error('[patterns] falha fatal:', err) + process.exit(2) +})