docs(tag-vocab): #3 spec — vocab pool/telemetry/prompt 강도/재처리 결정 (v0.2.3)

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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 12:02:06 +09:00
parent dbbec38079
commit 8206462ee4

View File

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