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