feat(tag-vocab): AiWorker — vocab fetch + per-tag hit/miss emit (#3 v0.2.3)

- 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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 12:33:16 +09:00
parent 26f1db5626
commit 3e0f710c70
2 changed files with 135 additions and 5 deletions

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,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;

View File

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