feat(recall): telemetryStats + EmitInput — recall 누적 + 열림율 + 평균 ageDays (#6 v0.2.3)

- DailyRow +4 cols (recall_shown/opened/dismissed/snoozed)
- accumulators + 4 branches + recallAgeDaysSum
- table 컬럼 +4
- summary lines: "- 회상 추천: shown N / opened O / dismissed D / snoozed S (열림율 X%)"
                 "- 회상 평균 ageDays: avg"
- TelemetryService.EmitInput union 15 → 19
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 13:17:49 +09:00
parent b94e68238c
commit 59cfb711cd
3 changed files with 63 additions and 5 deletions

View File

@@ -30,7 +30,11 @@ export type EmitInput =
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
| { 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(

View File

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

View File

@@ -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('데이터 없음');
});
});