docs(plan): F7 AI-primary due date 구현 계획 (D 채택)
This commit is contained in:
381
docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md
Normal file
381
docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md
Normal file
@@ -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: <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 합칠 수도 있음.)
|
||||
Reference in New Issue
Block a user