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:
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]+)*$/);
|
||||||
|
|||||||
@@ -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); // 내일 + 모레
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user