feat(tag-vocab): NoteRepository — getTopUsedTags + getTagIdByName (#3 v0.2.3)
- getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외 - getTagIdByName(name): COLLATE NOCASE lookup - AI+user source 통합 카운트 (Q1=C 결정) - 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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[] }
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user