6 Commits

Author SHA1 Message Date
altair823
06cfa1c151 chore(release): v0.2.2 — F7 + Quick Capture 스크롤 fix
- F7 (이미 main 병합): AI-primary due_date flow, 다중 후보 추출
- fix(quickcapture): textarea min-height: 0 + .card overflow: hidden 으로 hint 노출 보장

CHANGELOG.md / package.json 0.2.1 → 0.2.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:25:53 +09:00
altair823
579450ef4f docs(spec): promote F7 AI-primary due date
신규 spec 파일 추가 (구현 결과 반영). dogfood-feedback.md 의 F7
헤더 🔬 drafting → 🚀 promoted 로 갱신. F1 spec 의 후속 리스트에
F7 링크 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:07:06 +09:00
altair823
723dccd61d feat(ai): AI-primary due_date flow — rule as prompt candidates only
Flow 반전 (F7-D 채택):
- 기존: rule.iso ?? ai.dueDate (rule 우선)
- 신규: ai.dueDate ?? null (AI 우선)

규칙은 parseAllCandidates 로 모든 매치를 추출 → prompt 에 후보
힌트로 주입. AI 가 종합 판단. AI 실패 시 due_date null (별 fallback 없음).

해결되는 케이스: '내일 모레' → AI 가 ambiguous 인지 → null.

PROMPT_VERSION → 3. GenerateInput.dueDateCandidates 신규.
buildPrompt(rawText, todayKst, candidates) — 빈 배열일 때 hint 섹션 생략.

Tests:
- AiWorker.test.ts — 'rule priority' 테스트 → 'AI dueDate wins' flip
- AiWorker.test.ts — passes todayKst 테스트 확장 (dueDateCandidates 도 검증)
- AiWorker.test.ts — 신규 'passes parseAllCandidates result as dueDateCandidates'
- LocalOllamaProvider.test.ts / ollama-golden.test.ts — generate 호출에 dueDateCandidates: [] 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:12 +09:00
altair823
1c72b64c2f feat(due-date): parseAllCandidates — extract all matches (text order)
기존 parseDueDate (first-match-wins) 는 backward compat 로 보존.
parseAllCandidates 가 모든 high/medium 매치를 text 순서로 반환 — F7
AI-primary flow 의 prompt 후보 주입 입력으로 사용.

Overlapping-span suppression: 다음 주 월요일 같은 케이스에서 rule 10
(전체) 이 rule 13 (다음 주 alone) 을 포함하면 후자 매치 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:04:17 +09:00
altair823
2ee45bc53c docs(plan): F7 AI-primary due date 구현 계획 (D 채택) 2026-04-26 13:00:35 +09:00
altair823
742eec00f4 docs(feedback): add F7 — Due Date 규칙 파서 합성 표현 first-match-wins 한계
v0.2.1 dogfood 첫 실증 피드백. '내일 모레' → 내일로 잘못 잡힘.
규칙 파서 한계 3 (합성/양가 / 범위 / 모호) + 5 후보 방향 (A 화이트
리스트 / B 충돌 감지 / C UI 신호 / D AI 우선 / E 규칙 폐기).
1차 A+B 작은 PR 즉시 시도 가능, 2차 C UI 신호, 후속 D/E 결정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:56:32 +09:00
16 changed files with 910 additions and 26 deletions

View File

@@ -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

View 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 합칠 수도 있음.)

View File

@@ -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개 채우거나 비워둔 채 시작 가능.

View File

@@ -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 카운터 등)

View 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` 로 모든 호출 마이그레이션 후

View File

@@ -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>",

View File

@@ -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;

View File

@@ -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; }

View File

@@ -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 }

View File

@@ -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.

View File

@@ -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
}));
}

View File

@@ -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; }

View File

@@ -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]+)*$/);

View File

@@ -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); // 내일 + 모레
});
});

View File

@@ -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);

View File

@@ -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');
});
});