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;