feat(recall): telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas (#6 v0.2.3)

- RecallShownPayload { noteId, ageDays: int>=0 } .strict()
- recall_opened/dismissed/snoozed → NoteIdPayload 재사용
- TelemetryEventSchema union 15 → 19
- 단위 +3 cases (recall_shown valid, extra field 거부, opened/dismissed/snoozed valid)

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

View File

@@ -59,6 +59,11 @@ const TagVocabMissPayload = z.object({
vocabSize: z.number().int().nonnegative()
}).strict();
const RecallShownPayload = z.object({
noteId: z.string().min(1),
ageDays: z.number().int().nonnegative()
}).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(),
@@ -74,7 +79,11 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [
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(),
z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict()
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(),
z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict()
]);
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;

View File

@@ -306,3 +306,30 @@ describe('validateEvent — tag vocab', () => {
})).toThrow();
});
});
describe('validateEvent — recall', () => {
it('accepts recall_shown event', () => {
const e = validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'recall_shown',
payload: { noteId: 'n1', ageDays: 14 }
});
expect(e.kind).toBe('recall_shown');
});
it('rejects recall_shown with extra field (privacy)', () => {
expect(() => validateEvent({
ts: '2026-05-02T00:00:00.000Z',
kind: 'recall_shown',
payload: { noteId: 'n1', ageDays: 14, content: 'leak' }
})).toThrow();
});
it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => {
for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) {
const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } });
expect(e.kind).toBe(kind);
}
});
});