fix(tag-vocab): PR review round 1 — i1 dedup + m2 test gap (#3 v0.2.3)

- i1 (Important): AiWorker per-tag emit 루프에 res.tags Set dedup
  AI 가 같은 태그 중복 응답 시 hit count 2번 emit 되던 통계 왜곡 수정
  + 테스트 1개 (중복 태그 1 hit + 1 miss 검증)
- m2 (Minor): NoteRepository.getTopUsedTags LIMIT-then-filter 테스트 갭
  + 테스트 1개 (limit=3 + 한글 1 + kebab 2 → 결과 length=2 lock-in)

skip: m1 (per-tag serial await — ai_succeeded 패턴 일관),
      n1 (prompt 빈 줄 cosmetic), n2 (tagId positive — AUTOINCREMENT 1+)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 12:49:36 +09:00
parent ff07738b02
commit d8621d55e0
3 changed files with 43 additions and 1 deletions

View File

@@ -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) {

View File

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

View File

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