diff --git a/README.md b/README.md index 52f4ce3..ff94c2b 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,26 @@ Leave `RESEND_API_KEY` unset and links are printed to the server log instead, wh is what you want on a laptop. They are never shown in the browser: whoever typed an address is not necessarily whoever owns it. +### Writing a listing with a model + +An employer with a brief and no time can have a model expand it into the form. Set +one key and the box appears on `/post`; set neither and it does not: + +```bash +OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY=sk-ant-... +WRITER_MODEL=gpt-5.2-codex # optional; defaults to gpt-5.2 or claude-opus-5 +``` + +It fills the form in and stops. Nothing is written and nothing is published: the +person who asked reads and edits every field, and it still becomes a draft after +that. This is the same seam an employer's agent goes through over the API, which is +the whole point of the board. + +It will not invent compensation. If your brief says nothing about pay, every salary +field comes back empty, because a number nobody agreed to is worse than no number. +Drafting needs an account and is capped at ten an hour per account, so a board with a +key configured is not a public text generator. + ## Boards find each other Instances are independent. Each one has its own database, its own domain and its own diff --git a/migrations/0013_agent_drafts.sql b/migrations/0013_agent_drafts.sql new file mode 100644 index 0000000..b12cc20 --- /dev/null +++ b/migrations/0013_agent_drafts.sql @@ -0,0 +1,19 @@ +-- One row per time somebody asked a model to draft a listing. +-- +-- This exists to be counted, not to be read. A board with a key configured is +-- holding an account somebody pays for, and a form that calls a model is a +-- proxy to it: without a ceiling, one script turns the employer's key into a +-- public text generator. Signed in is already required; this is the second +-- half of that. +-- +-- Rows are kept rather than deleted after the window so that abuse is +-- visible after the fact. Nothing reads the brief back, so the brief is not +-- stored: it is the employer's unpublished writing and the count is the only +-- part this board needs. +create table if not exists agent_drafts ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id) on delete cascade, + created_at timestamptz not null default now() +); + +create index if not exists agent_drafts_user_recent on agent_drafts (user_id, created_at desc); diff --git a/package.json b/package.json index 90484c3..177f424 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.9.0", + "version": "0.10.0", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index fff1272..41c8aab 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,18 @@ export interface Config { /** Resend API key, or null to print sign-in links instead of sending them. */ resendApiKey: string | null; mailFrom: string; + /** + * Model keys for agent-assisted drafting on the post form. + * + * Whichever is set turns the feature on; neither leaves it off and absent + * from the page rather than present and broken. Two providers because this + * is software other people self-host, and a board that only works if you + * bank with one vendor is not self-hostable. + */ + anthropicApiKey: string | null; + openaiApiKey: string | null; + /** Overrides the per-provider default model. */ + writerModel: string | null; version: string; } @@ -101,12 +113,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { isDirectory: flag(env['DIRECTORY']), resendApiKey: env['RESEND_API_KEY']?.trim() || null, mailFrom: env['MAIL_FROM']?.trim() || defaultMailFrom(publicUrl, boardName), + anthropicApiKey: env['ANTHROPIC_API_KEY']?.trim() || null, + openaiApiKey: env['OPENAI_API_KEY']?.trim() || null, + writerModel: env['WRITER_MODEL']?.trim() || null, version: env['npm_package_version']?.trim() || VERSION, }; } /** Kept in step with package.json by the release script. */ -export const VERSION = '0.9.0'; +export const VERSION = '0.10.0'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/core/agentwriter.ts b/src/core/agentwriter.ts new file mode 100644 index 0000000..95a8238 --- /dev/null +++ b/src/core/agentwriter.ts @@ -0,0 +1,395 @@ +/** + * Turning a sentence into a draft listing. + * + * The board's thesis is agents hiring agents with a person in control at both + * ends, and this is that seam on the employer side for somebody who does not + * have an agent of their own. It fills the form and stops. Nothing is saved, + * nothing is published, and the person who asked reads every word before the + * listing exists. A model writing straight into the database would be the one + * shape this board has said it will not ship. + * + * Two providers, because this is MIT software other people self-host and a + * board that only works if you bank with one vendor is not self-hostable. The + * key that is set decides; when neither is set the feature is simply absent + * from the page rather than present and broken. + * + * Written against the two REST APIs directly rather than either vendor SDK. + * One dependency per provider to fill in a form is a poor trade for a package + * that reads a .docx by unzipping it by hand, and the request shapes here are + * small enough to read in full. + */ + +import type pg from 'pg'; +import type { Config } from '../config.ts'; +import { + AGENT_POLICIES, + EMPLOYMENT_TYPES, + SALARY_PERIODS, + SENIORITIES, + WORKPLACES, +} from '../schema/job.ts'; + +/** The model was asked and could not answer. Never fatal to the page. */ +export class AgentWriterProblem extends Error {} + +export type WriterProvider = 'anthropic' | 'openai'; + +/** + * Which provider this instance can use, or null for none. + * + * Anthropic first when both are set, for no better reason than that a board + * with both keys has to pick one and a coin flip at request time would make + * the same brief produce different listings. + */ +export function writerProvider(config: Config): WriterProvider | null { + if (config.anthropicApiKey !== null) return 'anthropic'; + if (config.openaiApiKey !== null) return 'openai'; + return null; +} + +/** + * What the model is asked for, and what it is asked not to do. + * + * The pay rules are the ones that matter. An invented salary range is worse + * than an empty one: the employer may not notice it, and a candidate who + * applies because of a number nobody agreed to has been misled by this board. + * So pay comes from the brief or not at all, and "unpaid" has to be stated + * rather than inferred from silence. + */ +const SYSTEM = [ + 'You write job listings for a job board. You are given a short brief from the', + 'employer and you expand it into a complete listing.', + '', + 'Return a single JSON object and nothing else. Every field is optional; leave', + 'out anything the brief does not support.', + '', + ' title a specific role title, under 80 characters', + ' description Markdown. What the work is, who it suits, how the team', + ' operates. Write in the employer\'s voice, second person', + ' to the reader. No headings above level 2. 150-350 words.', + ' requirements array of short strings', + ' responsibilities array of short strings', + ' tags array of lowercase topic words', + ' stack array of lowercase technology names', + ' employmentType one of: full-time, part-time, contract, internship, temporary', + ' workplace one of: remote, hybrid, onsite', + ' seniority one of: intern, junior, mid, senior, staff, principal, lead', + ' location free text, only if the brief gives one', + ' salaryMin integer, ONLY if the brief states pay', + ' salaryMax integer, ONLY if the brief states pay', + ' salaryPeriod one of: hour, day, week, month, year', + ' salaryUnpaid true ONLY if the brief says the role is unpaid', + '', + 'Rules you do not break:', + '- Never invent compensation. If the brief says nothing about pay, omit every', + ' salary field. A number nobody agreed to is worse than no number.', + '- Never invent a company name, a benefit, a funding stage, or a headcount.', + '- Do not write "competitive salary", "rockstar", "ninja", or "fast-paced".', + '- Do not address the reader as a candidate in the description title.', +].join('\n'); + +interface Context { + boardName: string; + orgName: string | null; +} + +/** + * A brief, expanded into values the post form can be rendered with. + * + * Returns form values rather than a job, because the next thing that happens + * is that a person looks at them. They go through the same `normaliseInput` + * every other posting path goes through when the form is finally submitted, + * so nothing here can put a listing into a state a hand-typed one could not + * reach. + */ +export async function draftListing( + brief: string, + context: Context, + config: Config, + fetchImpl: typeof fetch = fetch, +): Promise> { + const trimmed = brief.trim(); + if (trimmed.length < 10) { + throw new AgentWriterProblem('Say a little more about the role, and I will draft it.'); + } + + const provider = writerProvider(config); + if (provider === null) { + throw new AgentWriterProblem('This board has no model configured.'); + } + + const prompt = [ + `Board: ${context.boardName}.`, + context.orgName === null ? null : `Employer: ${context.orgName}.`, + '', + 'The brief:', + trimmed.slice(0, 4000), + ] + .filter((line) => line !== null) + .join('\n'); + + const raw = + provider === 'anthropic' + ? await askAnthropic(prompt, config, fetchImpl) + : await askOpenAi(prompt, config, fetchImpl); + + return fieldsFromModelJson(raw); +} + +/** + * The Messages API, by hand. + * + * Thinking is on by default on this model and `effort: low` is right for + * filling in a form: the work is recall and phrasing, not reasoning, and the + * person is waiting on a page for it. + */ +async function askAnthropic( + prompt: string, + config: Config, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': config.anthropicApiKey ?? '', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: config.writerModel ?? 'claude-opus-5', + max_tokens: 4000, + output_config: { effort: 'low' }, + system: SYSTEM, + messages: [{ role: 'user', content: prompt }], + }), + }); + + if (!response.ok) throw await problemFor(response, 'Anthropic'); + + const body = (await response.json()) as { + content?: { type: string; text?: string }[]; + stop_reason?: string; + }; + // A refusal arrives as a 200 with nothing usable in it, so the status alone + // is not evidence that there is text to read. + if (body.stop_reason === 'refusal') { + throw new AgentWriterProblem('The model declined to write that one.'); + } + const text = (body.content ?? []) + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join(''); + if (text.trim() === '') throw new AgentWriterProblem('The model returned nothing.'); + return text; +} + +/** + * Chat completions, by hand. + * + * `max_completion_tokens`, not `max_tokens`: the current models reject the + * older name. `json_object` is what keeps the reply parseable without asking + * the model nicely twice. + */ +async function askOpenAi( + prompt: string, + config: Config, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${config.openaiApiKey ?? ''}`, + }, + body: JSON.stringify({ + model: config.writerModel ?? 'gpt-5.2', + max_completion_tokens: 4000, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: prompt }, + ], + }), + }); + + if (!response.ok) throw await problemFor(response, 'OpenAI'); + + const body = (await response.json()) as { + choices?: { message?: { content?: string | null; refusal?: string | null } }[]; + }; + const choice = body.choices?.[0]?.message; + if (typeof choice?.refusal === 'string' && choice.refusal !== '') { + throw new AgentWriterProblem('The model declined to write that one.'); + } + const text = choice?.content ?? ''; + if (text.trim() === '') throw new AgentWriterProblem('The model returned nothing.'); + return text; +} + +/** + * An HTTP failure, said in words a person on a form can act on. + * + * The provider's own message is deliberately not shown: it is written for + * whoever holds the key, not for the employer typing a brief, and it can + * carry account details that do not belong on a public page. + */ +async function problemFor(response: Response, provider: string): Promise { + // Read and discard, so the connection is not left hanging on a body nobody + // consumed. + await response.text().catch(() => ''); + if (response.status === 401 || response.status === 403) { + return new AgentWriterProblem(`This board's ${provider} key was rejected.`); + } + if (response.status === 429) { + return new AgentWriterProblem('The model is rate limited right now. Try again shortly.'); + } + return new AgentWriterProblem(`${provider} could not be reached. Try again shortly.`); +} + +/** + * The model's JSON, reduced to form values this board will accept. + * + * Everything is checked against the same lists the form's own selects are + * built from, and anything unrecognised is dropped rather than corrected. A + * model that invents an employment type should leave that field blank for a + * person to fill in, not have its answer bent into the nearest legal value: + * the second one looks like the employer chose it. + * + * Exported because this is the part worth testing, and testing it needs no + * network and no key. + */ +export function fieldsFromModelJson(raw: string): Record { + const parsed = parseLoose(raw); + if (parsed === null) throw new AgentWriterProblem('The model did not return a listing.'); + + const out: Record = {}; + const put = (key: string, value: string): void => { + if (value.trim() !== '') out[key] = value.trim(); + }; + + put('title', text(parsed['title'], 140)); + put('description', text(parsed['description'], 20_000)); + put('location', text(parsed['location'], 120)); + + put('employmentType', oneOf(parsed['employmentType'], EMPLOYMENT_TYPES)); + put('workplace', oneOf(parsed['workplace'], WORKPLACES)); + put('seniority', oneOf(parsed['seniority'], SENIORITIES)); + put('salaryPeriod', oneOf(parsed['salaryPeriod'], SALARY_PERIODS)); + put('agentPolicy', oneOf(parsed['agentPolicy'], AGENT_POLICIES)); + + put('tags', list(parsed['tags'], 12)); + put('stack', list(parsed['stack'], 20)); + put('requirements', bullets(parsed['requirements'])); + put('responsibilities', bullets(parsed['responsibilities'])); + + // Pay only survives if the model actually returned a number. "Unpaid" and a + // range are mutually exclusive here for the same reason they are everywhere + // else on the board. + if (parsed['salaryUnpaid'] === true) { + out['salaryUnpaid'] = 'on'; + } else { + put('salaryMin', amount(parsed['salaryMin'])); + put('salaryMax', amount(parsed['salaryMax'])); + } + + if (out['title'] === undefined && out['description'] === undefined) { + throw new AgentWriterProblem('The model did not return a listing.'); + } + return out; +} + +/** + * JSON, whether or not it arrived alone. + * + * Both providers were asked for a bare object and both usually send one, but a + * model that wraps it in a ```json fence has still done the job, and failing + * the whole request over a fence would be the wrong place to be strict. + */ +function parseLoose(raw: string): Record | null { + const attempts = [raw]; + const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw); + if (fenced !== null) attempts.push(fenced[1] ?? ''); + const braced = /\{[\s\S]*\}/.exec(raw); + if (braced !== null) attempts.push(braced[0]); + + for (const attempt of attempts) { + try { + const value: unknown = JSON.parse(attempt); + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + } catch { + // Try the next shape. + } + } + return null; +} + +function text(value: unknown, max: number): string { + return typeof value === 'string' ? value.slice(0, max) : ''; +} + +function oneOf(value: unknown, allowed: readonly string[]): string { + if (typeof value !== 'string') return ''; + const found = allowed.find((item) => item === value.trim().toLowerCase()); + return found ?? ''; +} + +function amount(value: unknown): string { + const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return ''; + return String(Math.min(100_000_000, Math.round(parsed))); +} + +/** The form takes these comma separated, which is how a person edits them. */ +function list(value: unknown, max: number): string { + if (!Array.isArray(value)) return typeof value === 'string' ? value : ''; + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item !== '' && item.length <= 40) + .slice(0, max) + .join(', '); +} + +/** And these one per line. */ +function bullets(value: unknown): string { + if (!Array.isArray(value)) return typeof value === 'string' ? value : ''; + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().replace(/^[-*]\s*/, '')) + .filter((item) => item !== '') + .slice(0, 20) + .join('\n'); +} + +/** + * How many drafts this account has asked for in the last hour. + * + * The ceiling is per account rather than per board: one employer writing four + * listings in an afternoon is the use case, and a shared board-wide limit + * would let one of them lock out everybody else. + */ +export const DRAFTS_PER_HOUR = 10; + +export async function recentDraftCount(pool: pg.Pool, userId: string): Promise { + const result = await pool.query<{ count: number }>( + `select count(*)::int as count + from agent_drafts + where user_id = $1 + and created_at > now() - interval '1 hour'`, + [userId], + ); + return result.rows[0]?.count ?? 0; +} + +/** + * Recorded before the model is called, not after. + * + * A request that fails still cost the board something and still came from + * somebody, so counting only the successes would leave the cheapest way to + * burn the key uncounted. + */ +export async function recordDraft(pool: pg.Pool, userId: string): Promise { + await pool.query(`insert into agent_drafts (user_id) values ($1)`, [userId]); +} diff --git a/src/server/routes/pages.tsx b/src/server/routes/pages.tsx index 620c1b6..c8ad1af 100644 --- a/src/server/routes/pages.tsx +++ b/src/server/routes/pages.tsx @@ -100,6 +100,14 @@ import { resumePdf, type MediaFormat, } from '../../core/markdown-media.ts'; +import { + AgentWriterProblem, + draftListing, + DRAFTS_PER_HOUR, + recentDraftCount, + recordDraft, + writerProvider, +} from '../../core/agentwriter.ts'; import { readSpec } from './specs.ts'; import type { AppEnv } from '../deps.ts'; @@ -951,18 +959,78 @@ export function pageRoutes(): Hono { // --- posting ---------------------------------------------------------- pages.get('/post', async (c) => { - const { pool } = c.get('deps'); + const { pool, config } = c.get('deps'); const viewer = requireViewer(c); if (viewer instanceof Response) return viewer; return c.html( - + , ); }); + /** + * A brief, turned into a filled-in form. + * + * It renders the form and stops. Nothing is written, nothing is published, + * and the person who asked reads every field before a listing exists, which + * is the same seam an employer's agent goes through when it posts over the + * API and lands a draft. + */ + pages.post('/post/draft', async (c) => { + const { pool, config } = c.get('deps'); + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + + const form = await formOf(c); + const orgs = await listOrgsForUser(pool, viewer.id); + const brief = form['brief'] ?? ''; + const canDraft = writerProvider(config) !== null; + + const render = (values: Record, error?: string, status = 200): Html => + c.html( + + + , + status === 200 ? 200 : 400, + ); + + if (!canDraft) return render(form, 'This board has no model configured.', 400); + + // Counted before the model is called: a request that failed still cost the + // board something and still came from somebody. + if ((await recentDraftCount(pool, viewer.id)) >= DRAFTS_PER_HOUR) { + return render(form, `That is ${DRAFTS_PER_HOUR} drafts in an hour. Try again later.`, 400); + } + await recordDraft(pool, viewer.id); + + try { + const org = orgs.find((candidate) => candidate.slug === form['org']); + const drafted = await draftListing( + brief, + { boardName: config.boardName, orgName: org?.name ?? null }, + config, + ); + // The employer's own choice of org survives the round trip; the model + // does not get to pick who is hiring. + return render({ ...drafted, ...(form['org'] === undefined ? {} : { org: form['org'] }) }); + } catch (error) { + if (error instanceof AgentWriterProblem) return render(form, error.message, 400); + throw error; + } + }); + pages.post('/post', async (c) => { - const { pool } = c.get('deps'); + const { pool, config } = c.get('deps'); const viewer = requireViewer(c); if (viewer instanceof Response) return viewer; @@ -972,7 +1040,12 @@ export function pageRoutes(): Hono { const fail = (message: string): Html => c.html( - + , 400, ); diff --git a/src/views/post.tsx b/src/views/post.tsx index 3ca80c9..801ea12 100644 --- a/src/views/post.tsx +++ b/src/views/post.tsx @@ -20,7 +20,11 @@ export const PostJobPage: FC<{ orgs: Organisation[]; error?: string; values?: Record; -}> = ({ orgs, error, values = {} }) => ( + /** True when this board has a model key, so the brief box is worth showing. */ + canDraft?: boolean; + /** What was typed into it, so a failed draft does not lose the brief. */ + brief?: string; +}> = ({ orgs, error, values = {}, canDraft = false, brief = '' }) => (

Post a job

@@ -34,6 +38,35 @@ export const PostJobPage: FC<{ Add the employer first: add an employer. ) : ( + <> + {canDraft && ( +
+ + + +
+ + + It will not invent a salary. Pay comes from what you write here, or stays empty. + +
+
+ )} +