feat(retry): telemetry ai_retry_manual + stats AI 수동 재시도 (#2 v0.2.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,8 @@ export type EmitInput =
|
||||
| { kind: 'expired_batch_trash'; payload: { count: number } }
|
||||
| { kind: 'ollama_unreachable'; payload: { reason: string } }
|
||||
| { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
|
||||
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> };
|
||||
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
|
||||
| { kind: 'ai_retry_manual'; payload: { failedCount: number } };
|
||||
|
||||
export class TelemetryService {
|
||||
constructor(
|
||||
|
||||
@@ -46,6 +46,10 @@ const OllamaRecoveredPayload = z.object({
|
||||
|
||||
const EmptyPayload = z.object({}).strict();
|
||||
|
||||
const AiRetryManualPayload = z.object({
|
||||
failedCount: z.number().int().positive()
|
||||
}).strict();
|
||||
|
||||
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
|
||||
@@ -58,7 +62,8 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict()
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict()
|
||||
]);
|
||||
|
||||
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
|
||||
|
||||
@@ -23,6 +23,7 @@ interface DailyRow {
|
||||
ollama_unreachable: number;
|
||||
ollama_recovered: number;
|
||||
ollama_recheck_manual: number;
|
||||
ai_retry_manual: number;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
@@ -44,6 +45,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
let ollamaDowntimeSum = 0;
|
||||
let ollamaRecoveredCount = 0;
|
||||
let ollamaRecheckManualCount = 0;
|
||||
let aiRetryManualCount = 0;
|
||||
let aiRetryManualFailedSum = 0;
|
||||
for (const ev of events) {
|
||||
const day = kstDate(ev.ts);
|
||||
let row = byDay.get(day);
|
||||
@@ -53,7 +56,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
capture: 0, ai_succeeded: 0, ai_failed: 0,
|
||||
trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0,
|
||||
expired_banner_shown: 0, expired_batch_trash: 0,
|
||||
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0
|
||||
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0,
|
||||
ai_retry_manual: 0
|
||||
};
|
||||
byDay.set(day, row);
|
||||
}
|
||||
@@ -91,6 +95,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
} else if (ev.kind === 'ollama_recheck_manual') {
|
||||
row.ollama_recheck_manual += 1;
|
||||
ollamaRecheckManualCount += 1;
|
||||
} else if (ev.kind === 'ai_retry_manual') {
|
||||
row.ai_retry_manual += 1;
|
||||
aiRetryManualCount += 1;
|
||||
aiRetryManualFailedSum += ev.payload.failedCount;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
@@ -115,10 +123,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push('');
|
||||
lines.push('## 일자별 카운트');
|
||||
lines.push('');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|');
|
||||
for (const row of days) {
|
||||
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} |`);
|
||||
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 핵심 ratio');
|
||||
@@ -130,6 +138,7 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}건`);
|
||||
lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`);
|
||||
lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`);
|
||||
lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`);
|
||||
lines.push('');
|
||||
return { md: lines.join('\n'), eventCount };
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(3);
|
||||
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) =>
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual')
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual')
|
||||
? null
|
||||
: e.payload.noteId
|
||||
)).toEqual(['a', 'b', 'b']);
|
||||
@@ -164,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.kind).toBe('capture');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual') expect(ev.payload.noteId).toBe('a');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual') expect(ev.payload.noteId).toBe('a');
|
||||
});
|
||||
|
||||
it('returns [] when dir missing', async () => {
|
||||
|
||||
@@ -250,3 +250,31 @@ describe('ollama_unreachable / ollama_recovered / ollama_recheck_manual events',
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ai_retry_manual event', () => {
|
||||
it('parses valid ai_retry_manual', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: 5 }
|
||||
});
|
||||
if (ev.kind !== 'ai_retry_manual') throw new Error('discriminant');
|
||||
expect(ev.payload.failedCount).toBe(5);
|
||||
});
|
||||
|
||||
it('rejects ai_retry_manual with failedCount=0 (≥1 invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: 0 }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects ai_retry_manual with extra payload field (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: 5, rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,3 +151,16 @@ describe('aggregateStats — ollama_* events', () => {
|
||||
expect(r.md).toMatch(/평균 downtimeMs.*N\/A/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateStats — ai_retry_manual', () => {
|
||||
it('counts events and sums failedCount', () => {
|
||||
const events = [
|
||||
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 3 } },
|
||||
{ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 7 } }
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('ai_retry_manual');
|
||||
// 2회 / 누적 10건
|
||||
expect(r.md).toMatch(/AI 수동 재시도.*2회.*10건/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user