diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index dc63121..cb5e0c6 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -30,7 +30,11 @@ export type EmitInput = | { kind: 'ollama_recheck_manual'; payload: Record } | { kind: 'ai_retry_manual'; payload: { failedCount: number } } | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } - | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }; + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } + | { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } } + | { kind: 'recall_opened'; payload: { noteId: string } } + | { kind: 'recall_dismissed'; payload: { noteId: string } } + | { kind: 'recall_snoozed'; payload: { noteId: string } }; export class TelemetryService { constructor( diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index bdbb918..0054fe7 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -26,6 +26,10 @@ interface DailyRow { ai_retry_manual: number; tag_vocab_hit: number; tag_vocab_miss: number; + recall_shown: number; + recall_opened: number; + recall_dismissed: number; + recall_snoozed: number; } export interface StatsResult { @@ -51,6 +55,11 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta let aiRetryManualFailedSum = 0; let tagVocabHitCount = 0; let tagVocabMissCount = 0; + let recallShownCount = 0; + let recallOpenedCount = 0; + let recallDismissedCount = 0; + let recallSnoozedCount = 0; + let recallAgeDaysSum = 0; for (const ev of events) { const day = kstDate(ev.ts); let row = byDay.get(day); @@ -62,7 +71,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta expired_banner_shown: 0, expired_batch_trash: 0, ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0, ai_retry_manual: 0, - tag_vocab_hit: 0, tag_vocab_miss: 0 + tag_vocab_hit: 0, tag_vocab_miss: 0, + recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0 }; byDay.set(day, row); } @@ -110,6 +120,19 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta } else if (ev.kind === 'tag_vocab_miss') { row.tag_vocab_miss += 1; tagVocabMissCount += 1; + } else if (ev.kind === 'recall_shown') { + row.recall_shown += 1; + recallShownCount += 1; + recallAgeDaysSum += ev.payload.ageDays; + } else if (ev.kind === 'recall_opened') { + row.recall_opened += 1; + recallOpenedCount += 1; + } else if (ev.kind === 'recall_dismissed') { + row.recall_dismissed += 1; + recallDismissedCount += 1; + } else if (ev.kind === 'recall_snoozed') { + row.recall_snoozed += 1; + recallSnoozedCount += 1; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); @@ -130,6 +153,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta const tagVocabSummary = tagVocabTotal === 0 ? '(데이터 없음)' : `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`; + const recallSummary = recallShownCount === 0 + ? '(데이터 없음)' + : `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`; + const recallAvgAge = recallShownCount === 0 + ? '(데이터 없음)' + : `${Math.round(recallAgeDaysSum / recallShownCount)}`; const lines: string[] = []; lines.push('# Inkling Telemetry Stats'); lines.push(''); @@ -138,10 +167,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 | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |'); - 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 | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |'); + 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} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`); + 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} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`); } lines.push(''); lines.push('## 핵심 ratio'); @@ -155,6 +184,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`); lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`); lines.push(`- 태그 vocab: ${tagVocabSummary}`); + lines.push(`- 회상 추천: ${recallSummary}`); + lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`); lines.push(''); return { md: lines.join('\n'), eventCount }; } diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index 4fa74b5..418c1e8 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -187,4 +187,27 @@ describe('aggregateStats — tag_vocab hit/miss', () => { expect(r.md).toContain('태그 vocab'); expect(r.md).toContain('데이터 없음'); }); + + it('aggregates recall events with open rate + average ageDays', () => { + const events: TelemetryEvent[] = [ + e('2026-05-02T00:00:00Z', 'recall_shown', { noteId: 'n1', ageDays: 10 }), + e('2026-05-02T00:00:01Z', 'recall_shown', { noteId: 'n2', ageDays: 20 }), + e('2026-05-02T00:00:02Z', 'recall_shown', { noteId: 'n3', ageDays: 30 }), + e('2026-05-02T00:00:03Z', 'recall_shown', { noteId: 'n4', ageDays: 40 }), + e('2026-05-02T00:00:04Z', 'recall_opened', { noteId: 'n1' }), + e('2026-05-02T00:00:05Z', 'recall_opened', { noteId: 'n2' }), + e('2026-05-02T00:00:06Z', 'recall_dismissed', { noteId: 'n3' }), + e('2026-05-02T00:00:07Z', 'recall_snoozed', { noteId: 'n4' }) + ]; + const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('회상 추천: shown 4 / opened 2 / dismissed 1 / snoozed 1'); + expect(r.md).toContain('열림율 50.0%'); + expect(r.md).toContain('회상 평균 ageDays: 25'); // (10+20+30+40)/4 + }); + + it('회상 summary shows 데이터 없음 when no recall events', () => { + const r = aggregateStats([], new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('회상 추천'); + expect(r.md).toContain('데이터 없음'); + }); });