/** * 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 }; }