diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index 7c97b6b..d696c8b 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -1,6 +1,19 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { InferenceProvider } from './InferenceProvider.js'; import type { Note } from '@shared/types'; +import { parseDueDate } from '../services/dueDateParser.js'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +function todayKstAsDate(now: Date): Date { + // Returns a Date object whose UTC year/month/day match KST today + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); +} + +function todayKstAsIso(now: Date): string { + return todayKstAsDate(now).toISOString().slice(0, 10); +} export interface AiWorkerOptions { backoffsMs?: number[]; @@ -10,6 +23,7 @@ export interface AiWorkerOptions { warn: (msg: string, meta?: Record) => void; error: (msg: string, meta?: Record) => void; }; + now?: () => Date; } interface Job { noteId: string; attempts: number; } @@ -21,6 +35,7 @@ export class AiWorker { private backoffsMs: number[]; private onUpdate?: (note: Note) => void; private logger: NonNullable; + private now: () => Date; constructor( private repo: NoteRepository, @@ -30,6 +45,7 @@ export class AiWorker { this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; this.onUpdate = opts.onUpdate; this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} }; + this.now = opts.now ?? (() => new Date()); } async enqueue(noteId: string): Promise { @@ -82,12 +98,25 @@ export class AiWorker { try { const note = this.repo.findById(job.noteId); if (!note || note.aiStatus !== 'pending') return; - const res = await this.provider.generate({ text: note.rawText }); + const nowDate = this.now(); + const todayDate = todayKstAsDate(nowDate); + const todayIso = todayKstAsIso(nowDate); + const ruleResult = parseDueDate(note.rawText, todayDate); + const res = await this.provider.generate({ text: note.rawText, todayKst: todayIso }); + // Merge rule + AI: rule takes priority, AI fills if rule null + const finalDueDate = ruleResult.iso ?? res.dueDate ?? null; this.repo.updateAiResult(job.noteId, { - title: res.title, summary: res.summary, tags: res.tags, - provider: this.provider.name + title: res.title, + summary: res.summary, + tags: res.tags, + provider: this.provider.name, + dueDate: finalDueDate + }); + this.logger.info('ai.done', { + noteId: job.noteId, + attempt, + dueDateSource: ruleResult.iso !== null ? 'rule' : (res.dueDate !== null ? 'ai' : 'none') }); - this.logger.info('ai.done', { noteId: job.noteId, attempt }); this.emit(job.noteId); return; } catch (err) { diff --git a/src/main/ai/InferenceProvider.ts b/src/main/ai/InferenceProvider.ts index 8329e95..65d1319 100644 --- a/src/main/ai/InferenceProvider.ts +++ b/src/main/ai/InferenceProvider.ts @@ -1,6 +1,10 @@ import type { AiResponse } from './schema.js'; -export interface GenerateInput { text: string; } +export interface GenerateInput { + text: string; + todayKst: string; // ISO YYYY-MM-DD in KST +} + export interface HealthResult { ok: boolean; model?: string; reason?: string; } export interface InferenceProvider { diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 8a87144..97c8c9b 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -37,7 +37,7 @@ export class LocalOllamaProvider implements InferenceProvider { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: this.model, - prompt: buildPrompt(input.text), + prompt: buildPrompt(input.text, input.todayKst), format: 'json', stream: false, options: { temperature: this.temperature, num_predict: this.numPredict } diff --git a/tests/integration/ollama-golden.test.ts b/tests/integration/ollama-golden.test.ts index 9ea3d47..95d1022 100644 --- a/tests/integration/ollama-golden.test.ts +++ b/tests/integration/ollama-golden.test.ts @@ -20,7 +20,7 @@ describe.skipIf(skip)('LocalOllamaProvider integration', () => { ]; it.each(cases)('Korean title + 3 lines for: %s', async (input) => { - const r = await provider.generate({ text: input }); + const r = await provider.generate({ text: input, todayKst: '2026-04-26' }); expect(/[가-힣]/.test(r.title)).toBe(true); expect(r.summary.split('\n')).toHaveLength(3); for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 24fe922..a8a1e4a 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -10,7 +10,7 @@ function makeProvider(overrides: Partial = {}): InferenceProv return { name: 'mock', generate: vi.fn(async (): Promise => ({ - title: '제목', summary: 'a\nb\nc', tags: ['tag'] + title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null })), healthCheck: vi.fn(async () => ({ ok: true })), ...overrides @@ -73,7 +73,7 @@ describe('AiWorker', () => { running++; max = Math.max(max, running); await new Promise((r) => setTimeout(r, 10)); running--; - return { title: '제목', summary: 'a\nb\nc', tags: [] }; + return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null }; }) }); const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); @@ -81,4 +81,90 @@ describe('AiWorker', () => { await w.drain(); expect(max).toBe(1); }); + + it('rule parser match takes priority over AI dueDate', async () => { + const provider = { + name: 'mock', + generate: async (_input: any) => ({ + title: '내일', + summary: 'a\nb\nc', + tags: [], + dueDate: '2026-12-31' // AI returns far-future + }), + healthCheck: async () => ({ ok: true }) + } as any; + const w = new AiWorker(repo, provider, { + backoffsMs: [0], + now: () => new Date('2026-04-26T00:00:00.000Z') + }); + const { id } = repo.create({ rawText: '내일 회의' }); + await w.enqueue(id); + await w.drain(); + const note = repo.findById(id)!; + expect(note.dueDate).toBe('2026-04-27'); // 내일 from rule, not AI's 12-31 + }); + + it('rule null + AI value → AI used', async () => { + const provider = { + name: 'mock', + generate: async () => ({ + title: '월말 마감', + summary: 'a\nb\nc', + tags: [], + dueDate: '2026-04-30' // AI resolves "월말" + }), + healthCheck: async () => ({ ok: true }) + } as any; + const w = new AiWorker(repo, provider, { + backoffsMs: [0], + now: () => new Date('2026-04-26T00:00:00.000Z') + }); + const { id } = repo.create({ rawText: '월말 마감' }); + await w.enqueue(id); + await w.drain(); + const note = repo.findById(id)!; + expect(note.dueDate).toBe('2026-04-30'); + }); + + it('rule null + AI null → null', async () => { + const provider = { + name: 'mock', + generate: async () => ({ + title: '아무 메모', + summary: 'a\nb\nc', + tags: [], + dueDate: null + }), + healthCheck: async () => ({ ok: true }) + } as any; + const w = new AiWorker(repo, provider, { + backoffsMs: [0], + now: () => new Date('2026-04-26T00:00:00.000Z') + }); + const { id } = repo.create({ rawText: '아무 메모' }); + await w.enqueue(id); + await w.drain(); + const note = repo.findById(id)!; + expect(note.dueDate).toBeNull(); + }); + + it('passes todayKst to provider.generate', async () => { + const seen: any = {}; + const provider = { + name: 'mock', + generate: async (input: any) => { + seen.todayKst = input.todayKst; + return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null }; + }, + healthCheck: async () => ({ ok: true }) + } as any; + const w = new AiWorker(repo, provider, { + backoffsMs: [0], + now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST + }); + const { id } = repo.create({ rawText: 'x' }); + await w.enqueue(id); + await w.drain(); + expect(seen.todayKst).toBe('2026-04-27'); + }); }); diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index bcdaec3..44733fe 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -22,7 +22,7 @@ describe('LocalOllamaProvider', () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] }) }); - const r = await new LocalOllamaProvider().generate({ text: 'x' }); + const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26' }); expect(r.title).toBe('회의'); }); @@ -30,7 +30,9 @@ describe('LocalOllamaProvider', () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: 'not json' }); - await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i); + await expect( + new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26' }) + ).rejects.toThrow(/json/i); }); it('generate aborts on timeout', async () => { @@ -39,7 +41,7 @@ describe('LocalOllamaProvider', () => { return { statusCode: 200, data: '{}' }; }) as never); await expect( - new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x' }) + new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26' }) ).rejects.toThrow(); }, 2000);