# 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<void>;
}
```

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