From 4d070bb6c74a1aaa6d6126fab3da8c3f3ad16258 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 10:20:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(notes):=20findPromotionCandidates=20?= =?UTF-8?q?=E2=80=94=20tag=20threshold=20default=20notebook=20=ED=81=B4?= =?UTF-8?q?=EB=9F=AC=EC=8A=A4=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 22 +++++++++ tests/unit/NoteRepository.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 6cdaa02..de1778b 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1163,6 +1163,28 @@ export class NoteRepository { return rows.map((r) => this.hydrate(r)); } + /** + * v0.4 Task 5 — default notebook 안 active 노트를 tag 기준으로 cluster 하여 + * threshold 이상인 tag + noteIds 배열 반환. notebook 승격 제안 트리거용. + * completed/trashed/non-default-notebook 제외. + */ + findPromotionCandidates( + defaultNotebookId: string, + threshold: number = 3 + ): Array<{ tag: string; noteIds: string[] }> { + const rows = this.db.prepare( + `SELECT t.name AS tag, GROUP_CONCAT(n.id) AS ids, COUNT(DISTINCT n.id) AS cnt + FROM tags t + JOIN note_tags nt ON nt.tag_id = t.id + JOIN notes n ON n.id = nt.note_id + WHERE n.status = 'active' + AND n.notebook_id = ? + GROUP BY t.id + HAVING cnt >= ?` + ).all(defaultNotebookId, threshold) as Array<{ tag: string; ids: string; cnt: number }>; + return rows.map((r) => ({ tag: r.tag, noteIds: r.ids.split(',') })); + } + getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> { const rows = this.db .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`) diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index f172096..420881f 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -1256,6 +1256,75 @@ describe('NoteRepository.create with notebook', () => { }); }); +describe('NoteRepository.findPromotionCandidates', () => { + let db: Database.Database; + let repo: NoteRepository; + let defaultId: string; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + }); + + function insertWithTag(rawText: string, tagName: string, notebookId?: string): string { + const { id } = repo.create({ rawText, notebookId }); + repo.updateAiResult(id, { title: rawText, summary: 'a\nb\nc', tags: [tagName], provider: 'test', dueDate: null }); + return id; + } + + it('threshold 미만: 빈 결과', () => { + insertWithTag('n1', 'mlx-ops'); + insertWithTag('n2', 'mlx-ops'); + expect(repo.findPromotionCandidates(defaultId)).toEqual([]); + }); + + it('threshold 도달: tag 와 noteIds 반환', () => { + const a = insertWithTag('n1', 'mlx-ops'); + const b = insertWithTag('n2', 'mlx-ops'); + const c = insertWithTag('n3', 'mlx-ops'); + const r = repo.findPromotionCandidates(defaultId); + expect(r).toHaveLength(1); + expect(r[0]!.tag).toBe('mlx-ops'); + expect(r[0]!.noteIds.sort()).toEqual([a, b, c].sort()); + }); + + it('default 가 아닌 notebook 의 노트는 제외', () => { + db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','2099-01-01','2099-01-01')`).run(); + insertWithTag('n1', 'mlx-ops'); + insertWithTag('n2', 'mlx-ops'); + insertWithTag('n3', 'mlx-ops', 'nb-x'); + expect(repo.findPromotionCandidates(defaultId)).toEqual([]); + }); + + it('completed 제외 — active 만', () => { + insertWithTag('n1', 'mlx-ops'); + insertWithTag('n2', 'mlx-ops'); + const c = insertWithTag('n3', 'mlx-ops'); + repo.setStatus(c, 'completed', null); + expect(repo.findPromotionCandidates(defaultId)).toEqual([]); + }); + + it('threshold 인자로 cap 조절 가능', () => { + insertWithTag('n1', 'mlx-ops'); + insertWithTag('n2', 'mlx-ops'); + expect(repo.findPromotionCandidates(defaultId, 2)).toHaveLength(1); + }); + + it('여러 tag cluster 가 모두 반환', () => { + insertWithTag('a1', 'mlx-ops'); + insertWithTag('a2', 'mlx-ops'); + insertWithTag('a3', 'mlx-ops'); + insertWithTag('b1', 'keycloak'); + insertWithTag('b2', 'keycloak'); + insertWithTag('b3', 'keycloak'); + const r = repo.findPromotionCandidates(defaultId); + expect(r).toHaveLength(2); + expect(r.map((c) => c.tag).sort()).toEqual(['keycloak', 'mlx-ops']); + }); +}); + describe('NoteRepository.list / countByStatus with notebookId', () => { let db: Database.Database; let repo: NoteRepository;