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:
altair823
2026-04-26 11:14:46 +09:00
parent 4ee135dcd6
commit adae90eb61
6 changed files with 133 additions and 12 deletions

View File

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

View File

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

View File

@@ -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 }

View File

@@ -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]+)*$/);

View File

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

View File

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