Flow 반전 (F7-D 채택): - 기존: rule.iso ?? ai.dueDate (rule 우선) - 신규: ai.dueDate ?? null (AI 우선) 규칙은 parseAllCandidates 로 모든 매치를 추출 → prompt 에 후보 힌트로 주입. AI 가 종합 판단. AI 실패 시 due_date null (별 fallback 없음). 해결되는 케이스: '내일 모레' → AI 가 ambiguous 인지 → null. PROMPT_VERSION → 3. GenerateInput.dueDateCandidates 신규. buildPrompt(rawText, todayKst, candidates) — 빈 배열일 때 hint 섹션 생략. Tests: - AiWorker.test.ts — 'rule priority' 테스트 → 'AI dueDate wins' flip - AiWorker.test.ts — passes todayKst 테스트 확장 (dueDateCandidates 도 검증) - AiWorker.test.ts — 신규 'passes parseAllCandidates result as dueDateCandidates' - LocalOllamaProvider.test.ts / ollama-golden.test.ts — generate 호출에 dueDateCandidates: [] 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
import { request } from 'undici';
|
|
import { parseAiResponse, type AiResponse } from './schema.js';
|
|
import { buildPrompt } from './prompt.js';
|
|
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
|
|
|
export interface LocalOllamaOptions {
|
|
endpoint?: string;
|
|
model?: string;
|
|
timeoutMs?: number;
|
|
temperature?: number;
|
|
numPredict?: number;
|
|
}
|
|
|
|
export class LocalOllamaProvider implements InferenceProvider {
|
|
readonly name: string;
|
|
private endpoint: string;
|
|
private model: string;
|
|
private timeoutMs: number;
|
|
private temperature: number;
|
|
private numPredict: number;
|
|
|
|
constructor(opts: LocalOllamaOptions = {}) {
|
|
this.endpoint = opts.endpoint ?? 'http://localhost:11434';
|
|
this.model = opts.model ?? 'gemma4:e4b';
|
|
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
|
this.temperature = opts.temperature ?? 0.2;
|
|
this.numPredict = opts.numPredict ?? 512;
|
|
this.name = `local-ollama/${this.model}`;
|
|
}
|
|
|
|
async generate(input: GenerateInput): Promise<AiResponse> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
try {
|
|
const res = await request(`${this.endpoint}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: this.model,
|
|
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates),
|
|
format: 'json',
|
|
stream: false,
|
|
options: { temperature: this.temperature, num_predict: this.numPredict }
|
|
}),
|
|
signal: controller.signal
|
|
});
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
throw new Error(`ollama http ${res.statusCode}`);
|
|
}
|
|
const body = (await res.body.json()) as { response?: string };
|
|
if (!body.response) throw new Error('missing response field');
|
|
let parsed: unknown;
|
|
try { parsed = JSON.parse(body.response); }
|
|
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
|
|
return parseAiResponse(parsed);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async healthCheck(): Promise<HealthResult> {
|
|
try {
|
|
const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
|
|
if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` };
|
|
const body = (await res.body.json()) as { models?: Array<{ name: string }> };
|
|
const found = body.models?.some((m) => m.name === this.model);
|
|
return found ? { ok: true, model: this.model }
|
|
: { ok: false, reason: `${this.model} not installed` };
|
|
} catch (err) {
|
|
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
|
|
}
|
|
}
|
|
}
|