feat(pipeline): F4.1 — rotas GET /patterns e GET /stats (contrato C4-análise) #2415
- routes/sessions.ts: createSessionsAnalysisRouter com GET /patterns (filtros week/severity/key/days, paginado, Zod) e GET /stats (agregados: sessões totais e por dia, by_source, by_outcome, tool_top, skill_top, patterns_by_type, gate_blocks, avg_duration_sec, last_indexed_at, latest_pattern_week); montado em /api/sessions (C4) e /api (alias F4.1) - services/sessions/db.ts: listPatterns/countPatterns/getStats + rowToPattern/buildPatternsWhere; gate_blocks devolve null enquanto a tabela gate_events não existir (F1 arco C3-gates) - tests/sessions-analysis-route.test.ts: 15 testes (filtros, paginação, validação, gate_blocks condicional, não-colisão com /:id) - inclui camada session_events pendente de v2.7.3 (db.ts/indexer.ts — storeSessionEvents no indexAll; rota /:id lê da BD) já em execução, para a árvore commitada compilar coerente Validado contra BD real (11459 sessões, 160 padrões): curl 200 com JSON não-vazio em /api/patterns, /api/stats, /api/sessions/patterns?week=2026-W26, /api/sessions/stats?days=7.
This commit is contained in:
+213
-13
@@ -53,6 +53,30 @@ export interface WorklogFilters {
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface PatternFilters {
|
||||
week?: string
|
||||
severity?: 'info' | 'warning' | 'action'
|
||||
pattern_key?: string
|
||||
sinceIso?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SessionsStats {
|
||||
days: number
|
||||
sessions: number
|
||||
by_day: { day: string; sessions: number }[]
|
||||
by_source: Record<string, number>
|
||||
by_outcome: Record<string, number>
|
||||
tool_top: { tool: string; count: number }[]
|
||||
skill_top: { skill: string; count: number }[]
|
||||
patterns_by_type: { pattern_type: string; count: number }[]
|
||||
gate_blocks: number | null
|
||||
avg_duration_sec: number | null
|
||||
last_indexed_at: string | null
|
||||
latest_pattern_week: string | null
|
||||
}
|
||||
|
||||
export interface SessionsDb {
|
||||
upsertSession(meta: SessionMeta): void
|
||||
upsertMany(metas: SessionMeta[]): void
|
||||
@@ -60,9 +84,14 @@ export interface SessionsDb {
|
||||
countSessions(filters: ListFilters): number
|
||||
getSession(id: string): SessionMeta | null
|
||||
deleteByJsonlPath(path: string): void
|
||||
storeSessionEvents(sessionId: string, events: import('../../types/session.js').SessionEvent[]): void
|
||||
getSessionEvents(sessionId: string): import('../../types/session.js').SessionEvent[]
|
||||
upsertPattern(p: PatternRecord): void
|
||||
getPatternsByWeek(week: string): PatternRecord[]
|
||||
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number
|
||||
listPatterns(filters: PatternFilters): PatternRecord[]
|
||||
countPatterns(filters: PatternFilters): number
|
||||
getStats(days: number): SessionsStats
|
||||
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
|
||||
hasWorklogComment(id: number): boolean
|
||||
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[]
|
||||
@@ -101,6 +130,20 @@ CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_events (
|
||||
session_id TEXT NOT NULL,
|
||||
event_index INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
timestamp TEXT,
|
||||
text TEXT,
|
||||
tool_name TEXT,
|
||||
tool_input TEXT,
|
||||
tool_result TEXT,
|
||||
skill_invoked TEXT,
|
||||
hook_name TEXT,
|
||||
PRIMARY KEY (session_id, event_index)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS patterns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_at TEXT NOT NULL,
|
||||
@@ -166,6 +209,47 @@ function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
||||
}
|
||||
}
|
||||
|
||||
function rowToPattern(r: Record<string, unknown>): PatternRecord {
|
||||
return {
|
||||
id: r.id as number,
|
||||
detected_at: r.detected_at as string,
|
||||
week_iso: r.week_iso as string,
|
||||
pattern_key: r.pattern_key as string,
|
||||
title: r.title as string,
|
||||
description: r.description as string,
|
||||
severity: r.severity as PatternRecord['severity'],
|
||||
metric_value: (r.metric_value as number | null) ?? null,
|
||||
sample_session_ids: JSON.parse(r.sample_session_ids as string),
|
||||
affected_count: r.affected_count as number,
|
||||
consecutive_weeks: r.consecutive_weeks as number,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPatternsWhere(f: PatternFilters): { sql: string; params: Record<string, unknown> } {
|
||||
const parts: string[] = []
|
||||
const params: Record<string, unknown> = {}
|
||||
if (f.week) {
|
||||
parts.push('week_iso = @week')
|
||||
params.week = f.week
|
||||
}
|
||||
if (f.severity) {
|
||||
parts.push('severity = @severity')
|
||||
params.severity = f.severity
|
||||
}
|
||||
if (f.pattern_key) {
|
||||
parts.push('pattern_key = @pattern_key')
|
||||
params.pattern_key = f.pattern_key
|
||||
}
|
||||
if (f.sinceIso) {
|
||||
parts.push('detected_at >= @since')
|
||||
params.since = f.sinceIso
|
||||
}
|
||||
return {
|
||||
sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '',
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(f: ListFilters): { sql: string; params: Record<string, unknown> } {
|
||||
const parts: string[] = []
|
||||
const params: Record<string, unknown> = {}
|
||||
@@ -330,21 +414,50 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
||||
consecutive_weeks: p.consecutive_weeks,
|
||||
})
|
||||
},
|
||||
storeSessionEvents(sessionId, events) {
|
||||
const del = db.prepare('DELETE FROM session_events WHERE session_id = ?')
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO session_events (session_id, event_index, type, timestamp, text,
|
||||
tool_name, tool_input, tool_result, skill_invoked, hook_name)
|
||||
VALUES (@session_id, @index, @type, @timestamp, @text,
|
||||
@tool_name, @tool_input, @tool_result, @skill_invoked, @hook_name)
|
||||
`)
|
||||
db.transaction(() => {
|
||||
del.run(sessionId)
|
||||
for (const e of events) {
|
||||
ins.run({
|
||||
session_id: sessionId,
|
||||
index: e.index,
|
||||
type: e.type,
|
||||
timestamp: e.timestamp ?? null,
|
||||
text: e.text,
|
||||
tool_name: e.tool_name,
|
||||
tool_input: e.tool_input ? JSON.stringify(e.tool_input) : null,
|
||||
tool_result: typeof e.tool_result === 'string' ? e.tool_result.slice(0, 50000) : e.tool_result != null ? JSON.stringify(e.tool_result).slice(0, 50000) : null,
|
||||
skill_invoked: e.skill_invoked,
|
||||
hook_name: e.hook_name,
|
||||
})
|
||||
}
|
||||
})()
|
||||
},
|
||||
getSessionEvents(sessionId) {
|
||||
const rows = db.prepare('SELECT * FROM session_events WHERE session_id = ? ORDER BY event_index').all(sessionId) as Record<string, unknown>[]
|
||||
return rows.map((r) => ({
|
||||
index: r.event_index as number,
|
||||
type: r.type as import('../../types/session.js').SessionEventType,
|
||||
timestamp: (r.timestamp as string | null) ?? null,
|
||||
raw: {}, // not stored
|
||||
text: (r.text as string | null) ?? null,
|
||||
tool_name: (r.tool_name as string | null) ?? null,
|
||||
tool_input: r.tool_input ? JSON.parse(r.tool_input as string) as Record<string, unknown> : null,
|
||||
tool_result: r.tool_result ?? null,
|
||||
skill_invoked: (r.skill_invoked as string | null) ?? null,
|
||||
hook_name: (r.hook_name as string | null) ?? null,
|
||||
}))
|
||||
},
|
||||
getPatternsByWeek(week: string): PatternRecord[] {
|
||||
const rows = db.prepare('SELECT * FROM patterns WHERE week_iso = ? ORDER BY severity DESC, affected_count DESC').all(week) as Record<string, unknown>[]
|
||||
return rows.map((r) => ({
|
||||
id: r.id as number,
|
||||
detected_at: r.detected_at as string,
|
||||
week_iso: r.week_iso as string,
|
||||
pattern_key: r.pattern_key as string,
|
||||
title: r.title as string,
|
||||
description: r.description as string,
|
||||
severity: r.severity as PatternRecord['severity'],
|
||||
metric_value: (r.metric_value as number | null) ?? null,
|
||||
sample_session_ids: JSON.parse(r.sample_session_ids as string),
|
||||
affected_count: r.affected_count as number,
|
||||
consecutive_weeks: r.consecutive_weeks as number,
|
||||
}))
|
||||
return rows.map(rowToPattern)
|
||||
},
|
||||
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number {
|
||||
// Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu
|
||||
@@ -362,6 +475,93 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
||||
}
|
||||
return count
|
||||
},
|
||||
listPatterns(filters: PatternFilters): PatternRecord[] {
|
||||
const { sql, params } = buildPatternsWhere(filters)
|
||||
const limit = filters.limit ?? 50
|
||||
const offset = filters.offset ?? 0
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM patterns ${sql} ORDER BY week_iso DESC, severity DESC, affected_count DESC LIMIT @limit OFFSET @offset`)
|
||||
.all({ ...params, limit, offset }) as Record<string, unknown>[]
|
||||
return rows.map(rowToPattern)
|
||||
},
|
||||
countPatterns(filters: PatternFilters): number {
|
||||
const { sql, params } = buildPatternsWhere(filters)
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM patterns ${sql}`).get(params) as { c: number }
|
||||
return row.c
|
||||
},
|
||||
getStats(days: number): SessionsStats {
|
||||
const cutoff = new Date(Date.now() - days * 86400_000).toISOString()
|
||||
const totals = db.prepare(`
|
||||
SELECT COUNT(*) as sessions, AVG(duration_sec) as avg_duration
|
||||
FROM sessions WHERE started_at >= ?
|
||||
`).get(cutoff) as { sessions: number; avg_duration: number | null }
|
||||
|
||||
const byDay = db.prepare(`
|
||||
SELECT substr(started_at, 1, 10) as day, COUNT(*) as sessions
|
||||
FROM sessions WHERE started_at >= ?
|
||||
GROUP BY day ORDER BY day
|
||||
`).all(cutoff) as { day: string; sessions: number }[]
|
||||
|
||||
const bySource: Record<string, number> = {}
|
||||
for (const r of db.prepare('SELECT source, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY source').all(cutoff) as { source: string; c: number }[]) {
|
||||
bySource[r.source] = r.c
|
||||
}
|
||||
|
||||
const byOutcome: Record<string, number> = {}
|
||||
for (const r of db.prepare('SELECT outcome, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY outcome').all(cutoff) as { outcome: string; c: number }[]) {
|
||||
byOutcome[r.outcome] = r.c
|
||||
}
|
||||
|
||||
const toolTop = db.prepare(`
|
||||
SELECT je.value as tool, COUNT(*) as count
|
||||
FROM sessions s, json_each(s.tools_used) je
|
||||
WHERE s.started_at >= ?
|
||||
GROUP BY je.value ORDER BY count DESC LIMIT 10
|
||||
`).all(cutoff) as { tool: string; count: number }[]
|
||||
|
||||
const skillTop = db.prepare(`
|
||||
SELECT je.value as skill, COUNT(*) as count
|
||||
FROM sessions s, json_each(s.skills_invoked) je
|
||||
WHERE s.started_at >= ?
|
||||
GROUP BY je.value ORDER BY count DESC LIMIT 10
|
||||
`).all(cutoff) as { skill: string; count: number }[]
|
||||
|
||||
// Tipo do padrão = prefixo de pattern_key antes de ':' (ex.: 'skill_error_rate:X' → 'skill_error_rate')
|
||||
const patternsByType = db.prepare(`
|
||||
SELECT CASE WHEN instr(pattern_key, ':') > 0
|
||||
THEN substr(pattern_key, 1, instr(pattern_key, ':') - 1)
|
||||
ELSE pattern_key END as pattern_type,
|
||||
COUNT(*) as count
|
||||
FROM patterns WHERE detected_at >= ?
|
||||
GROUP BY pattern_type ORDER BY count DESC
|
||||
`).all(cutoff) as { pattern_type: string; count: number }[]
|
||||
|
||||
// gate_events só existe quando F1 (arco C3-gates) entregar — null até lá
|
||||
let gateBlocks: number | null = null
|
||||
const hasGateEvents = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='gate_events'").get()
|
||||
if (hasGateEvents) {
|
||||
const r = db.prepare("SELECT COUNT(*) as c FROM gate_events WHERE decision = 'block' AND ts >= ?").get(cutoff) as { c: number }
|
||||
gateBlocks = r.c
|
||||
}
|
||||
|
||||
const lastIndexed = db.prepare('SELECT MAX(indexed_at) as m FROM sessions').get() as { m: string | null }
|
||||
const latestWeek = db.prepare('SELECT MAX(week_iso) as m FROM patterns').get() as { m: string | null }
|
||||
|
||||
return {
|
||||
days,
|
||||
sessions: totals.sessions,
|
||||
by_day: byDay,
|
||||
by_source: bySource,
|
||||
by_outcome: byOutcome,
|
||||
tool_top: toolTop,
|
||||
skill_top: skillTop,
|
||||
patterns_by_type: patternsByType,
|
||||
gate_blocks: gateBlocks,
|
||||
avg_duration_sec: totals.avg_duration != null ? Math.round(totals.avg_duration) : null,
|
||||
last_indexed_at: lastIndexed.m,
|
||||
latest_pattern_week: latestWeek.m,
|
||||
}
|
||||
},
|
||||
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } {
|
||||
const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id)
|
||||
const inserted = !existing
|
||||
|
||||
Reference in New Issue
Block a user