Merge pull request 'feat(tag-vocab): #3 태그 vocab — prompt + telemetry (v0.2.3 6/7)' (#18) from feat/v023-tag-vocab into main

Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-05-02 03:53:01 +00:00
18 changed files with 1793 additions and 18 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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)`:

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

View File

@@ -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<void>;
}
@@ -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,25 @@ export class AiWorker {
attempts: attempt + 1
}
}).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 new Set(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;

View File

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

View File

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

View File

@@ -1,21 +1,27 @@
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`
: '';
// 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}
${candidateBlock}
${candidateBlock}${vocabBlock}
Input note (raw text, may be fragmented, any language):
---
${rawText}

View File

@@ -40,6 +40,8 @@ export interface ImportNoteResult {
status: ImportNoteStatus;
}
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
export class NoteRepository {
constructor(private db: Database.Database) {}
@@ -215,6 +217,44 @@ 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 만 (휴지통 노트 태그 제외).
*
* 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(*) AS 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) => KEBAB_CASE_RE.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[] }

View File

@@ -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<string, never> }
| { 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(

View File

@@ -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<typeof TelemetryEventSchema>;

View File

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

View File

@@ -3,9 +3,12 @@ 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';
type EmittedEvent = { kind: string; payload: unknown };
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
return {
name: 'mock',
@@ -197,10 +200,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 +423,138 @@ 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: 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);
expect(miss).toHaveLength(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 () => {
// 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: 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 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: 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 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
});
});

View File

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

View File

@@ -652,4 +652,94 @@ 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;
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'] });
repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' });
const top = repo.getTopUsedTags();
expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only)
expect(top.indexOf('meeting')).toBeGreaterThan(0);
});
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('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' });
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();
});
});

View File

@@ -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 () => {

31
tests/unit/prompt.test.ts Normal file
View File

@@ -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 after header and before JSON rules', () => {
const out = buildPrompt('hello', '2026-05-02', [], ['design']);
const headerIdx = out.indexOf("Today's date");
const vocabIdx = out.indexOf('Existing vocabulary');
const jsonRulesIdx = out.indexOf('Return a JSON object');
expect(headerIdx).toBeGreaterThan(-1);
expect(vocabIdx).toBeGreaterThan(headerIdx);
expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
});
});

View File

@@ -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();
});
});

View File

@@ -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('데이터 없음');
});
});