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