Files
inkling/src/main/ai/batchClassify.ts

179 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}