From 7aef46dc1abace721bd1e13a504e369f2e648f39 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Fri, 15 May 2026 10:36:59 +0900
Subject: [PATCH] =?UTF-8?q?feat(ai):=20AiWorker=20=E2=80=94=20notebook=5Fm?=
=?UTF-8?q?atch=20=EB=A7=A4=EC=B9=98=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20mo?=
=?UTF-8?q?veNote?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- NotebookRepository.findByName(name) 추가 — COLLATE NOCASE case-insensitive 조회
- AiWorkerOptions.notebookRepo 옵션 추가 (optional Pick)
- processJob: generate 전 notebookRepo.list() → notebooks 배열 GenerateInput 에 주입
- processJob: updateAiResult 후 res.notebookMatch valid 이름이면 findByName + moveNote 호출
- main/index.ts: AiWorker 생성 시 notebookRepo 전달
- NotebookRepository.test.ts: findByName 3개 테스트 추가
- AiWorker.test.ts: notebook 매칭 describe 4개 테스트 추가 (총 45 테스트 통과)
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/main/ai/AiWorker.ts | 18 ++++-
src/main/index.ts | 4 +-
src/main/repository/NotebookRepository.ts | 11 +++
tests/unit/AiWorker.test.ts | 98 +++++++++++++++++++++++
tests/unit/NotebookRepository.test.ts | 15 ++++
5 files changed, 144 insertions(+), 2 deletions(-)
diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts
index 7908ccb..33ffad4 100644
--- a/src/main/ai/AiWorker.ts
+++ b/src/main/ai/AiWorker.ts
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import type { NoteRepository } from '../repository/NoteRepository.js';
+import type { NotebookRepository } from '../repository/NotebookRepository.js';
import type { Note } from '@shared/types';
import type { AiFailedReason } from '../services/telemetryEvents.js';
import type { SettingsService } from '../services/SettingsService.js';
@@ -48,6 +49,8 @@ export interface AiWorkerOptions {
settings?: Pick;
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
mediaStore?: Pick;
+ /** v0.4 — AI 응답의 notebook_match 처리용. 미전달 시 notebook 매칭 skip. */
+ notebookRepo?: Pick;
}
interface Job { noteId: string; attempts: number; }
@@ -65,6 +68,7 @@ export class AiWorker {
private telemetry?: AiTelemetryEmitter;
private settings?: Pick;
private mediaStore?: Pick;
+ private notebookRepo?: Pick;
constructor(
private repo: NoteRepository,
@@ -79,6 +83,7 @@ export class AiWorker {
this.telemetry = opts.telemetry;
this.settings = opts.settings;
this.mediaStore = opts.mediaStore;
+ this.notebookRepo = opts.notebookRepo;
}
async enqueue(noteId: string): Promise {
@@ -183,8 +188,9 @@ export class AiWorker {
})
);
}
+ const notebookNames = this.notebookRepo ? this.notebookRepo.list().map((nb) => nb.name) : [];
const res = await this.holder.get().generate(
- { text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
+ { text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab, notebooks: notebookNames },
{ visionModel: visionModel ?? undefined }
);
// AI primary: AI's dueDate is final (no rule merge)
@@ -195,6 +201,16 @@ export class AiWorker {
provider: this.holder.get().name,
dueDate: res.dueDate ?? null
});
+ // AI 의 notebook_match 가 valid 이름이면 자동 이동.
+ if (res.notebookMatch && this.notebookRepo) {
+ const nb = this.notebookRepo.findByName(res.notebookMatch);
+ if (nb) {
+ this.notebookRepo.moveNote(job.noteId, nb.id);
+ this.logger.info('ai.notebook.match', { noteId: job.noteId, notebook: nb.name });
+ } else {
+ this.logger.info('ai.notebook.miss', { noteId: job.noteId, attempted: res.notebookMatch });
+ }
+ }
this.unreachableBackoffStep = 0; // 성공 시 step reset
this.logger.info('ai.done', {
noteId: job.noteId,
diff --git a/src/main/index.ts b/src/main/index.ts
index 364aa2a..ac8f2a2 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -162,7 +162,9 @@ app.whenReady().then(async () => {
telemetry,
// v0.3.1 Cut F — vision 지원
settings: settingsSvc,
- mediaStore: store
+ mediaStore: store,
+ // v0.4 — notebook_match 자동 이동
+ notebookRepo: notebookRepo
});
const notify = new NotificationService({
diff --git a/src/main/repository/NotebookRepository.ts b/src/main/repository/NotebookRepository.ts
index 5315183..036b1bc 100644
--- a/src/main/repository/NotebookRepository.ts
+++ b/src/main/repository/NotebookRepository.ts
@@ -60,6 +60,17 @@ export class NotebookRepository {
}
}
+ /** name 으로 notebook 조회 (COLLATE NOCASE — case-insensitive). */
+ findByName(name: string): Notebook | null {
+ const r = this.db.prepare(
+ `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at,
+ (SELECT COUNT(*) FROM notes n
+ WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
+ FROM notebooks nb WHERE nb.name = ? COLLATE NOCASE`
+ ).get(name) as Record | undefined;
+ return r ? this.hydrate(r) : null;
+ }
+
/** notes.notebook_id 갱신만 (status 등은 보존). */
moveNote(noteId: string, notebookId: string): void {
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts
index c8043d1..315c806 100644
--- a/tests/unit/AiWorker.test.ts
+++ b/tests/unit/AiWorker.test.ts
@@ -566,6 +566,104 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
});
});
+describe('AiWorker notebook matching', () => {
+ let db: Database.Database;
+ let repo: NoteRepository;
+
+ beforeEach(() => {
+ db = new Database(':memory:');
+ runMigrations(db);
+ repo = new NoteRepository(db);
+ });
+
+ it('AI 응답의 notebookMatch 가 valid 이름이면 moveNote 호출', async () => {
+ const moveNote = vi.fn();
+ const notebookRepo = {
+ list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
+ findByName: (n: string) => n === '회사' ? { id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 } : null,
+ moveNote
+ };
+ const provider = makeProvider({
+ generate: vi.fn(async (): Promise => ({
+ title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: '회사'
+ }))
+ });
+ const { id } = repo.create({ rawText: '회사 업무' });
+ const w = new AiWorker(repo, new ProviderHolder(provider), {
+ backoffsMs: [0],
+ notebookRepo
+ });
+ await w.enqueue(id);
+ await w.drain();
+ expect(moveNote).toHaveBeenCalledWith(id, 'nb-회사');
+ });
+
+ it('AI 응답 notebookMatch null 시 moveNote 호출 X', async () => {
+ const moveNote = vi.fn();
+ const notebookRepo = {
+ list: () => [{ id: 'nb-회사', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }],
+ findByName: (_n: string) => null,
+ moveNote
+ };
+ const provider = makeProvider({
+ generate: vi.fn(async (): Promise => ({
+ title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null
+ }))
+ });
+ const { id } = repo.create({ rawText: '일반 메모' });
+ const w = new AiWorker(repo, new ProviderHolder(provider), {
+ backoffsMs: [0],
+ notebookRepo
+ });
+ await w.enqueue(id);
+ await w.drain();
+ expect(moveNote).not.toHaveBeenCalled();
+ });
+
+ it('notebooks 배열을 provider.generate 에 전달', async () => {
+ let capturedNotebooks: string[] | undefined;
+ const notebookRepo = {
+ list: () => [
+ { id: 'nb-1', name: '회사', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 },
+ { id: 'nb-2', name: '개인', color: null, createdAt: 't', updatedAt: 't', noteCount: 0 }
+ ],
+ findByName: (_n: string) => null,
+ moveNote: vi.fn()
+ };
+ const provider = makeProvider({
+ generate: vi.fn(async (input: any): Promise => {
+ capturedNotebooks = input.notebooks;
+ return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: null };
+ })
+ });
+ const { id } = repo.create({ rawText: '테스트' });
+ const w = new AiWorker(repo, new ProviderHolder(provider), {
+ backoffsMs: [0],
+ notebookRepo
+ });
+ await w.enqueue(id);
+ await w.drain();
+ expect(capturedNotebooks).toEqual(['회사', '개인']);
+ });
+
+ it('notebookRepo 미전달 시 notebooks 빈 배열 전달 + moveNote 호출 X', async () => {
+ let capturedNotebooks: string[] | undefined;
+ const provider = makeProvider({
+ generate: vi.fn(async (input: any): Promise => {
+ capturedNotebooks = input.notebooks;
+ return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null, notebookMatch: '회사' };
+ })
+ });
+ const { id } = repo.create({ rawText: '테스트' });
+ const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0] });
+ await w.enqueue(id);
+ await w.drain();
+ expect(capturedNotebooks).toEqual([]);
+ // notebookRepo 없으므로 moveNote 미호출 — note 는 done 상태
+ expect(repo.findById(id)?.aiStatus).toBe('done');
+ });
+});
+
describe('vocab COLLATE NOCASE', () => {
let db: Database.Database;
let repo: NoteRepository;
diff --git a/tests/unit/NotebookRepository.test.ts b/tests/unit/NotebookRepository.test.ts
index 72bf39f..2d83c92 100644
--- a/tests/unit/NotebookRepository.test.ts
+++ b/tests/unit/NotebookRepository.test.ts
@@ -97,4 +97,19 @@ describe('NotebookRepository', () => {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('not_found');
});
+
+ it('findByName: 이름으로 조회', () => {
+ const nb = repo.create({ name: '회사' });
+ const found = repo.findByName('회사');
+ expect(found?.id).toBe(nb.id);
+ });
+
+ it('findByName: case-insensitive', () => {
+ repo.create({ name: 'Work' });
+ expect(repo.findByName('work')?.name).toBe('Work');
+ });
+
+ it('findByName: 없으면 null', () => {
+ expect(repo.findByName('없음')).toBeNull();
+ });
});