import { readFile } from 'node:fs/promises'; import type { NoteRepository } from '../repository/NoteRepository.js'; import type { Note } from '@shared/types'; import type { AiFailedReason } from '../services/telemetryEvents.js'; import type { SettingsService } from '../services/SettingsService.js'; import type { MediaStore } from '../services/MediaStore.js'; import { ProviderHolder } from './ProviderHolder.js'; import { parseAllCandidates } from '../services/dueDateParser.js'; import { ZodError } from 'zod'; import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js'; // v0.2.6 #29 — backlog 의 top-N 튜닝은 dogfood telemetry 후 (현재 magic 만 추출). const VOCAB_TOP_N = 20; function classifyReason(err: unknown): AiFailedReason { if (err instanceof ZodError) return 'schema'; const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) { return 'unreachable'; } if (msg.includes('timeout') || msg.includes('timedout') || msg.includes('aborted')) { return 'timeout'; } return 'other'; } export interface AiTelemetryEmitter { emit(input: | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } } | { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } } | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } } | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } } ): Promise; } export interface AiWorkerOptions { backoffsMs?: number[]; unreachableBackoffsMs?: number[]; onUpdate?: (note: Note) => void; logger?: { info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; error: (msg: string, meta?: Record) => void; }; now?: () => Date; telemetry?: AiTelemetryEmitter; /** v0.3.1 Cut F — vision 지원. 미전달 시 vision 비활성. */ settings?: Pick; /** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */ mediaStore?: Pick; } interface Job { noteId: string; attempts: number; } export class AiWorker { private queue: Job[] = []; private running = false; private drainResolvers: Array<() => void> = []; private backoffsMs: number[]; private unreachableBackoffsMs: number[]; private unreachableBackoffStep = 0; private onUpdate?: (note: Note) => void; private logger: NonNullable; private now: () => Date; private telemetry?: AiTelemetryEmitter; private settings?: Pick; private mediaStore?: Pick; constructor( private repo: NoteRepository, private holder: ProviderHolder, opts: AiWorkerOptions = {} ) { this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; this.unreachableBackoffsMs = opts.unreachableBackoffsMs ?? [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]; this.onUpdate = opts.onUpdate; this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} }; this.now = opts.now ?? (() => new Date()); this.telemetry = opts.telemetry; this.settings = opts.settings; this.mediaStore = opts.mediaStore; } async enqueue(noteId: string): Promise { this.queue.push({ noteId, attempts: 0 }); this.kick(); } async loadFromDb(): Promise { for (const j of this.repo.getAllPendingJobs()) { this.queue.push({ noteId: j.noteId, attempts: j.attempts }); } this.kick(); } async drain(): Promise { if (!this.running && this.queue.length === 0) return; await new Promise((resolve) => { this.drainResolvers.push(resolve); this.kick(); }); } private kick(): void { if (this.running) return; if (this.queue.length === 0) { this.resolveDrainers(); return; } this.running = true; void this.loop(); } private async loop(): Promise { try { while (this.queue.length > 0) { const job = this.queue.shift()!; await this.processJob(job); } } finally { this.running = false; this.resolveDrainers(); } } private resolveDrainers(): void { const r = this.drainResolvers.splice(0); for (const fn of r) fn(); } private async processJob(job: Job): Promise { // `max` 는 schema/other 분기 (attempts 증가) 의 cap 이다. // unreachable/timeout 분기는 `attempt -= 1; continue` 로 인덱스 stay — max 와 무관 무한 retry. const max = this.backoffsMs.length; for (let attempt = job.attempts; attempt < max; attempt++) { const startMs = this.now().getTime(); try { const note = this.repo.findById(job.noteId); if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return; const nowDate = this.now(); const todayDate = kstTodayAsDate(nowDate); const todayIso = kstTodayIso(nowDate); const candidates = parseAllCandidates(note.rawText, todayDate); const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N); // v0.3.1 Cut F — vision path: visionModel + note.media → base64 images // final review fix: note.media[].bytes 로 fast-fail (readFile/base64 비용 회피). // 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달. const visionModel = this.settings ? await this.settings.getVisionModel() : null; let images: Array<{ base64: string; mime: string }> | undefined; if (visionModel && note.media.length > 0 && this.mediaStore) { const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024); if (oversize) { throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`); } images = await Promise.all( note.media.map(async (m) => { const buf = await readFile(this.mediaStore!.absolutePath(m.relPath)); return { base64: buf.toString('base64'), mime: m.mime }; }) ); } const res = await this.holder.get().generate( { text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab }, { visionModel: visionModel ?? undefined } ); // AI primary: AI's dueDate is final (no rule merge) this.repo.updateAiResult(job.noteId, { title: res.title, summary: res.summary, tags: res.tags, provider: this.holder.get().name, dueDate: res.dueDate ?? null }); this.unreachableBackoffStep = 0; // 성공 시 step reset this.logger.info('ai.done', { noteId: job.noteId, attempt, dueDateSource: res.dueDate !== null ? 'ai' : 'none', candidatesCount: candidates.length }); if (this.telemetry) { const telemetry = this.telemetry; await telemetry.emit({ kind: 'ai_succeeded', payload: { noteId: job.noteId, durationMs: this.now().getTime() - startMs, 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.map((v) => v.toLowerCase())); await Promise.all( Array.from(new Set(res.tags)).map(async (tagName) => { if (vocabSet.has(tagName.toLowerCase())) { const tagId = this.repo.getTagIdByName(tagName); if (tagId !== null) { await telemetry.emit({ kind: 'tag_vocab_hit', payload: { tagId, vocabSize: vocab.length } }).catch(() => {}); } } else { await telemetry.emit({ kind: 'tag_vocab_miss', payload: { vocabSize: vocab.length } }).catch(() => {}); } }) ); } this.emit(job.noteId); return; } catch (err) { const reason = classifyReason(err); const msg = (err as Error).message; this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg, reason }); if (reason === 'unreachable' || reason === 'timeout') { // 무한 retry: attempts 증가 안 함, in-place loop + sleep. // markAiFailed / ai_failed emit 안 함 — ratio 통계는 schema/other 만 누적. const sleepMs = this.nextBackoffMs(this.unreachableBackoffStep); // step 이 cap 도달 후엔 인덱스 stay — increment 는 무의미하지만 안전한 no-op. // (Math.min 가드: cap 넘어가도 length-1 로 묶임.) if (this.unreachableBackoffStep < this.unreachableBackoffsMs.length - 1) { this.unreachableBackoffStep += 1; } const nextRunAt = new Date(Date.now() + sleepMs).toISOString(); this.repo.setNextRunAt(job.noteId, nextRunAt, msg); await this.sleep(sleepMs); attempt -= 1; // for 루프 attempt++ 상쇄 — 같은 attempt 인덱스로 재시도 continue; } // schema / other: 기존 max 3 retry 정책 const isLast = attempt === max - 1; const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString(); this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg); if (isLast) { this.repo.markAiFailed(job.noteId, msg); this.logger.error('ai.failed', { noteId: job.noteId, err: msg }); if (this.telemetry) { await this.telemetry.emit({ kind: 'ai_failed', payload: { noteId: job.noteId, reason, attempts: attempt + 1 } }).catch(() => {}); } this.emit(job.noteId); return; } await this.sleep(this.backoffsMs[attempt + 1] ?? 0); } } } private nextBackoffMs(step: number): number { const idx = Math.min(step, this.unreachableBackoffsMs.length - 1); return this.unreachableBackoffsMs[idx]!; } private emit(noteId: string): void { if (!this.onUpdate) return; const note = this.repo.findById(noteId); if (note) this.onUpdate(note); } private sleep(ms: number): Promise { if (ms <= 0) return Promise.resolve(); return new Promise((r) => setTimeout(r, ms)); } }