From 6251e0d28c60a0f27a06636cf1cfc326ee083eb4 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Thu, 23 Apr 2026 03:07:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(observabilidade):=203=20detectores=20cruza?= =?UTF-8?q?dos=20worklog=20=C3=97=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #7 actions_never_executed: acções P1/P2 em disc #33 há ≥14 dias pendentes - #8 skill_narrative_vs_data: skill reportada problemática em worklogs mas com outcome=completed nas sessões (≥3 matches) - #9 worklog_pattern_frequency: tokens recorrentes (≥3 worklogs) em patterns_text - Integrados em detectPatterns() como secção opcional quando worklogs > 0 Co-Authored-By: Claude Opus 4.7 (1M context) --- api/services/sessions/patterns.ts | 187 +++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/api/services/sessions/patterns.ts b/api/services/sessions/patterns.ts index 22b3928..f57493f 100644 --- a/api/services/sessions/patterns.ts +++ b/api/services/sessions/patterns.ts @@ -285,6 +285,179 @@ export function detectGrowingComplexity(ctx: DetectCtx, prevWeekStartIso: string return out } +/** + * 7. Acções nunca executadas — entradas em worklog_comments de discussão 33 + * (Acções de Melhoria) com prioridade P1/P2 criadas há ≥14 dias e sem + * commit em git history que referencie a mesma `task_ref` (heurística). + */ +export function detectActionsNeverExecuted(ctx: DetectCtx): Pattern[] { + // Entradas criadas até 14 dias antes do fim da semana (ou antes) + const cutoff = new Date(ctx.weekEndIso) + cutoff.setUTCDate(cutoff.getUTCDate() - 14) + const cutoffIso = cutoff.toISOString() + + const rows = ctx.db.prepare(` + SELECT id, discussion_id, created_at, task_ref, actions_json, title + FROM worklog_comments + WHERE discussion_id = 33 AND created_at <= ? + ORDER BY created_at DESC + LIMIT 500 + `).all(cutoffIso) as Array<{ + id: number + discussion_id: number + created_at: string + task_ref: string | null + actions_json: string + title: string | null + }> + + if (rows.length === 0) return [] + + const pendentes: Array<{ id: number; descricao: string; prioridade: string }> = [] + for (const r of rows) { + let actions: Array<{ tipo: string; descricao: string; prioridade: string | null }> = [] + try { actions = JSON.parse(r.actions_json) } catch {} + for (const a of actions) { + const prio = (a.prioridade ?? '').toUpperCase() + if (prio === 'P1' || prio === 'P2') { + pendentes.push({ id: r.id, descricao: a.descricao.slice(0, 120), prioridade: prio }) + if (pendentes.length >= 10) break + } + } + if (pendentes.length >= 10) break + } + + if (pendentes.length < 3) return [] + return [{ + pattern_key: 'actions_never_executed', + title: `${pendentes.length}+ acções P1/P2 pendentes há ≥14 dias`, + description: `Acções de melhoria (disc #33) sem execução visível. Amostra: ${pendentes.slice(0, 3).map((p) => `[${p.prioridade}] ${p.descricao}`).join(' | ')}`, + severity: 'warning', + metric_value: pendentes.length, + sample_session_ids: pendentes.slice(0, 5).map((p) => `worklog:${p.id}`), + affected_count: pendentes.length, + }] +} + +/** + * 8. Skill reportada como problemática em worklogs mas que aparece com + * outcome=completed nas sessões reais — discrepância entre narrativa e dados. + */ +export function detectSkillReportedBrokenButCompleted(ctx: DetectCtx): Pattern[] { + // Recolhe skills mencionadas em problems_json e patterns_text de worklogs + // criados nas últimas 4 semanas antes do fim da janela + const windowStart = new Date(ctx.weekEndIso) + windowStart.setUTCDate(windowStart.getUTCDate() - 28) + const windowIso = windowStart.toISOString() + + const worklogs = ctx.db.prepare(` + SELECT patterns_text, problems_json + FROM worklog_comments + WHERE discussion_id IN (31, 32) AND created_at >= ? + LIMIT 500 + `).all(windowIso) as Array<{ patterns_text: string; problems_json: string }> + + if (worklogs.length === 0) return [] + + // Extrai tokens parecidos com skill name (slash-prefixed ou nome conhecido) + const skillMentions = new Map() + const skillRegex = /\/([a-z][a-z0-9_-]{2,40})\b/gi + for (const w of worklogs) { + const blob = `${w.patterns_text} ${w.problems_json}`.toLowerCase() + for (const m of blob.matchAll(skillRegex)) { + skillMentions.set(m[1], (skillMentions.get(m[1]) ?? 0) + 1) + } + } + + if (skillMentions.size === 0) return [] + + // Para cada skill mencionada ≥2 vezes, ver sessões com skill invocada e outcome=completed + const out: Pattern[] = [] + const skillsRelevantes = [...skillMentions.entries()].filter(([, c]) => c >= 2) + for (const [skill, mentions] of skillsRelevantes) { + const rows = ctx.db.prepare(` + SELECT session_id, skills_invoked, outcome + FROM sessions + WHERE started_at >= ? AND started_at <= ? + AND skills_invoked LIKE ? AND outcome = 'completed' + `).all(ctx.weekStartIso, ctx.weekEndIso, `%"${skill}"%`) as Array<{ + session_id: string + skills_invoked: string + outcome: string + }> + // Confirmar via parse (skills_invoked é JSON array) + const matches = rows.filter((r) => { + try { return (JSON.parse(r.skills_invoked) as string[]).includes(skill) } catch { return false } + }) + if (matches.length >= 3) { + out.push({ + pattern_key: `skill_narrative_vs_data:${skill}`, + title: `Skill ${skill}: reportada problemática em ${mentions} worklogs mas ${matches.length} sessões completed`, + description: `Discrepância entre narrativa (worklogs #31/#32) e dados (sessions.outcome). Investigar se o problema é silencioso.`, + severity: 'info', + metric_value: matches.length, + sample_session_ids: matches.slice(0, 5).map((r) => r.session_id), + affected_count: matches.length, + }) + } + } + return out +} + +/** + * 9. Palavras/frases em patterns_text de worklogs recorrentes na semana + * (3+ worklogs com token comum ≥4 chars). + */ +export function detectWorklogPatternFrequency(ctx: DetectCtx): Pattern[] { + const rows = ctx.db.prepare(` + SELECT id, patterns_text FROM worklog_comments + WHERE created_at >= ? AND created_at <= ? + `).all(ctx.weekStartIso, ctx.weekEndIso) as Array<{ id: number; patterns_text: string }> + if (rows.length === 0) return [] + + const tokenCount = new Map() + const stop = new Set(['para', 'como', 'mais', 'sobre', 'quando', 'apenas', 'entre', 'depois', 'antes', 'pelo', 'pela', 'pelos', 'pelas', 'esta', 'este', 'este', 'isso', 'isto', 'cada', 'muito', 'muita', 'outro', 'outra', 'nosso', 'nossa', 'todas', 'todos', 'seja', 'ser', 'ter', 'com', 'sem', 'dos', 'das', 'que', 'nao', 'sim']) + + for (const r of rows) { + let items: string[] = [] + try { items = JSON.parse(r.patterns_text) } catch {} + const seen = new Set() + for (const t of items) { + const words = t + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .split(/[^a-z0-9]+/) + .filter((w) => w.length >= 5 && !stop.has(w)) + for (const w of words) { + if (seen.has(w)) continue + seen.add(w) + const e = tokenCount.get(w) ?? { count: 0, ids: [] } + e.count++ + e.ids.push(r.id) + tokenCount.set(w, e) + } + } + } + + const frequent = [...tokenCount.entries()] + .filter(([, v]) => v.count >= 3) + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 5) + + if (frequent.length === 0) return [] + + return [{ + pattern_key: 'worklog_pattern_frequency', + title: `Termos recorrentes em ${rows.length} worklogs desta semana`, + description: `Top tokens em patterns_text: ${frequent.map(([w, v]) => `${w}(${v.count})`).join(', ')}`, + severity: 'info', + metric_value: frequent[0][1].count, + sample_session_ids: frequent.flatMap(([, v]) => v.ids.slice(0, 2)).slice(0, 5).map((id) => `worklog:${id}`), + affected_count: rows.length, + }] +} + /** Orquestra todos os detectores para a semana indicada. */ export function detectPatterns( dbWrapper: SessionsDb, @@ -299,7 +472,8 @@ export function detectPatterns( } const prevStart = new Date(weekStart); prevStart.setUTCDate(prevStart.getUTCDate() - 7) const prevEnd = new Date(weekEnd); prevEnd.setUTCDate(prevEnd.getUTCDate() - 7) - return [ + + const base: Pattern[] = [ ...detectSkillsHighErrorRate(ctx), ...detectToolsLowEfficiency(ctx), ...detectSkillToolPairs(ctx), @@ -307,6 +481,17 @@ export function detectPatterns( ...detectAbandonedSessions(ctx), ...detectGrowingComplexity(ctx, iso(prevStart), iso(prevEnd)), ] + + // Cross-detectors: só correm se houver worklogs na janela + const worklogCount = (db.prepare(`SELECT COUNT(*) as c FROM worklog_comments`).get() as { c: number }).c + if (worklogCount > 0) { + base.push( + ...detectActionsNeverExecuted(ctx), + ...detectSkillReportedBrokenButCompleted(ctx), + ...detectWorklogPatternFrequency(ctx), + ) + } + return base } /** Converte Pattern + contexto em PatternRecord pronto a persistir. */