diff --git a/src/lib/ai/providers.test.ts b/src/lib/ai/providers.test.ts index d2a4aba5..462ebf91 100644 --- a/src/lib/ai/providers.test.ts +++ b/src/lib/ai/providers.test.ts @@ -58,6 +58,14 @@ describe("OllamaProvider", () => { }); }); + it("never lets the model think, and puts the schema in the instructions for JSON", async () => { + const requests = stubFetch(() => ollamaChat('{"name":"Jane","years":9}')); + const provider = new OllamaProvider("http://ollama:11434", "qwen3.5:4b", 60_000); + await provider.complete({ ...ask, schema: Person }); + expect(requests[0].body).toMatchObject({ think: false, format: { type: "object" } }); + expect((requests[0].body as { messages: { content: string }[] }).messages[0].content).toMatch(/^Be brief\.\n\nReply with only a JSON object[\s\S]*"years"/); + }); + it("asks for JSON with the schema and validates the answer", async () => { const requests = stubFetch(() => ollamaChat('{"name":"Jane","years":9}')); const provider = new OllamaProvider("http://ollama:11434", "qwen2.5:3b", 60_000); diff --git a/src/lib/ai/providers/ollama.ts b/src/lib/ai/providers/ollama.ts index 80a825db..15ea2916 100644 --- a/src/lib/ai/providers/ollama.ts +++ b/src/lib/ai/providers/ollama.ts @@ -33,11 +33,18 @@ export class OllamaProvider extends BaseProvider { } protected async chat(call: ChatCall): Promise { + const schema = call.json ? z.toJSONSchema(call.json.schema) : undefined; + // A model on local hardware can think past any token limit and never answer, so it doesn't think. + // One that doesn't think ignores `format`, so the schema goes into the instructions as well. + const system = schema + ? `${call.system}\n\nReply with only a JSON object, no other text, that matches this JSON schema:\n${JSON.stringify(schema)}` + : call.system; const response = await this.client(call.signal).chat({ model: call.model, - messages: [{ role: "system", content: call.system }, ...call.messages], + messages: [{ role: "system", content: system }, ...call.messages], stream: false, - format: call.json ? z.toJSONSchema(call.json.schema) : undefined, + think: false, + format: schema, options: { temperature: call.temperature, num_predict: call.maxTokens, num_ctx: CONTEXT_TOKENS }, }); return { text: response.message.content, cutOff: response.done_reason === "length" }; diff --git a/src/lib/ai/tasks/extract-posting.ts b/src/lib/ai/tasks/extract-posting.ts index 4c37c2fc..0ac26bc3 100644 --- a/src/lib/ai/tasks/extract-posting.ts +++ b/src/lib/ai/tasks/extract-posting.ts @@ -7,7 +7,8 @@ import { postingForPrompt, system, UNTRUSTED_POSTING, type Task } from "./prompt export const EXTRACT_POSTING_VERSION = "extract-posting@1"; // What the model returns. The deadline is free text here and checked in -// code, since models like to answer "15 Oct" rather than an ISO date. +// code, since models like to answer "15 Oct" rather than an ISO date. Small +// models leave out the keys of a requirement that don't apply, so those read as null. const answerSchema = z.object({ title: z.string().nullable(), company: z.string().nullable(), @@ -16,7 +17,13 @@ const answerSchema = z.object({ mode: z.string().nullable(), salary: z.string().nullable(), deadline: z.string().nullable(), - requirements: z.array(requirementSchema), + requirements: z.array( + requirementSchema.extend({ + skill: z.string().nullable().default(null), + years: z.number().nullable().default(null), + language: z.string().nullable().default(null), + }), + ), asksForReferences: z.boolean(), language: z.string().nullable(), }); @@ -25,6 +32,7 @@ const INSTRUCTIONS = `You read job postings and pull out their details as JSON. - Use only what the posting says; use null for anything it doesn't mention. Keep the posting's own wording for work mode and salary, e.g. "Hybrid · 3 days in office" or "€75,000–€90,000". - location: the city or region only, e.g. "Amsterdam". +- company and role: the employer and the job title as the posting names them, often in its heading or first lines. null only when the posting names none. - title: "{role} · {company}", or null when either is missing. - deadline: the application deadline as YYYY-MM-DD. When the posting gives no year, it's the first such date after today. - requirements: one entry per thing the posting asks of the candidate, in its order, each shortened to the essentials (e.g. "5+ years of product design, ideally on complex B2B tools"). kind is: @@ -38,16 +46,20 @@ const INSTRUCTIONS = `You read job postings and pull out their details as JSON. /** NEW-3: title, company, role, location, work mode, salary, deadline, typed requirements and language. */ export const extractPosting: Task<{ posting: string; today?: Date }, Extracted> = async (provider, { posting, today = new Date() }, { signal }) => { - const answer = await provider.complete({ - system: system(INSTRUCTIONS, `Today is ${localDate(today)}.`, UNTRUSTED_POSTING), - messages: [{ role: "user", content: postingForPrompt(posting) }], - schema: answerSchema, - schemaName: "extracted_posting", - // Reading, not writing: no creativity or long thought wanted. - creativity: "FAITHFUL", - effort: "low", - signal, - }); + const ask = () => + provider.complete({ + system: system(INSTRUCTIONS, `Today is ${localDate(today)}.`, UNTRUSTED_POSTING), + messages: [{ role: "user", content: postingForPrompt(posting) }], + schema: answerSchema, + schemaName: "extracted_posting", + // Reading, not writing: no creativity or long thought wanted. + creativity: "FAITHFUL", + effort: "low", + signal, + }); + let answer = await ask(); + // A small model sometimes answers with every detail null while listing requirements; one more look usually finds them. + if (!answer.title && !answer.company && !answer.role) answer = await ask(); const role = answer.role?.trim() || null; const company = answer.company?.trim() || null; return { diff --git a/src/lib/ai/tasks/tasks.test.ts b/src/lib/ai/tasks/tasks.test.ts index 10a731f7..eb4c4eb8 100644 --- a/src/lib/ai/tasks/tasks.test.ts +++ b/src/lib/ai/tasks/tasks.test.ts @@ -76,6 +76,15 @@ describe("extractPosting", () => { expect(sent(provider).system).toContain("Today is 2026-09-28."); }); + it("asks once more when the answer names no company, role or title, and takes requirements without their empty fields", async () => { + const empty = { ...extracted, title: null, company: null, role: null }; + const replies = [empty, { ...extracted, requirements: [{ text: "Figma", kind: "skill", skill: "Figma" }] }]; + const provider = new MockProvider({ reply: () => replies.shift() }); + const { data } = await extractPosting(provider, { posting, today }, options); + expect(provider.calls).toHaveLength(2); + expect(data).toMatchObject({ company: "Ledgerly", requirements: [{ text: "Figma", years: null, language: null }] }); + }); + it("only keeps whole dates", () => { expect(isoDate("2026-10-15")).toBe("2026-10-15"); expect(isoDate("15 Oct 2026")).toBe("2026-10-15");