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`
|
- `Constraints`
|
||||||
- `Do-not-use terms`
|
- `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:
|
Defaults:
|
||||||
|
|
||||||
- Tone: `professional`
|
- Tone: `professional`
|
||||||
|
|||||||
@ -15,7 +15,7 @@ It lets you configure:
|
|||||||
|
|
||||||
- LLM provider and models
|
- LLM provider and models
|
||||||
- Webhook destinations and secret
|
- Webhook destinations and secret
|
||||||
- Display and Ghostwriter defaults
|
- Display and writing-style defaults
|
||||||
- Service credentials and basic auth
|
- Service credentials and basic auth
|
||||||
- Reactive Resume project selection
|
- Reactive Resume project selection
|
||||||
- Tracer Links readiness verification
|
- 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
|
- Toggle visa sponsor badge visibility in job lists/details
|
||||||
|
|
||||||
### Ghostwriter
|
### Writing Style
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
- Pick a preset for a quick starting point
|
||||||
- Set global writing defaults:
|
- Set global writing defaults:
|
||||||
- Tone
|
- Tone
|
||||||
- Formality
|
- Formality
|
||||||
- Constraints
|
- Constraints
|
||||||
- Do-not-use terms
|
- 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
|
### Reactive Resume
|
||||||
|
|
||||||
@ -120,6 +123,7 @@ Readiness requires:
|
|||||||
- Set penalty amount
|
- Set penalty amount
|
||||||
- Optional auto-skip threshold for low-score jobs
|
- Optional auto-skip threshold for low-score jobs
|
||||||
- Block jobs from companies that match configured keyword tokens
|
- 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
|
### 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,
|
missingSalaryPenalty: null,
|
||||||
autoSkipScoreThreshold: null,
|
autoSkipScoreThreshold: null,
|
||||||
blockedCompanyKeywords: [],
|
blockedCompanyKeywords: [],
|
||||||
|
scoringInstructions: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
type LlmProviderValue = LlmProviderId | null;
|
type LlmProviderValue = LlmProviderId | null;
|
||||||
@ -145,6 +146,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
missingSalaryPenalty: null,
|
missingSalaryPenalty: null,
|
||||||
autoSkipScoreThreshold: null,
|
autoSkipScoreThreshold: null,
|
||||||
blockedCompanyKeywords: null,
|
blockedCompanyKeywords: null,
|
||||||
|
scoringInstructions: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||||
@ -183,6 +185,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
missingSalaryPenalty: data.missingSalaryPenalty.override,
|
missingSalaryPenalty: data.missingSalaryPenalty.override,
|
||||||
autoSkipScoreThreshold: data.autoSkipScoreThreshold.override,
|
autoSkipScoreThreshold: data.autoSkipScoreThreshold.override,
|
||||||
blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [],
|
blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [],
|
||||||
|
scoringInstructions: data.scoringInstructions.override ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeString = (value: string | null | undefined) => {
|
const normalizeString = (value: string | null | undefined) => {
|
||||||
@ -334,6 +337,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
effective: settings?.blockedCompanyKeywords?.value ?? [],
|
effective: settings?.blockedCompanyKeywords?.value ?? [],
|
||||||
default: settings?.blockedCompanyKeywords?.default ?? [],
|
default: settings?.blockedCompanyKeywords?.default ?? [],
|
||||||
},
|
},
|
||||||
|
scoringInstructions: {
|
||||||
|
effective: settings?.scoringInstructions?.value ?? "",
|
||||||
|
default: settings?.scoringInstructions?.default ?? "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -799,6 +806,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
? null
|
? null
|
||||||
: normalized;
|
: normalized;
|
||||||
})(),
|
})(),
|
||||||
|
scoringInstructions: nullIfSame(
|
||||||
|
normalizeString(data.scoringInstructions),
|
||||||
|
scoring.scoringInstructions.default,
|
||||||
|
),
|
||||||
...envPayload,
|
...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 { ChatValues } from "@client/pages/settings/types";
|
||||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type React from "react";
|
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 {
|
import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
@ -16,6 +22,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
type ChatSettingsSectionProps = {
|
type ChatSettingsSectionProps = {
|
||||||
values: ChatValues;
|
values: ChatValues;
|
||||||
@ -23,6 +30,21 @@ type ChatSettingsSectionProps = {
|
|||||||
isSaving: boolean;
|
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> = ({
|
export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -30,15 +52,100 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { tone, formality, constraints, doNotUse } = values;
|
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 (
|
return (
|
||||||
<AccordionItem value="chat" className="border rounded-lg px-4">
|
<AccordionItem value="chat" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-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>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-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="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="chatStyleTone" className="text-sm font-medium">
|
<label htmlFor="chatStyleTone" className="text-sm font-medium">
|
||||||
@ -49,7 +156,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
value={field.value ?? tone.default}
|
value={normalizeBlank(field.value) ?? tone.default}
|
||||||
onValueChange={(value) => field.onChange(value)}
|
onValueChange={(value) => field.onChange(value)}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
>
|
>
|
||||||
@ -79,7 +186,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
value={field.value ?? formality.default}
|
value={normalizeBlank(field.value) ?? formality.default}
|
||||||
onValueChange={(value) => field.onChange(value)}
|
onValueChange={(value) => field.onChange(value)}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
>
|
>
|
||||||
@ -97,23 +204,54 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsInput
|
<div className="space-y-2">
|
||||||
label="Constraints"
|
<label
|
||||||
inputProps={register("chatStyleConstraints")}
|
htmlFor="chatStyleConstraints"
|
||||||
placeholder="Example: keep answers under 120 words and include bullet points"
|
className="text-sm font-medium"
|
||||||
disabled={isLoading || isSaving}
|
>
|
||||||
helper="Optional global writing constraints used by Ghostwriter replies."
|
Constraints
|
||||||
current={constraints.effective || "—"}
|
</label>
|
||||||
/>
|
<Textarea
|
||||||
|
id="chatStyleConstraints"
|
||||||
|
placeholder="Example: keep answers under 120 words and include bullet points"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
{...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
|
<div className="space-y-2">
|
||||||
label="Do-not-use terms"
|
<label htmlFor="chatStyleDoNotUse" className="text-sm font-medium">
|
||||||
inputProps={register("chatStyleDoNotUse")}
|
Do-not-use terms
|
||||||
placeholder="Example: synergize, leverage"
|
</label>
|
||||||
disabled={isLoading || isSaving}
|
<TokenizedInput
|
||||||
helper="Optional comma-separated words or phrases to avoid."
|
id="chatStyleDoNotUse"
|
||||||
current={doNotUse.effective || "—"}
|
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}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Current:{" "}
|
||||||
|
<span className="font-mono">{doNotUse.effective || "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
type ScoringSettingsSectionProps = {
|
type ScoringSettingsSectionProps = {
|
||||||
values: ScoringValues;
|
values: ScoringValues;
|
||||||
@ -36,6 +37,7 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
|
|||||||
missingSalaryPenalty,
|
missingSalaryPenalty,
|
||||||
autoSkipScoreThreshold,
|
autoSkipScoreThreshold,
|
||||||
blockedCompanyKeywords,
|
blockedCompanyKeywords,
|
||||||
|
scoringInstructions,
|
||||||
} = values;
|
} = values;
|
||||||
const { control, watch, setValue } = useFormContext<UpdateSettingsInput>();
|
const { control, watch, setValue } = useFormContext<UpdateSettingsInput>();
|
||||||
const [blockedCompanyKeywordDraft, setBlockedCompanyKeywordDraft] =
|
const [blockedCompanyKeywordDraft, setBlockedCompanyKeywordDraft] =
|
||||||
@ -168,6 +170,44 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
|
|||||||
|
|
||||||
<Separator />
|
<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">
|
<div className="space-y-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="blocked-company-keywords"
|
htmlFor="blocked-company-keywords"
|
||||||
@ -232,6 +272,15 @@ export const ScoringSettingsSection: React.FC<ScoringSettingsSectionProps> = ({
|
|||||||
Default: {autoSkipScoreThreshold.default ?? "Disabled"}
|
Default: {autoSkipScoreThreshold.default ?? "Disabled"}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</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.
|
* Settings page constants.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ChatValues } from "@client/pages/settings/types";
|
||||||
import type { JobStatus } from "@shared/types";
|
import type { JobStatus } from "@shared/types";
|
||||||
|
|
||||||
/** All available job statuses for clearing */
|
/** All available job statuses for clearing */
|
||||||
@ -25,3 +26,98 @@ export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
|
|||||||
skipped: "User skipped this job",
|
skipped: "User skipped this job",
|
||||||
expired: "Deadline passed",
|
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>;
|
missingSalaryPenalty: EffectiveDefault<number>;
|
||||||
autoSkipScoreThreshold: EffectiveDefault<number | null>;
|
autoSkipScoreThreshold: EffectiveDefault<number | null>;
|
||||||
blockedCompanyKeywords: EffectiveDefault<string[]>;
|
blockedCompanyKeywords: EffectiveDefault<string[]>;
|
||||||
|
scoringInstructions: EffectiveDefault<string>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -158,7 +158,7 @@ function getAnalyticsUserId(): string | null {
|
|||||||
|
|
||||||
function getAnalyticsAppVersion(): string | null {
|
function getAnalyticsAppVersion(): string | null {
|
||||||
try {
|
try {
|
||||||
return typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__.trim()
|
return typeof __APP_VERSION__ !== "undefined" && __APP_VERSION__?.trim()
|
||||||
? __APP_VERSION__
|
? __APP_VERSION__
|
||||||
: null;
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -27,21 +27,20 @@ export const settingsRouter = Router();
|
|||||||
/**
|
/**
|
||||||
* GET /api/settings - Get app settings (effective + defaults)
|
* GET /api/settings - Get app settings (effective + defaults)
|
||||||
*/
|
*/
|
||||||
settingsRouter.get("/", async (_req: Request, res: Response) => {
|
settingsRouter.get(
|
||||||
try {
|
"/",
|
||||||
|
asyncRoute(async (_req: Request, res: Response) => {
|
||||||
const data = await getEffectiveSettings();
|
const data = await getEffectiveSettings();
|
||||||
res.json({ success: true, data });
|
ok(res, data);
|
||||||
} catch (error) {
|
}),
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
);
|
||||||
res.status(500).json({ success: false, error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/settings - Update settings overrides
|
* PATCH /api/settings - Update settings overrides
|
||||||
*/
|
*/
|
||||||
settingsRouter.patch("/", async (req: Request, res: Response) => {
|
settingsRouter.patch(
|
||||||
try {
|
"/",
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
if (isDemoMode()) {
|
if (isDemoMode()) {
|
||||||
return sendDemoBlocked(
|
return sendDemoBlocked(
|
||||||
res,
|
res,
|
||||||
@ -62,12 +61,9 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
|
|||||||
maxCount: data.backupMaxCount.value,
|
maxCount: data.backupMaxCount.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json({ success: true, data });
|
ok(res, data);
|
||||||
} catch (error) {
|
}),
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
);
|
||||||
res.status(400).json({ success: false, error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume (v4/v5 adapter)
|
* 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 });
|
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 {
|
export function toAppError(error: unknown): AppError {
|
||||||
if (error instanceof AppError) return error;
|
if (error instanceof AppError) return error;
|
||||||
if (error instanceof ZodError) {
|
if (isZodErrorLike(error)) {
|
||||||
return badRequest(error.message, error.flatten());
|
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") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
return requestTimeout("Request timed out");
|
return requestTimeout("Request timed out");
|
||||||
|
|||||||
@ -7,35 +7,27 @@ vi.mock("../repositories/jobs", () => ({
|
|||||||
getJobById: vi.fn(),
|
getJobById: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./settings", () => ({
|
|
||||||
getEffectiveSettings: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./profile", () => ({
|
vi.mock("./profile", () => ({
|
||||||
getProfile: vi.fn(),
|
getProfile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./writing-style", () => ({
|
||||||
|
getWritingStyle: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getJobById } from "../repositories/jobs";
|
import { getJobById } from "../repositories/jobs";
|
||||||
import { getProfile } from "./profile";
|
import { getProfile } from "./profile";
|
||||||
import { getEffectiveSettings } from "./settings";
|
import { getWritingStyle } from "./writing-style";
|
||||||
|
|
||||||
describe("buildJobChatPromptContext", () => {
|
describe("buildJobChatPromptContext", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
vi.mocked(getWritingStyle).mockResolvedValue({
|
||||||
chatStyleTone: {
|
tone: "professional",
|
||||||
value: "professional",
|
formality: "medium",
|
||||||
default: "professional",
|
constraints: "",
|
||||||
override: null,
|
doNotUse: "",
|
||||||
},
|
});
|
||||||
chatStyleFormality: {
|
|
||||||
value: "medium",
|
|
||||||
default: "medium",
|
|
||||||
override: null,
|
|
||||||
},
|
|
||||||
chatStyleConstraints: { value: "", default: "", override: null },
|
|
||||||
chatStyleDoNotUse: { value: "", default: "", override: null },
|
|
||||||
} as any);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds context with style directives and snapshots", async () => {
|
it("builds context with style directives and snapshots", async () => {
|
||||||
@ -47,28 +39,12 @@ describe("buildJobChatPromptContext", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(getJobById).mockResolvedValue(job);
|
vi.mocked(getJobById).mockResolvedValue(job);
|
||||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
vi.mocked(getWritingStyle).mockResolvedValue({
|
||||||
chatStyleTone: {
|
tone: "direct",
|
||||||
value: "direct",
|
formality: "high",
|
||||||
default: "professional",
|
constraints: "Keep responses under 120 words",
|
||||||
override: "direct",
|
doNotUse: "synergy, leverage",
|
||||||
},
|
});
|
||||||
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(getProfile).mockResolvedValue({
|
vi.mocked(getProfile).mockResolvedValue({
|
||||||
basics: {
|
basics: {
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
|
|||||||
@ -4,18 +4,11 @@ import { sanitizeUnknown } from "@infra/sanitize";
|
|||||||
import type { Job, ResumeProfile } from "@shared/types";
|
import type { Job, ResumeProfile } from "@shared/types";
|
||||||
import * as jobsRepo from "../repositories/jobs";
|
import * as jobsRepo from "../repositories/jobs";
|
||||||
import { getProfile } from "./profile";
|
import { getProfile } from "./profile";
|
||||||
import { getEffectiveSettings } from "./settings";
|
import { getWritingStyle, type WritingStyle } from "./writing-style";
|
||||||
|
|
||||||
type JobChatStyle = {
|
|
||||||
tone: string;
|
|
||||||
formality: string;
|
|
||||||
constraints: string;
|
|
||||||
doNotUse: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JobChatPromptContext = {
|
export type JobChatPromptContext = {
|
||||||
job: Job;
|
job: Job;
|
||||||
style: JobChatStyle;
|
style: WritingStyle;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
jobSnapshot: string;
|
jobSnapshot: string;
|
||||||
profileSnapshot: string;
|
profileSnapshot: string;
|
||||||
@ -103,7 +96,7 @@ function buildProfileSnapshot(profile: ResumeProfile): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSystemPrompt(style: JobChatStyle): string {
|
function buildSystemPrompt(style: WritingStyle): string {
|
||||||
return compactJoin([
|
return compactJoin([
|
||||||
"You are Ghostwriter, a job-application writing assistant for a single job.",
|
"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.",
|
"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(
|
export async function buildJobChatPromptContext(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
): Promise<JobChatPromptContext> {
|
): Promise<JobChatPromptContext> {
|
||||||
@ -136,7 +118,7 @@ export async function buildJobChatPromptContext(
|
|||||||
throw notFound("Job not found");
|
throw notFound("Job not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = await resolveStyle();
|
const style = await getWritingStyle();
|
||||||
|
|
||||||
let profile: ResumeProfile = {};
|
let profile: ResumeProfile = {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -419,6 +419,50 @@ describe("salary penalty", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("penalty application", () => {
|
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 () => {
|
it("should not apply penalty when disabled", async () => {
|
||||||
const { scoreJobSuitability } = await import("./scorer");
|
const { scoreJobSuitability } = await import("./scorer");
|
||||||
const { LlmService } = await import("./llm/service");
|
const { LlmService } = await import("./llm/service");
|
||||||
|
|||||||
@ -15,6 +15,10 @@ interface SuitabilityResult {
|
|||||||
reason: string; // Explanation
|
reason: string; // Explanation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScoringPreferences = {
|
||||||
|
instructions: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** JSON schema for suitability scoring response */
|
/** JSON schema for suitability scoring response */
|
||||||
const SCORING_SCHEMA: JsonSchemaDefinition = {
|
const SCORING_SCHEMA: JsonSchemaDefinition = {
|
||||||
name: "job_suitability_score",
|
name: "job_suitability_score",
|
||||||
@ -96,7 +100,9 @@ export async function scoreJobSuitability(
|
|||||||
process.env.MODEL ||
|
process.env.MODEL ||
|
||||||
"google/gemini-3-flash-preview";
|
"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 llm = new LlmService();
|
||||||
const result = await llm.callJson<{ score: number; reason: string }>({
|
const result = await llm.callJson<{ score: number; reason: string }>({
|
||||||
@ -252,6 +258,7 @@ export function parseJsonFromContent(
|
|||||||
function buildScoringPrompt(
|
function buildScoringPrompt(
|
||||||
job: Job,
|
job: Job,
|
||||||
profile: Record<string, unknown>,
|
profile: Record<string, unknown>,
|
||||||
|
preferences: ScoringPreferences,
|
||||||
): string {
|
): 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.
|
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 DESCRIPTION:
|
||||||
${job.jobDescription || "No description available"}
|
${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.
|
IMPORTANT: Respond with ONLY a valid JSON object. No markdown, no code fences, no explanation outside the JSON.
|
||||||
|
|
||||||
REQUIRED FORMAT (exactly this structure):
|
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 { getSetting } from "../repositories/settings";
|
||||||
import { LlmService } from "./llm/service";
|
import { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
|
import { getWritingStyle } from "./writing-style";
|
||||||
|
|
||||||
export interface TailoredData {
|
export interface TailoredData {
|
||||||
summary: string;
|
summary: string;
|
||||||
@ -67,17 +68,19 @@ export async function generateTailoring(
|
|||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
profile: ResumeProfile,
|
profile: ResumeProfile,
|
||||||
): Promise<TailoringResult> {
|
): Promise<TailoringResult> {
|
||||||
const [overrideModel, overrideModelTailoring] = await Promise.all([
|
const [overrideModel, overrideModelTailoring, writingStyle] =
|
||||||
getSetting("model"),
|
await Promise.all([
|
||||||
getSetting("modelTailoring"),
|
getSetting("model"),
|
||||||
]);
|
getSetting("modelTailoring"),
|
||||||
|
getWritingStyle(),
|
||||||
|
]);
|
||||||
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
||||||
const model =
|
const model =
|
||||||
overrideModelTailoring ||
|
overrideModelTailoring ||
|
||||||
overrideModel ||
|
overrideModel ||
|
||||||
process.env.MODEL ||
|
process.env.MODEL ||
|
||||||
"google/gemini-3-flash-preview";
|
"google/gemini-3-flash-preview";
|
||||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
const prompt = buildTailoringPrompt(profile, jobDescription, writingStyle);
|
||||||
|
|
||||||
const llm = new LlmService();
|
const llm = new LlmService();
|
||||||
const result = await llm.callJson<TailoredData>({
|
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
|
// Extract only needed parts of profile to save tokens
|
||||||
const relevantProfile = {
|
const relevantProfile = {
|
||||||
basics: {
|
basics: {
|
||||||
@ -182,6 +189,12 @@ INSTRUCTIONS:
|
|||||||
- Keep my original skill levels and categories, just rename/reorder keywords to prioritize JD terms.
|
- 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": [...] }.
|
- 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):
|
OUTPUT FORMAT (JSON):
|
||||||
{
|
{
|
||||||
"headline": "...",
|
"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,
|
parse: parseJsonArrayOrNull,
|
||||||
serialize: serializeNullableJsonArray,
|
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: {
|
searchCities: {
|
||||||
kind: "typed" as const,
|
kind: "typed" as const,
|
||||||
schema: z.string().trim().max(100),
|
schema: z.string().trim().max(100),
|
||||||
|
|||||||
@ -163,6 +163,11 @@ export const createAppSettings = (
|
|||||||
default: [],
|
default: [],
|
||||||
override: null,
|
override: null,
|
||||||
},
|
},
|
||||||
|
scoringInstructions: {
|
||||||
|
value: "",
|
||||||
|
default: "",
|
||||||
|
override: null,
|
||||||
|
},
|
||||||
searchCities: {
|
searchCities: {
|
||||||
value: "United Kingdom",
|
value: "United Kingdom",
|
||||||
default: "United Kingdom",
|
default: "United Kingdom",
|
||||||
|
|||||||
@ -126,6 +126,7 @@ export interface AppSettings {
|
|||||||
gradcrackerMaxJobsPerTerm: Resolved<number>;
|
gradcrackerMaxJobsPerTerm: Resolved<number>;
|
||||||
searchTerms: Resolved<string[]>;
|
searchTerms: Resolved<string[]>;
|
||||||
blockedCompanyKeywords: Resolved<string[]>;
|
blockedCompanyKeywords: Resolved<string[]>;
|
||||||
|
scoringInstructions: Resolved<string>;
|
||||||
searchCities: Resolved<string>;
|
searchCities: Resolved<string>;
|
||||||
jobspyResultsWanted: Resolved<number>;
|
jobspyResultsWanted: Resolved<number>;
|
||||||
jobspyCountryIndeed: Resolved<string>;
|
jobspyCountryIndeed: Resolved<string>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user