From 3e0f710c7058579049778ed80c1e58d3b04a6c77 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 12:33:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(tag-vocab):=20AiWorker=20=E2=80=94=20vocab?= =?UTF-8?q?=20fetch=20+=20per-tag=20hit/miss=20emit=20(#3=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) - collectingTelemetry mock → AiTelemetryEmitter 타입 적용 (typecheck 통과) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 24 +++++++- tests/unit/AiWorker.test.ts | 116 ++++++++++++++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index b1de1c6..b9181b3 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -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; } @@ -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,24 @@ export class AiWorker { 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; diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index 4c5487d..4212940 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -3,6 +3,7 @@ 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'; @@ -197,10 +198,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 +421,110 @@ 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: 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); + }); +});