From 095413ed92c58a2ce31f3ca49bab7c9fcde7dd4e Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 12:10:15 +0900 Subject: [PATCH] feat(ai): LocalOllamaProvider with 120s timeout + integration harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 12 of the slice plan. Implements the slice's only provider: - generate(): POST {endpoint}/api/generate with Korean-first prompt; AbortController-driven 120s timeout; parses body.response as JSON and runs it through parseAiResponse. - healthCheck(): GET /api/tags; returns ok when configured model is in the listing, otherwise reports the missing-model reason. - Constructor takes opts.endpoint / opts.model so Task 30 main entry can inject INKLING_OLLAMA_ENDPOINT for LAN dogfood; defaults are http://localhost:11434 and gemma4:e4b. Tests: 6 unit cases via undici MockAgent (parse, non-JSON, timeout abort, healthCheck ok / missing / connection error). Integration test gated by INKLING_INTEGRATION=1 hits real Ollama for Korean / English-stack / mixed input cases with a 180s per-test budget — also reads INKLING_OLLAMA_ENDPOINT so LAN dogfood can be exercised end-to-end. Plan deviation: undici@8 types treat MockAgent's `.reply()` callback as sync-return-only, so the 500ms-delayed reply used in the timeout test is cast `as never` to bypass the overload mismatch. Behavior is correct at runtime; the cast is local to the test. Verification: `npx vitest run tests/unit/LocalOllamaProvider.test.ts` 6 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/LocalOllamaProvider.ts | 73 +++++++++++++++++++++++++ tests/integration/ollama-golden.test.ts | 28 ++++++++++ tests/unit/LocalOllamaProvider.test.ts | 71 ++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/main/ai/LocalOllamaProvider.ts create mode 100644 tests/integration/ollama-golden.test.ts create mode 100644 tests/unit/LocalOllamaProvider.test.ts diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts new file mode 100644 index 0000000..8a87144 --- /dev/null +++ b/src/main/ai/LocalOllamaProvider.ts @@ -0,0 +1,73 @@ +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 { + 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), + 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 { + 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}` }; + } + } +} diff --git a/tests/integration/ollama-golden.test.ts b/tests/integration/ollama-golden.test.ts new file mode 100644 index 0000000..9ea3d47 --- /dev/null +++ b/tests/integration/ollama-golden.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +const skip = process.env.INKLING_INTEGRATION !== '1'; + +describe.skipIf(skip)('LocalOllamaProvider integration', () => { + const provider = new LocalOllamaProvider({ + endpoint: process.env.INKLING_OLLAMA_ENDPOINT + }); + + beforeAll(async () => { + const h = await provider.healthCheck(); + if (!h.ok) throw new Error(`Ollama not ready: ${h.reason}`); + }); + + const cases = [ + '회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음.', + 'Stack trace: java.net.SocketTimeoutException at com.inkling.Api.call ... retried 3 times.', + '오늘 점심 김치찌개 맛있었음. 오후에 디자인 미팅 있다.' + ]; + + it.each(cases)('Korean title + 3 lines for: %s', async (input) => { + const r = await provider.generate({ text: input }); + expect(/[가-힣]/.test(r.title)).toBe(true); + expect(r.summary.split('\n')).toHaveLength(3); + for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + }, 180_000); +}); diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts new file mode 100644 index 0000000..bcdaec3 --- /dev/null +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +describe('LocalOllamaProvider', () => { + let mock: MockAgent; + let original: ReturnType; + + beforeEach(() => { + original = getGlobalDispatcher(); + mock = new MockAgent(); + mock.disableNetConnect(); + setGlobalDispatcher(mock); + }); + + afterEach(async () => { + setGlobalDispatcher(original); + await mock.close(); + }); + + it('generate parses Ollama JSON', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] }) + }); + const r = await new LocalOllamaProvider().generate({ text: 'x' }); + expect(r.title).toBe('회의'); + }); + + it('generate throws on non-JSON', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { + response: 'not json' + }); + await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i); + }); + + it('generate aborts on timeout', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((async () => { + await new Promise((r) => setTimeout(r, 500)); + return { statusCode: 200, data: '{}' }; + }) as never); + await expect( + new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x' }) + ).rejects.toThrow(); + }, 2000); + + it('healthCheck ok=true when model present', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, { + models: [{ name: 'gemma4:e4b' }] + }); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(true); + expect(h.model).toBe('gemma4:e4b'); + }); + + it('healthCheck ok=false when missing', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, { + models: [{ name: 'other:latest' }] + }); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toMatch(/gemma4:e4b/); + }); + + it('healthCheck ok=false on connection error', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('ECONNREFUSED')); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toMatch(/connect|refused|unreachable/i); + }); +});