#!/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)}

`, ``, `

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) })