feat(notes): findPromotionCandidates — tag threshold default notebook 클러스터
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user