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