feat(notes): findPromotionCandidates — tag threshold default notebook 클러스터

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 10:20:41 +09:00
parent d01cd5f350
commit 4d070bb6c7
2 changed files with 91 additions and 0 deletions

View File

@@ -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`)

View File

@@ -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;