diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index b9181b3..d08b348 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -166,8 +166,9 @@ export class AiWorker { } }).catch(() => {}); // v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장) + // dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장 const vocabSet = new Set(vocab); - for (const tagName of res.tags) { + for (const tagName of new Set(res.tags)) { if (vocabSet.has(tagName)) { const tagId = this.repo.getTagIdByName(tagName); if (tagId !== null) { diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index cc62f66..afc0bbe 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -531,4 +531,30 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { const hits = emits.filter((e) => e.kind === 'tag_vocab_hit'); expect(hits).toHaveLength(3); }); + + it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => { + // Pre-seed: 'design' in vocab + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적 + dueDate: null + })) + }); + const emits: EmittedEvent[] = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } + }); + await w.enqueue(id); + await w.drain(); + const hit = emits.filter((e) => e.kind === 'tag_vocab_hit'); + const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); + expect(hit).toHaveLength(1); // 'design' 중복 → 1 hit (dedup) + expect(miss).toHaveLength(1); // 'meeting' 1 miss + }); }); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 8097b9b..ff87460 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -716,6 +716,21 @@ describe('NoteRepository — failed retry helpers', () => { expect(repo.getTopUsedTags(10)).toHaveLength(5); }); + it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => { + // 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외 + // 결과 length = 2 (limit=3 보다 작음) + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.updateUserAiFields(a, { tags: ['회의'] }); // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X + repo.updateUserAiFields(b, { tags: ['design'] }); + repo.updateUserAiFields(c, { tags: ['meeting'] }); + const top = repo.getTopUsedTags(3); + expect(top.length).toBeLessThan(3); // SQL 은 3개 가져왔지만 regex 가 1개 제거 + expect(top).not.toContain('회의'); + expect(top).toEqual(expect.arrayContaining(['design', 'meeting'])); + }); + it('getTagIdByName returns id when present, null when absent', () => { const a = repo.create({ rawText: 'a' }).id; repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' });