Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06cfa1c151 | ||
|
|
579450ef4f | ||
|
|
723dccd61d | ||
|
|
1c72b64c2f | ||
|
|
2ee45bc53c | ||
|
|
742eec00f4 |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -3,6 +3,26 @@
|
||||
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
||||
|
||||
## [0.2.2] — 2026-04-26
|
||||
|
||||
v0.2.1 dogfood 중 발견된 F7 (Due Date 합성 표현) + Quick Capture 스크롤 버그를 묶은 패치.
|
||||
|
||||
### 수정
|
||||
|
||||
- **F7 — AI-primary due_date flow** (#11, 1c72b64·723dccd·579450e): "내일 모레" 가 "내일" 로 잘못 매칭되던 문제. 규칙 파서를 first-match-wins 단일 추출에서 `parseAllCandidates` 다중 후보 추출로 확장하고, 후보 리스트를 프롬프트 힌트로 주입해 AI 의 `dueDate` 를 최종값으로 채택. 모호한 합성 표현 ("내일 모레", "다음 주 이번 주") 은 AI 가 `null` 반환 → 사용자 수동 입력으로 위임. `PROMPT_VERSION` 3.
|
||||
- **Quick Capture 창 스크롤 차단**: Ctrl+Shift+J 캡처 창에서 입력이 길어지면 textarea 가 flex `min-height: auto` 때문에 hint (`Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기`) 를 카드 밖으로 밀어내고 윈도우에 스크롤바가 생기던 문제. `textarea { min-height: 0 }` + `.card`/`html, body, #root { overflow: hidden }` 로 textarea 자체 스크롤로만 동작하도록 격리.
|
||||
|
||||
### 게이트
|
||||
|
||||
- 단위 테스트: 197 → **205** (+8, F7 `parseAllCandidates` 7 + AiWorker flow flip 1)
|
||||
- e2e smoke: 1/1
|
||||
- typecheck: 0 errors
|
||||
- 신규 npm dependency: 0
|
||||
|
||||
### 업그레이드
|
||||
|
||||
v0.2.1 인스톨러 위에 v0.2.2 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v2 그대로).
|
||||
|
||||
## [0.2.1] — 2026-04-26
|
||||
|
||||
v0.2.0 dogfood-feedback 8 항목 로드맵 (`docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md`) 을 한 번에 흡수한 cut.
|
||||
@@ -79,5 +99,6 @@ v0.2.0 인스톨러 위에 v0.2.1 인스톨러를 같은 위치에 실행하면
|
||||
|
||||
슬라이스 v0.4 — 가장 얇은 종단 경로 (Quick Capture → SQLite → 로컬 Ollama → Inbox) + electron-builder NSIS 인스톨러 + Windows autostart hook + post-slice 마이그레이션 정리. 자세한 결정 이력은 `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` 와 git log 참조.
|
||||
|
||||
[0.2.2]: https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.2
|
||||
[0.2.1]: https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.1
|
||||
[0.2.0]: https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.0
|
||||
|
||||
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 합칠 수도 있음.)
|
||||
@@ -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개 채우거나 비워둔 채 시작 가능.
|
||||
|
||||
@@ -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 카운터 등)
|
||||
|
||||
44
docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md
Normal file
44
docs/superpowers/specs/2026-04-26-f7-ai-primary-due-date.md
Normal file
@@ -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` 로 모든 호출 마이그레이션 후
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(/(?<!\d)(\d{1,2})\/(\d{1,2})(?!\d)/, (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] };
|
||||
});
|
||||
|
||||
// 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<RegExp> = [
|
||||
/월말/,
|
||||
/주말/,
|
||||
/퇴근\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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||
<title>Inkling Capture</title>
|
||||
<style>
|
||||
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; }
|
||||
html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; overflow: hidden; }
|
||||
body { -webkit-app-region: drag; }
|
||||
.card {
|
||||
-webkit-app-region: no-drag;
|
||||
@@ -13,8 +13,9 @@
|
||||
border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.4);
|
||||
height: calc(100% - 24px); margin: 12px;
|
||||
display: flex; flex-direction: column; padding: 12px;
|
||||
overflow: hidden; box-sizing: border-box;
|
||||
}
|
||||
textarea { flex: 1; background: transparent; color: inherit; border: none; outline: none; font-size: 14px; resize: none; }
|
||||
textarea { flex: 1; min-height: 0; background: transparent; color: inherit; border: none; outline: none; font-size: 14px; resize: none; }
|
||||
.thumbs { display: flex; gap: 6px; }
|
||||
.thumbs img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; }
|
||||
.hint { color: #888; font-size: 11px; margin-top: 6px; }
|
||||
|
||||
@@ -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]+)*$/);
|
||||
|
||||
@@ -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); // 내일 + 모레
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user