Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/ai/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions src/lib/ai/providers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,18 @@ export class OllamaProvider extends BaseProvider {
}

protected async chat(call: ChatCall): Promise<ChatAnswer> {
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" };
Expand Down
36 changes: 24 additions & 12 deletions src/lib/ai/tasks/extract-posting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
});
Expand All @@ -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:
Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/lib/ai/tasks/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down