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:
@@ -1,5 +1,6 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||||
|
import type { NotebookRepository } from '../repository/NotebookRepository.js';
|
||||||
import type { Note } from '@shared/types';
|
import type { Note } from '@shared/types';
|
||||||
import type { AiFailedReason } from '../services/telemetryEvents.js';
|
import type { AiFailedReason } from '../services/telemetryEvents.js';
|
||||||
import type { SettingsService } from '../services/SettingsService.js';
|
import type { SettingsService } from '../services/SettingsService.js';
|
||||||
@@ -48,6 +49,8 @@ export interface AiWorkerOptions {
|
|||||||
settings?: Pick<SettingsService, 'getVisionModel'>;
|
settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||||
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
|
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
|
||||||
mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||||
|
/** v0.4 — AI 응답의 notebook_match 처리용. 미전달 시 notebook 매칭 skip. */
|
||||||
|
notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Job { noteId: string; attempts: number; }
|
interface Job { noteId: string; attempts: number; }
|
||||||
@@ -65,6 +68,7 @@ export class AiWorker {
|
|||||||
private telemetry?: AiTelemetryEmitter;
|
private telemetry?: AiTelemetryEmitter;
|
||||||
private settings?: Pick<SettingsService, 'getVisionModel'>;
|
private settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||||
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||||
|
private notebookRepo?: Pick<NotebookRepository, 'list' | 'findByName' | 'moveNote'>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private repo: NoteRepository,
|
private repo: NoteRepository,
|
||||||
@@ -79,6 +83,7 @@ export class AiWorker {
|
|||||||
this.telemetry = opts.telemetry;
|
this.telemetry = opts.telemetry;
|
||||||
this.settings = opts.settings;
|
this.settings = opts.settings;
|
||||||
this.mediaStore = opts.mediaStore;
|
this.mediaStore = opts.mediaStore;
|
||||||
|
this.notebookRepo = opts.notebookRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueue(noteId: string): Promise<void> {
|
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(
|
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 }
|
{ visionModel: visionModel ?? undefined }
|
||||||
);
|
);
|
||||||
// AI primary: AI's dueDate is final (no rule merge)
|
// AI primary: AI's dueDate is final (no rule merge)
|
||||||
@@ -195,6 +201,16 @@ export class AiWorker {
|
|||||||
provider: this.holder.get().name,
|
provider: this.holder.get().name,
|
||||||
dueDate: res.dueDate ?? null
|
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.unreachableBackoffStep = 0; // 성공 시 step reset
|
||||||
this.logger.info('ai.done', {
|
this.logger.info('ai.done', {
|
||||||
noteId: job.noteId,
|
noteId: job.noteId,
|
||||||
|
|||||||
@@ -162,7 +162,9 @@ app.whenReady().then(async () => {
|
|||||||
telemetry,
|
telemetry,
|
||||||
// v0.3.1 Cut F — vision 지원
|
// v0.3.1 Cut F — vision 지원
|
||||||
settings: settingsSvc,
|
settings: settingsSvc,
|
||||||
mediaStore: store
|
mediaStore: store,
|
||||||
|
// v0.4 — notebook_match 자동 이동
|
||||||
|
notebookRepo: notebookRepo
|
||||||
});
|
});
|
||||||
|
|
||||||
const notify = new NotificationService({
|
const notify = new NotificationService({
|
||||||
|
|||||||
@@ -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 등은 보존). */
|
/** notes.notebook_id 갱신만 (status 등은 보존). */
|
||||||
moveNote(noteId: string, notebookId: string): void {
|
moveNote(noteId: string, notebookId: string): void {
|
||||||
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
|
this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`)
|
||||||
|
|||||||
@@ -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', () => {
|
describe('vocab COLLATE NOCASE', () => {
|
||||||
let db: Database.Database;
|
let db: Database.Database;
|
||||||
let repo: NoteRepository;
|
let repo: NoteRepository;
|
||||||
|
|||||||
@@ -97,4 +97,19 @@ describe('NotebookRepository', () => {
|
|||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) expect(r.reason).toBe('not_found');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user