diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 9563ac3..9492c68 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -215,6 +215,40 @@ export class NoteRepository { .run(nextRunAt, lastError.slice(0, 500), noteId); } + /** + * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. + * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). + * deleted_at IS NULL 만 (휴지통 노트 태그 제외). + */ + getTopUsedTags(limit = 20): string[] { + const rows = this.db + .prepare( + `SELECT t.name, COUNT(*) c + FROM tags t + JOIN note_tags nt ON nt.tag_id = t.id + JOIN notes n ON n.id = nt.note_id + WHERE n.deleted_at IS NULL + GROUP BY t.id + ORDER BY c DESC, t.id ASC + LIMIT ?` + ) + .all(limit) as Array<{ name: string; c: number }>; + return rows + .map((r) => r.name) + .filter((n) => /^[a-z0-9-]+$/.test(n)); + } + + /** + * v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장. + * tags.name COLLATE NOCASE 라 case-insensitive lookup. + */ + getTagIdByName(name: string): number | null { + const row = this.db + .prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`) + .get(name) as { id: number } | undefined; + return row ? row.id : null; + } + updateUserAiFields( id: string, fields: { title?: string; summary?: string; tags?: string[] } diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 93e5e8c..c875f8a 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -652,4 +652,73 @@ describe('NoteRepository — failed retry helpers', () => { expect(job.attempts).toBe(1); // 변화 없음 expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z'); }); + + it('getTopUsedTags returns [] when no notes', () => { + expect(repo.getTopUsedTags()).toEqual([]); + }); + + it('getTopUsedTags orders by count desc, id asc tiebreaker', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' }); + repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); + // counts: design=3, meeting=2, qa=1 + expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']); + }); + + it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => { + const a = repo.create({ rawText: 'a' }).id; + // user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증 + repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] }); + expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout'])); + expect(repo.getTopUsedTags()).not.toContain('회의'); + expect(repo.getTopUsedTags()).not.toContain('Meeting'); + expect(repo.getTopUsedTags()).not.toContain('two words'); + }); + + it('getTopUsedTags counts AI + user sources together', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); + repo.updateUserAiFields(b, { tags: ['design'] }); + // design count 는 AI 1 + user 1 = 2 + const top = repo.getTopUsedTags(); + expect(top).toContain('design'); + }); + + it('getTopUsedTags excludes tags from deleted notes', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' }); + repo.trash(a, new Date().toISOString()); + expect(repo.getTopUsedTags()).not.toContain('lonely'); + }); + + it('getTopUsedTags respects LIMIT parameter', () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + ids.push(id); + repo.updateAiResult(id, { + title: 't', summary: 'a\nb\nc', + tags: [`tag-${i}`], + provider: 'p' + }); + } + expect(repo.getTopUsedTags(3)).toHaveLength(3); + expect(repo.getTopUsedTags(10)).toHaveLength(5); + }); + + 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' }); + const id = repo.getTagIdByName('hello'); + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + // case-insensitive + expect(repo.getTagIdByName('HELLO')).toBe(id); + // absent + expect(repo.getTagIdByName('nothere')).toBeNull(); + }); });