Add shared writing style preferences (#240)

* Add shared writing style preferences

* Address PR review feedback

* Add scoring instructions settings

* Polish writing style inputs

* Clarify do-not-use terms guidance

* Normalize blank writing style overrides

* Refactor writing style resolution to use effective values and enhance blank value handling
This commit is contained in:
Shaheer Sarfaraz 2026-03-06 17:31:11 +00:00 committed by GitHub
parent 2214e6d2cb
commit 3fee6e0bef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 910 additions and 113 deletions

View File

@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "job-ops"
[setup]
script = ""
[[actions]]
name = "Run"
icon = "run"
command = "cd orchestrator && npm rebuild && npm run db:migrate && csex"

View File

@ -44,6 +44,8 @@ Global settings affecting generations:
- `Constraints`
- `Do-not-use terms`
`Do-not-use terms` are passed as guidance in the prompt. They are not enforced by a hard post-generation filter, so the model should avoid them but may still use them occasionally.
Defaults:
- Tone: `professional`

View File

@ -15,7 +15,7 @@ It lets you configure:
- LLM provider and models
- Webhook destinations and secret
- Display and Ghostwriter defaults
- Display and writing-style defaults
- Service credentials and basic auth
- Reactive Resume project selection
- Tracer Links readiness verification
@ -62,15 +62,18 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
- Toggle visa sponsor badge visibility in job lists/details
### Ghostwriter
### Writing Style
![Ghostwriter settings section](/img/features/settings-ghostwriter-section.png)
- Pick a preset for a quick starting point
- Set global writing defaults:
- Tone
- Formality
- Constraints
- Do-not-use terms
- These settings apply to Ghostwriter and resume tailoring
- Do-not-use terms are model guidance, not a guaranteed output filter
### Reactive Resume
@ -120,6 +123,7 @@ Readiness requires:
- Set penalty amount
- Optional auto-skip threshold for low-score jobs
- Block jobs from companies that match configured keyword tokens
- Add custom scoring instructions to tell the AI what to weigh more or less
### Danger Zone

View File

@ -341,4 +341,45 @@ describe("SettingsPage", () => {
}),
);
});
it("saves scoring instructions from scoring settings", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({
...baseSettings,
scoringInstructions: {
value:
"Open to relocating, so do not mark down for location discrepancies.",
default: "",
override:
"Open to relocating, so do not mark down for location discrepancies.",
},
});
renderPage();
const scoringTrigger = await screen.findByRole("button", {
name: /scoring settings/i,
});
fireEvent.click(scoringTrigger);
const textarea = screen.getByLabelText(/scoring instructions/i);
fireEvent.change(textarea, {
target: {
value:
"Open to relocating, so do not mark down for location discrepancies.",
},
});
const saveButton = screen.getByRole("button", { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
scoringInstructions:
"Open to relocating, so do not mark down for location discrepancies.",
}),
);
});
});

View File

@ -90,6 +90,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
missingSalaryPenalty: null,
autoSkipScoreThreshold: null,
blockedCompanyKeywords: [],
scoringInstructions: "",
};
type LlmProviderValue = LlmProviderId | null;
@ -145,6 +146,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
missingSalaryPenalty: null,
autoSkipScoreThreshold: null,
blockedCompanyKeywords: null,
scoringInstructions: null,
};
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
@ -183,6 +185,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
missingSalaryPenalty: data.missingSalaryPenalty.override,
autoSkipScoreThreshold: data.autoSkipScoreThreshold.override,
blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [],
scoringInstructions: data.scoringInstructions.override ?? "",
});
const normalizeString = (value: string | null | undefined) => {
@ -334,6 +337,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
effective: settings?.blockedCompanyKeywords?.value ?? [],
default: settings?.blockedCompanyKeywords?.default ?? [],
},
scoringInstructions: {
effective: settings?.scoringInstructions?.value ?? "",
default: settings?.scoringInstructions?.default ?? "",
},
},
};
};
@ -799,6 +806,10 @@ export const SettingsPage: React.FC = () => {
? null
: normalized;
})(),
scoringInstructions: nullIfSame(
normalizeString(data.scoringInstructions),
scoring.scoringInstructions.default,
),
...envPayload,
};

View File

@ -0,0 +1,116 @@
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { Accordion } from "@/components/ui/accordion";
import { ChatSettingsSection } from "./ChatSettingsSection";
vi.mock("@/components/ui/select", () => {
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void;
} | null>(null);
const Select = ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value?: string;
onValueChange?: (value: string) => void;
}) => {
return (
<SelectContext.Provider value={{ onValueChange }}>
<div>
<input readOnly value={value ?? ""} aria-label="select-value" />
{children}
</div>
</SelectContext.Provider>
);
};
const SelectContent = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
);
const SelectItem = ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const context = React.useContext(SelectContext);
return (
<button type="button" onClick={() => context?.onValueChange?.(value)}>
{children}
</button>
);
};
const SelectTrigger = ({ children }: { children: React.ReactNode }) => (
<>{children}</>
);
const SelectValue = () => null;
return {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
};
});
const ChatSettingsHarness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
chatStyleTone: "",
chatStyleFormality: "",
chatStyleConstraints: "",
chatStyleDoNotUse: "",
},
});
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["chat"]}>
<ChatSettingsSection
values={{
tone: { effective: "professional", default: "professional" },
formality: { effective: "medium", default: "medium" },
constraints: { effective: "", default: "" },
doNotUse: { effective: "", default: "" },
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
);
};
describe("ChatSettingsSection", () => {
it("treats blank overrides as unset so preset and selects stay aligned", () => {
render(<ChatSettingsHarness />);
expect(screen.getAllByDisplayValue("professional").length).toBeGreaterThan(
0,
);
expect(screen.getByDisplayValue("medium")).toBeInTheDocument();
});
it("applies preset values to the writing style fields", () => {
render(<ChatSettingsHarness />);
fireEvent.click(screen.getAllByRole("button", { name: "Friendly" })[0]);
expect(screen.getAllByDisplayValue("friendly").length).toBeGreaterThan(0);
expect(screen.getByDisplayValue("low")).toBeInTheDocument();
expect(
screen.getByDisplayValue(
"Keep the response warm, approachable, and confident.",
),
).toBeInTheDocument();
});
});

View File

@ -1,8 +1,14 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { TokenizedInput } from "@client/pages/orchestrator/TokenizedInput";
import {
getMatchingWritingStylePresetId,
resolveWritingStyleDraft,
WRITING_STYLE_PRESETS,
} from "@client/pages/settings/constants";
import type { ChatValues } from "@client/pages/settings/types";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
@ -16,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
type ChatSettingsSectionProps = {
values: ChatValues;
@ -23,6 +30,21 @@ type ChatSettingsSectionProps = {
isSaving: boolean;
};
function parseTokenizedTerms(input: string): string[] {
return input
.split(/[\n,]/g)
.map((value) => value.trim())
.filter(Boolean);
}
function parseStoredTerms(value: string | null | undefined): string[] {
return parseTokenizedTerms(value ?? "");
}
function normalizeBlank(value: string | null | undefined): string | undefined {
return value == null || value === "" ? undefined : value;
}
export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
values,
isLoading,
@ -30,15 +52,100 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
}) => {
const { tone, formality, constraints, doNotUse } = values;
const { control, register } = useFormContext<UpdateSettingsInput>();
const { control, register, setValue } = useFormContext<UpdateSettingsInput>();
const [doNotUseDraft, setDoNotUseDraft] = useState("");
const [toneValue, formalityValue, constraintsValue, doNotUseValue] = useWatch(
{
control,
name: [
"chatStyleTone",
"chatStyleFormality",
"chatStyleConstraints",
"chatStyleDoNotUse",
],
},
);
const toneDraft = normalizeBlank(toneValue);
const formalityDraft = normalizeBlank(formalityValue);
const constraintsDraft = normalizeBlank(constraintsValue);
const doNotUseDraftValue = normalizeBlank(doNotUseValue);
const resolvedStyle = resolveWritingStyleDraft({
values: {
tone: toneDraft,
formality: formalityDraft,
constraints: constraintsDraft,
doNotUse: doNotUseDraftValue,
},
defaults: values,
});
const selectedPresetId =
getMatchingWritingStylePresetId(resolvedStyle) ?? "custom";
const doNotUseTokens = parseStoredTerms(
doNotUseDraftValue ?? doNotUse.default,
);
return (
<AccordionItem value="chat" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Ghostwriter</span>
<span className="text-base font-semibold">Writing Style</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
These defaults shape AI-generated writing across Ghostwriter and
resume tailoring.
</p>
<div className="space-y-2">
<label htmlFor="writingStylePreset" className="text-sm font-medium">
Preset
</label>
<Select
value={selectedPresetId}
onValueChange={(value) => {
if (value === "custom") return;
const preset = WRITING_STYLE_PRESETS.find(
(item) => item.id === value,
);
if (!preset) return;
setValue("chatStyleTone", preset.values.tone, {
shouldDirty: true,
});
setValue("chatStyleFormality", preset.values.formality, {
shouldDirty: true,
});
setValue("chatStyleConstraints", preset.values.constraints, {
shouldDirty: true,
});
setValue("chatStyleDoNotUse", preset.values.doNotUse, {
shouldDirty: true,
});
}}
disabled={isLoading || isSaving}
>
<SelectTrigger id="writingStylePreset">
<SelectValue placeholder="Choose a writing preset" />
</SelectTrigger>
<SelectContent>
{WRITING_STYLE_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
{selectedPresetId === "custom"
? "Your current values are custom."
: (WRITING_STYLE_PRESETS.find(
(preset) => preset.id === selectedPresetId,
)?.description ?? "")}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="chatStyleTone" className="text-sm font-medium">
@ -49,7 +156,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
control={control}
render={({ field }) => (
<Select
value={field.value ?? tone.default}
value={normalizeBlank(field.value) ?? tone.default}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
>
@ -79,7 +186,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
control={control}
render={({ field }) => (
<Select
value={field.value ?? formality.default}
value={normalizeBlank(field.value) ?? formality.default}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
>
@ -97,23 +204,54 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
</div>
</div>
<SettingsInput
label="Constraints"
inputProps={register("chatStyleConstraints")}
<div className="space-y-2">
<label
htmlFor="chatStyleConstraints"
className="text-sm font-medium"
>
Constraints
</label>
<Textarea
id="chatStyleConstraints"
placeholder="Example: keep answers under 120 words and include bullet points"
disabled={isLoading || isSaving}
helper="Optional global writing constraints used by Ghostwriter replies."
current={constraints.effective || "—"}
{...register("chatStyleConstraints")}
/>
<div className="text-xs text-muted-foreground">
Optional global writing constraints applied to Ghostwriter replies
and resume tailoring.
</div>
<div className="text-xs text-muted-foreground">
Current:{" "}
<span className="font-mono">{constraints.effective || "—"}</span>
</div>
</div>
<SettingsInput
label="Do-not-use terms"
inputProps={register("chatStyleDoNotUse")}
placeholder="Example: synergize, leverage"
<div className="space-y-2">
<label htmlFor="chatStyleDoNotUse" className="text-sm font-medium">
Do-not-use terms
</label>
<TokenizedInput
id="chatStyleDoNotUse"
values={doNotUseTokens}
draft={doNotUseDraft}
parseInput={parseTokenizedTerms}
onDraftChange={setDoNotUseDraft}
onValuesChange={(nextValues) =>
setValue("chatStyleDoNotUse", nextValues.join(", "), {
shouldDirty: true,
})
}
placeholder='e.g. "synergize", "leverage"'
helperText="Optional words or phrases the AI should avoid when generating text. This is guidance to the model, not a guaranteed blocklist."
removeLabelPrefix="Remove do-not-use term"
disabled={isLoading || isSaving}
helper="Optional comma-separated words or phrases to avoid."
current={doNotUse.effective || "—"}
/>
<div className="text-xs text-muted-foreground">
Current:{" "}
<span className="font-mono">{doNotUse.effective || "—"}</span>
</div>
</div>
<Separator />

View File

@ -12,6 +12,7 @@ import {
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
type ScoringSettingsSectionProps = {
values: ScoringValues;
@ -36,6 +37,7 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
missingSalaryPenalty,
autoSkipScoreThreshold,
blockedCompanyKeywords,
scoringInstructions,
} = values;
const { control, watch, setValue } = useFormContext<UpdateSettingsInput>();
const [blockedCompanyKeywordDraft, setBlockedCompanyKeywordDraft] =
@ -168,6 +170,44 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
<Separator />
<div className="space-y-3">
<label
htmlFor="scoringInstructions"
className="text-sm font-medium leading-none"
>
Scoring Instructions
</label>
<Controller
name="scoringInstructions"
control={control}
render={({ field }) => (
<div className="space-y-2">
<Textarea
id="scoringInstructions"
value={field.value ?? scoringInstructions.default}
onChange={(event) => field.onChange(event.target.value)}
placeholder="Example: Open to relocating, so do not mark down for location discrepancies. Prioritize visa sponsorship and backend API work."
disabled={isLoading || isSaving}
maxLength={4000}
/>
<div className="text-xs text-muted-foreground">
Optional guidance for the AI scorer about what to weigh more
or less. This only changes scoring, not Ghostwriter or
tailoring.
</div>
<div className="text-xs text-muted-foreground">
Current:{" "}
<span className="font-mono">
{scoringInstructions.effective || "—"}
</span>
</div>
</div>
)}
/>
</div>
<Separator />
<div className="space-y-3">
<label
htmlFor="blocked-company-keywords"
@ -232,6 +272,15 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
Default: {autoSkipScoreThreshold.default ?? "Disabled"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Scoring Instructions
</div>
<div className="break-words font-mono text-xs">
Effective: {scoringInstructions.effective || "—"} | Default:{" "}
{scoringInstructions.default || "—"}
</div>
</div>
</div>
</div>
</AccordionContent>

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
getMatchingWritingStylePresetId,
resolveWritingStyleDraft,
} from "./constants";
describe("settings constants", () => {
it("falls back to effective defaults when overrides are blank", () => {
expect(
resolveWritingStyleDraft({
values: {
tone: "",
formality: null,
constraints: "",
doNotUse: undefined,
},
defaults: {
tone: { effective: "professional", default: "professional" },
formality: { effective: "medium", default: "medium" },
constraints: {
effective: "Keep it warm",
default: "Keep it warm",
},
doNotUse: { effective: "", default: "" },
},
}),
).toEqual({
tone: "professional",
formality: "medium",
constraints: "Keep it warm",
doNotUse: "",
});
});
it("uses effective values instead of registry defaults for blank drafts", () => {
expect(
resolveWritingStyleDraft({
values: {
tone: "",
formality: "",
constraints: " ",
doNotUse: null,
},
defaults: {
tone: { effective: "friendly", default: "professional" },
formality: { effective: "low", default: "medium" },
constraints: {
effective: "Keep the response warm, approachable, and confident.",
default: "",
},
doNotUse: { effective: "synergy", default: "" },
},
}),
).toEqual({
tone: "friendly",
formality: "low",
constraints: "Keep the response warm, approachable, and confident.",
doNotUse: "synergy",
});
});
it("detects matching presets from a resolved draft", () => {
expect(
getMatchingWritingStylePresetId({
tone: "friendly",
formality: "low",
constraints: "Keep the response warm, approachable, and confident.",
doNotUse: "",
}),
).toBe("friendly");
expect(
getMatchingWritingStylePresetId({
tone: "friendly",
formality: "low",
constraints: "Custom note",
doNotUse: "",
}),
).toBeNull();
});
});

View File

@ -2,6 +2,7 @@
* Settings page constants.
*/
import type { ChatValues } from "@client/pages/settings/types";
import type { JobStatus } from "@shared/types";
/** All available job statuses for clearing */
@ -25,3 +26,98 @@ export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
skipped: "User skipped this job",
expired: "Deadline passed",
};
export type WritingStylePresetId =
| "professional"
| "concise"
| "direct"
| "friendly";
export type WritingStyleDraft = {
tone: string;
formality: string;
constraints: string;
doNotUse: string;
};
export type WritingStylePreset = {
id: WritingStylePresetId;
label: string;
description: string;
values: WritingStyleDraft;
};
export const WRITING_STYLE_PRESETS: WritingStylePreset[] = [
{
id: "professional",
label: "Professional",
description: "Balanced default for polished, job-ready writing.",
values: {
tone: "professional",
formality: "medium",
constraints: "",
doNotUse: "",
},
},
{
id: "concise",
label: "Concise",
description: "Keeps responses compact and easy to scan.",
values: {
tone: "concise",
formality: "medium",
constraints: "Keep the response tight, practical, and easy to scan.",
doNotUse: "",
},
},
{
id: "direct",
label: "Direct",
description: "Uses plain, decisive phrasing with minimal hedging.",
values: {
tone: "direct",
formality: "medium",
constraints: "Prioritize clarity and direct wording over flourish.",
doNotUse: "",
},
},
{
id: "friendly",
label: "Friendly",
description: "Warm and personable while staying professional.",
values: {
tone: "friendly",
formality: "low",
constraints: "Keep the response warm, approachable, and confident.",
doNotUse: "",
},
},
];
export function resolveWritingStyleDraft(args: {
values: Partial<Record<keyof WritingStyleDraft, string | null | undefined>>;
defaults: ChatValues;
}): WritingStyleDraft {
const { values, defaults } = args;
return {
tone: values.tone?.trim() || defaults.tone.effective,
formality: values.formality?.trim() || defaults.formality.effective,
constraints: values.constraints?.trim() || defaults.constraints.effective,
doNotUse: values.doNotUse?.trim() || defaults.doNotUse.effective,
};
}
export function getMatchingWritingStylePresetId(
style: WritingStyleDraft,
): WritingStylePresetId | null {
const match = WRITING_STYLE_PRESETS.find(
(preset) =>
preset.values.tone === style.tone &&
preset.values.formality === style.formality &&
preset.values.constraints === style.constraints &&
preset.values.doNotUse === style.doNotUse,
);
return match?.id ?? null;
}

View File

@ -49,4 +49,5 @@ export type ScoringValues = {
missingSalaryPenalty: EffectiveDefault<number>;
autoSkipScoreThreshold: EffectiveDefault<number | null>;
blockedCompanyKeywords: EffectiveDefault<string[]>;
scoringInstructions: EffectiveDefault<string>;
};

View File

@ -158,7 +158,7 @@ function getAnalyticsUserId(): string | null {
function getAnalyticsAppVersion(): string | null {
try {
return typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__.trim()
return typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__?.trim()
? __APP_VERSION__
: null;
} catch {

View File

@ -27,21 +27,20 @@ export const settingsRouter = Router();
/**
* GET /api/settings - Get app settings (effective + defaults)
*/
settingsRouter.get("/", async (_req: Request, res: Response) => {
try {
settingsRouter.get(
"/",
asyncRoute(async (_req: Request, res: Response) => {
const data = await getEffectiveSettings();
res.json({ success: true, data });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
ok(res, data);
}),
);
/**
* PATCH /api/settings - Update settings overrides
*/
settingsRouter.patch("/", async (req: Request, res: Response) => {
try {
settingsRouter.patch(
"/",
asyncRoute(async (req: Request, res: Response) => {
if (isDemoMode()) {
return sendDemoBlocked(
res,
@ -62,12 +61,9 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
maxCount: data.backupMaxCount.value,
});
}
res.json({ success: true, data });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(400).json({ success: false, error: message });
}
});
ok(res, data);
}),
);
/**
* GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume (v4/v5 adapter)

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { toAppError } from "./errors";
describe("toAppError", () => {
it("preserves issues for zod-like errors without a local flatten method", () => {
const error = Object.assign(new Error("Invalid payload"), {
name: "ZodError",
issues: [
{
code: "custom",
path: ["field"],
message: "Field is invalid",
},
],
});
const appError = toAppError(error);
expect(appError.status).toBe(400);
expect(appError.code).toBe("INVALID_REQUEST");
expect(appError.details).toEqual({
formErrors: [],
fieldErrors: {},
issues: [
{
code: "custom",
path: ["field"],
message: "Field is invalid",
},
],
});
});
});

View File

@ -103,10 +103,25 @@ export function serviceUnavailable(message: string): AppError {
return new AppError({ status: 503, code: "SERVICE_UNAVAILABLE", message });
}
function isZodErrorLike(error: unknown): error is ZodError {
if (error instanceof ZodError) return true;
if (!(error instanceof Error) || error.name !== "ZodError") return false;
return Array.isArray((error as { issues?: unknown }).issues);
}
export function toAppError(error: unknown): AppError {
if (error instanceof AppError) return error;
if (error instanceof ZodError) {
return badRequest(error.message, error.flatten());
if (isZodErrorLike(error)) {
const details =
typeof error.flatten === "function"
? error.flatten()
: {
formErrors: [],
fieldErrors: {},
issues: error.issues,
};
return badRequest(error.message, details);
}
if (error instanceof Error && error.name === "AbortError") {
return requestTimeout("Request timed out");

View File

@ -7,35 +7,27 @@ vi.mock("../repositories/jobs", () => ({
getJobById: vi.fn(),
}));
vi.mock("./settings", () => ({
getEffectiveSettings: vi.fn(),
}));
vi.mock("./profile", () => ({
getProfile: vi.fn(),
}));
vi.mock("./writing-style", () => ({
getWritingStyle: vi.fn(),
}));
import { getJobById } from "../repositories/jobs";
import { getProfile } from "./profile";
import { getEffectiveSettings } from "./settings";
import { getWritingStyle } from "./writing-style";
describe("buildJobChatPromptContext", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getEffectiveSettings).mockResolvedValue({
chatStyleTone: {
value: "professional",
default: "professional",
override: null,
},
chatStyleFormality: {
value: "medium",
default: "medium",
override: null,
},
chatStyleConstraints: { value: "", default: "", override: null },
chatStyleDoNotUse: { value: "", default: "", override: null },
} as any);
vi.mocked(getWritingStyle).mockResolvedValue({
tone: "professional",
formality: "medium",
constraints: "",
doNotUse: "",
});
});
it("builds context with style directives and snapshots", async () => {
@ -47,28 +39,12 @@ describe("buildJobChatPromptContext", () => {
});
vi.mocked(getJobById).mockResolvedValue(job);
vi.mocked(getEffectiveSettings).mockResolvedValue({
chatStyleTone: {
value: "direct",
default: "professional",
override: "direct",
},
chatStyleFormality: {
value: "high",
default: "medium",
override: "high",
},
chatStyleConstraints: {
value: "Keep responses under 120 words",
default: "",
override: "Keep responses under 120 words",
},
chatStyleDoNotUse: {
value: "synergy, leverage",
default: "",
override: "synergy, leverage",
},
} as any);
vi.mocked(getWritingStyle).mockResolvedValue({
tone: "direct",
formality: "high",
constraints: "Keep responses under 120 words",
doNotUse: "synergy, leverage",
});
vi.mocked(getProfile).mockResolvedValue({
basics: {
name: "Test User",

View File

@ -4,18 +4,11 @@ import { sanitizeUnknown } from "@infra/sanitize";
import type { Job, ResumeProfile } from "@shared/types";
import * as jobsRepo from "../repositories/jobs";
import { getProfile } from "./profile";
import { getEffectiveSettings } from "./settings";
type JobChatStyle = {
tone: string;
formality: string;
constraints: string;
doNotUse: string;
};
import { getWritingStyle, type WritingStyle } from "./writing-style";
export type JobChatPromptContext = {
job: Job;
style: JobChatStyle;
style: WritingStyle;
systemPrompt: string;
jobSnapshot: string;
profileSnapshot: string;
@ -103,7 +96,7 @@ function buildProfileSnapshot(profile: ResumeProfile): string {
]);
}
function buildSystemPrompt(style: JobChatStyle): string {
function buildSystemPrompt(style: WritingStyle): string {
return compactJoin([
"You are Ghostwriter, a job-application writing assistant for a single job.",
"Use only the provided job and profile context unless the user gives extra details.",
@ -117,17 +110,6 @@ function buildSystemPrompt(style: JobChatStyle): string {
]);
}
async function resolveStyle(): Promise<JobChatStyle> {
const settings = await getEffectiveSettings();
return {
tone: settings.chatStyleTone.value,
formality: settings.chatStyleFormality.value,
constraints: settings.chatStyleConstraints.value,
doNotUse: settings.chatStyleDoNotUse.value,
};
}
export async function buildJobChatPromptContext(
jobId: string,
): Promise<JobChatPromptContext> {
@ -136,7 +118,7 @@ export async function buildJobChatPromptContext(
throw notFound("Job not found");
}
const style = await resolveStyle();
const style = await getWritingStyle();
let profile: ResumeProfile = {};
try {

View File

@ -419,6 +419,50 @@ describe("salary penalty", () => {
});
describe("penalty application", () => {
it("includes custom scoring instructions in the scorer prompt", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm/service");
getEffectiveSettingsMock.mockResolvedValue({
penalizeMissingSalary: { value: false, default: false, override: null },
missingSalaryPenalty: { value: 10, default: 10, override: null },
scoringInstructions: {
value:
"Open to relocating, so do not mark down for location discrepancies.",
default: "",
override:
"Open to relocating, so do not mark down for location discrepancies.",
},
rxresumeBaseResumeId: "base-resume-123",
} as any);
const callJsonMock = vi
.spyOn(LlmService.prototype, "callJson")
.mockResolvedValue({
success: true,
data: { score: 80, reason: "Good match" },
});
const job = createJob({
id: "test-job-1",
title: "Software Engineer",
});
await scoreJobSuitability(job, {});
expect(callJsonMock).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
expect.objectContaining({
content: expect.stringContaining(
"Open to relocating, so do not mark down for location discrepancies.",
),
}),
],
}),
);
});
it("should not apply penalty when disabled", async () => {
const { scoreJobSuitability } = await import("./scorer");
const { LlmService } = await import("./llm/service");

View File

@ -15,6 +15,10 @@ interface SuitabilityResult {
reason: string; // Explanation
}
type ScoringPreferences = {
instructions: string;
};
/** JSON schema for suitability scoring response */
const SCORING_SCHEMA: JsonSchemaDefinition = {
name: "job_suitability_score",
@ -96,7 +100,9 @@ export async function scoreJobSuitability(
process.env.MODEL ||
"google/gemini-3-flash-preview";
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile));
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
instructions: settings.scoringInstructions?.value ?? "",
});
const llm = new LlmService();
const result = await llm.callJson<{ score: number; reason: string }>({
@ -252,6 +258,7 @@ export function parseJsonFromContent(
function buildScoringPrompt(
job: Job,
profile: Record<string, unknown>,
preferences: ScoringPreferences,
): string {
return `You are evaluating a job listing for a candidate. Score how suitable this job is for the candidate on a scale of 0-100.
@ -276,6 +283,13 @@ Disciplines: ${job.disciplines || "Not specified"}
JOB DESCRIPTION:
${job.jobDescription || "No description available"}
SCORING INSTRUCTIONS:
${
preferences.instructions
? preferences.instructions
: "No additional custom scoring instructions."
}
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
REQUIRED FORMAT (exactly this structure):

View File

@ -0,0 +1,76 @@
import type { ResumeProfile } from "@shared/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
const callJsonMock = vi.fn();
const getProviderMock = vi.fn();
const getBaseUrlMock = vi.fn();
vi.mock("../repositories/settings", () => ({
getSetting: vi.fn(),
}));
vi.mock("./llm/service", () => ({
LlmService: class {
callJson = callJsonMock;
getProvider = getProviderMock;
getBaseUrl = getBaseUrlMock;
},
}));
vi.mock("./writing-style", () => ({
getWritingStyle: vi.fn(),
}));
import { getSetting } from "../repositories/settings";
import { generateTailoring } from "./summary";
import { getWritingStyle } from "./writing-style";
describe("generateTailoring", () => {
beforeEach(() => {
vi.clearAllMocks();
getProviderMock.mockReturnValue("openrouter");
getBaseUrlMock.mockReturnValue("https://openrouter.ai");
callJsonMock.mockResolvedValue({
success: true,
data: {
summary: "Tailored summary",
headline: "Senior Engineer",
skills: [],
},
});
vi.mocked(getSetting).mockResolvedValue(null);
vi.mocked(getWritingStyle).mockResolvedValue({
tone: "friendly",
formality: "low",
constraints: "Keep it under 90 words",
doNotUse: "synergy",
});
});
it("passes shared writing-style instructions into tailoring prompts", async () => {
const profile: ResumeProfile = {
basics: {
name: "Test User",
label: "Engineer",
summary: "Existing summary",
},
};
await generateTailoring("Build APIs", profile);
expect(callJsonMock).toHaveBeenCalledTimes(1);
const request = callJsonMock.mock.calls[0]?.[0];
expect(request?.messages?.[0]?.content).toContain(
"WRITING STYLE PREFERENCES:",
);
expect(request?.messages?.[0]?.content).toContain("Tone: friendly");
expect(request?.messages?.[0]?.content).toContain("Formality: low");
expect(request?.messages?.[0]?.content).toContain(
"Additional constraints: Keep it under 90 words",
);
expect(request?.messages?.[0]?.content).toContain(
"Avoid these words or phrases: synergy",
);
});
});

View File

@ -7,6 +7,7 @@ import type { ResumeProfile } from "@shared/types";
import { getSetting } from "../repositories/settings";
import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types";
import { getWritingStyle } from "./writing-style";
export interface TailoredData {
summary: string;
@ -67,9 +68,11 @@ export async function generateTailoring(
jobDescription: string,
profile: ResumeProfile,
): Promise<TailoringResult> {
const [overrideModel, overrideModelTailoring] = await Promise.all([
const [overrideModel, overrideModelTailoring, writingStyle] =
await Promise.all([
getSetting("model"),
getSetting("modelTailoring"),
getWritingStyle(),
]);
// Precedence: Tailoring-specific override > Global override > Env var > Default
const model =
@ -77,7 +80,7 @@ export async function generateTailoring(
overrideModel ||
process.env.MODEL ||
"google/gemini-3-flash-preview";
const prompt = buildTailoringPrompt(profile, jobDescription);
const prompt = buildTailoringPrompt(profile, jobDescription, writingStyle);
const llm = new LlmService();
const result = await llm.callJson<TailoredData>({
@ -132,7 +135,11 @@ export async function generateSummary(
};
}
function buildTailoringPrompt(profile: ResumeProfile, jd: string): string {
function buildTailoringPrompt(
profile: ResumeProfile,
jd: string,
writingStyle: Awaited<ReturnType<typeof getWritingStyle>>,
): string {
// Extract only needed parts of profile to save tokens
const relevantProfile = {
basics: {
@ -182,6 +189,12 @@ INSTRUCTIONS:
- Keep my original skill levels and categories, just rename/reorder keywords to prioritize JD terms.
- Return the full "items" array for the skills section, preserving the structure: { "name": "Frontend", "keywords": [...] }.
WRITING STYLE PREFERENCES:
- Tone: ${writingStyle.tone}
- Formality: ${writingStyle.formality}
${writingStyle.constraints ? `- Additional constraints: ${writingStyle.constraints}` : ""}
${writingStyle.doNotUse ? `- Avoid these words or phrases: ${writingStyle.doNotUse}` : ""}
OUTPUT FORMAT (JSON):
{
"headline": "...",

View File

@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@server/repositories/settings", () => ({
getSetting: vi.fn(),
}));
import { getSetting } from "@server/repositories/settings";
import { getWritingStyle } from "./writing-style";
describe("getWritingStyle", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("uses defaults when no overrides are stored", async () => {
vi.mocked(getSetting).mockResolvedValue(null);
await expect(getWritingStyle()).resolves.toEqual({
tone: "professional",
formality: "medium",
constraints: "",
doNotUse: "",
});
});
it("uses stored overrides when present", async () => {
vi.mocked(getSetting).mockImplementation(async (key) => {
switch (key) {
case "chatStyleTone":
return "friendly";
case "chatStyleFormality":
return "low";
case "chatStyleConstraints":
return "Keep it short";
case "chatStyleDoNotUse":
return "synergy";
default:
return null;
}
});
await expect(getWritingStyle()).resolves.toEqual({
tone: "friendly",
formality: "low",
constraints: "Keep it short",
doNotUse: "synergy",
});
});
});

View File

@ -0,0 +1,35 @@
import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry";
export type WritingStyle = {
tone: string;
formality: string;
constraints: string;
doNotUse: string;
};
export async function getWritingStyle(): Promise<WritingStyle> {
const [toneRaw, formalityRaw, constraintsRaw, doNotUseRaw] =
await Promise.all([
settingsRepo.getSetting("chatStyleTone"),
settingsRepo.getSetting("chatStyleFormality"),
settingsRepo.getSetting("chatStyleConstraints"),
settingsRepo.getSetting("chatStyleDoNotUse"),
]);
return {
tone:
settingsRegistry.chatStyleTone.parse(toneRaw ?? undefined) ??
settingsRegistry.chatStyleTone.default(),
formality:
settingsRegistry.chatStyleFormality.parse(formalityRaw ?? undefined) ??
settingsRegistry.chatStyleFormality.default(),
constraints:
settingsRegistry.chatStyleConstraints.parse(
constraintsRaw ?? undefined,
) ?? settingsRegistry.chatStyleConstraints.default(),
doNotUse:
settingsRegistry.chatStyleDoNotUse.parse(doNotUseRaw ?? undefined) ??
settingsRegistry.chatStyleDoNotUse.default(),
};
}

View File

@ -200,6 +200,14 @@ export const settingsRegistry = {
parse: parseJsonArrayOrNull,
serialize: serializeNullableJsonArray,
},
scoringInstructions: {
kind: "typed" as const,
schema: z.string().trim().max(4000),
default: (): string => "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
searchCities: {
kind: "typed" as const,
schema: z.string().trim().max(100),

View File

@ -163,6 +163,11 @@ export const createAppSettings = (
default: [],
override: null,
},
scoringInstructions: {
value: "",
default: "",
override: null,
},
searchCities: {
value: "United Kingdom",
default: "United Kingdom",

View File

@ -126,6 +126,7 @@ export interface AppSettings {
gradcrackerMaxJobsPerTerm: Resolved<number>;
searchTerms: Resolved<string[]>;
blockedCompanyKeywords: Resolved<string[]>;
scoringInstructions: Resolved<string>;
searchCities: Resolved<string>;
jobspyResultsWanted: Resolved<number>;
jobspyCountryIndeed: Resolved<string>;