179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
/**
|
||
* 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<string> };
|
||
}
|
||
|
||
/** 한 번에 전달할 노트 최대 개수. 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<BatchClassifyResult> {
|
||
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 };
|
||
}
|