From 2ee45bc53c81ca1a7a3d5fd169e61160647aa7fb Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 13:00:35 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20F7=20AI-primary=20due=20date=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20(D=20=EC=B1=84?= =?UTF-8?q?=ED=83=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-26-f7-ai-primary-due-date.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md 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 합칠 수도 있음.)