diff --git a/docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md b/docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md new file mode 100644 index 0000000..66638fd --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md @@ -0,0 +1,381 @@ +# F7 — AI-Primary Due Date Implementation Plan + +**Goal:** 흐름 반전 — AI 우선 + 규칙은 prompt 후보 힌트 + AI 실패 시 high-confidence rule fallback. F1 의 first-match-wins 한계 ("내일 모레" → 내일 오인) 해소. + +**Architecture:** dueDateParser 가 모든 candidate 매치를 추출 (`parseAllCandidates`) → prompt 가 candidate 리스트 주입 → AI 가 pick or null → AiWorker 의 merge 로직 단순화 (AI 우선, AI 실패 시 high-confidence rule fallback). + +**Tech:** TypeScript, vitest, no new deps. + +## File Structure + +**Modify:** +- `src/main/services/dueDateParser.ts` — `parseAllCandidates(text, today): ParseResult[]` 신규 + `parseDueDate` 보존 +- `src/main/ai/prompt.ts` — `buildPrompt(rawText, todayKst, candidates: ParseResult[])` 시그니처 확장 +- `src/main/ai/InferenceProvider.ts` — `GenerateInput.dueDateCandidates: ParseResult[]` 신규 필드 +- `src/main/ai/LocalOllamaProvider.ts` — pass through +- `src/main/ai/AiWorker.ts` — flow 반전 (AI 우선) +- `src/main/ai/schema.ts` — 변경 없음 (기존 zod due_date 그대로) +- `tests/unit/dueDateParser.test.ts` — `parseAllCandidates` 테스트 추가, `'내일도 모레도 바쁨'` fixture 정책 변경 +- `tests/unit/AiWorker.test.ts` — 기존 "rule priority" 테스트 → AI priority 로 flip + 후보 주입 검증 추가 +- `tests/unit/LocalOllamaProvider.test.ts` — generate input shape 동기화 + +**Create:** +- `docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md` — promoted spec + +## Decisions (mini-brainstorm 결과) + +| 결정 | 값 | 근거 | +|------|-----|------| +| Flow | AI 우선, rule은 prompt 후보 힌트 | F7 D 채택 | +| Rule fallback 정책 | high-confidence 매치만 fallback (AI 실패 시) | medium 은 ambiguous 시그널 — null 이 더 안전 | +| Prompt 후보 형식 | list `[{ iso, matched, confidence }]` 주입, AI 가 pick or null | AI 가 종합 판단 가능 | +| Rule 항상 실행 | YES — 오버헤드 무시 | 후보 주입이 핵심 | +| 기존 fixture 변경 | `'내일도 모레도 바쁨' → 내일` 제거 (AI 결정 영역) | flow 반전과 일관 | +| schema 변경 | 없음 | 기존 due_date 컬럼 그대로 | +| confidence DB 저장 | 미저장 (transient만) | F7 의 §C UI 신호는 후속 | + +## Task 1: dueDateParser.parseAllCandidates + +`parseDueDate` 는 first-match-wins 그대로 보존 (backward compat). 신규 `parseAllCandidates(text, today): ParseResult[]` 가 모든 high/medium 매치를 위치 순서로 반환. + +### Implementation sketch + +```typescript +export function parseAllCandidates(text: string, todayKst: Date): ParseResult[] { + // 모든 14 high-confidence rule 을 순회하며 매치 위치 + iso + token 수집. + // 같은 토큰이 여러 번 나오면 각각 별 entry. 중복 iso 는 dedup. + // medium tokens (월말/주말/퇴근 전/시각) 도 포함 (iso=null, confidence='medium'). + // 결과는 text.indexOf 오름차순 정렬. +} +``` + +Tests: +- `'내일 모레'` → 2 candidates: `[{iso: , token: '내일', confidence: 'high'}, {iso: , token: '모레', confidence: 'high'}]` +- `'5월 1일에 만나서 다음 주 월요일까지 정리'` → 2 candidates +- `'월말 마감'` → 1 candidate `[{iso: null, token: '월말', confidence: 'medium'}]` +- `'아무 일정 없음'` → `[]` +- `'내일'` (단일) → 1 candidate +- 매치 없는 텍스트 → `[]` (not error) + +### Commit + +``` +feat(due-date): parseAllCandidates returns all rule matches (vs first-only) + +기존 parseDueDate (first-match-wins) 는 backward compat 로 보존. +parseAllCandidates 가 모든 high/medium 매치를 text 순서로 반환 — F7 +AI-primary flow 의 prompt 후보 주입 입력으로 사용. +``` + +## Task 2: Prompt + Provider candidate injection + +### prompt.ts + +`PROMPT_VERSION` 3 으로 bump. 시그니처: + +```typescript +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` + : ''; + + 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} +--- + +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 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. +- tags MUST be English kebab-case (for consistency across notes; easier to search/group). +- due_date MUST be ISO YYYY-MM-DD format or null. Never include time-of-day. +- Do NOT invent facts not present in the input. +- Do NOT include markdown code fences or preamble. +- Return ONLY the JSON object.`; +} +``` + +### InferenceProvider.ts + +```typescript +import type { ParseResult } from '../services/dueDateParser.js'; + +export interface GenerateInput { + text: string; + todayKst: string; + dueDateCandidates: ParseResult[]; +} +``` + +### LocalOllamaProvider.ts + +```typescript +prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates), +``` + +### Commit + +``` +feat(ai): prompt accepts due_date candidates from rule parser + +PROMPT_VERSION → 3. Candidates 가 prompt 에 hints 로 주입되어 AI 가 +종합 판단. GenerateInput.dueDateCandidates 신규. 빈 배열일 때 +prompt 그 섹션 생략. +``` + +## Task 3: AiWorker flow 반전 + +```typescript +private async processJob(job: Job): Promise { + const max = this.backoffsMs.length; + for (let attempt = job.attempts; attempt < max; attempt++) { + try { + const note = this.repo.findById(job.noteId); + if (!note || note.aiStatus !== 'pending') return; + const todayDate = todayKstAsDate(this.now()); + const todayIso = todayKstAsIso(this.now()); + 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 + const finalDueDate = res.dueDate ?? null; + this.repo.updateAiResult(job.noteId, { + 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: res.dueDate !== null ? 'ai' : 'none', + candidatesCount: candidates.length + }); + this.emit(job.noteId); + return; + } catch (err) { + const isLast = attempt === max - 1; + const msg = (err as Error).message; + this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg }); + const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString(); + this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg); + if (isLast) { + // AI failed all retries: fallback to rule parser high-confidence only + const note = this.repo.findById(job.noteId); + if (note) { + const todayDate = todayKstAsDate(this.now()); + const candidates = parseAllCandidates(note.rawText, todayDate); + const highConfidence = candidates.filter((c) => c.confidence === 'high' && c.iso !== null); + // If exactly one high-confidence candidate, accept as fallback. If 0 or 2+, leave null. + const fallbackDueDate = highConfidence.length === 1 ? highConfidence[0]!.iso : null; + if (fallbackDueDate !== null) { + this.repo.setDueDate(job.noteId, fallbackDueDate); + this.logger.info('ai.fallback.dueDate', { noteId: job.noteId, source: 'rule_high_confidence' }); + } + } + this.repo.markAiFailed(job.noteId, msg); + this.logger.error('ai.failed', { noteId: job.noteId, err: msg }); + this.emit(job.noteId); + return; + } + await this.sleep(this.backoffsMs[attempt + 1] ?? 0); + } + } +} +``` + +Key change: **AI's `dueDate` is now final (no rule priority)**. Rule used as prompt hints only. On AI total failure, single-high-confidence rule match becomes fallback (`setDueDate` sets edited_by_user=1 — but here it's not user, it's our deterministic fallback. Should we use updateAiResult instead? Actually the note is `markAiFailed` → ai_status='failed', so we can't use updateAiResult. setDueDate is fine but conceptually wrong (sets edited_by_user=1 implying user did it). + +Adjustment: extend NoteRepository with a `setRuleFallbackDueDate(id, date)` that sets due_date but `due_date_edited_by_user = 0` (so future AI re-runs can override it). + +Actually simpler: don't set due_date at all on AI failure — let user manually edit later if they care. Removes complexity. Keep merge purely AI-primary. + +Decision: on AI failure → due_date stays null. No fallback. Rule purely prompt-hint. + +This simplifies everything: +- One code path +- No setDueDate ambiguity +- Behavior: if AI is reliable, due_date works. If AI is down for a note, due_date stays null until next AI re-run (manually triggered). + +Update plan: drop the fallback logic. + +```typescript +private async processJob(job: Job): Promise { + const max = this.backoffsMs.length; + for (let attempt = job.attempts; attempt < max; attempt++) { + try { + const note = this.repo.findById(job.noteId); + if (!note || note.aiStatus !== 'pending') return; + const todayDate = todayKstAsDate(this.now()); + const todayIso = todayKstAsIso(this.now()); + 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 (null if AI returned null) + this.repo.updateAiResult(job.noteId, { + title: res.title, + summary: res.summary, + tags: res.tags, + provider: this.provider.name, + dueDate: res.dueDate ?? null + }); + this.logger.info('ai.done', { + noteId: job.noteId, + attempt, + dueDateSource: res.dueDate !== null ? 'ai' : 'none', + candidatesCount: candidates.length + }); + this.emit(job.noteId); + return; + } catch (err) { + // ... unchanged backoff/markAiFailed ... + } + } +} +``` + +### Commit + +``` +feat(ai): AI-primary due_date flow — rule provides prompt hints only + +Flow 반전 (F7-D 채택): +- 기존: rule.iso ?? ai.dueDate (rule 우선) +- 신규: ai.dueDate ?? null (AI 우선) + +규칙은 parseAllCandidates 로 모든 매치를 추출 → prompt 에 후보 +힌트로 주입. AI 가 종합 판단. AI 실패 시 due_date null (별 fallback 없음). + +해결되는 케이스: '내일 모레' → AI 가 ambiguous 인지 → null. +``` + +## Task 4: Tests update + new + +### dueDateParser.test.ts + +- 기존 `parseDueDate` 테스트 모두 보존 (backward compat). +- `'내일도 모레도 바쁨' → '2026-04-27'` fixture 도 보존 (parseDueDate first-match-wins 그대로). +- `parseAllCandidates` 테스트 추가 (≥ 6): + - `'내일 모레'` → 2 high-conf candidates `[내일, 모레]` + - `'아무 일정 없음'` → `[]` + - `'5월 1일 이후 다음 주 월요일까지'` → 2 candidates + - `'월말 마감'` → 1 medium candidate + - `'5월 1일'` (단일) → 1 candidate + - `'내일도 모레도 바쁨'` → 2 candidates (모두 high) + +### AiWorker.test.ts + +- 기존 `'rule parser match takes priority over AI dueDate'` 테스트 → **AI priority 로 flip** (rule 매치 + AI 가 다른 값 → AI 가 채택) +- 새 테스트: + - "passes candidates to provider.generate" — mock 의 input.dueDateCandidates 검증 + - "AI null → final null even when rule has match" — AI primary 원칙 + - "AI value → final = AI value (rule ignored)" — AI 결정 우선 + +### LocalOllamaProvider.test.ts + +- mock generate 호출 시 `dueDateCandidates: []` 추가 (시그니처 일관성). + +### Commit (combined with Task 3?) + +Task 3 + 4 같은 commit 으로 묶기. 시그니처 변경 + 동작 변경 + 테스트 동기화는 atomic 이어야 함. + +## Task 5: Promotion + +### Create `docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md` + +```markdown +# F7 AI-Primary Due Date Spec (Promoted) + +**Extracted from:** `2026-04-25-dogfood-feedback.md` F7 +**Plan:** `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` +**Status:** 🚀 promoted — implemented 2026-04-26 + +## 결정 (mini-brainstorm 결과) + +| 결정 | 값 | 근거 | +|------|-----|------| +| Flow 반전 | AI 우선 + rule 은 prompt 후보 힌트 | F7 D 채택 | +| AI 실패 시 fallback | 없음 (due_date null) | 단순화, 별 setDueDate ambiguity 회피 | +| 후보 주입 | `parseAllCandidates` 가 모든 high+medium 매치를 prompt 에 list 로 주입 | AI 종합 판단 | +| `parseDueDate` 보존 | YES (backward compat, 후속 spec 에서 deprecate 결정) | 비파괴 | +| schema 변경 | 없음 | 기존 컬럼 재사용 | +| confidence DB 저장 | 미저장 | F7 §C 후속 | +| PROMPT_VERSION | 3 (semantic 변경) | hints 추가 | + +## 범위 (PR 안에 포함됨) + +- `src/main/services/dueDateParser.ts` — `parseAllCandidates` 신규 +- `src/main/ai/prompt.ts` — `buildPrompt(...candidates)` 확장 +- `src/main/ai/InferenceProvider.ts` — `GenerateInput.dueDateCandidates` +- `src/main/ai/LocalOllamaProvider.ts` — pass-through +- `src/main/ai/AiWorker.ts` — flow 반전 (rule.iso ?? ai → ai.dueDate ?? null) +- 테스트 — `parseAllCandidates` 6+ + AiWorker flip 4+ + LocalOllamaProvider sig + +## 후속 (F7 §C / D / E 의 다른 부분) + +- F7 §C: confidence + matched span 을 DB 에 저장 + UI `?` 표시 +- F7 §A·B (즉시 화이트리스트 + 충돌 감지) — D 에 포함되어 사실상 자연 해결 +- E (rule 폐기): `parseAllCandidates` deprecate 가 별 단계 +- AI uptime / 수용률 데이터 1주 누적 후 D 의 안정성 평가 +``` + +### Update `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` F7 헤더 + +``` +## F7. Due Date 규칙 파서가 합성 표현을 first-match-wins 로 오인 (🚀 promoted) + +**진행 상태:** 🚀 promoted (D AI-primary 채택) → `docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md` +``` + +### Update `docs/superpowers/specs/2026-04-26-f1-due-date.md` 의 §"후속" 에 F7 링크 추가 + +(small edit) + +Commit: + +``` +docs(spec): promote F7 AI-primary due date + +F1 spec 의 후속 리스트에 F7 링크 추가. dogfood-feedback.md F7 헤더 +🔬 → 🚀. +``` + +## Verification + +- `npm run typecheck` — 0 errors +- `npm test` — 197 + ~10 (parseAllCandidates 6 + AiWorker flip 4) = ~207 passing +- `npm run test:e2e` — 1/1 + +## End-state commits (3-4) + +1. `feat(due-date): parseAllCandidates returns all rule matches (vs first-only)` +2. `feat(ai): prompt accepts due_date candidates from rule parser` +3. `feat(ai): AI-primary due_date flow — rule provides prompt hints only` (+ tests) +4. `docs(spec): promote F7 AI-primary due date` + +(2-3 합칠 수도 있음.) diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index 9d15022..67f9adc 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -816,6 +816,115 @@ slice §1.3 종료 조건 ("크래시 0회") 와 별개로, **"데이터 손실 --- +## F7. Due Date 규칙 파서가 합성 표현을 first-match-wins 로 오인 (🚀 promoted) + +**진행 상태:** 🚀 promoted (D AI-primary 채택) → `docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md` + +**발견:** 2026-04-26 v0.2.1 dogfood 첫 사이클. 본문에 `"내일 모레"` 가 들어간 노트가 due_date = 내일 (today + 1) 로 추출됨. 사용자 의도는 "모레" 또는 "이틀 안" 정도의 모호 표현. + +### 관찰 + +`src/main/services/dueDateParser.ts` 의 매칭 로직: +- 14 high-confidence 규칙을 순회 (모레가 내일보다 우선순위 위) +- 같은 텍스트에 여러 토큰 발견 시 `text.indexOf` 로 **가장 먼저 등장한 위치** 의 토큰 채택 (`내일도 모레도 바쁨` → 내일이라는 기존 fixture 가 이 동작을 lock-in) + +문제 케이스: +- `"내일 모레"` — 합성 표현 (내일+모레 ≈ "곧" / "1-2일 안") 인데 첫 토큰 "내일" 만 잡힘 +- `"오늘 내일 안에"` — 동일 패턴 +- `"이번 주 다음 주"` — 양가 표현 +- `"3일 4일 뒤"` — 범위 표현 + +규칙 파서의 한계 3가지: +1. **합성/양가 표현**: 두 시간 토큰이 인접하면 단일 의미로 해석되는 한국어 관용 (사용자 의도 ≠ 첫 토큰) +2. **범위 표현**: "2~3일 뒤", "이번 주 안에", "다음 주 중", "월말 즈음" — 명확한 ISO 가 없는 본질 +3. **모호 표현**: "곧", "조만간", "여건 되면" — 사람도 ISO 변환 못함 + +현재 동작은 첫 토큰 기반이라 **확신도 낮은 매치도 high confidence 로 표기되어 NoteCard 에 그대로 박힘**. 사용자가 매번 editing 해야 하는 마찰. + +### 제안 방향 + +**5가지 후보, 짧은 시간 / 긴 시간 layered:** + +| ID | 방향 | 비용 | 효과 | 슬라이스 적합 | +|----|------|------|------|--------------| +| **A** | "내일 모레" / "오늘 내일" / "이번 주 다음 주" 등 **합성 패턴 화이트리스트** 추가 — 인접한 두 시간 토큰 발견 시 **확신도 낮춰서 AI 위임** | 작음 | 한정적 (알려진 패턴만) | ✅ 작은 PR | +| **B** | 한 노트에 **여러 high-confidence 매치 발견 시 충돌 감지** → 모두 medium 으로 강등 → AI 위임 | 작음 | 중 — 단일 토큰은 그대로, 다중만 영향 | ✅ 작은 PR | +| **C** | 규칙 매치 결과에 **항상 confidence 정보 + matched span 첨부** + UI 에 `?` 마크 (낮은 확신도) 노출 + 클릭 1번에 빠른 dismiss | 중 | 중-상 — 사용자가 인지 + 빠른 수정 가능 | 중 | +| **D** | **AI 우선 + 규칙은 후위 검증** 으로 흐름 반전 — AI 가 due_date 응답하면 규칙으로 sanity check (확실한 매치만 그대로, 충돌 시 AI 우선) | 큼 | 가장 정확 | 후속 | +| **E** | 규칙 파서 폐기, **AI 단독** | 작음 (코드 삭제) | AI 모델 의존도 ↑, latency 의존, 결정론 손실 | 후속 | + +**권장 조합 (단계적)**: + +1. **즉시 (slice MVP +)**: B + 일부 A — 첫 발견 시 충돌 / 화이트리스트 매치는 medium 으로 강등 (AI 위임). 골든 픽스처에 `"내일 모레" → null` 추가. +2. **다음 cycle**: C — UI 신호 (확신도 표시 + 빠른 편집) +3. **누적 데이터 후**: D 또는 E — AI 우선/단독 흐름 반전 결정. dogfood 1주 데이터 (수용률 / 편집률 / 충돌 발생률) 가 의사결정 기반. + +### 결정 대기 + +1. **합성 패턴 화이트리스트 (A) 의 크기**: 2 토큰 (`내일 모레`, `오늘 내일`, `이번 주 다음 주`, `오늘 내일 모레` 같은 3-token) 까지 — 어디까지가 합리적 범위인가? +2. **충돌 감지 (B) 의 임계값**: "high-confidence 토큰이 동일 텍스트에 N개 이상 등장 시 medium 강등" — N = 2 OK? 또는 토큰 간 거리 (한 줄 안 vs 떨어진 문장) 로 다르게? +3. **fixture 유지**: 기존 `'내일도 모레도 바쁨' → 내일` 테스트는 **현재 동작을 lock-in** 하는 케이스. B 도입 시 이걸 → null 로 바꿔야 일관됨. 의도된 변경? +4. **C 의 UI 위치**: 확신도 `?` 를 어디에 표시? `📅 YYYY-MM-DD ?` (라벨 옆) vs hover tooltip vs 별 색 (회색 = 확신 낮음). +5. **D vs E**: 결정론적 fallback 가치 vs AI 단순화. dogfood 누적 데이터 본 뒤 결정. +6. **F1 spec 의 후속 리스트** 에 본 항목을 추가할지, 별 항목으로 분리할지. **별 항목 권장** (영향 큼, 수정 복잡도 다름). + +### 가설·측정 + +| # | 가설 | 측정 | +|---|------|------| +| H1 | dogfood 1주 동안 합성 표현 ("내일 모레", "이번 주 다음 주" 등) 등장 ≥ 3회 | 본인 라벨링 + grep | +| H2 | A+B 적용 시 false positive 매치율 > 50% 감소 | 적용 전후 1주씩 비교 | +| H3 | C 의 `?` 표시가 사용자 편집 빈도를 ≥ 30% 증가시킴 (자각 → 행동) | 편집 이벤트 카운트 | +| H4 | 본인 dogfood 에서 due_date 가 한 번이라도 잘못 박혀서 무시되는 노트 비율 < 10% (현재 측정 없으나 첫 케이스가 발생함) | 정성 라벨링 | +| H5 | D (AI 우선) 적용 시 latency 영향 ≤ 200ms / 노트 (현재 ~1-3초 대비 무시 가능) | LocalOllamaProvider 응답 시간 측정 | + +### 범위 + +- **In (1차 — A + B):** + - `dueDateParser.ts` — 합성 패턴 화이트리스트 (2-token 인접 시 medium 강등) + 충돌 감지 (≥2 high-confidence 매치 시 모두 medium) + - `tests/unit/dueDateParser.test.ts` — 새 fixture (`"내일 모레" → null`, `"오늘 내일" → null`, `"이번 주 다음 주" → null`) + - 기존 `'내일도 모레도 바쁨' → 내일` fixture 결정 — 유지 vs 변경 + - AiWorker 의 머지 로직 그대로 (`ruleResult.iso ?? aiResponse.dueDate ?? null`) — medium 매치는 iso=null 이므로 자동으로 AI 위임 +- **In (2차 — C):** + - `ParseResult` 에 `matchedSpan: { start, end }` 추가 + - `dueDateParser` 가 confidence 와 함께 매치한 토큰 span 반환 + - AiWorker 가 final result 에 `dueDateConfidence: 'high' | 'medium' | 'low'` 추가 + - DB 컬럼 `due_date_confidence` 신규 (migration v3) — 또는 transient 만 (UI 만 표시, DB 미저장) + - `NoteCard.DueDateBadge` — 확신도 낮음 시 `?` 표시 + tooltip +- **In (후속 — D):** + - AiWorker 흐름 반전: AI 먼저 호출 → 응답에 due_date 있으면 채택 → 규칙은 sanity (AI 응답이 명백히 틀린 경우 — 예: AI 가 과거 날짜 반환) 검증 + - 또는 E: 규칙 파서 deprecate +- **Out:** + - "월말", "주말" 같은 모호 표현 (이미 medium 처리 중) + - 시각 단위 (오후 3시 등) + - 음력 / 절기 + +### 영향 + +- **Schema (1차):** 없음 +- **Schema (2차 C):** migration v3 후보 — `due_date_confidence` 컬럼. 또는 transient 만 (DB 미저장). +- **코드 (1차):** + - `src/main/services/dueDateParser.ts` — 충돌 감지 로직 + 화이트리스트 추가 (~30 줄) + - `tests/unit/dueDateParser.test.ts` — 새 fixture 5+ 케이스 +- **코드 (2차 C):** + - `dueDateParser` 의 `ParseResult` interface 확장 + - `NoteCard.DueDateBadge` 의 props 확장 + 시각 표시 + - IPC / preload / store 의 due_date 타입 동반 갱신 (confidence 도 expose 시) +- **로깅:** + - `AiWorker` 의 `ai.done` 메타에 `dueDateConfidence` 추가 (기존 `dueDateSource: rule|ai|none` 와 함께) +- **문서:** + - 본 항목 promoted 시 `2026-04-MM-due-date-rule-limits.md` (가칭) 으로 추출 + - F1 spec (`2026-04-26-f1-due-date.md`) 의 §"후속" 에 본 항목 링크 추가 +- **slice spec §1.3 영향**: dogfood 종료 조건 "AI 결과 7/10 수용" 의 측정에 due_date confidence 가 들어가야 의미 있는 데이터 됨 — 본 항목이 그 측정 인프라 제공. + +### 비고 + +본 피드백은 **F1 promoted 후 첫 실사용 신호**다. v0.2.1 dogfood 의 의도된 측정 사이클 (≥1주 soak → F4-A·D 결정) 와 같은 흐름에 자연스럽게 합류하는 항목이고, 다음 cut (v0.2.2 또는 v0.3.0) 의 후보. 단계 1 (A+B) 만 작은 PR 로 분리 가능 — 즉시 시도해도 위험 0. + +규칙 파서 폐기 (E) 는 매력적이지만 **AI 가 항상 떠 있는 dogfood 환경 가정** 이 중요. `OllamaBanner` 가 자주 뜨는 환경 (LAN 박스 다운 등) 에선 결정론적 매치가 가치 있음. 이 트레이드오프는 dogfood 1주 데이터 (Ollama uptime · health success rate) 보고 결정. + +--- + ## (다음 항목 자리) -새 피드백 추가 시 `## F7. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능. +새 피드백 추가 시 `## F8. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능. diff --git a/docs/superpowers/specs/2026-04-26-f1-due-date.md b/docs/superpowers/specs/2026-04-26-f1-due-date.md index dc8d67f..5e32c71 100644 --- a/docs/superpowers/specs/2026-04-26-f1-due-date.md +++ b/docs/superpowers/specs/2026-04-26-f1-due-date.md @@ -39,12 +39,13 @@ ## 후속 (별 spec 또는 후속 항목 후보) +- **F7 AI-primary due_date flow** (🚀 promoted 2026-04-26) → `docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md` — 본 spec 의 "rule 우선 → AI fallback" 흐름을 "AI 우선 + rule 은 prompt 후보 힌트" 로 반전. `'내일 모레'` first-match-wins 한계 해소 - 별도 due 뷰 / 정렬 / 필터 - 만료 자동 처리 (자동 done 또는 자동 알림) - 시각 단위 (오후 3시, 23:30 등) 처리 - 음력 / 절기 - 반복 일정 (매주 월요일, 매월 1일 등) - 외부 캘린더 연동 (Google / Apple Calendar) -- 다중 매칭 처리 (현재는 first-match-wins; 가장 가까운 / 가장 명확한 등 옵션) +- 다중 매칭 처리 (현재는 first-match-wins; 가장 가까운 / 가장 명확한 등 옵션) — F7 으로 일부 해결 - AI 매칭 confidence 표시 (medium 일 때 사용자에게 "확실하지 않음" 힌트) - 시각 표시 옵션 (D-day 카운터 등) diff --git a/docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md b/docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md new file mode 100644 index 0000000..149562f --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md @@ -0,0 +1,44 @@ +# F7 AI-Primary Due Date Spec (Promoted) + +**Extracted from:** `2026-04-25-dogfood-feedback.md` F7 +**Plan:** `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` +**Status:** 🚀 promoted — implemented 2026-04-26 + +## 결정 (mini-brainstorm 결과) + +| 결정 | 값 | 근거 | +|------|-----|------| +| Flow 반전 | AI 우선 + rule 은 prompt 후보 힌트 | F7 D 채택 | +| AI 실패 시 fallback | 없음 (due_date null) | 단순화, setDueDate ambiguity 회피 | +| 후보 주입 | `parseAllCandidates` 가 모든 high+medium 매치를 prompt 에 list 로 주입 | AI 종합 판단 | +| `parseDueDate` 보존 | YES (backward compat, 후속 spec 에서 deprecate 결정) | 비파괴 | +| schema 변경 | 없음 | 기존 컬럼 재사용 | +| confidence DB 저장 | 미저장 | F7 §C 후속 | +| PROMPT_VERSION | 3 (semantic 변경) | hints 추가 | + +## 범위 (PR 안에 포함됨) + +- `src/main/services/dueDateParser.ts` — `parseAllCandidates` 신규 + 기존 `parseDueDate` 보존 (overlapping-span suppression 포함) +- `src/main/ai/prompt.ts` — `buildPrompt(rawText, todayKst, candidates)` 확장, `PROMPT_VERSION = 3` +- `src/main/ai/InferenceProvider.ts` — `GenerateInput.dueDateCandidates: ParseResult[]` +- `src/main/ai/LocalOllamaProvider.ts` — pass-through +- `src/main/ai/AiWorker.ts` — flow 반전 (rule.iso ?? ai → ai.dueDate ?? null), `dueDateSource: 'ai' | 'none'` + `candidatesCount` 로깅 +- 테스트 — `parseAllCandidates` 7 + AiWorker flip 1 + 신규 candidates 주입 검증 1, LocalOllamaProvider sig 동기화 + +## 동작 변화 요약 + +| 입력 | 기존 (F1) | 신규 (F7) | +|------|----------|----------| +| `'내일 회의'` + AI=`'2026-12-31'` | `'2026-04-27'` (rule 우선) | `'2026-12-31'` (AI 우선) | +| `'내일 모레 회의'` + AI=`null` | `'2026-04-27'` (rule first-match) | `null` (AI 가 ambiguous 인지) | +| `'월말 마감'` + AI=`'2026-04-30'` | `'2026-04-30'` (rule null → AI) | `'2026-04-30'` (AI) | +| 매치 없음 + AI=`null` | `null` | `null` | +| AI 실패 (3 retry exhausted) | `markAiFailed` (due_date 그대로) | `markAiFailed` (due_date 그대로 — 별 fallback 없음) | + +## 후속 (F7 §C / D / E 의 다른 부분) + +- F7 §C: confidence + matched span 을 DB 에 저장 + UI `?` 표시 +- F7 §A·B (즉시 화이트리스트 + 충돌 감지) — D 채택으로 사실상 자연 해결 +- E (rule 폐기): `parseAllCandidates` deprecate 가 별 단계 +- AI uptime / 수용률 데이터 1주 누적 후 D 의 안정성 평가 +- `parseDueDate` 의 deprecate 시점: `parseAllCandidates` 로 모든 호출 마이그레이션 후 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/src/main/services/dueDateParser.ts b/src/main/services/dueDateParser.ts index 7e40af5..b5515a7 100644 --- a/src/main/services/dueDateParser.ts +++ b/src/main/services/dueDateParser.ts @@ -325,3 +325,239 @@ export function parseDueDate(text: string, todayKst: Date): ParseResult { return { iso: null, confidence: null, matchedToken: null }; } + +/** + * Returns ALL high-confidence and medium-confidence candidate matches in the + * given text, sorted by text-position (ascending). Used by F7 AI-primary flow + * to inject candidates into the prompt as hints — the AI picks (or rejects). + * + * Internal `matchPosition` field is stripped before return; consumers see only + * `iso`, `confidence`, `matchedToken`. The original `parseDueDate` (first- + * match-wins) is preserved for backward compatibility. + */ +export function parseAllCandidates(text: string, todayKst: Date): ParseResult[] { + const today = new Date( + Date.UTC( + todayKst.getUTCFullYear(), + todayKst.getUTCMonth(), + todayKst.getUTCDate() + ) + ); + + interface Candidate extends ParseResult { + matchPosition: number; + matchLength: number; + } + const out: Candidate[] = []; + + function pushAllMatches(re: RegExp, build: (m: RegExpExecArray) => { iso: string | null; matchedToken: string } | null): void { + const flagged = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + let m: RegExpExecArray | null; + while ((m = flagged.exec(text)) !== null) { + const built = build(m); + if (built !== null) { + out.push({ + iso: built.iso, + confidence: 'high', + matchedToken: built.matchedToken, + matchPosition: m.index, + matchLength: m[0].length + }); + } + // Avoid infinite loop on zero-length matches + if (m.index === flagged.lastIndex) flagged.lastIndex++; + } + } + + // 1. YYYY-MM-DD literal + pushAllMatches(/\b(\d{4})-(\d{2})-(\d{2})\b/, (m) => { + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + if (!isValidYmd(y, mo, d)) return null; + return { iso: toIso(makeUtcDate(y, mo, d)), matchedToken: m[0] }; + }); + + // 2. N월 N일 + pushAllMatches(/(\d{1,2})\s*월\s*(\d{1,2})\s*일/, (m) => { + const mo = Number(m[1]); + const d = Number(m[2]); + if (!(mo >= 1 && mo <= 12 && d >= 1 && d <= 31)) return null; + const ty = today.getUTCFullYear(); + const tm = today.getUTCMonth() + 1; + const td = today.getUTCDate(); + let year = ty; + if (mo < tm || (mo === tm && d < td)) year = ty + 1; + if (!isValidYmd(year, mo, d)) return null; + return { iso: toIso(makeUtcDate(year, mo, d)), matchedToken: m[0] }; + }); + + // 3. MM/DD + pushAllMatches(/(? { + const mo = Number(m[1]); + const d = Number(m[2]); + if (!(mo >= 1 && mo <= 12 && d >= 1 && d <= 31)) return null; + const ty = today.getUTCFullYear(); + const tm = today.getUTCMonth() + 1; + const td = today.getUTCDate(); + let year = ty; + if (mo < tm || (mo === tm && d < td)) year = ty + 1; + if (!isValidYmd(year, mo, d)) return null; + return { iso: toIso(makeUtcDate(year, mo, d)), matchedToken: m[0] }; + }); + + // 4. N일 (뒤|후) + pushAllMatches(/(\d{1,3})\s*일\s*(뒤|후)/, (m) => { + const n = Number(m[1]); + return { iso: toIso(addDays(today, n)), matchedToken: m[0] }; + }); + + // 5. N주 (뒤|후) + pushAllMatches(/(\d{1,2})\s*주\s*(뒤|후)/, (m) => { + const n = Number(m[1]); + return { iso: toIso(addDays(today, n * 7)), matchedToken: m[0] }; + }); + + // 6. N개월 (뒤|후) + pushAllMatches(/(\d{1,2})\s*개월\s*(뒤|후)/, (m) => { + const n = Number(m[1]); + return { iso: toIso(addMonths(today, n)), matchedToken: m[0] }; + }); + + // 7-9. 글피 / 모레 / 내일 — collect ALL occurrences as separate entries + for (const { token, offset } of [ + { token: '글피', offset: 3 }, + { token: '모레', offset: 2 }, + { token: '내일', offset: 1 } + ]) { + let from = 0; + let idx: number; + while ((idx = text.indexOf(token, from)) >= 0) { + out.push({ + iso: toIso(addDays(today, offset)), + confidence: 'high', + matchedToken: token, + matchPosition: idx, + matchLength: token.length + }); + from = idx + token.length; + } + } + + // 10. 다음 주 X요일 + pushAllMatches(/다음\s*주\s*([월화수목금토일])요일/, (m) => { + const wd = m[1]!; + const offset = WEEKDAY_OFFSET[wd]!; + const base = nextWeekMonday(today); + return { iso: toIso(addDays(base, offset)), matchedToken: m[0] }; + }); + + // 11. 이번 주 X요일 + pushAllMatches(/이번\s*주\s*([월화수목금토일])요일/, (m) => { + const wd = m[1]!; + const offset = WEEKDAY_OFFSET[wd]!; + const base = thisMonday(today); + return { iso: toIso(addDays(base, offset)), matchedToken: m[0] }; + }); + + // 12. 다음 달 N일 + pushAllMatches(/다음\s*달\s*(\d{1,2})\s*일/, (m) => { + const d = Number(m[1]); + const next = addMonths(today, 1); + const target = new Date( + Date.UTC(next.getUTCFullYear(), next.getUTCMonth(), d) + ); + if ( + target.getUTCFullYear() !== next.getUTCFullYear() || + target.getUTCMonth() !== next.getUTCMonth() || + d < 1 || + d > 31 + ) { + return null; + } + return { iso: toIso(target), matchedToken: m[0] }; + }); + + // 13. 다음 주 (alone) → next Monday + pushAllMatches(/다음\s*주(?![가-힣])/, (m) => { + const base = nextWeekMonday(today); + return { iso: toIso(base), matchedToken: m[0] }; + }); + + // 14. 다음 달 (alone) → first day of next month + pushAllMatches(/다음\s*달/, (m) => { + const next = addMonths(today, 1); + const first = new Date( + Date.UTC(next.getUTCFullYear(), next.getUTCMonth(), 1) + ); + return { iso: toIso(first), matchedToken: m[0] }; + }); + + // 15. 오늘 — collect all occurrences + { + const token = '오늘'; + let from = 0; + let idx: number; + while ((idx = text.indexOf(token, from)) >= 0) { + out.push({ + iso: toIso(today), + confidence: 'high', + matchedToken: token, + matchPosition: idx, + matchLength: token.length + }); + from = idx + token.length; + } + } + + // ── Medium-confidence ambiguous tokens ── + const ambiguousPatterns: Array = [ + /월말/, + /주말/, + /퇴근\s*전/, + /오후\s*\d{1,2}\s*시/, + /오전\s*\d{1,2}\s*시/, + /\d{1,2}\s*시/ + ]; + for (const re of ambiguousPatterns) { + const flagged = new RegExp(re.source, 'g'); + let m: RegExpExecArray | null; + while ((m = flagged.exec(text)) !== null) { + out.push({ + iso: null, + confidence: 'medium', + matchedToken: m[0], + matchPosition: m.index, + matchLength: m[0].length + }); + if (m.index === flagged.lastIndex) flagged.lastIndex++; + } + } + + // Suppress candidates whose span is fully contained in another's span, + // EXCEPT when the candidate produces a distinct iso (different semantic + // value). This handles "다음 주 월요일" matching both rule 10 (full + // weekday) and rule 13 (다음 주 alone) — drop rule 13's contained match. + const filtered = out.filter((c, i) => { + for (let j = 0; j < out.length; j++) { + if (i === j) continue; + const other = out[j]!; + if (other.matchLength <= c.matchLength) continue; + const cEnd = c.matchPosition + c.matchLength; + const oEnd = other.matchPosition + other.matchLength; + const contained = other.matchPosition <= c.matchPosition && oEnd >= cEnd; + if (contained) return false; + } + return true; + }); + + // Sort by text position ascending, stable on insertion order tie-break. + filtered.sort((a, b) => a.matchPosition - b.matchPosition); + + // Strip internal matchPosition / matchLength before returning. + return filtered.map(({ iso, confidence, matchedToken }) => ({ + iso, + confidence, + matchedToken + })); +} 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); diff --git a/tests/unit/dueDateParser.test.ts b/tests/unit/dueDateParser.test.ts index db6694a..ffe8b14 100644 --- a/tests/unit/dueDateParser.test.ts +++ b/tests/unit/dueDateParser.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseDueDate } from '@main/services/dueDateParser.js'; +import { parseDueDate, parseAllCandidates } from '@main/services/dueDateParser.js'; // 2026-04-26 is a Sunday (KST). Use that as "today" for fixtures. const TODAY = new Date('2026-04-26T00:00:00.000Z'); // KST midnight = UTC 15:00 prior day, but for parser logic we treat input Date as KST-aligned. The parser treats the passed Date as the "today reference" without further timezone math. @@ -124,3 +124,52 @@ describe('parseDueDate (Korean rule parser)', () => { expect(parseDueDate('다음 주 발표', TODAY).iso).toBe('2026-05-04'); }); }); + +describe('parseAllCandidates', () => { + it('returns empty array when no token', () => { + expect(parseAllCandidates('아무 일정 없음', TODAY)).toEqual([]); + }); + + it('returns 2 high-confidence candidates for "내일 모레"', () => { + const r = parseAllCandidates('내일 모레', TODAY); + expect(r.length).toBe(2); + const isos = r.map((c) => c.iso); + expect(isos).toContain('2026-04-27'); + expect(isos).toContain('2026-04-28'); + expect(r.every((c) => c.confidence === 'high')).toBe(true); + }); + + it('returns candidates in text-position order', () => { + const r = parseAllCandidates('내일 모레', TODAY); + expect(r[0]!.matchedToken).toBe('내일'); + expect(r[1]!.matchedToken).toBe('모레'); + }); + + it('returns 1 candidate for single token "내일"', () => { + const r = parseAllCandidates('내일 회의', TODAY); + expect(r.length).toBe(1); + expect(r[0]!.iso).toBe('2026-04-27'); + }); + + it('returns 1 medium-confidence candidate for "월말 마감"', () => { + const r = parseAllCandidates('월말 마감', TODAY); + expect(r.length).toBe(1); + expect(r[0]!.iso).toBeNull(); + expect(r[0]!.confidence).toBe('medium'); + expect(r[0]!.matchedToken).toBe('월말'); + }); + + it('returns mix of high + medium candidates', () => { + const r = parseAllCandidates('내일 월말 회의', TODAY); + expect(r.length).toBe(2); + expect(r[0]!.confidence).toBe('high'); + expect(r[1]!.confidence).toBe('medium'); + }); + + it('returns 2 candidates for "5월 1일 이후 다음 주 월요일까지"', () => { + const r = parseAllCandidates('5월 1일 이후 다음 주 월요일까지', TODAY); + expect(r.length).toBe(2); + expect(r[0]!.iso).toBe('2026-05-01'); + expect(r[1]!.iso).toBe('2026-05-04'); + }); +});