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:
parent
2214e6d2cb
commit
3fee6e0bef
11
.codex/environments/environment.toml
Normal file
11
.codex/environments/environment.toml
Normal 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"
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||

|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@ -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.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 />
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
81
orchestrator/src/client/pages/settings/constants.test.ts
Normal file
81
orchestrator/src/client/pages/settings/constants.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -49,4 +49,5 @@ export type ScoringValues = {
|
||||
missingSalaryPenalty: EffectiveDefault<number>;
|
||||
autoSkipScoreThreshold: EffectiveDefault<number | null>;
|
||||
blockedCompanyKeywords: EffectiveDefault<string[]>;
|
||||
scoringInstructions: EffectiveDefault<string>;
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
33
orchestrator/src/server/infra/errors.test.ts
Normal file
33
orchestrator/src/server/infra/errors.test.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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):
|
||||
|
||||
76
orchestrator/src/server/services/summary.test.ts
Normal file
76
orchestrator/src/server/services/summary.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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": "...",
|
||||
|
||||
49
orchestrator/src/server/services/writing-style.test.ts
Normal file
49
orchestrator/src/server/services/writing-style.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
35
orchestrator/src/server/services/writing-style.ts
Normal file
35
orchestrator/src/server/services/writing-style.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -163,6 +163,11 @@ export const createAppSettings = (
|
||||
default: [],
|
||||
override: null,
|
||||
},
|
||||
scoringInstructions: {
|
||||
value: "",
|
||||
default: "",
|
||||
override: null,
|
||||
},
|
||||
searchCities: {
|
||||
value: "United Kingdom",
|
||||
default: "United Kingdom",
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user