feat(ai): AiWorker merges rule parser + AI due_date
GenerateInput gains todayKst field. AiWorker computes KST-aligned date once per job, runs parseDueDate on rawText, calls provider.generate with todayKst, then merges: rule.iso wins if matched (deterministic), else AI's due_date, else null. Logs dueDateSource (rule|ai|none) for debugging. now() injection for testability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>) => void;
|
||||
error: (msg: string, meta?: Record<string, unknown>) => 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<AiWorkerOptions['logger']>;
|
||||
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<void> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]+)*$/);
|
||||
|
||||
@@ -10,7 +10,7 @@ function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProv
|
||||
return {
|
||||
name: 'mock',
|
||||
generate: vi.fn(async (): Promise<AiResponse> => ({
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user