feat(ai): AI-primary due_date flow — rule as prompt candidates only

Flow 반전 (F7-D 채택):
- 기존: rule.iso ?? ai.dueDate (rule 우선)
- 신규: ai.dueDate ?? null (AI 우선)

규칙은 parseAllCandidates 로 모든 매치를 추출 → prompt 에 후보
힌트로 주입. AI 가 종합 판단. AI 실패 시 due_date null (별 fallback 없음).

해결되는 케이스: '내일 모레' → AI 가 ambiguous 인지 → null.

PROMPT_VERSION → 3. GenerateInput.dueDateCandidates 신규.
buildPrompt(rawText, todayKst, candidates) — 빈 배열일 때 hint 섹션 생략.

Tests:
- AiWorker.test.ts — 'rule priority' 테스트 → 'AI dueDate wins' flip
- AiWorker.test.ts — passes todayKst 테스트 확장 (dueDateCandidates 도 검증)
- AiWorker.test.ts — 신규 'passes parseAllCandidates result as dueDateCandidates'
- LocalOllamaProvider.test.ts / ollama-golden.test.ts — generate 호출에 dueDateCandidates: [] 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 13:06:12 +09:00
parent 1c72b64c2f
commit 723dccd61d
7 changed files with 62 additions and 20 deletions

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, todayKst: '2026-04-26' });
const r = await provider.generate({ text: input, todayKst: '2026-04-26', dueDateCandidates: [] });
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

@@ -82,14 +82,14 @@ describe('AiWorker', () => {
expect(max).toBe(1);
});
it('rule parser match takes priority over AI dueDate', async () => {
it('AI dueDate wins over rule candidates (flow reversed from F1)', async () => {
const provider = {
name: 'mock',
generate: async (_input: any) => ({
generate: async () => ({
title: '내일',
summary: 'a\nb\nc',
tags: [],
dueDate: '2026-12-31' // AI returns far-future
dueDate: '2026-12-31' // AI says far future, rule has '내일'
}),
healthCheck: async () => ({ ok: true })
} as any;
@@ -101,7 +101,7 @@ describe('AiWorker', () => {
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
expect(note.dueDate).toBe('2026-12-31'); // AI value, not rule
});
it('rule null + AI value → AI used', async () => {
@@ -154,6 +154,7 @@ describe('AiWorker', () => {
name: 'mock',
generate: async (input: any) => {
seen.todayKst = input.todayKst;
seen.dueDateCandidates = input.dueDateCandidates;
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
},
healthCheck: async () => ({ ok: true })
@@ -166,5 +167,29 @@ describe('AiWorker', () => {
await w.enqueue(id);
await w.drain();
expect(seen.todayKst).toBe('2026-04-27');
expect(Array.isArray(seen.dueDateCandidates)).toBe(true);
expect(seen.dueDateCandidates.length).toBe(0);
});
it('passes parseAllCandidates result to provider.generate as dueDateCandidates', async () => {
let captured: any = null;
const provider = {
name: 'mock',
generate: async (input: any) => {
captured = input;
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-26T00:00:00.000Z')
});
const { id } = repo.create({ rawText: '내일 모레 회의' });
await w.enqueue(id);
await w.drain();
expect(captured.dueDateCandidates).toBeDefined();
expect(Array.isArray(captured.dueDateCandidates)).toBe(true);
expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레
});
});

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', todayKst: '2026-04-26' });
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
expect(r.title).toBe('회의');
});
@@ -31,7 +31,7 @@ describe('LocalOllamaProvider', () => {
response: 'not json'
});
await expect(
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26' })
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
).rejects.toThrow(/json/i);
});
@@ -41,7 +41,7 @@ describe('LocalOllamaProvider', () => {
return { statusCode: 200, data: '{}' };
}) as never);
await expect(
new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26' })
new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
).rejects.toThrow();
}, 2000);