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:
altair823
2026-05-02 03:24:31 +09:00
parent 449eb76683
commit 12c267aabd
6 changed files with 64 additions and 8 deletions

View File

@@ -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(

View File

@@ -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>;

View File

@@ -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 };
}

View File

@@ -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 () => {

View File

@@ -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();
});
});

View File

@@ -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건/);
});
});