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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user