From e2b16d44d78fb1ea217d93e5bd2474f8e7b188a0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:14:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(tag-vocab):=20T1=20review=20minor/nit=204?= =?UTF-8?q?=EA=B1=B4=20=EC=9D=BC=EA=B4=84=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/main/repository/NoteRepository.ts | 10 ++++++++-- tests/unit/NoteRepository.test.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 9492c68..8a3a996 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -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)); } /** diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index c875f8a..8097b9b 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -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', () => {