From 8206462ee4457c22a441469a89537ae436b4d534 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:02:06 +0900 Subject: [PATCH 01/14] =?UTF-8?q?docs(tag-vocab):=20#3=20spec=20=E2=80=94?= =?UTF-8?q?=20vocab=20pool/telemetry/prompt=20=EA=B0=95=EB=8F=84/=EC=9E=AC?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B2=B0=EC=A0=95=20(v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mini-brainstorm 4개 결정: - Q1=C: vocab pool = AI+user 통합 + kebab-case 필터 - Q2=A: telemetry emit 단위 = 태그별 (per-tag hit/miss) - Q3=B: prompt 강도 = "Prefer" (우선, MUST 아님) - Q4=A: 기존 노트 재처리 = 자연 진화 (X) 핵심 invariant 6개 + privacy invariant + tests ≥19개 약속. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-02-v023-tag-vocab-design.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md diff --git a/docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md b/docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md new file mode 100644 index 0000000..ab40c0b --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md @@ -0,0 +1,255 @@ +# v0.2.3 #3 태그 vocab — Design Spec + +> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #3 (6번째 cut) + +## 1. Goal + +기존 `tags` 테이블의 자주 쓰인 태그들을 AI prompt 에 vocabulary 로 주입해, AI 가 의미 일치 시 새 태그 생성 대신 기존 태그를 재사용하도록 유도. 효과는 `tag_vocab_hit` / `tag_vocab_miss` telemetry 로 측정. + +## 2. Decisions (mini-brainstorm 합의) + +| # | 질문 | 선택 | 이유 | +|---|---|---|---| +| Q1 | vocab pool 범위 | **C** AI+user 통합 + kebab-case 필터 | 사용자가 형식 맞춰 단 태그도 재사용 가치 있음, 단 형식 안 맞는 한글/공백 태그는 prompt 오염 | +| Q2 | telemetry emit 단위 | **A** 태그별 (per-tag hit/miss) | roadmap §3 #3 합의 시그니처 + 기존 누적 카운터 통계 모델과 정합 | +| Q3 | prompt 강제력 강도 | **B** "Prefer" (우선) | "MUST" 는 semantic mismatch 시 false hit, "For reference" 는 효과 미미; "Prefer" 는 우선순위 신호 + escape hatch 보장 | +| Q4 | 기존 노트 재처리 | **A** 자연 진화 (X) | invariant (user-edited 결과 보호) 와 합치, 새 노트만으로 hit/miss 충분 수집, B 는 사용자 결과 변경 | + +## 3. Architecture & data flow + +``` +AiWorker.processJob() + ├─ const vocab = repo.getTopUsedTags(20) ← SQL fetch (kebab-case 필터) + ├─ provider.generate({ ..., vocab }) ← 새 input 필드 + │ └─ LocalOllamaProvider.generate() + │ └─ buildPrompt(rawText, todayKst, candidates, vocab) + │ └─ vocab.length > 0 시 prompt 라인 추가 + ├─ AI response (tags: ['design', 'meeting', ...]) + ├─ repo.updateAiResult(...) ← 기존 흐름, tag insert + └─ for tag of res.tags: ← per-tag hit/miss 분류 + if vocabSet.has(tag): + tagId = repo.getTagIdByName(tag) ← insert 후 보장 + emit tag_vocab_hit { tagId, vocabSize } + else: + emit tag_vocab_miss { vocabSize } +``` + +### 3.1 Invariants + +1. **매 generate 마다 SQL fetch** — vocab 캐싱/invalidation 안 함 (out of scope) +2. **vocab 빈 케이스 (N=0)** → prompt 라인 자체 생략, AI 자유롭게 새 태그 생성 +3. **tagId** 는 hit 시 db tag id (`getTagIdByName` lookup, `updateAiResult` 후 호출이라 insert 보장) +4. **PROMPT_VERSION 3 → 4** (marker only, retry 트리거 X) +5. **vocab snapshot 동결** — 같은 generate call 의 `vocab` 배열로 hit/miss 판정. 처리 중 다른 노트가 새 태그 추가해도 이번 노트 분류엔 영향 X +6. **emit 순서** — `updateAiResult` 후 emit (tagId 확보 보장) + +## 4. Components + +### 4.1 `NoteRepository` + +#### `getTopUsedTags(limit = 20): string[]` + +```sql +SELECT t.name, COUNT(*) c +FROM tags t +JOIN note_tags nt ON nt.tag_id = t.id +JOIN notes n ON n.id = nt.note_id +WHERE n.deleted_at IS NULL +GROUP BY t.id +ORDER BY c DESC, t.id ASC +LIMIT ? +``` + +JS-side 후처리: +```typescript +return rows + .map((r) => r.name) + .filter((n) => /^[a-z0-9-]+$/.test(n)); +``` + +- `source` 무시 (AI+user 통합 — Q1=C) +- `t.id ASC` tiebreaker (deterministic) +- regex 필터로 한글/공백/대문자 태그 제외 + +#### `getTagIdByName(name: string): number | null` + +```sql +SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1 +``` + +대소문자 무시 (tag table `name COLLATE NOCASE` 와 정합). + +### 4.2 `prompt.ts` + +```typescript +export const PROMPT_VERSION = 4; // bump from 3 + +export function buildPrompt( + rawText: string, + todayKst: string, + candidates: ParseResult[] = [], + vocab: string[] = [] +): string { + const candidateBlock = ...; // 기존 로직 유지 + const vocabBlock = vocab.length > 0 + ? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n` + : ''; + return `... ${candidateBlock} ${vocabBlock} ...`; +} +``` + +### 4.3 `InferenceProvider` + `LocalOllamaProvider` + +```typescript +export interface GenerateInput { + text: string; + todayKst: string; + dueDateCandidates: ParseResult[]; + vocab?: string[]; // optional, 미전달 시 buildPrompt 가 빈 배열 처리 +} +``` + +`LocalOllamaProvider.generate()` 가 `buildPrompt(text, todayKst, candidates, input.vocab ?? [])` 호출. + +### 4.4 `AiWorker.processJob` + +generate 호출 직전: +```typescript +const vocab = this.repo.getTopUsedTags(20); +const res = await this.provider.generate({ + text: note.rawText, + todayKst: todayIso, + dueDateCandidates: candidates, + vocab +}); +``` + +`updateAiResult` 후 emit 루프: +```typescript +const vocabSet = new Set(vocab); +for (const tagName of res.tags) { + if (vocabSet.has(tagName)) { + const tagId = this.repo.getTagIdByName(tagName); + if (tagId !== null && this.telemetry) { + await this.telemetry.emit({ + kind: 'tag_vocab_hit', + payload: { tagId, vocabSize: vocab.length } + }).catch(() => {}); + } + } else if (this.telemetry) { + await this.telemetry.emit({ + kind: 'tag_vocab_miss', + payload: { vocabSize: vocab.length } + }).catch(() => {}); + } +} +``` + +### 4.5 `telemetryEvents.ts` — zod schema + +```typescript +const TagVocabHitPayload = z.object({ + tagId: z.number().int().positive(), + vocabSize: z.number().int().nonnegative() +}).strict(); + +const TagVocabMissPayload = z.object({ + vocabSize: z.number().int().nonnegative() +}).strict(); +``` + +`TelemetryEventSchema` discriminatedUnion 13 → **15** entries. + +### 4.6 `telemetryStats.ts` — 누적 + +- `DailyRow` 에 `tag_vocab_hit: number`, `tag_vocab_miss: number` 추가 +- accumulator 분기 2개 +- table 컬럼 2개 추가 +- summary 라인: + ``` + - 태그 vocab: hit/miss = {N}/{M} (적중률 {X}%) + ``` + N+M=0 시 `(데이터 없음)` 표기 + +### 4.7 `TelemetryService.EmitInput` union 확장 (15 entries) + +### 4.8 `AiWorker.AiTelemetryEmitter` interface 확장 + +```typescript +export interface AiTelemetryEmitter { + emit(input: + | { kind: 'ai_succeeded'; payload: ... } + | { kind: 'ai_failed'; payload: ... } + | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } + ): Promise; +} +``` + +## 5. Privacy invariant + +- `tag_vocab_hit.payload.tagId` — 숫자 id 만, 태그 이름 X +- `tag_vocab_miss.payload` — `vocabSize` 만 (tagId 없음) +- prompt 본문에 vocab 이름 들어가지만 **prompt 는 telemetry 가 아님** (모델 컨텍스트, local Ollama 머신 내부에서만 처리) +- `.strict()` zod 가드 + extra field 거부 테스트로 invariant 보호 + +## 6. Tests (≥19개) + +### NoteRepository.test.ts (7) +1. 빈 db → `[]` +2. 정렬 (count desc, id asc tiebreaker) +3. kebab-case 필터 — 한글/공백/대문자 태그 제외 +4. AI+user source 통합 카운트 +5. `deleted_at IS NULL` 필터 +6. LIMIT 적용 (>20 시 잘림) +7. `getTagIdByName` — 존재 시 id, 없으면 null + +### prompt.test.ts (4) +8. `PROMPT_VERSION === 4` +9. vocab=[] → 라인 자체 생략 +10. vocab 1+ → "Prefer reusing..." 문구 + comma-separated 리스트 +11. vocab 라인 위치 (candidate block 뒤, JSON rules 앞) + +### AiWorker.test.ts (4) +12. vocab fetch + provider.generate 에 vocab 전달 + hit emit +13. miss emit (vocab 밖의 tag), vocabSize 정확 +14. vocab=[] 시 모든 응답 태그 miss +15. 응답 태그 3개 → 3개 emit (per-tag 검증) + +### telemetryEvents.test.ts (3) +16. `tag_vocab_hit` valid parse +17. `tag_vocab_hit` extra field 거부 (privacy) +18. `tag_vocab_miss` valid parse, tagId 필드 없음 + +### telemetryStats.test.ts (1) +19. hit 5 + miss 3 → daily row + summary "적중률 62.5%" + +기존 단위 363 + **19** = **382** 예상. Q3 phrasing 변경으로 LocalOllamaProvider 기존 테스트 일부 string assertion 수정 가능 (±5). + +## 7. Out of scope + +(roadmap §3 #3 + 본 cut 결정) + +- 임베딩 유사도 dedup ("회의" ↔ "meeting" semantic 매핑) +- 사용자 controlled vocabulary 화이트리스트 +- 자동 normalize ("회의" ↔ "미팅") +- top-N 튜닝 (N=20 hardcoded) +- vocab cache invalidation 정책 (매번 SQL fetch) +- vocab 시간 범위 필터 (최근 N일 → 전체 사용) +- 기존 `ai_status='done'` 노트 일괄 재처리 (Q4=A 자연 진화) +- 명시적 "AI 결과 재처리" trigger UI (v0.2.4 backlog) +- `promptVersion` 을 telemetry payload 에 포함 (v0.2.4 검토 — 단일 버전 cut 에선 무의미) +- `idx_note_tags_tag_id` 인덱스 추가 (현재 dogfood 규모에선 불필요, v0.2.4 검토) + +## 8. Gates (roadmap §3.1 공통) + +- typecheck 0 +- 단위 363 → 382 (+19), 모두 통과 +- e2e 1/1 +- 새 SQL: `getTopUsedTags` (3-table JOIN) + `getTagIdByName` (single-table) — 인덱스 영향 dogfood 규모에서 무시 + +## 9. Roadmap relation + +- v0.2.3 dogfood feedback #3 (6번째 cut) +- 다음 cut: #6 리마인드 1 spike (7번째, 마지막) +- v0.2.4 후속: top-N 튜닝, controlled vocabulary, normalize, embeddings dedup -- 2.49.1 From 853ca39c0d01ece3573e78f410aa6bd18bc537d1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:07:28 +0900 Subject: [PATCH 02/14] =?UTF-8?q?docs(tag-vocab):=20#3=20plan=20=E2=80=94?= =?UTF-8?q?=208=20tasks=20TDD=20+=2021=20=EB=8B=A8=EC=9C=84=20cases=20(v0.?= =?UTF-8?q?2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 task TDD plan: T1 NoteRepository (getTopUsedTags + getTagIdByName, +7 cases) T2 prompt.ts (PROMPT_VERSION 4 + vocab param, +4 cases, 신규 prompt.test.ts) T3 InferenceProvider + LocalOllamaProvider (vocab passthrough, +1 case) T4 telemetryEvents (zod schemas, +3 cases) T5 telemetryStats (누적 + summary, +2 cases) T6 TelemetryService EmitInput + narrowing 확장 T7 AiWorker (vocab fetch + per-tag emit, +4 cases) T8 closure (gates + roadmap) 총 신규 단위 +21 (spec budget 19 + 2 surplus). 단위 363 → 382 (±5) 예상. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-02-v023-tag-vocab.md | 1091 +++++++++++++++++ 1 file changed, 1091 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-v023-tag-vocab.md diff --git a/docs/superpowers/plans/2026-05-02-v023-tag-vocab.md b/docs/superpowers/plans/2026-05-02-v023-tag-vocab.md new file mode 100644 index 0000000..60e11d0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-v023-tag-vocab.md @@ -0,0 +1,1091 @@ +# v0.2.3 #3 태그 vocab Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AI prompt 에 자주 쓰인 태그 vocabulary 를 주입해 재사용 유도하고, 태그별 hit/miss telemetry 로 효과 측정. + +**Architecture:** `repo.getTopUsedTags(20)` SQL → `provider.generate(input + vocab)` → `buildPrompt` 가 vocab 라인 추가 → AI 응답 → `repo.updateAiResult` 후 per-tag hit/miss emit. 매 generate 마다 SQL fetch (캐싱 X). vocab 빈 케이스 시 prompt 라인 자체 생략. + +**Tech Stack:** better-sqlite3 12.9, zod 4.3.6, vitest 4, undici MockAgent (LocalOllamaProvider 테스트), TypeScript strict. PROMPT_VERSION 3 → 4. + +**Spec:** `docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md` + +--- + +## File Structure + +| File | Role | Action | +|------|------|--------| +| `src/main/repository/NoteRepository.ts` | DB layer | +`getTopUsedTags(limit)` +`getTagIdByName(name)` | +| `src/main/ai/prompt.ts` | LLM prompt | bump PROMPT_VERSION 3→4, +`vocab` 4th param, +vocabBlock | +| `src/main/ai/InferenceProvider.ts` | Provider contract | `GenerateInput.vocab?: string[]` | +| `src/main/ai/LocalOllamaProvider.ts` | Ollama impl | `generate()` 에서 `input.vocab ?? []` 를 buildPrompt 에 전달 | +| `src/main/ai/AiWorker.ts` | Job loop | vocab fetch 후 generate 호출, response 후 per-tag hit/miss emit, AiTelemetryEmitter union 확장 | +| `src/main/services/telemetryEvents.ts` | zod schema | +`TagVocabHitPayload` +`TagVocabMissPayload` 13→15 union | +| `src/main/services/telemetryStats.ts` | 누적 통계 | DailyRow +2 cols, accumulators, summary line "태그 vocab" | +| `src/main/services/TelemetryService.ts` | EmitInput type | union 13→15 | +| `tests/unit/NoteRepository.test.ts` | 테스트 | +7 cases | +| `tests/unit/prompt.test.ts` | 테스트 (신규) | +4 cases | +| `tests/unit/AiWorker.test.ts` | 테스트 | +4 cases | +| `tests/unit/telemetryEvents.test.ts` | 테스트 | +3 cases | +| `tests/unit/telemetryStats.test.ts` | 테스트 | +1 case | +| `tests/unit/TelemetryService.test.ts` | 테스트 | narrowing guards 확장 | + +총 신규 단위 19개 + narrowing guard widening = 단위 363 → **382 (±5)**. + +--- + +## Task 1: NoteRepository — getTopUsedTags + getTagIdByName + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` (add 2 methods after `setNextRunAt`, around line 217) +- Test: `tests/unit/NoteRepository.test.ts` (append at end of `describe('NoteRepository')`) + +- [ ] **Step 1: Write failing test — getTopUsedTags 빈 db** + +```typescript +// in tests/unit/NoteRepository.test.ts (append) +it('getTopUsedTags returns [] when no notes', () => { + expect(repo.getTopUsedTags()).toEqual([]); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `npm test -- NoteRepository` +Expected: FAIL with `repo.getTopUsedTags is not a function` + +- [ ] **Step 3: Implement getTopUsedTags + getTagIdByName** + +Add after `setNextRunAt(...)` method (around line 217): + +```typescript + /** + * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. + * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). + * deleted_at IS NULL 만 (휴지통 노트 태그 제외). + */ + getTopUsedTags(limit = 20): string[] { + const rows = this.db + .prepare( + `SELECT t.name, COUNT(*) c + FROM tags t + JOIN note_tags nt ON nt.tag_id = t.id + JOIN notes n ON n.id = nt.note_id + WHERE n.deleted_at IS NULL + GROUP BY t.id + ORDER BY c DESC, t.id ASC + LIMIT ?` + ) + .all(limit) as Array<{ name: string; c: number }>; + return rows + .map((r) => r.name) + .filter((n) => /^[a-z0-9-]+$/.test(n)); + } + + /** + * v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장. + * tags.name COLLATE NOCASE 라 case-insensitive lookup. + */ + getTagIdByName(name: string): number | null { + const row = this.db + .prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`) + .get(name) as { id: number } | undefined; + return row ? row.id : null; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- NoteRepository` +Expected: PASS + +- [ ] **Step 5: Add 6 more cases** + +Append to `tests/unit/NoteRepository.test.ts`: + +```typescript +it('getTopUsedTags orders by count desc, id asc tiebreaker', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' }); + repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); + // counts: design=3, meeting=2, qa=1 + expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']); +}); + +it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => { + const a = repo.create({ rawText: 'a' }).id; + // user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증 + repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] }); + expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout'])); + expect(repo.getTopUsedTags()).not.toContain('회의'); + expect(repo.getTopUsedTags()).not.toContain('Meeting'); + expect(repo.getTopUsedTags()).not.toContain('two words'); +}); + +it('getTopUsedTags counts AI + user sources together', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); + repo.updateUserAiFields(b, { tags: ['design'] }); + // design count 는 AI 1 + user 1 = 2 + const top = repo.getTopUsedTags(); + expect(top).toContain('design'); +}); + +it('getTopUsedTags excludes tags from deleted notes', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' }); + repo.softDelete(a); + expect(repo.getTopUsedTags()).not.toContain('lonely'); +}); + +it('getTopUsedTags respects LIMIT parameter', () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + ids.push(id); + repo.updateAiResult(id, { + title: 't', summary: 'a\nb\nc', + tags: [`tag-${i}`], + provider: 'p' + }); + } + expect(repo.getTopUsedTags(3)).toHaveLength(3); + expect(repo.getTopUsedTags(10)).toHaveLength(5); +}); + +it('getTagIdByName returns id when present, null when absent', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' }); + const id = repo.getTagIdByName('hello'); + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + // case-insensitive + expect(repo.getTagIdByName('HELLO')).toBe(id); + // absent + expect(repo.getTagIdByName('nothere')).toBeNull(); +}); +``` + +- [ ] **Step 6: Run all tests, verify pass** + +Run: `npm test -- NoteRepository` +Expected: PASS — 7 new cases. Pre-existing cases unchanged. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): NoteRepository — getTopUsedTags + getTagIdByName (#3 v0.2.3) + +- getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외 +- getTagIdByName(name): COLLATE NOCASE lookup +- AI+user source 통합 카운트 (Q1=C 결정) +- 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: prompt.ts — PROMPT_VERSION 4 + vocab parameter + +**Files:** +- Modify: `src/main/ai/prompt.ts` (entire file) +- Create: `tests/unit/prompt.test.ts` + +- [ ] **Step 1: Write failing test for new test file** + +Create `tests/unit/prompt.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { buildPrompt, PROMPT_VERSION } from '@main/ai/prompt.js'; + +describe('prompt', () => { + it('PROMPT_VERSION is 4', () => { + expect(PROMPT_VERSION).toBe(4); + }); + + it('buildPrompt with empty vocab omits vocabulary line entirely', () => { + const out = buildPrompt('hello', '2026-05-02', [], []); + expect(out).not.toContain('vocabulary'); + expect(out).not.toContain('Prefer reusing'); + }); + + it('buildPrompt with vocab includes Prefer instruction + comma-separated list', () => { + const out = buildPrompt('hello', '2026-05-02', [], ['design', 'meeting', 'qa']); + expect(out).toContain('Existing vocabulary tags'); + expect(out).toContain('design, meeting, qa'); + expect(out).toContain('Prefer reusing'); + }); + + it('vocab block appears between candidate block and JSON rules', () => { + const out = buildPrompt('hello', '2026-05-02', [], ['design']); + const candidateIdx = out.indexOf('Today\'s date'); + const vocabIdx = out.indexOf('Existing vocabulary'); + const jsonRulesIdx = out.indexOf('Return a JSON object'); + expect(candidateIdx).toBeGreaterThan(-1); + expect(vocabIdx).toBeGreaterThan(candidateIdx); + expect(jsonRulesIdx).toBeGreaterThan(vocabIdx); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `npm test -- prompt` +Expected: FAIL — `PROMPT_VERSION` is 3, `buildPrompt` only takes 3 args. + +- [ ] **Step 3: Modify prompt.ts** + +Replace entire file: + +```typescript +import type { ParseResult } from '../services/dueDateParser.js'; + +export const PROMPT_VERSION = 4; + +export function buildPrompt( + rawText: string, + todayKst: string, + candidates: ParseResult[] = [], + vocab: string[] = [] +): 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` + : ''; + + const vocabBlock = vocab.length > 0 + ? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n` + : ''; + + return `You organize raw personal notes into structured metadata. + +Today's date in Korea Standard Time (KST): ${todayKst} +${candidateBlock}${vocabBlock} +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.`; +} +``` + +- [ ] **Step 4: Run prompt tests, verify pass** + +Run: `npm test -- prompt` +Expected: PASS — 4 cases. + +- [ ] **Step 5: Run full test suite to catch regressions** + +Run: `npm test` +Expected: PASS or only LocalOllamaProvider tests fail (string assertions on prompt content). Note any failures for Task 3. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ai/prompt.ts tests/unit/prompt.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): prompt.ts — PROMPT_VERSION 4 + vocab parameter (#3 v0.2.3) + +- PROMPT_VERSION 3 → 4 (marker bump, retry 트리거 X) +- buildPrompt 4번째 param vocab: string[] = [] +- vocab.length > 0 시 "Existing vocabulary tags" + "Prefer reusing" 라인 추가 +- vocab=[] 시 라인 자체 생략 (Q3=B 결정) +- 단위 +4 cases (신규 prompt.test.ts) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: InferenceProvider + LocalOllamaProvider — vocab passthrough + +**Files:** +- Modify: `src/main/ai/InferenceProvider.ts` (1 line added) +- Modify: `src/main/ai/LocalOllamaProvider.ts:40` (vocab 전달) +- Test: `tests/unit/LocalOllamaProvider.test.ts` (1 case 추가, 기존 string assertion 영향 가능 시 fix) + +- [ ] **Step 1: Write failing test — vocab 전달 검증** + +Append to `tests/unit/LocalOllamaProvider.test.ts` inside `describe('LocalOllamaProvider')`: + +```typescript +it('generate passes vocab into prompt body', async () => { + let capturedBody: string = ''; + mock.get('http://localhost:11434').intercept({ + path: '/api/generate', method: 'POST' + }).reply((opts) => { + capturedBody = opts.body as string; + return { statusCode: 200, data: JSON.stringify({ + response: JSON.stringify({ title: 't', summary: 'a\nb\nc', tags: ['design'] }) + }) }; + }); + await new LocalOllamaProvider().generate({ + text: 'x', todayKst: '2026-05-02', dueDateCandidates: [], + vocab: ['design', 'meeting'] + }); + const parsed = JSON.parse(capturedBody) as { prompt: string }; + expect(parsed.prompt).toContain('design, meeting'); + expect(parsed.prompt).toContain('Prefer reusing'); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +Run: `npm test -- LocalOllamaProvider` +Expected: FAIL — `vocab` not in `GenerateInput` type, or prompt body missing vocab line. + +- [ ] **Step 3: Modify InferenceProvider.ts** + +Replace `GenerateInput` interface in `src/main/ai/InferenceProvider.ts`: + +```typescript +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[]; + vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리. +} + +export interface HealthResult { ok: boolean; model?: string; reason?: string; } + +export interface InferenceProvider { + readonly name: string; + generate(input: GenerateInput): Promise; + healthCheck(): Promise; +} +``` + +- [ ] **Step 4: Modify LocalOllamaProvider.ts:40** + +Change line 40 from: +```typescript +prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates), +``` +to: +```typescript +prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []), +``` + +- [ ] **Step 5: Run LocalOllamaProvider tests, verify pass** + +Run: `npm test -- LocalOllamaProvider` +Expected: PASS — new case + all existing cases (vocab default `[]` → 기존 prompt 동작 변경 없음). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ai/InferenceProvider.ts src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): InferenceProvider.vocab + LocalOllamaProvider 전달 (#3 v0.2.3) + +- GenerateInput.vocab?: string[] (optional, 미전달 시 빈 배열 처리) +- LocalOllamaProvider.generate 가 input.vocab ?? [] 를 buildPrompt 4th 인자로 +- 단위 +1 case (vocab → prompt body) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: telemetryEvents — TagVocabHit + TagVocabMiss zod schemas + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` (after `AiRetryManualPayload`, expand union 13→15) +- Test: `tests/unit/telemetryEvents.test.ts` (+3 cases) + +- [ ] **Step 1: Write failing tests** + +Append to `tests/unit/telemetryEvents.test.ts`: + +```typescript +describe('validateEvent — tag vocab', () => { + it('accepts tag_vocab_hit event', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_hit'); + }); + + it('accepts tag_vocab_miss event without tagId', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_miss', + payload: { vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_miss'); + }); + + it('rejects tag_vocab_hit with extra field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17, tagName: 'leak' } + })).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify fail** + +Run: `npm test -- telemetryEvents` +Expected: FAIL — `tag_vocab_hit` not in discriminator union. + +- [ ] **Step 3: Modify telemetryEvents.ts** + +Append after `AiRetryManualPayload` (line 51): + +```typescript +const TagVocabHitPayload = z.object({ + tagId: z.number().int().positive(), + vocabSize: z.number().int().nonnegative() +}).strict(); + +const TagVocabMissPayload = z.object({ + vocabSize: z.number().int().nonnegative() +}).strict(); +``` + +Append to `TelemetryEventSchema` union (after `ai_retry_manual` entry, line 66): + +```typescript + z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict() +``` + +(Add comma after `ai_retry_manual` line.) + +- [ ] **Step 4: Run tests, verify pass** + +Run: `npm test -- telemetryEvents` +Expected: PASS — 3 new cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/telemetryEvents.ts tests/unit/telemetryEvents.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): telemetryEvents — tag_vocab_hit/miss zod schemas (#3 v0.2.3) + +- TagVocabHitPayload { tagId: int>0, vocabSize: int>=0 } .strict() +- TagVocabMissPayload { vocabSize: int>=0 } .strict() +- TelemetryEventSchema union 13 → 15 +- 단위 +3 cases (hit accept, miss accept, hit extra field 거부) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: telemetryStats — tag_vocab_hit/miss 누적 + summary + +**Files:** +- Modify: `src/main/services/telemetryStats.ts` (DailyRow + accumulators + table + summary) +- Test: `tests/unit/telemetryStats.test.ts` (+1 case) + +- [ ] **Step 1: Write failing test** + +Append to `tests/unit/telemetryStats.test.ts`: + +```typescript +it('aggregates tag_vocab hit/miss with success rate', () => { + const events: TelemetryEvent[] = [ + e('2026-05-02T00:00:00Z', 'tag_vocab_hit', { tagId: 1, vocabSize: 10 }), + e('2026-05-02T00:00:01Z', 'tag_vocab_hit', { tagId: 2, vocabSize: 10 }), + e('2026-05-02T00:00:02Z', 'tag_vocab_hit', { tagId: 3, vocabSize: 10 }), + e('2026-05-02T00:00:03Z', 'tag_vocab_hit', { tagId: 4, vocabSize: 10 }), + e('2026-05-02T00:00:04Z', 'tag_vocab_hit', { tagId: 5, vocabSize: 10 }), + e('2026-05-02T00:00:05Z', 'tag_vocab_miss', { vocabSize: 10 }), + e('2026-05-02T00:00:06Z', 'tag_vocab_miss', { vocabSize: 10 }), + e('2026-05-02T00:00:07Z', 'tag_vocab_miss', { vocabSize: 10 }) + ]; + const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('태그 vocab: hit/miss = 5/3'); + expect(r.md).toContain('적중률 62.5%'); +}); + +it('태그 vocab summary shows 데이터 없음 when no events', () => { + const r = aggregateStats([], new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('태그 vocab'); + expect(r.md).toContain('데이터 없음'); +}); +``` + +- [ ] **Step 2: Run test, verify fail** + +Run: `npm test -- telemetryStats` +Expected: FAIL — summary 라인 없음. + +- [ ] **Step 3: Modify telemetryStats.ts** + +In `DailyRow` interface (line 12), add 2 fields after `ai_retry_manual`: + +```typescript + ai_retry_manual: number; + tag_vocab_hit: number; + tag_vocab_miss: number; +} +``` + +In `aggregateStats` add 2 accumulators (after `aiRetryManualFailedSum`, around line 49): + +```typescript + let aiRetryManualFailedSum = 0; + let tagVocabHitCount = 0; + let tagVocabMissCount = 0; +``` + +In row init block (around line 54), add 2 fields: + +```typescript + row = { + date: day, + capture: 0, ai_succeeded: 0, ai_failed: 0, + trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0, + expired_banner_shown: 0, expired_batch_trash: 0, + ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0, + ai_retry_manual: 0, + tag_vocab_hit: 0, tag_vocab_miss: 0 + }; +``` + +In if/else if chain (after `ai_retry_manual` branch, line 102), add 2 branches: + +```typescript + } else if (ev.kind === 'tag_vocab_hit') { + row.tag_vocab_hit += 1; + tagVocabHitCount += 1; + } else if (ev.kind === 'tag_vocab_miss') { + row.tag_vocab_miss += 1; + tagVocabMissCount += 1; + } +``` + +After `const totalUnreachable = ...` (line 117), add: + +```typescript + const tagVocabTotal = tagVocabHitCount + tagVocabMissCount; + const tagVocabSummary = tagVocabTotal === 0 + ? '(데이터 없음)' + : `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`; +``` + +In table header line (line 126), append columns: + +```typescript + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |'); + lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|'); +``` + +In table row (line 129), append: + +```typescript + lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`); +``` + +After "AI 수동 재시도" line (line 141), add: + +```typescript + lines.push(`- 태그 vocab: ${tagVocabSummary}`); +``` + +- [ ] **Step 4: Run test, verify pass** + +Run: `npm test -- telemetryStats` +Expected: PASS — 2 new cases. Pre-existing cases may need table column adjustment (e.g., `'| 2026-05-01 | 2 | 1 | 0 |'` partial-match assertions still work since they use `toContain`). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/telemetryStats.ts tests/unit/telemetryStats.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): telemetryStats — hit/miss 누적 + summary 적중률 (#3 v0.2.3) + +- DailyRow +2 cols (tag_vocab_hit, tag_vocab_miss) +- accumulators + branches +- table 컬럼 +2 +- summary "- 태그 vocab: hit/miss = N/M (적중률 X%)" 또는 "(데이터 없음)" +- 단위 +1 case + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: TelemetryService — EmitInput union + narrowing guards + +**Files:** +- Modify: `src/main/services/TelemetryService.ts:18-31` (EmitInput union) +- Modify: `tests/unit/TelemetryService.test.ts:151,167` (narrowing guards 확장) + +- [ ] **Step 1: Modify EmitInput union** + +In `src/main/services/TelemetryService.ts`, replace `EmitInput` type (line 18-31): + +```typescript +export type EmitInput = + | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } + | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } + | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'trash'; payload: { noteId: string } } + | { kind: 'restore'; payload: { noteId: string } } + | { kind: 'permanent_delete'; payload: { noteId: string } } + | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } + | { kind: 'expired_batch_trash'; payload: { count: number } } + | { kind: 'ollama_unreachable'; payload: { reason: string } } + | { kind: 'ollama_recovered'; payload: { downtimeMs: number } } + | { kind: 'ollama_recheck_manual'; payload: Record } + | { kind: 'ai_retry_manual'; payload: { failedCount: number } } + | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }; +``` + +- [ ] **Step 2: Run typecheck, verify pass** + +Run: `npm run typecheck` +Expected: PASS — no errors. + +- [ ] **Step 3: Extend narrowing guards in test** + +In `tests/unit/TelemetryService.test.ts`, update line 151: + +```typescript + expect(events.map((e) => + (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss') + ? null + : e.payload.noteId + )).toEqual(['a', 'b', 'b']); +``` + +And line 167: + +```typescript + if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a'); +``` + +- [ ] **Step 4: Run TelemetryService tests, verify pass** + +Run: `npm test -- TelemetryService` +Expected: PASS — narrowing guards covered both new kinds. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): TelemetryService EmitInput +tag_vocab_hit/miss + 테스트 narrowing 확장 (#3 v0.2.3) + +- EmitInput union 13 → 15 +- narrowing guards (noteId 없는 kind 분기) 에 tag_vocab_hit/miss 추가 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: AiWorker — vocab fetch + per-tag hit/miss emit + +**Files:** +- Modify: `src/main/ai/AiWorker.ts` (AiTelemetryEmitter union 확장 + processJob vocab 흐름) +- Test: `tests/unit/AiWorker.test.ts` (+4 cases) + +- [ ] **Step 1: Write failing tests** + +Append to `tests/unit/AiWorker.test.ts` inside `describe('AiWorker')`: + +```typescript +it('fetches vocab and passes to provider.generate', async () => { + // Pre-seed 1 note with tag 'design' so vocab non-empty + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const generateMock = vi.fn(async () => ({ + title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null + })); + const w = new AiWorker(repo, makeProvider({ generate: generateMock }), { + backoffsMs: [0, 0, 0] + }); + await w.enqueue(id); + await w.drain(); + expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({ + vocab: expect.arrayContaining(['design']) + })); +}); + +it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => { + // Pre-seed: 'design' in vocab + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'newtag'], // 1 hit + 1 miss + dueDate: null + })) + }); + const emits: Array<{ kind: string; payload: unknown }> = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { + emit: vi.fn(async (input) => { emits.push(input); }) + } + }); + await w.enqueue(id); + await w.drain(); + const hit = emits.filter((e) => e.kind === 'tag_vocab_hit'); + const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); + expect(hit).toHaveLength(1); + expect(miss).toHaveLength(1); + expect((hit[0]!.payload as { tagId: number }).tagId).toBeGreaterThan(0); + expect((hit[0]!.payload as { vocabSize: number }).vocabSize).toBe(1); + expect((miss[0]!.payload as { vocabSize: number }).vocabSize).toBe(1); +}); + +it('all tags miss when vocab is empty', async () => { + // No seed → vocab=[] + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'meeting', 'qa'], + dueDate: null + })) + }); + const emits: Array<{ kind: string; payload: unknown }> = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } + }); + await w.enqueue(id); + await w.drain(); + const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); + expect(miss).toHaveLength(3); + expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0); +}); + +it('emits one event per tag (3 tags → 3 events)', async () => { + // Pre-seed: all 3 in vocab + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'meeting', 'qa'], + dueDate: null + })) + }); + const emits: Array<{ kind: string; payload: unknown }> = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } + }); + await w.enqueue(id); + await w.drain(); + const hits = emits.filter((e) => e.kind === 'tag_vocab_hit'); + expect(hits).toHaveLength(3); +}); +``` + +- [ ] **Step 2: Run tests, verify fail** + +Run: `npm test -- AiWorker` +Expected: FAIL — `vocab` not passed to generate, no `tag_vocab_*` emits, AiTelemetryEmitter type rejects new kinds. + +- [ ] **Step 3: Extend AiTelemetryEmitter interface** + +In `src/main/ai/AiWorker.ts:31-36`, replace: + +```typescript +export interface AiTelemetryEmitter { + emit(input: + | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } + | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } + ): Promise; +} +``` + +- [ ] **Step 4: Modify processJob — vocab fetch + per-tag emit** + +In `src/main/ai/AiWorker.ts:122-167` (processJob method body), make 2 changes: + +**4a) Before `provider.generate` call (around line 134), fetch vocab:** + +```typescript + const candidates = parseAllCandidates(note.rawText, todayDate); + const vocab = this.repo.getTopUsedTags(20); + const res = await this.provider.generate({ + text: note.rawText, + todayKst: todayIso, + dueDateCandidates: candidates, + vocab + }); +``` + +**4b) After `this.emit(job.noteId)` (line 165, just before `return`), insert per-tag classification:** + +Find this block (around line 155-165): +```typescript + if (this.telemetry) { + await this.telemetry.emit({ + kind: 'ai_succeeded', + payload: { + noteId: job.noteId, + durationMs: this.now().getTime() - startMs, + attempts: attempt + 1 + } + }).catch(() => {}); + } + this.emit(job.noteId); + return; +``` + +Replace with: +```typescript + if (this.telemetry) { + await this.telemetry.emit({ + kind: 'ai_succeeded', + payload: { + noteId: job.noteId, + durationMs: this.now().getTime() - startMs, + attempts: attempt + 1 + } + }).catch(() => {}); + // v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장) + const vocabSet = new Set(vocab); + for (const tagName of res.tags) { + if (vocabSet.has(tagName)) { + const tagId = this.repo.getTagIdByName(tagName); + if (tagId !== null) { + await this.telemetry.emit({ + kind: 'tag_vocab_hit', + payload: { tagId, vocabSize: vocab.length } + }).catch(() => {}); + } + } else { + await this.telemetry.emit({ + kind: 'tag_vocab_miss', + payload: { vocabSize: vocab.length } + }).catch(() => {}); + } + } + } + this.emit(job.noteId); + return; +``` + +- [ ] **Step 5: Run AiWorker tests, verify pass** + +Run: `npm test -- AiWorker` +Expected: PASS — 4 new cases + 9 pre-existing cases unchanged. + +- [ ] **Step 6: Run full test suite, verify all pass** + +Run: `npm test` +Expected: PASS — 363 + 19 = 382 cases. + +- [ ] **Step 7: Run typecheck + e2e** + +Run: `npm run typecheck && npm run test:e2e` +Expected: typecheck 0 errors, e2e 1/1 pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts +git commit -m "$(cat <<'EOF' +feat(tag-vocab): AiWorker — vocab fetch + per-tag hit/miss emit (#3 v0.2.3) + +- processJob 가 generate 직전 repo.getTopUsedTags(20) fetch +- provider.generate 에 vocab 전달 (LocalOllamaProvider 가 prompt 에 주입) +- ai_succeeded emit 후 per-tag 분류 → tag_vocab_hit/miss emit + - hit: vocabSet.has + getTagIdByName lookup → { tagId, vocabSize } + - miss: { vocabSize } +- AiTelemetryEmitter union 4종 (ai_succeeded/ai_failed/tag_vocab_hit/tag_vocab_miss) +- 단위 +4 cases (vocab passthrough, hit+miss, vocab=[] all miss, per-tag emit count) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Closure — roadmap mark complete + final gates + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (mark #3 complete) + +- [ ] **Step 1: Verify final gate matrix** + +Run sequentially: +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: +- typecheck: 0 errors +- 단위: 363 + 19 = **382/382** (±5 for any LocalOllamaProvider string drift) +- e2e: 1/1 + +If any failures, fix before proceeding. + +- [ ] **Step 2: Mark #3 complete in roadmap** + +In `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md`, find the `#3 태그 vocab (6번)` heading or progress table and mark complete (follow same pattern used for #1, #2, #4, #5, #7 — typically a checkbox or status column update). + +If roadmap uses progress table style (look at how prior items were marked): +```markdown +| #3 태그 vocab | ✅ 머지 (PR #__) | +``` + +Adjust to actual roadmap format. Read the file first to confirm pattern. + +- [ ] **Step 3: Commit closure** + +```bash +git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +git commit -m "$(cat <<'EOF' +chore(tag-vocab): #3 closure — gates verified + roadmap mark complete + +- typecheck 0 / 단위 382 / e2e 1 +- v0.2.3 6/7 (#3 태그 vocab 머지) +- 다음: #6 리마인드 spike (마지막 항목) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 4: Push branch** + +```bash +git push -u origin feat/v023-tag-vocab +``` + +- [ ] **Step 5: Create PR via gitea-ops** + +Use `gitea-pr` script: +```bash +gitea-pr --title "feat(tag-vocab): #3 태그 vocab — prompt + telemetry (v0.2.3 6/7)" \ + --body "$(cat <<'EOF' +## Summary +v0.2.3 dogfood feedback roadmap §3 #3 cut. AI prompt 에 vocab 주입 + per-tag hit/miss telemetry. + +mini-brainstorm 4개 결정: +- Q1=C: vocab pool = AI+user 통합 + kebab-case 필터 +- Q2=A: telemetry emit 단위 = 태그별 +- Q3=B: prompt 강도 = "Prefer" (우선) +- Q4=A: 기존 노트 재처리 = 자연 진화 (X) + +## Changes +- NoteRepository: getTopUsedTags(20) + getTagIdByName(name) +- prompt.ts: PROMPT_VERSION 3 → 4, vocab 4번째 param, vocabBlock +- InferenceProvider/LocalOllamaProvider: vocab passthrough +- AiWorker: vocab fetch + per-tag hit/miss emit +- telemetry: tag_vocab_hit/miss zod schema + stats 누적 + summary +- TelemetryService.EmitInput union 13 → 15 + +## Test Plan +- [x] typecheck 0 +- [x] 단위 363 → 382 (+19) +- [x] e2e 1/1 +- [ ] dogfood: vocab 라인이 실제 Ollama 프롬프트에 들어가는지 확인 +EOF +)" \ + --head feat/v023-tag-vocab \ + --base main +``` + +- [ ] **Step 6: Final reviewer dispatch (subagent-driven 모드 시)** + +If executing under `superpowers:subagent-driven-development`, dispatch one final code-reviewer over the whole branch range (`main..HEAD`) before opening PR. + +--- + +## Self-Review Checklist (executed inline by plan author) + +**1. Spec coverage:** +- ✅ Q1 (vocab pool kebab-case 필터) → Task 1 step 5 case "filters non-kebab-case" +- ✅ Q2 (per-tag emit) → Task 7 case "emits one event per tag (3 tags → 3 events)" +- ✅ Q3 ("Prefer" 강도) → Task 2 case "Prefer reusing" +- ✅ Q4 (자연 진화, retry X) → No retry logic in any task; new notes only path +- ✅ All 7 invariants from spec §3.1 covered (caching X =매 fetch in T7; 빈 vocab → 라인 생략 in T2; tagId via getTagIdByName in T7; PROMPT_VERSION marker bump in T2; vocab snapshot via local var in T7; emit 후 순서 in T7) +- ✅ Privacy invariant: T4 case "rejects extra field" + payload schemas omit tag name + +**2. Placeholder scan:** +- No "TBD", "TODO", or "fill in details" anywhere +- Step 2 of Task 8 references "follow pattern used for prior items" — actionable since prior pattern exists in same doc + +**3. Type consistency:** +- `getTopUsedTags(limit)` 시그니처 (T1) === T7 호출부 `repo.getTopUsedTags(20)` +- `getTagIdByName(name): number | null` (T1) === T7 호출부 `repo.getTagIdByName(tagName)` + null check +- `GenerateInput.vocab?: string[]` (T3) === T7 `provider.generate({ ..., vocab })` (matches optional) +- `AiTelemetryEmitter` 4-kind union (T7) === `TelemetryService.EmitInput` superset (T6) +- `TagVocabHitPayload.tagId: int positive` (T4) === AiWorker emit `{ tagId: number; vocabSize: number }` (T7) — `getTagIdByName` returns `number | null`, null path skips emit + +**Coverage of 19 tests:** +- T1: 7 (NoteRepository) +- T2: 4 (prompt — version, empty omit, Prefer text, vocab block position) +- T3: 1 (LocalOllamaProvider passthrough) +- T4: 3 (telemetryEvents — hit, miss, privacy reject) +- T5: 2 (telemetryStats — hit/miss aggregation, 데이터 없음) +- T6: narrowing guard updates only (no new test, but extends existing) +- T7: 4 (AiWorker — vocab fetch, hit+miss, all miss, 3 events) + +**Total: 7 + 4 + 1 + 3 + 2 + 4 = 21 cases.** Spec budgeted 19 — 2 extra (T5 추가 case + T2 vocab block position)는 acceptable surplus. + +--- + +## Roadmap Relation + +- v0.2.3 dogfood feedback #3 (6번째 cut) +- 다음 cut: #6 리마인드 spike (7번째, 마지막) +- v0.2.4 backlog 후속: top-N 튜닝, controlled vocabulary, embeddings dedup -- 2.49.1 From df8a53aec18a022a23ed133a226c6ef7bcf64eac Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:10:36 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(tag-vocab):=20NoteRepository=20?= =?UTF-8?q?=E2=80=94=20getTopUsedTags=20+=20getTagIdByName=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외 - getTagIdByName(name): COLLATE NOCASE lookup - AI+user source 통합 카운트 (Q1=C 결정) - 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 34 +++++++++++++ tests/unit/NoteRepository.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 9563ac3..9492c68 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -215,6 +215,40 @@ export class NoteRepository { .run(nextRunAt, lastError.slice(0, 500), noteId); } + /** + * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. + * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). + * deleted_at IS NULL 만 (휴지통 노트 태그 제외). + */ + getTopUsedTags(limit = 20): string[] { + const rows = this.db + .prepare( + `SELECT t.name, COUNT(*) c + FROM tags t + JOIN note_tags nt ON nt.tag_id = t.id + JOIN notes n ON n.id = nt.note_id + WHERE n.deleted_at IS NULL + GROUP BY t.id + ORDER BY c DESC, t.id ASC + LIMIT ?` + ) + .all(limit) as Array<{ name: string; c: number }>; + return rows + .map((r) => r.name) + .filter((n) => /^[a-z0-9-]+$/.test(n)); + } + + /** + * v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장. + * tags.name COLLATE NOCASE 라 case-insensitive lookup. + */ + getTagIdByName(name: string): number | null { + const row = this.db + .prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`) + .get(name) as { id: number } | undefined; + return row ? row.id : null; + } + updateUserAiFields( id: string, fields: { title?: string; summary?: string; tags?: string[] } diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 93e5e8c..c875f8a 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -652,4 +652,73 @@ describe('NoteRepository — failed retry helpers', () => { expect(job.attempts).toBe(1); // 변화 없음 expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z'); }); + + it('getTopUsedTags returns [] when no notes', () => { + expect(repo.getTopUsedTags()).toEqual([]); + }); + + it('getTopUsedTags orders by count desc, id asc tiebreaker', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' }); + repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); + // counts: design=3, meeting=2, qa=1 + expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']); + }); + + it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => { + const a = repo.create({ rawText: 'a' }).id; + // user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증 + repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] }); + expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout'])); + expect(repo.getTopUsedTags()).not.toContain('회의'); + expect(repo.getTopUsedTags()).not.toContain('Meeting'); + expect(repo.getTopUsedTags()).not.toContain('two words'); + }); + + it('getTopUsedTags counts AI + user sources together', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); + repo.updateUserAiFields(b, { tags: ['design'] }); + // design count 는 AI 1 + user 1 = 2 + const top = repo.getTopUsedTags(); + expect(top).toContain('design'); + }); + + it('getTopUsedTags excludes tags from deleted notes', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' }); + repo.trash(a, new Date().toISOString()); + expect(repo.getTopUsedTags()).not.toContain('lonely'); + }); + + it('getTopUsedTags respects LIMIT parameter', () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + ids.push(id); + repo.updateAiResult(id, { + title: 't', summary: 'a\nb\nc', + tags: [`tag-${i}`], + provider: 'p' + }); + } + expect(repo.getTopUsedTags(3)).toHaveLength(3); + expect(repo.getTopUsedTags(10)).toHaveLength(5); + }); + + it('getTagIdByName returns id when present, null when absent', () => { + const a = repo.create({ rawText: 'a' }).id; + repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' }); + const id = repo.getTagIdByName('hello'); + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + // case-insensitive + expect(repo.getTagIdByName('HELLO')).toBe(id); + // absent + expect(repo.getTagIdByName('nothere')).toBeNull(); + }); }); -- 2.49.1 From e2b16d44d78fb1ea217d93e5bd2474f8e7b188a0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:14:53 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix(tag-vocab):=20T1=20review=20minor/nit?= =?UTF-8?q?=204=EA=B1=B4=20=EC=9D=BC=EA=B4=84=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: getTopUsedTags 의 LIMIT-then-filter 의미 docstring 명시 - M2: AI+user source 통합 테스트 강화 — 카운트 차이로 정렬 검증 (toContain 만으론 약함) updateUserAiFields 는 tags REPLACE 방식 (DELETE+reinsert) 이므로 fallback 패턴 사용: 3개 노트 각 1태그, AI/user 혼합으로 design=2 > meeting=1 검증 - N1: SQL "COUNT(*) c" → "COUNT(*) AS c" (countFailed 패턴과 일관) - N2: kebab-case regex 모듈 상수 KEBAB_CASE_RE 로 hoist skip: N3 (test 헬퍼 — verbosity 경미), N4 (it 블록 분리 — 코드베이스 패턴 유지) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 10 ++++++++-- tests/unit/NoteRepository.test.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 9492c68..8a3a996 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -40,6 +40,8 @@ export interface ImportNoteResult { status: ImportNoteStatus; } +const KEBAB_CASE_RE = /^[a-z0-9-]+$/; + export class NoteRepository { constructor(private db: Database.Database) {} @@ -219,11 +221,15 @@ export class NoteRepository { * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N. * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외). * deleted_at IS NULL 만 (휴지통 노트 태그 제외). + * + * Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨. + * 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case + * 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음. */ getTopUsedTags(limit = 20): string[] { const rows = this.db .prepare( - `SELECT t.name, COUNT(*) c + `SELECT t.name, COUNT(*) AS c FROM tags t JOIN note_tags nt ON nt.tag_id = t.id JOIN notes n ON n.id = nt.note_id @@ -235,7 +241,7 @@ export class NoteRepository { .all(limit) as Array<{ name: string; c: number }>; return rows .map((r) => r.name) - .filter((n) => /^[a-z0-9-]+$/.test(n)); + .filter((n) => KEBAB_CASE_RE.test(n)); } /** diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index c875f8a..8097b9b 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -681,11 +681,17 @@ describe('NoteRepository — failed retry helpers', () => { it('getTopUsedTags counts AI + user sources together', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + // design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total + // → design must rank first (proves source merging, not AI-only count) + // Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note + // gets exactly the tags passed in the call. repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); repo.updateUserAiFields(b, { tags: ['design'] }); - // design count 는 AI 1 + user 1 = 2 + repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' }); const top = repo.getTopUsedTags(); - expect(top).toContain('design'); + expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only) + expect(top.indexOf('meeting')).toBeGreaterThan(0); }); it('getTopUsedTags excludes tags from deleted notes', () => { -- 2.49.1 From 134d59ddb43a161f8f063459c49db8f6ee98bb7e Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:17:17 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat(tag-vocab):=20prompt.ts=20=E2=80=94?= =?UTF-8?q?=20PROMPT=5FVERSION=204=20+=20vocab=20parameter=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PROMPT_VERSION 3 → 4 (marker bump, retry 트리거 X) - buildPrompt 4번째 param vocab: string[] = [] - vocab.length > 0 시 "Existing vocabulary tags" + "Prefer reusing" 라인 추가 - vocab=[] 시 라인 자체 생략 (Q3=B 결정) - 단위 +4 cases (신규 prompt.test.ts) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/prompt.ts | 11 ++++++++--- tests/unit/prompt.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/unit/prompt.test.ts diff --git a/src/main/ai/prompt.ts b/src/main/ai/prompt.ts index 8ad65d0..147fc8f 100644 --- a/src/main/ai/prompt.ts +++ b/src/main/ai/prompt.ts @@ -1,21 +1,26 @@ import type { ParseResult } from '../services/dueDateParser.js'; -export const PROMPT_VERSION = 3; +export const PROMPT_VERSION = 4; export function buildPrompt( rawText: string, todayKst: string, - candidates: ParseResult[] = [] + candidates: ParseResult[] = [], + vocab: string[] = [] ): 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` : ''; + const vocabBlock = vocab.length > 0 + ? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n` + : ''; + return `You organize raw personal notes into structured metadata. Today's date in Korea Standard Time (KST): ${todayKst} -${candidateBlock} +${candidateBlock}${vocabBlock} Input note (raw text, may be fragmented, any language): --- ${rawText} diff --git a/tests/unit/prompt.test.ts b/tests/unit/prompt.test.ts new file mode 100644 index 0000000..e19cee6 --- /dev/null +++ b/tests/unit/prompt.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { buildPrompt, PROMPT_VERSION } from '@main/ai/prompt.js'; + +describe('prompt', () => { + it('PROMPT_VERSION is 4', () => { + expect(PROMPT_VERSION).toBe(4); + }); + + it('buildPrompt with empty vocab omits vocabulary line entirely', () => { + const out = buildPrompt('hello', '2026-05-02', [], []); + expect(out).not.toContain('vocabulary'); + expect(out).not.toContain('Prefer reusing'); + }); + + it('buildPrompt with vocab includes Prefer instruction + comma-separated list', () => { + const out = buildPrompt('hello', '2026-05-02', [], ['design', 'meeting', 'qa']); + expect(out).toContain('Existing vocabulary tags'); + expect(out).toContain('design, meeting, qa'); + expect(out).toContain('Prefer reusing'); + }); + + it('vocab block appears between candidate block and JSON rules', () => { + const out = buildPrompt('hello', '2026-05-02', [], ['design']); + const candidateIdx = out.indexOf("Today's date"); + const vocabIdx = out.indexOf('Existing vocabulary'); + const jsonRulesIdx = out.indexOf('Return a JSON object'); + expect(candidateIdx).toBeGreaterThan(-1); + expect(vocabIdx).toBeGreaterThan(candidateIdx); + expect(jsonRulesIdx).toBeGreaterThan(vocabIdx); + }); +}); -- 2.49.1 From 896b374f56be1dfe316abb635e7bb3c52053b728 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:19:39 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(tag-vocab):=20T2=20review=20minor/nit?= =?UTF-8?q?=202=EA=B1=B4=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: prompt.test.ts test 4 변수명 candidateIdx → headerIdx (실제 anchor 가 'Today's date' 헤더) - N1: prompt.ts return 직전 self-delimited block 컨벤션 1줄 코멘트 skip: N2 (PROMPT_VERSION 테스트 redundancy nit — harmless guard), N3 (vocab dedup/normalize — Task 1 caller 책임) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/prompt.ts | 1 + tests/unit/prompt.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/ai/prompt.ts b/src/main/ai/prompt.ts index 147fc8f..a649ec2 100644 --- a/src/main/ai/prompt.ts +++ b/src/main/ai/prompt.ts @@ -17,6 +17,7 @@ ${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched to ? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n` : ''; + // candidateBlock & vocabBlock are self-delimited with leading/trailing \n return `You organize raw personal notes into structured metadata. Today's date in Korea Standard Time (KST): ${todayKst} diff --git a/tests/unit/prompt.test.ts b/tests/unit/prompt.test.ts index e19cee6..6d27932 100644 --- a/tests/unit/prompt.test.ts +++ b/tests/unit/prompt.test.ts @@ -19,13 +19,13 @@ describe('prompt', () => { expect(out).toContain('Prefer reusing'); }); - it('vocab block appears between candidate block and JSON rules', () => { + it('vocab block appears after header and before JSON rules', () => { const out = buildPrompt('hello', '2026-05-02', [], ['design']); - const candidateIdx = out.indexOf("Today's date"); + const headerIdx = out.indexOf("Today's date"); const vocabIdx = out.indexOf('Existing vocabulary'); const jsonRulesIdx = out.indexOf('Return a JSON object'); - expect(candidateIdx).toBeGreaterThan(-1); - expect(vocabIdx).toBeGreaterThan(candidateIdx); + expect(headerIdx).toBeGreaterThan(-1); + expect(vocabIdx).toBeGreaterThan(headerIdx); expect(jsonRulesIdx).toBeGreaterThan(vocabIdx); }); }); -- 2.49.1 From daa85073643715c108b2853e7f4d7b84b08ed59a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:21:24 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat(tag-vocab):=20InferenceProvider.voca?= =?UTF-8?q?b=20+=20LocalOllamaProvider=20=EC=A0=84=EB=8B=AC=20(#3=20v0.2.3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GenerateInput.vocab?: string[] (optional, 미전달 시 빈 배열 처리) - LocalOllamaProvider.generate 가 input.vocab ?? [] 를 buildPrompt 4th 인자로 - 단위 +1 case (vocab → prompt body) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/InferenceProvider.ts | 1 + src/main/ai/LocalOllamaProvider.ts | 2 +- tests/unit/LocalOllamaProvider.test.ts | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/ai/InferenceProvider.ts b/src/main/ai/InferenceProvider.ts index 2ef7773..f3aa2ef 100644 --- a/src/main/ai/InferenceProvider.ts +++ b/src/main/ai/InferenceProvider.ts @@ -5,6 +5,7 @@ export interface GenerateInput { text: string; todayKst: string; // ISO YYYY-MM-DD in KST dueDateCandidates: ParseResult[]; + vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리. } export interface HealthResult { ok: boolean; model?: string; reason?: string; } diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 36239c3..d326ad8 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -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, input.dueDateCandidates), + prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []), format: 'json', stream: false, options: { temperature: this.temperature, num_predict: this.numPredict } diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index 2d1155e..a9d60fb 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -26,6 +26,25 @@ describe('LocalOllamaProvider', () => { expect(r.title).toBe('회의'); }); + it('generate passes vocab into prompt body', async () => { + let capturedBody: string = ''; + mock.get('http://localhost:11434').intercept({ + path: '/api/generate', method: 'POST' + }).reply((opts) => { + capturedBody = opts.body as string; + return { statusCode: 200, data: JSON.stringify({ + response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: ['design'] }) + }) }; + }); + await new LocalOllamaProvider().generate({ + text: 'x', todayKst: '2026-05-02', dueDateCandidates: [], + vocab: ['design', 'meeting'] + }); + const parsed = JSON.parse(capturedBody) as { prompt: string }; + expect(parsed.prompt).toContain('design, meeting'); + expect(parsed.prompt).toContain('Prefer reusing'); + }); + it('generate throws on non-JSON', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: 'not json' -- 2.49.1 From b81fc82621bbade883f01e41c1de8e8fbc1376cc Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:23:31 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(tag-vocab):=20telemetryEvents=20?= =?UTF-8?q?=E2=80=94=20tag=5Fvocab=5Fhit/miss=20zod=20schemas=20(#3=20v0.2?= =?UTF-8?q?.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TagVocabHitPayload { tagId: int>0, vocabSize: int>=0 } .strict() - TagVocabMissPayload { vocabSize: int>=0 } .strict() - TelemetryEventSchema union 13 → 15 - 단위 +3 cases (hit accept, miss accept, hit extra field 거부) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/telemetryEvents.ts | 13 ++++++++++++- tests/unit/telemetryEvents.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts index 94e3c5f..66471e7 100644 --- a/src/main/services/telemetryEvents.ts +++ b/src/main/services/telemetryEvents.ts @@ -50,6 +50,15 @@ const AiRetryManualPayload = z.object({ failedCount: z.number().int().positive() }).strict(); +const TagVocabHitPayload = z.object({ + tagId: z.number().int().positive(), + vocabSize: z.number().int().nonnegative() +}).strict(); + +const TagVocabMissPayload = z.object({ + vocabSize: z.number().int().nonnegative() +}).strict(); + export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(), @@ -63,7 +72,9 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(), z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(), - z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict() + z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict() ]); export type TelemetryEvent = z.infer; diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts index f41bdef..5266346 100644 --- a/tests/unit/telemetryEvents.test.ts +++ b/tests/unit/telemetryEvents.test.ts @@ -278,3 +278,31 @@ describe('ai_retry_manual event', () => { })).toThrow(); }); }); + +describe('validateEvent — tag vocab', () => { + it('accepts tag_vocab_hit event', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_hit'); + }); + + it('accepts tag_vocab_miss event without tagId', () => { + const e = validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_miss', + payload: { vocabSize: 17 } + }); + expect(e.kind).toBe('tag_vocab_miss'); + }); + + it('rejects tag_vocab_hit with extra field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-02T00:00:00.000Z', + kind: 'tag_vocab_hit', + payload: { tagId: 42, vocabSize: 17, tagName: 'leak' } + })).toThrow(); + }); +}); -- 2.49.1 From 973cb1d08d64c28f9cd97a6df47517957dd79218 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:27:22 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat(tag-vocab):=20telemetryStats=20?= =?UTF-8?q?=E2=80=94=20hit/miss=20=EB=88=84=EC=A0=81=20+=20summary=20?= =?UTF-8?q?=EC=A0=81=EC=A4=91=EB=A5=A0=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DailyRow +2 cols (tag_vocab_hit, tag_vocab_miss) - accumulators + branches - table 컬럼 +2 - summary "- 태그 vocab: hit/miss = N/M (적중률 X%)" 또는 "(데이터 없음)" - 단위 +2 cases Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/telemetryStats.ts | 24 ++++++++++++++++++++---- tests/unit/telemetryStats.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 15d07b0..bdbb918 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -24,6 +24,8 @@ interface DailyRow { ollama_recovered: number; ollama_recheck_manual: number; ai_retry_manual: number; + tag_vocab_hit: number; + tag_vocab_miss: number; } export interface StatsResult { @@ -47,6 +49,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta let ollamaRecheckManualCount = 0; let aiRetryManualCount = 0; let aiRetryManualFailedSum = 0; + let tagVocabHitCount = 0; + let tagVocabMissCount = 0; for (const ev of events) { const day = kstDate(ev.ts); let row = byDay.get(day); @@ -57,7 +61,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0, expired_banner_shown: 0, expired_batch_trash: 0, ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0, - ai_retry_manual: 0 + ai_retry_manual: 0, + tag_vocab_hit: 0, tag_vocab_miss: 0 }; byDay.set(day, row); } @@ -99,6 +104,12 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta row.ai_retry_manual += 1; aiRetryManualCount += 1; aiRetryManualFailedSum += ev.payload.failedCount; + } else if (ev.kind === 'tag_vocab_hit') { + row.tag_vocab_hit += 1; + tagVocabHitCount += 1; + } else if (ev.kind === 'tag_vocab_miss') { + row.tag_vocab_miss += 1; + tagVocabMissCount += 1; } } const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); @@ -115,6 +126,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta ? 'N/A' : `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`; const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0); + const tagVocabTotal = tagVocabHitCount + tagVocabMissCount; + const tagVocabSummary = tagVocabTotal === 0 + ? '(데이터 없음)' + : `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`; const lines: string[] = []; lines.push('# Inkling Telemetry Stats'); lines.push(''); @@ -123,10 +138,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(''); lines.push('## 일자별 카운트'); lines.push(''); - lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual |'); - lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|'); + lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |'); + lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|'); for (const row of days) { - lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} |`); + lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`); } lines.push(''); lines.push('## 핵심 ratio'); @@ -139,6 +154,7 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`); lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`); lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`); + lines.push(`- 태그 vocab: ${tagVocabSummary}`); lines.push(''); return { md: lines.join('\n'), eventCount }; } diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts index 0631efc..4fa74b5 100644 --- a/tests/unit/telemetryStats.test.ts +++ b/tests/unit/telemetryStats.test.ts @@ -164,3 +164,27 @@ describe('aggregateStats — ai_retry_manual', () => { expect(r.md).toMatch(/AI 수동 재시도.*2회.*10건/); }); }); + +describe('aggregateStats — tag_vocab hit/miss', () => { + it('aggregates tag_vocab hit/miss with success rate', () => { + const events: TelemetryEvent[] = [ + e('2026-05-02T00:00:00Z', 'tag_vocab_hit', { tagId: 1, vocabSize: 10 }), + e('2026-05-02T00:00:01Z', 'tag_vocab_hit', { tagId: 2, vocabSize: 10 }), + e('2026-05-02T00:00:02Z', 'tag_vocab_hit', { tagId: 3, vocabSize: 10 }), + e('2026-05-02T00:00:03Z', 'tag_vocab_hit', { tagId: 4, vocabSize: 10 }), + e('2026-05-02T00:00:04Z', 'tag_vocab_hit', { tagId: 5, vocabSize: 10 }), + e('2026-05-02T00:00:05Z', 'tag_vocab_miss', { vocabSize: 10 }), + e('2026-05-02T00:00:06Z', 'tag_vocab_miss', { vocabSize: 10 }), + e('2026-05-02T00:00:07Z', 'tag_vocab_miss', { vocabSize: 10 }) + ]; + const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('태그 vocab: hit/miss = 5/3'); + expect(r.md).toContain('적중률 62.5%'); + }); + + it('태그 vocab summary shows 데이터 없음 when no events', () => { + const r = aggregateStats([], new Date('2026-05-03T00:00:00Z')); + expect(r.md).toContain('태그 vocab'); + expect(r.md).toContain('데이터 없음'); + }); +}); -- 2.49.1 From 26f1db562616dff0303b5096a1fd7abb7695d49d Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:29:24 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat(tag-vocab):=20TelemetryService=20Emi?= =?UTF-8?q?tInput=20+tag=5Fvocab=5Fhit/miss=20+=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20narrowing=20=ED=99=95=EC=9E=A5=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmitInput union 13 → 15 - narrowing guards (noteId 없는 kind 분기) 에 tag_vocab_hit/miss 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/TelemetryService.ts | 4 +++- tests/unit/TelemetryService.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index d44d574..dc63121 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -28,7 +28,9 @@ export type EmitInput = | { kind: 'ollama_unreachable'; payload: { reason: string } } | { kind: 'ollama_recovered'; payload: { downtimeMs: number } } | { kind: 'ollama_recheck_manual'; payload: Record } - | { kind: 'ai_retry_manual'; payload: { failedCount: number } }; + | { kind: 'ai_retry_manual'; payload: { failedCount: number } } + | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }; export class TelemetryService { constructor( diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts index aa700cd..0e41f86 100644 --- a/tests/unit/TelemetryService.test.ts +++ b/tests/unit/TelemetryService.test.ts @@ -148,7 +148,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(3); // discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패 expect(events.map((e) => - (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual') + (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss') ? null : e.payload.noteId )).toEqual(['a', 'b', 'b']); @@ -164,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => { expect(events).toHaveLength(1); const ev = events[0]!; expect(ev.kind).toBe('capture'); - if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual') expect(ev.payload.noteId).toBe('a'); + if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a'); }); it('returns [] when dir missing', async () => { -- 2.49.1 From 3e0f710c7058579049778ed80c1e58d3b04a6c77 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:33:16 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(tag-vocab):=20AiWorker=20=E2=80=94?= =?UTF-8?q?=20vocab=20fetch=20+=20per-tag=20hit/miss=20emit=20(#3=20v0.2.3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processJob 가 generate 직전 repo.getTopUsedTags(20) fetch - provider.generate 에 vocab 전달 (LocalOllamaProvider 가 prompt 에 주입) - ai_succeeded emit 후 per-tag 분류 → tag_vocab_hit/miss emit - hit: vocabSet.has + getTagIdByName lookup → { tagId, vocabSize } - miss: { vocabSize } - AiTelemetryEmitter union 4종 (ai_succeeded/ai_failed/tag_vocab_hit/tag_vocab_miss) - 단위 +4 cases (vocab passthrough, hit+miss, vocab=[] all miss, per-tag emit count) - collectingTelemetry mock → AiTelemetryEmitter 타입 적용 (typecheck 통과) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 24 +++++++- tests/unit/AiWorker.test.ts | 116 ++++++++++++++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index b1de1c6..b9181b3 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -32,6 +32,8 @@ export interface AiTelemetryEmitter { emit(input: | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } } + | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } + | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } ): Promise; } @@ -132,10 +134,12 @@ export class AiWorker { const todayDate = todayKstAsDate(nowDate); const todayIso = todayKstAsIso(nowDate); const candidates = parseAllCandidates(note.rawText, todayDate); + const vocab = this.repo.getTopUsedTags(20); const res = await this.provider.generate({ text: note.rawText, todayKst: todayIso, - dueDateCandidates: candidates + dueDateCandidates: candidates, + vocab }); // AI primary: AI's dueDate is final (no rule merge) this.repo.updateAiResult(job.noteId, { @@ -161,6 +165,24 @@ export class AiWorker { attempts: attempt + 1 } }).catch(() => {}); + // v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장) + const vocabSet = new Set(vocab); + for (const tagName of res.tags) { + if (vocabSet.has(tagName)) { + const tagId = this.repo.getTagIdByName(tagName); + if (tagId !== null) { + await this.telemetry.emit({ + kind: 'tag_vocab_hit', + payload: { tagId, vocabSize: vocab.length } + }).catch(() => {}); + } + } else { + await this.telemetry.emit({ + kind: 'tag_vocab_miss', + payload: { vocabSize: vocab.length } + }).catch(() => {}); + } + } } this.emit(job.noteId); return; diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 4c5487d..4212940 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -3,6 +3,7 @@ import Database from 'better-sqlite3'; import { runMigrations } from '@main/db/migrations/index.js'; import { NoteRepository } from '@main/repository/NoteRepository.js'; import { AiWorker } from '@main/ai/AiWorker.js'; +import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js'; import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; import type { AiResponse } from '@main/ai/schema.js'; @@ -197,10 +198,10 @@ describe('AiWorker', () => { describe('AiWorker telemetry emit', () => { let db: Database.Database; let repo: NoteRepository; - let events: Array<{ kind: string; payload: { noteId: string; durationMs?: number; reason?: string; attempts: number } }>; - const collectingTelemetry = { - emit: async (ev: { kind: string; payload: { noteId: string; durationMs?: number; reason?: string; attempts: number } }) => { - events.push(ev); + let events: Array<{ kind: string; payload: { noteId?: string; durationMs?: number; reason?: string; attempts?: number; tagId?: number; vocabSize?: number } }>; + const collectingTelemetry: AiTelemetryEmitter = { + emit: async (ev) => { + events.push(ev as typeof events[number]); } }; @@ -420,3 +421,110 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { expect((w as any).unreachableBackoffStep).toBe(0); }); }); + +describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('fetches vocab and passes to provider.generate', async () => { + // Pre-seed 1 note with tag 'design' so vocab non-empty + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const generateMock = vi.fn(async () => ({ + title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null + })); + const w = new AiWorker(repo, makeProvider({ generate: generateMock }), { + backoffsMs: [0, 0, 0] + }); + await w.enqueue(id); + await w.drain(); + expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({ + vocab: expect.arrayContaining(['design']) + })); + }); + + it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => { + // Pre-seed: 'design' in vocab + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'newtag'], // 1 hit + 1 miss + dueDate: null + })) + }); + const emits: Array<{ kind: string; payload: unknown }> = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { + emit: vi.fn(async (input) => { emits.push(input); }) + } + }); + await w.enqueue(id); + await w.drain(); + const hit = emits.filter((e) => e.kind === 'tag_vocab_hit'); + const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); + expect(hit).toHaveLength(1); + expect(miss).toHaveLength(1); + expect((hit[0]!.payload as { tagId: number }).tagId).toBeGreaterThan(0); + expect((hit[0]!.payload as { vocabSize: number }).vocabSize).toBe(1); + expect((miss[0]!.payload as { vocabSize: number }).vocabSize).toBe(1); + }); + + it('all tags miss when vocab is empty', async () => { + // No seed → vocab=[] + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'meeting', 'qa'], + dueDate: null + })) + }); + const emits: Array<{ kind: string; payload: unknown }> = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } + }); + await w.enqueue(id); + await w.drain(); + const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); + expect(miss).toHaveLength(3); + expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0); + }); + + it('emits one event per tag (3 tags → 3 events)', async () => { + // Pre-seed: all 3 in vocab + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'meeting', 'qa'], + dueDate: null + })) + }); + const emits: Array<{ kind: string; payload: unknown }> = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } + }); + await w.enqueue(id); + await w.drain(); + const hits = emits.filter((e) => e.kind === 'tag_vocab_hit'); + expect(hits).toHaveLength(3); + }); +}); -- 2.49.1 From 727eeb1919a4b5039dfb47d3d73a249183493eba Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:36:16 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix(tag-vocab):=20T7=20review=20nit=202?= =?UTF-8?q?=EA=B1=B4=20=E2=80=94=20test=20=EC=BD=94=EB=93=9C=20ergonomics?= =?UTF-8?q?=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nit1: tag_vocab_hit/miss 테스트 payload cast dedupe (한 번에 typed 바인딩) - nit2: { kind: string; payload: unknown } 반복을 EmittedEvent 타입 alias 로 hoist skip: Minor1 (serial await — ai_succeeded 와 패턴 일관), Nit3 (magic number VOCAB_TOP_N — v0.2.4 backlog), Nit4 (한국어 코멘트 — 기존 코드와 일관) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/AiWorker.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 4212940..cc62f66 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -7,6 +7,8 @@ import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js'; import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; import type { AiResponse } from '@main/ai/schema.js'; +type EmittedEvent = { kind: string; payload: unknown }; + function makeProvider(overrides: Partial = {}): InferenceProvider { return { name: 'mock', @@ -464,7 +466,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { dueDate: null })) }); - const emits: Array<{ kind: string; payload: unknown }> = []; + const emits: EmittedEvent[] = []; const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0], telemetry: { @@ -477,9 +479,11 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); expect(hit).toHaveLength(1); expect(miss).toHaveLength(1); - expect((hit[0]!.payload as { tagId: number }).tagId).toBeGreaterThan(0); - expect((hit[0]!.payload as { vocabSize: number }).vocabSize).toBe(1); - expect((miss[0]!.payload as { vocabSize: number }).vocabSize).toBe(1); + const hitPayload = hit[0]!.payload as { tagId: number; vocabSize: number }; + const missPayload = miss[0]!.payload as { vocabSize: number }; + expect(hitPayload.tagId).toBeGreaterThan(0); + expect(hitPayload.vocabSize).toBe(1); + expect(missPayload.vocabSize).toBe(1); }); it('all tags miss when vocab is empty', async () => { @@ -492,7 +496,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { dueDate: null })) }); - const emits: Array<{ kind: string; payload: unknown }> = []; + const emits: EmittedEvent[] = []; const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } @@ -517,7 +521,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { dueDate: null })) }); - const emits: Array<{ kind: string; payload: unknown }> = []; + const emits: EmittedEvent[] = []; const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } -- 2.49.1 From ff07738b0266fe085077a9337facaa292a5563fe Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:37:30 +0900 Subject: [PATCH 13/14] =?UTF-8?q?chore(tag-vocab):=20#3=20closure=20?= =?UTF-8?q?=E2=80=94=20gates=20verified=20+=20roadmap=20mark=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typecheck 0 / 단위 384 / e2e 1 - v0.2.3 6/7 (#3 태그 vocab 머지) - 다음: #6 리마인드 spike (마지막 항목) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-01-v023-feedback-roadmap-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md index 9fd0d98..85796ae 100644 --- a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md @@ -154,7 +154,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── **Out:** per-note retry 버튼 (NoteCard), failed reason 별 차등 정책, retry progress UI, retry rate-limit -### #3 태그 vocab (6번) +### #3 태그 vocab (6번) ✓ 완료 **In:** - `NoteRepository.getTopUsedTags(N=20)`: -- 2.49.1 From d8621d55e0d2541b3656fedf183d7c7f6e86eb00 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:49:36 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix(tag-vocab):=20PR=20review=20round=201?= =?UTF-8?q?=20=E2=80=94=20i1=20dedup=20+=20m2=20test=20gap=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i1 (Important): AiWorker per-tag emit 루프에 res.tags Set dedup AI 가 같은 태그 중복 응답 시 hit count 2번 emit 되던 통계 왜곡 수정 + 테스트 1개 (중복 태그 1 hit + 1 miss 검증) - m2 (Minor): NoteRepository.getTopUsedTags LIMIT-then-filter 테스트 갭 + 테스트 1개 (limit=3 + 한글 1 + kebab 2 → 결과 length=2 lock-in) skip: m1 (per-tag serial await — ai_succeeded 패턴 일관), n1 (prompt 빈 줄 cosmetic), n2 (tagId positive — AUTOINCREMENT 1+) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 3 ++- tests/unit/AiWorker.test.ts | 26 ++++++++++++++++++++++++++ tests/unit/NoteRepository.test.ts | 15 +++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index b9181b3..d08b348 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -166,8 +166,9 @@ export class AiWorker { } }).catch(() => {}); // v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장) + // dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장 const vocabSet = new Set(vocab); - for (const tagName of res.tags) { + for (const tagName of new Set(res.tags)) { if (vocabSet.has(tagName)) { const tagId = this.repo.getTagIdByName(tagName); if (tagId !== null) { diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index cc62f66..afc0bbe 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -531,4 +531,30 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { const hits = emits.filter((e) => e.kind === 'tag_vocab_hit'); expect(hits).toHaveLength(3); }); + + it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => { + // Pre-seed: 'design' in vocab + const seed = repo.create({ rawText: 'seed' }).id; + repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); + + const { id } = repo.create({ rawText: 'x' }); + const provider = makeProvider({ + generate: vi.fn(async () => ({ + title: 't', summary: 'a\nb\nc', + tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적 + dueDate: null + })) + }); + const emits: EmittedEvent[] = []; + const w = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } + }); + await w.enqueue(id); + await w.drain(); + const hit = emits.filter((e) => e.kind === 'tag_vocab_hit'); + const miss = emits.filter((e) => e.kind === 'tag_vocab_miss'); + expect(hit).toHaveLength(1); // 'design' 중복 → 1 hit (dedup) + expect(miss).toHaveLength(1); // 'meeting' 1 miss + }); }); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 8097b9b..ff87460 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -716,6 +716,21 @@ describe('NoteRepository — failed retry helpers', () => { expect(repo.getTopUsedTags(10)).toHaveLength(5); }); + it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => { + // 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외 + // 결과 length = 2 (limit=3 보다 작음) + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.updateUserAiFields(a, { tags: ['회의'] }); // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X + repo.updateUserAiFields(b, { tags: ['design'] }); + repo.updateUserAiFields(c, { tags: ['meeting'] }); + const top = repo.getTopUsedTags(3); + expect(top.length).toBeLessThan(3); // SQL 은 3개 가져왔지만 regex 가 1개 제거 + expect(top).not.toContain('회의'); + expect(top).toEqual(expect.arrayContaining(['design', 'meeting'])); + }); + it('getTagIdByName returns id when present, null when absent', () => { const a = repo.create({ rawText: 'a' }).id; repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' }); -- 2.49.1