feat(observabilidade): 3 detectores cruzados worklog × sessions
- #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) <noreply@anthropic.com>
This commit is contained in:
@@ -285,6 +285,179 @@ export function detectGrowingComplexity(ctx: DetectCtx, prevWeekStartIso: string
|
|||||||
return out
|
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<string, number>()
|
||||||
|
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<string, { count: number; ids: number[] }>()
|
||||||
|
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<string>()
|
||||||
|
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. */
|
/** Orquestra todos os detectores para a semana indicada. */
|
||||||
export function detectPatterns(
|
export function detectPatterns(
|
||||||
dbWrapper: SessionsDb,
|
dbWrapper: SessionsDb,
|
||||||
@@ -299,7 +472,8 @@ export function detectPatterns(
|
|||||||
}
|
}
|
||||||
const prevStart = new Date(weekStart); prevStart.setUTCDate(prevStart.getUTCDate() - 7)
|
const prevStart = new Date(weekStart); prevStart.setUTCDate(prevStart.getUTCDate() - 7)
|
||||||
const prevEnd = new Date(weekEnd); prevEnd.setUTCDate(prevEnd.getUTCDate() - 7)
|
const prevEnd = new Date(weekEnd); prevEnd.setUTCDate(prevEnd.getUTCDate() - 7)
|
||||||
return [
|
|
||||||
|
const base: Pattern[] = [
|
||||||
...detectSkillsHighErrorRate(ctx),
|
...detectSkillsHighErrorRate(ctx),
|
||||||
...detectToolsLowEfficiency(ctx),
|
...detectToolsLowEfficiency(ctx),
|
||||||
...detectSkillToolPairs(ctx),
|
...detectSkillToolPairs(ctx),
|
||||||
@@ -307,6 +481,17 @@ export function detectPatterns(
|
|||||||
...detectAbandonedSessions(ctx),
|
...detectAbandonedSessions(ctx),
|
||||||
...detectGrowingComplexity(ctx, iso(prevStart), iso(prevEnd)),
|
...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. */
|
/** Converte Pattern + contexto em PatternRecord pronto a persistir. */
|
||||||
|
|||||||
Reference in New Issue
Block a user