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('')
+ for (const p of patterns) {
+ const samples = p.sample_session_ids
+ .slice(0, 5)
+ .map((id) => `${escapeHtml(id.slice(0, 8))}`)
+ .join(', ')
+ lines.push(
+ `- ${escapeHtml(p.title)} [${p.severity}] — ` +
+ `${p.affected_count} sessões` +
+ (p.metric_value != null ? `, métrica ${p.metric_value}` : '') +
+ `
Descrição: ${escapeHtml(p.description)}` +
+ `
Sample: ${samples}` +
+ `
Semanas consecutivas: ${p.consecutive_weeks} `,
+ )
+ }
+ 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)
+})