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:'); + }); +});