fix(tag-vocab): T1 review minor/nit 4건 일괄 (#3 v0.2.3)

- M1: getTopUsedTags 의 LIMIT-then-filter 의미 docstring 명시
- M2: AI+user source 통합 테스트 강화 — 카운트 차이로 정렬 검증 (toContain 만으론 약함)
  updateUserAiFields 는 tags REPLACE 방식 (DELETE+reinsert) 이므로
  fallback 패턴 사용: 3개 노트 각 1태그, AI/user 혼합으로 design=2 > meeting=1 검증
- N1: SQL "COUNT(*) c" → "COUNT(*) AS c" (countFailed 패턴과 일관)
- N2: kebab-case regex 모듈 상수 KEBAB_CASE_RE 로 hoist

skip: N3 (test 헬퍼 — verbosity 경미), N4 (it 블록 분리 — 코드베이스 패턴 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 12:14:53 +09:00
parent df8a53aec1
commit e2b16d44d7
2 changed files with 16 additions and 4 deletions

View File

@@ -40,6 +40,8 @@ export interface ImportNoteResult {
status: ImportNoteStatus;
}
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
constructor(private db: Database.Database) {}
@@ -219,11 +221,15 @@ export class NoteRepository {
* v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
* source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
* deleted_at IS NULL 만 (휴지통 노트 태그 제외).
*
* Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨.
* 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case
* 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음.
*/
getTopUsedTags(limit = 20): string[] {
const rows = this.db
.prepare(
`SELECT t.name, COUNT(*) c
`SELECT t.name, COUNT(*) AS c
FROM tags t
JOIN note_tags nt ON nt.tag_id = t.id
JOIN notes n ON n.id = nt.note_id
@@ -235,7 +241,7 @@ export class NoteRepository {
.all(limit) as Array<{ name: string; c: number }>;
return rows
.map((r) => r.name)
.filter((n) => /^[a-z0-9-]+$/.test(n));
.filter((n) => KEBAB_CASE_RE.test(n));
}
/**

View File

@@ -681,11 +681,17 @@ describe('NoteRepository — failed retry helpers', () => {
it('getTopUsedTags counts AI + user sources together', () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const c = repo.create({ rawText: 'c' }).id;
// design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total
// → design must rank first (proves source merging, not AI-only count)
// Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note
// gets exactly the tags passed in the call.
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
repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' });
const top = repo.getTopUsedTags();
expect(top).toContain('design');
expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only)
expect(top.indexOf('meeting')).toBeGreaterThan(0);
});
it('getTopUsedTags excludes tags from deleted notes', () => {