feat(ai): AiWorker — notebook_match 매치 시 자동 moveNote

- NotebookRepository.findByName(name) 추가 — COLLATE NOCASE case-insensitive 조회
- AiWorkerOptions.notebookRepo 옵션 추가 (optional Pick<NotebookRepository, ...>)
- 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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-15 10:36:59 +09:00
parent 359d94e7e6
commit 7aef46dc1a
5 changed files with 144 additions and 2 deletions

View File

@@ -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<SettingsService, 'getVisionModel'>;
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
mediaStore?: Pick<MediaStore, 'absolutePath'>;
/** v0.4 — AI 응답의 notebook_match 처리용. 미전달 시 notebook 매칭 skip. */
notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
}
interface Job { noteId: string; attempts: number; }
@@ -65,6 +68,7 @@ export class AiWorker {
private telemetry?: AiTelemetryEmitter;
private settings?: Pick<SettingsService, 'getVisionModel'>;
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
private notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
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<void> {
@@ -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,

View File

@@ -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({

View File

@@ -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<string, unknown> | 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=?`)

View File

@@ -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<AiResponse> => ({
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<AiResponse> => ({
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<AiResponse> => {
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<AiResponse> => {
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;

View File

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