From 4cfaa31bc399f8f15f4b0e51dc6932c293682219 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Fri, 15 May 2026 15:14:29 +0900
Subject: [PATCH] =?UTF-8?q?feat(ai):=20batch=20classify=20=E2=80=94=20defa?=
=?UTF-8?q?ult=20notebook=20=EC=9D=98=20=EB=85=B8=ED=8A=B8=EB=93=A4=20?=
=?UTF-8?q?=EC=9D=BC=EA=B4=84=20fit=20=EB=A7=A4=EC=B9=AD=20(=EB=8B=A8?=
=?UTF-8?q?=EC=9D=BC=20prompt)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/ai/batchClassify.ts | 178 ++++++++++++++++++++++++++
src/main/ipc/inboxApi.ts | 15 +++
src/preload/index.ts | 2 +
src/shared/types.ts | 14 ++
tests/unit/batchClassify.test.ts | 212 +++++++++++++++++++++++++++++++
5 files changed, 421 insertions(+)
create mode 100644 src/main/ai/batchClassify.ts
create mode 100644 tests/unit/batchClassify.test.ts
diff --git a/src/main/ai/batchClassify.ts b/src/main/ai/batchClassify.ts
new file mode 100644
index 0000000..04a2a5a
--- /dev/null
+++ b/src/main/ai/batchClassify.ts
@@ -0,0 +1,178 @@
+/**
+ * v0.4 T4 — default notebook 의 active 노트들을 단일 AI prompt 로 일괄 분류.
+ * N notes × 1 call 대신 한 번에 묶어 호출한다.
+ *
+ * Top N cap: 50 (prompt 크기 제한).
+ */
+
+import { z } from 'zod';
+import type { NoteRepository } from '../repository/NoteRepository.js';
+import type { NotebookRepository } from '../repository/NotebookRepository.js';
+
+export interface BatchClassifyResult {
+ assignments: Array<{ noteId: string; notebookId: string | null; notebookName: string | null }>;
+ skippedReason?: string;
+}
+
+export interface BatchClassifyDeps {
+ noteRepo: NoteRepository;
+ notebookRepo: NotebookRepository;
+ provider: { generateRaw: (prompt: string) => Promise };
+}
+
+/** 한 번에 전달할 노트 최대 개수. prompt 크기 제한 대응. */
+const TOP_N_CAP = 50;
+
+// ---------------------------------------------------------------------------
+// Zod schema for AI response
+// ---------------------------------------------------------------------------
+
+const AssignmentSchema = z.object({
+ id: z.string(),
+ notebook: z.string().nullable()
+});
+
+const BatchResponseSchema = z.object({
+ assignments: z.array(AssignmentSchema)
+});
+
+// ---------------------------------------------------------------------------
+// Prompt builder
+// ---------------------------------------------------------------------------
+
+/**
+ * 한국어 batch 분류 prompt.
+ * 기존 buildPrompt 와 별도 함수 — 개별 메타데이터 생성이 아니라 batch 분류 전용.
+ */
+export function buildBatchClassifyPrompt(
+ notes: Array<{ id: string; snippet: string }>,
+ notebookNames: string[]
+): string {
+ const notebookList = notebookNames.join(', ');
+ const noteLines = notes
+ .map((n) => `- ${n.id}: "${n.snippet}"`)
+ .join('\n');
+
+ return `당신은 노트를 노트북으로 정리하는 AI 어시스턴트입니다. \
+아래 노트 목록과 사용 가능한 노트북 목록을 보고 각 노트가 어느 노트북에 가장 잘 맞는지 추천해주세요. \
+명확히 속하지 않으면 null 을 반환하세요.
+
+사용 가능한 노트북: ${notebookList}
+
+노트 목록 (id 와 짧은 제목/내용):
+${noteLines}
+
+규칙:
+- "assignments" 배열을 포함한 JSON 객체만 반환하세요.
+- 각 assignment 의 id 는 입력 id 와 정확히 일치해야 합니다.
+- notebook 은 위 사용 가능한 노트북 목록 중 하나 (대소문자 무관) 또는 null 이어야 합니다.
+- 새 노트북 이름을 만들지 마세요.
+- 마크다운 코드펜스나 설명 없이 JSON 만 반환하세요.
+
+예시:
+{"assignments": [{"id": "N1", "notebook": "회사"}, {"id": "N2", "notebook": null}]}`;
+}
+
+// ---------------------------------------------------------------------------
+// loose JSON extract (classifyStatus 와 동일한 패턴)
+// ---------------------------------------------------------------------------
+
+function parseJsonLoose(raw: string): unknown {
+ try { return JSON.parse(raw); } catch { /* fallback below */ }
+ const first = raw.indexOf('{');
+ const last = raw.lastIndexOf('}');
+ if (first >= 0 && last > first) {
+ const slice = raw.slice(first, last + 1);
+ try { return JSON.parse(slice); } catch { /* fall through */ }
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Main service function
+// ---------------------------------------------------------------------------
+
+export async function batchClassifyDefault(deps: BatchClassifyDeps): Promise {
+ const { noteRepo, notebookRepo, provider } = deps;
+
+ // 1. default notebook 조회
+ const defaultNb = notebookRepo.getDefault();
+ if (!defaultNb) {
+ return { assignments: [], skippedReason: 'no_default_notebook' };
+ }
+
+ // 2. 다른 notebook 목록 (default 제외) — 후보 notebook
+ const allNotebooks = notebookRepo.list();
+ const otherNotebooks = allNotebooks.filter((nb) => nb.id !== defaultNb.id);
+ if (otherNotebooks.length === 0) {
+ return { assignments: [], skippedReason: 'no_other_notebooks' };
+ }
+
+ // 3. default notebook 의 active 노트 조회 (cap 적용)
+ const notes = noteRepo.listByStatus('active', { notebookId: defaultNb.id, limit: TOP_N_CAP });
+ if (notes.length === 0) {
+ return { assignments: [], skippedReason: 'no_notes' };
+ }
+
+ // 4. 노트 snippet 생성 — aiTitle 우선, 없으면 rawText 앞 50자
+ const noteSnippets = notes.map((n) => ({
+ id: n.id,
+ snippet: (n.aiTitle ?? n.rawText).slice(0, 50)
+ }));
+
+ // 5. prompt 구성
+ const notebookNames = otherNotebooks.map((nb) => nb.name);
+ const prompt = buildBatchClassifyPrompt(noteSnippets, notebookNames);
+
+ // 6. AI 호출
+ let rawJson: string;
+ try {
+ rawJson = await provider.generateRaw(prompt);
+ } catch {
+ return { assignments: [], skippedReason: 'ai_error' };
+ }
+
+ // 7. JSON parse
+ const parsed = parseJsonLoose(rawJson);
+ if (parsed === null) {
+ return { assignments: [], skippedReason: 'parse_error' };
+ }
+
+ // 8. Zod validate
+ const validated = BatchResponseSchema.safeParse(parsed);
+ if (!validated.success) {
+ return { assignments: [], skippedReason: 'parse_error' };
+ }
+
+ // 9. 응답 매핑
+ const inputIds = new Set(notes.map((n) => n.id));
+ const assignments: BatchClassifyResult['assignments'] = [];
+
+ for (const item of validated.data.assignments) {
+ // input list 에 없는 id 는 skip
+ if (!inputIds.has(item.id)) continue;
+
+ if (item.notebook === null) {
+ assignments.push({ noteId: item.id, notebookId: null, notebookName: null });
+ continue;
+ }
+
+ // notebook name → NotebookRepository.findByName (case-insensitive)
+ const matched = notebookRepo.findByName(item.notebook);
+ if (!matched) {
+ // hallucinate 된 이름 — null 로 처리 (skip 대신 null 매핑으로 결과에는 포함)
+ assignments.push({ noteId: item.id, notebookId: null, notebookName: null });
+ continue;
+ }
+
+ // default notebook 으로 매핑된 경우는 null 처리 (이미 default 에 있음)
+ if (matched.id === defaultNb.id) {
+ assignments.push({ noteId: item.id, notebookId: null, notebookName: null });
+ continue;
+ }
+
+ assignments.push({ noteId: item.id, notebookId: matched.id, notebookName: matched.name });
+ }
+
+ return { assignments };
+}
diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts
index 24b7d13..c2cd946 100644
--- a/src/main/ipc/inboxApi.ts
+++ b/src/main/ipc/inboxApi.ts
@@ -12,6 +12,7 @@ import type { Note, NoteStatus } from '@shared/types';
import type { HealthResult } from '../ai/InferenceProvider.js';
import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js';
import { classifyStatus } from '../ai/classifyStatus.js';
+import { batchClassifyDefault } from '../ai/batchClassify.js';
import type { SettingsService } from '../services/SettingsService.js';
import type { ProviderHolder } from '../ai/ProviderHolder.js';
@@ -261,6 +262,20 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
});
});
+ // v0.4 T4 — AI batch classify: default notebook 노트 일괄 fit 매칭 (단일 prompt).
+ ipcMain.handle('inbox:batch-classify-default', async () => {
+ if (!deps.notebookRepo) return { assignments: [], skippedReason: 'no_notebook_repo' };
+ const provider = deps.providerHolder.get();
+ if (typeof provider.generateRaw !== 'function') {
+ return { assignments: [], skippedReason: 'no_generate_raw' };
+ }
+ return batchClassifyDefault({
+ noteRepo: deps.repo,
+ notebookRepo: deps.notebookRepo,
+ provider: provider as { generateRaw: (prompt: string) => Promise }
+ });
+ });
+
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 6d0055c..518f709 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -108,6 +108,8 @@ const api: InklingApi = {
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
+ // v0.4 T4 — AI batch classify (단일 prompt).
+ batchClassifyDefault: () => ipcRenderer.invoke('inbox:batch-classify-default'),
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
listPromotionCandidates: () => ipcRenderer.invoke('inbox:list-promotion-candidates'),
getPromotionDismissedTags: () => ipcRenderer.invoke('inbox:get-promotion-dismissed-tags'),
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 12acfcc..4faa6e8 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -97,6 +97,18 @@ export interface Note {
notebookId: string;
}
+// v0.4 T4 — AI batch classify 결과.
+export interface BatchClassifyAssignment {
+ noteId: string;
+ notebookId: string | null;
+ notebookName: string | null;
+}
+
+export interface BatchClassifyResult {
+ assignments: BatchClassifyAssignment[];
+ skippedReason?: string;
+}
+
// v0.4 Task 11 — tag 기반 notebook 승격 제안 후보.
// suggestedName 은 renderer 가 toTitleCase(tag) 로 채움 — IPC 응답에는 없음.
export interface PromotionCandidate {
@@ -259,6 +271,8 @@ export interface InboxApi {
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
setVisionModel(value: string | null): Promise<{ ok: true }>;
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
+ // v0.4 T4 — AI batch classify: default notebook 노트 일괄 fit 매칭 (단일 prompt).
+ batchClassifyDefault(): Promise;
// v0.4 Task 11 — promotion candidates + dismissed/snoozed 영속화.
listPromotionCandidates(): Promise;
getPromotionDismissedTags(): Promise;
diff --git a/tests/unit/batchClassify.test.ts b/tests/unit/batchClassify.test.ts
new file mode 100644
index 0000000..16d38e6
--- /dev/null
+++ b/tests/unit/batchClassify.test.ts
@@ -0,0 +1,212 @@
+import { describe, it, expect, vi } from 'vitest';
+import { batchClassifyDefault } from '../../src/main/ai/batchClassify';
+
+// ---------------------------------------------------------------------------
+// Minimal fakes
+// ---------------------------------------------------------------------------
+
+interface FakeNote { id: string; rawText: string; aiTitle: string | null; notebookId: string; status: string }
+interface FakeNotebook { id: string; name: string }
+
+function makeNoteRepo(notes: FakeNote[], defaultNotebookId: string) {
+ return {
+ listByStatus: vi.fn((_status: string, opts: { notebookId?: string; limit?: number } = {}) => {
+ if (opts.notebookId !== defaultNotebookId) return [];
+ const limit = opts.limit ?? notes.length;
+ return notes.slice(0, limit);
+ }),
+ getDefaultNotebookId: vi.fn(() => defaultNotebookId)
+ };
+}
+
+function makeNotebookRepo(notebooks: FakeNotebook[]) {
+ return {
+ getDefault: vi.fn(() => notebooks[0] ?? null),
+ list: vi.fn(() => notebooks),
+ findByName: vi.fn((name: string) => notebooks.find((n) => n.name.toLowerCase() === name.toLowerCase()) ?? null)
+ };
+}
+
+function makeProvider(json: string) {
+ return {
+ generateRaw: vi.fn(async (_p: string) => json)
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('batchClassifyDefault', () => {
+ it('default notebook 의 노트들 + 다른 notebook 들 prompt 구성 및 결과 매핑', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
+ const notes: FakeNote[] = [
+ { id: 'n1', rawText: 'MLX 계정 생성 가이드', aiTitle: 'MLX 가이드', notebookId: 'nb-default', status: 'active' },
+ { id: 'n2', rawText: '미용실 예약', aiTitle: null, notebookId: 'nb-default', status: 'active' }
+ ];
+
+ const aiJson = JSON.stringify({
+ assignments: [
+ { id: 'n1', notebook: '회사' },
+ { id: 'n2', notebook: null }
+ ]
+ });
+
+ const result = await batchClassifyDefault({
+ noteRepo: makeNoteRepo(notes, 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
+ provider: makeProvider(aiJson)
+ });
+
+ expect(result.assignments).toHaveLength(2);
+
+ const n1 = result.assignments.find((a) => a.noteId === 'n1');
+ expect(n1?.notebookId).toBe('nb-work');
+ expect(n1?.notebookName).toBe('회사');
+
+ const n2 = result.assignments.find((a) => a.noteId === 'n2');
+ expect(n2?.notebookId).toBeNull();
+ expect(n2?.notebookName).toBeNull();
+ });
+
+ it('다른 notebook 0개면 빈 결과', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const notes: FakeNote[] = [
+ { id: 'n1', rawText: '노트', aiTitle: null, notebookId: 'nb-default', status: 'active' }
+ ];
+
+ const provider = makeProvider('{}');
+ const result = await batchClassifyDefault({
+ noteRepo: makeNoteRepo(notes, 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb]) as never,
+ provider
+ });
+
+ expect(result.assignments).toHaveLength(0);
+ expect(result.skippedReason).toBe('no_other_notebooks');
+ // provider 가 호출되면 안 됨
+ expect(provider.generateRaw).not.toHaveBeenCalled();
+ });
+
+ it('default 의 노트 0건이면 빈 결과', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
+
+ const provider = makeProvider('{}');
+ const result = await batchClassifyDefault({
+ noteRepo: makeNoteRepo([], 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
+ provider
+ });
+
+ expect(result.assignments).toHaveLength(0);
+ expect(result.skippedReason).toBe('no_notes');
+ expect(provider.generateRaw).not.toHaveBeenCalled();
+ });
+
+ it('AI 가 hallucinate 한 새 notebook 이름은 skip (notebookId=null 매핑)', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
+ const notes: FakeNote[] = [
+ { id: 'n1', rawText: '테스트 메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
+ ];
+
+ // AI 가 존재하지 않는 notebook 이름 반환
+ const aiJson = JSON.stringify({
+ assignments: [{ id: 'n1', notebook: '없는노트북이름XYZ' }]
+ });
+
+ const result = await batchClassifyDefault({
+ noteRepo: makeNoteRepo(notes, 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
+ provider: makeProvider(aiJson)
+ });
+
+ expect(result.assignments).toHaveLength(1);
+ const a = result.assignments[0];
+ expect(a?.noteId).toBe('n1');
+ // 매칭 실패 → null
+ expect(a?.notebookId).toBeNull();
+ expect(a?.notebookName).toBeNull();
+ });
+
+ it('AI 응답의 id 가 입력 list 에 없으면 skip', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
+ const notes: FakeNote[] = [
+ { id: 'n1', rawText: '메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
+ ];
+
+ // AI 가 unknown-id 반환
+ const aiJson = JSON.stringify({
+ assignments: [
+ { id: 'unknown-id', notebook: '회사' },
+ { id: 'n1', notebook: '회사' }
+ ]
+ });
+
+ const result = await batchClassifyDefault({
+ noteRepo: makeNoteRepo(notes, 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
+ provider: makeProvider(aiJson)
+ });
+
+ const ids = result.assignments.map((a) => a.noteId);
+ expect(ids).not.toContain('unknown-id');
+ expect(ids).toContain('n1');
+ });
+
+ it('provider 가 invalid JSON 반환 시 빈 결과 + skippedReason', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
+ const notes: FakeNote[] = [
+ { id: 'n1', rawText: '메모', aiTitle: null, notebookId: 'nb-default', status: 'active' }
+ ];
+
+ const result = await batchClassifyDefault({
+ noteRepo: makeNoteRepo(notes, 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
+ provider: makeProvider('not valid json at all!!!')
+ });
+
+ expect(result.assignments).toHaveLength(0);
+ expect(result.skippedReason).toMatch(/parse_error|ai_error/);
+ });
+
+ it('top N cap (50) 이 적용되어 prompt 에 최대 50개 노트만 포함', async () => {
+ const defaultNb: FakeNotebook = { id: 'nb-default', name: '기본' };
+ const otherNb: FakeNotebook = { id: 'nb-work', name: '회사' };
+
+ // 60개 노트 생성
+ const notes: FakeNote[] = Array.from({ length: 60 }, (_, i) => ({
+ id: `n${i}`,
+ rawText: `메모 ${i}`,
+ aiTitle: null,
+ notebookId: 'nb-default',
+ status: 'active'
+ }));
+
+ // 50개만 assignments 반환
+ const assignments = notes.slice(0, 50).map((n) => ({ id: n.id, notebook: '회사' }));
+ const aiJson = JSON.stringify({ assignments });
+
+ const capturedPrompts: string[] = [];
+ const provider = {
+ generateRaw: vi.fn(async (p: string) => { capturedPrompts.push(p); return aiJson; })
+ };
+
+ await batchClassifyDefault({
+ noteRepo: makeNoteRepo(notes, 'nb-default') as never,
+ notebookRepo: makeNotebookRepo([defaultNb, otherNb]) as never,
+ provider
+ });
+
+ // prompt 에 n50~n59 (cap 이후) 는 포함 안 됨
+ const prompt = capturedPrompts[0] ?? '';
+ // 처음 50개 (n0~n49) 중 일부는 있어야 함
+ expect(prompt).toContain('n0');
+ // n50 이후는 없어야 함
+ expect(prompt).not.toContain('- n50:');
+ });
+});