# 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: <today+1>, token: '내일', confidence: 'high'}, {iso: <today+2>, 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<void> {
  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<void> {
  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 합칠 수도 있음.)
