From 12c267aabd4c73acb749c5bc3f5d04435fce63ca Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 03:24:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(retry):=20telemetry=20ai=5Fretry=5Fmanual?= =?UTF-8?q?=20+=20stats=20AI=20=EC=88=98=EB=8F=99=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20(#2=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/TelemetryService.ts | 3 ++- src/main/services/telemetryEvents.ts | 7 ++++++- src/main/services/telemetryStats.ts | 17 ++++++++++++---- tests/unit/TelemetryService.test.ts | 4 ++-- tests/unit/telemetryEvents.test.ts | 28 +++++++++++++++++++++++++++ tests/unit/telemetryStats.test.ts | 13 +++++++++++++ 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index 0e3a00e..d44d574 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -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 }; + | { kind: 'ollama_recheck_manual'; payload: Record } + | { kind: 'ai_retry_manual'; payload: { failedCount: number } }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index f608866..94e3c5f 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -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; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 62f3173..15d07b0 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -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 }; } diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index 183dadf..aa700cd 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -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 () => { diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index 1c74727..f41bdef 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index e3a2c91..0631efc 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -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건/); + }); +});