From 723dccd61df09e6edbc532a6f25204229a1b351c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 13:06:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20AI-primary=20due=5Fdate=20flow=20?= =?UTF-8?q?=E2=80=94=20rule=20as=20prompt=20candidates=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/main/ai/AiWorker.ts | 18 ++++++++------ src/main/ai/InferenceProvider.ts | 2 ++ src/main/ai/LocalOllamaProvider.ts | 2 +- src/main/ai/prompt.ts | 19 +++++++++++--- tests/integration/ollama-golden.test.ts | 2 +- tests/unit/AiWorker.test.ts | 33 ++++++++++++++++++++++--- tests/unit/LocalOllamaProvider.test.ts | 6 ++--- 7 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index d696c8b..a626f56 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -1,7 +1,7 @@ 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'; +import { parseAllCandidates } from '../services/dueDateParser.js'; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; @@ -101,21 +101,25 @@ export class AiWorker { 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; + const candidates = parseAllCandidates(note.rawText, todayDate); + const res = await this.provider.generate({ + text: note.rawText, + todayKst: todayIso, + dueDateCandidates: candidates + }); + // AI primary: AI's dueDate is final (no rule merge) this.repo.updateAiResult(job.noteId, { title: res.title, summary: res.summary, tags: res.tags, provider: this.provider.name, - dueDate: finalDueDate + dueDate: res.dueDate ?? null }); this.logger.info('ai.done', { noteId: job.noteId, attempt, - dueDateSource: ruleResult.iso !== null ? 'rule' : (res.dueDate !== null ? 'ai' : 'none') + dueDateSource: res.dueDate !== null ? 'ai' : 'none', + candidatesCount: candidates.length }); this.emit(job.noteId); return; diff --git a/src/main/ai/InferenceProvider.ts b/src/main/ai/InferenceProvider.ts index 65d1319..2ef7773 100644 --- a/src/main/ai/InferenceProvider.ts +++ b/src/main/ai/InferenceProvider.ts @@ -1,8 +1,10 @@ import type { AiResponse } from './schema.js'; +import type { ParseResult } from '../services/dueDateParser.js'; export interface GenerateInput { text: string; todayKst: string; // ISO YYYY-MM-DD in KST + dueDateCandidates: ParseResult[]; } export interface HealthResult { ok: boolean; model?: string; reason?: string; } diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 97c8c9b..36239c3 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -37,7 +37,7 @@ export class LocalOllamaProvider implements InferenceProvider { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: this.model, - prompt: buildPrompt(input.text, input.todayKst), + prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates), format: 'json', stream: false, options: { temperature: this.temperature, num_predict: this.numPredict } diff --git a/src/main/ai/prompt.ts b/src/main/ai/prompt.ts index 2cbdf95..8ad65d0 100644 --- a/src/main/ai/prompt.ts +++ b/src/main/ai/prompt.ts @@ -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. Today's date in Korea Standard Time (KST): ${todayKst} - +${candidateBlock} Input note (raw text, may be fragmented, any language): --- ${rawText} @@ -14,7 +25,7 @@ Return a JSON object with EXACTLY these keys: - "title": concise title in KOREAN (max 60 chars) - "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. -- "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: - title and summary MUST be written in Korean regardless of input language. diff --git a/tests/integration/ollama-golden.test.ts b/tests/integration/ollama-golden.test.ts index 95d1022..67b5ed2 100644 --- a/tests/integration/ollama-golden.test.ts +++ b/tests/integration/ollama-golden.test.ts @@ -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]+)*$/); diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index a8a1e4a..9bef385 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -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); // 내일 + 모레 }); }); diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index 44733fe..2d1155e 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -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);