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

@@ -1,7 +1,7 @@
import type { NoteRepository } from '../repository/NoteRepository.js'; import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js'; import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types'; import type { Note } from '@shared/types';
import { parseDueDate } from '../services/dueDateParser.js'; import { parseAllCandidates } from '../services/dueDateParser.js';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000; const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@@ -101,21 +101,25 @@ export class AiWorker {
const nowDate = this.now(); const nowDate = this.now();
const todayDate = todayKstAsDate(nowDate); const todayDate = todayKstAsDate(nowDate);
const todayIso = todayKstAsIso(nowDate); const todayIso = todayKstAsIso(nowDate);
const ruleResult = parseDueDate(note.rawText, todayDate); const candidates = parseAllCandidates(note.rawText, todayDate);
const res = await this.provider.generate({ text: note.rawText, todayKst: todayIso }); const res = await this.provider.generate({
// Merge rule + AI: rule takes priority, AI fills if rule null text: note.rawText,
const finalDueDate = ruleResult.iso ?? res.dueDate ?? null; todayKst: todayIso,
dueDateCandidates: candidates
});
// AI primary: AI's dueDate is final (no rule merge)
this.repo.updateAiResult(job.noteId, { this.repo.updateAiResult(job.noteId, {
title: res.title, title: res.title,
summary: res.summary, summary: res.summary,
tags: res.tags, tags: res.tags,
provider: this.provider.name, provider: this.provider.name,
dueDate: finalDueDate dueDate: res.dueDate ?? null
}); });
this.logger.info('ai.done', { this.logger.info('ai.done', {
noteId: job.noteId, noteId: job.noteId,
attempt, attempt,
dueDateSource: ruleResult.iso !== null ? 'rule' : (res.dueDate !== null ? 'ai' : 'none') dueDateSource: res.dueDate !== null ? 'ai' : 'none',
candidatesCount: candidates.length
}); });
this.emit(job.noteId); this.emit(job.noteId);
return; return;

View File

@@ -1,8 +1,10 @@
import type { AiResponse } from './schema.js'; import type { AiResponse } from './schema.js';
import type { ParseResult } from '../services/dueDateParser.js';
export interface GenerateInput { export interface GenerateInput {
text: string; text: string;
todayKst: string; // ISO YYYY-MM-DD in KST todayKst: string; // ISO YYYY-MM-DD in KST
dueDateCandidates: ParseResult[];
} }
export interface HealthResult { ok: boolean; model?: string; reason?: string; } export interface HealthResult { ok: boolean; model?: string; reason?: string; }

View File

@@ -37,7 +37,7 @@ export class LocalOllamaProvider implements InferenceProvider {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.model, model: this.model,
prompt: buildPrompt(input.text, input.todayKst), prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates),
format: 'json', format: 'json',
stream: false, stream: false,
options: { temperature: this.temperature, num_predict: this.numPredict } options: { temperature: this.temperature, num_predict: this.numPredict }

View File

@@ -1,10 +1,21 @@
export const PROMPT_VERSION = 2; import type { ParseResult } from '../services/dueDateParser.js';
export const PROMPT_VERSION = 3;
export function buildPrompt(
rawText: string,
todayKst: string,
candidates: ParseResult[] = []
): string {
const candidateBlock = candidates.length > 0
? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched token: "${c.matchedToken ?? '?'}" (confidence: ${c.confidence ?? 'low'})`).join('\n')}\n`
: '';
export function buildPrompt(rawText: string, todayKst: string): string {
return `You organize raw personal notes into structured metadata. return `You organize raw personal notes into structured metadata.
Today's date in Korea Standard Time (KST): ${todayKst} Today's date in Korea Standard Time (KST): ${todayKst}
${candidateBlock}
Input note (raw text, may be fragmented, any language): Input note (raw text, may be fragmented, any language):
--- ---
${rawText} ${rawText}
@@ -14,7 +25,7 @@ Return a JSON object with EXACTLY these keys:
- "title": concise title in KOREAN (max 60 chars) - "title": concise title in KOREAN (max 60 chars)
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n". - "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n".
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags. - "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.
- "due_date": ISO YYYY-MM-DD date if you can clearly extract a deadline relative to today (${todayKst} KST), else null. Examples: "월말" → last day of current month; "주말" → upcoming Saturday; "퇴근 전" → today. Be conservative — only return a date if confident. - "due_date": ISO YYYY-MM-DD if you are CONFIDENT about a deadline, else null. Consider rule candidates above as hints but use your own judgment — if multiple ambiguous candidates ("내일 모레", "이번 주 다음 주"), prefer null. If the user wrote "오늘 PR 리뷰" with no deadline implication, return null.
Rules: Rules:
- title and summary MUST be written in Korean regardless of input language. - title and summary MUST be written in Korean regardless of input language.

View File

@@ -20,7 +20,7 @@ describe.skipIf(skip)('LocalOllamaProvider integration', () => {
]; ];
it.each(cases)('Korean title + 3 lines for: %s', async (input) => { 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(/[가-힣]/.test(r.title)).toBe(true);
expect(r.summary.split('\n')).toHaveLength(3); expect(r.summary.split('\n')).toHaveLength(3);
for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); 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); 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 = { const provider = {
name: 'mock', name: 'mock',
generate: async (_input: any) => ({ generate: async () => ({
title: '내일', title: '내일',
summary: 'a\nb\nc', summary: 'a\nb\nc',
tags: [], tags: [],
dueDate: '2026-12-31' // AI returns far-future dueDate: '2026-12-31' // AI says far future, rule has '내일'
}), }),
healthCheck: async () => ({ ok: true }) healthCheck: async () => ({ ok: true })
} as any; } as any;
@@ -101,7 +101,7 @@ describe('AiWorker', () => {
await w.enqueue(id); await w.enqueue(id);
await w.drain(); await w.drain();
const note = repo.findById(id)!; 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 () => { it('rule null + AI value → AI used', async () => {
@@ -154,6 +154,7 @@ describe('AiWorker', () => {
name: 'mock', name: 'mock',
generate: async (input: any) => { generate: async (input: any) => {
seen.todayKst = input.todayKst; seen.todayKst = input.todayKst;
seen.dueDateCandidates = input.dueDateCandidates;
return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null }; return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
}, },
healthCheck: async () => ({ ok: true }) healthCheck: async () => ({ ok: true })
@@ -166,5 +167,29 @@ describe('AiWorker', () => {
await w.enqueue(id); await w.enqueue(id);
await w.drain(); await w.drain();
expect(seen.todayKst).toBe('2026-04-27'); 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, { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] }) 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('회의'); expect(r.title).toBe('회의');
}); });
@@ -31,7 +31,7 @@ describe('LocalOllamaProvider', () => {
response: 'not json' response: 'not json'
}); });
await expect( 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); ).rejects.toThrow(/json/i);
}); });
@@ -41,7 +41,7 @@ describe('LocalOllamaProvider', () => {
return { statusCode: 200, data: '{}' }; return { statusCode: 200, data: '{}' };
}) as never); }) as never);
await expect( 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(); ).rejects.toThrow();
}, 2000); }, 2000);