Add language settings for AI-generated resume output (#252)
* Add language settings for AI-generated resume output * Resolve merge conflicts for language settings PR * Fix language settings review feedback and CI lint * Tighten language setting precedence and onboarding validation --------- Co-authored-by: saad <Saad>
This commit is contained in:
parent
faea61a249
commit
f92b80dfe2
@ -47,7 +47,7 @@ Global settings affecting generations:
|
||||
|
||||
Ghostwriter follows the output language you request in your prompt. For example, `Ecris en français` should produce a French reply.
|
||||
|
||||
If you want a persistent default language, put it in `Constraints`, for example: `Always respond in French.`
|
||||
If you want a persistent default language, set it in **Settings → Writing Style & Language**.
|
||||
|
||||
`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.
|
||||
|
||||
@ -85,7 +85,7 @@ Compatibility thread endpoints remain, but UI behavior is one thread per job.
|
||||
|
||||
- Check model/provider configuration in Settings.
|
||||
- Tighten prompts with explicit output intent (for example, "3 bullet points for recruiter outreach").
|
||||
- If you need a non-English response every time, add that language requirement to `Constraints`.
|
||||
- If you need a non-English response every time, set it in **Settings → Writing Style & Language**.
|
||||
|
||||
### Missing context in answers
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
|
||||
|
||||
- Toggle visa sponsor badge visibility in job lists/details
|
||||
|
||||
### Writing Style
|
||||
### Writing Style & Language
|
||||
|
||||

|
||||
|
||||
@ -70,12 +70,41 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
|
||||
- Set global writing defaults:
|
||||
- Tone
|
||||
- Formality
|
||||
- Output language mode
|
||||
- Manual output language
|
||||
- Constraints
|
||||
- Do-not-use terms
|
||||
- These settings apply to Ghostwriter and resume tailoring
|
||||
- `Constraints` can also set a default output language, for example `Always respond in French.`
|
||||
- Use the output language controls as the primary way to choose generated language
|
||||
- Choose how AI output language is resolved:
|
||||
- `Manual`: always use the language you select, such as English, German, French, or Spanish
|
||||
- `Match Resume`: detect the dominant language from your resume/profile content and use that language for generated output
|
||||
- If language detection is unclear or there is not enough resume/profile text, JobOps falls back to English
|
||||
- Resume tailoring keeps the exact source wording for ATS-sensitive resume headlines and job titles, even when the rest of the tailored content is generated in the selected language
|
||||
- Do-not-use terms are model guidance, not a guaranteed output filter
|
||||
|
||||
#### Writing Style & Language workflow
|
||||
|
||||
Use these steps when you want Ghostwriter and resume tailoring to stay in a specific language:
|
||||
|
||||
1. Open **Settings**.
|
||||
2. Expand **Writing Style & Language**.
|
||||
3. Choose a preset if you want a starting point for tone and formality.
|
||||
4. Under the language control, choose one of these modes:
|
||||
- **Manual**: pick the output language directly.
|
||||
- **Match Resume**: let JobOps infer the language from your resume/profile text.
|
||||
5. If you chose **Manual**, select the language you want the AI to use.
|
||||
6. Review the rest of the writing defaults such as tone, formality, constraints, and do-not-use terms.
|
||||
7. Click **Save Changes**.
|
||||
8. Run Ghostwriter or start resume tailoring again so the new language preference is applied to new output.
|
||||
|
||||
Defaults and constraints:
|
||||
|
||||
- `Manual` is best when you always want output in one language regardless of the resume source text.
|
||||
- `Match Resume` is best when your base resume is already written in the language you want to preserve.
|
||||
- If JobOps cannot determine a reliable resume/profile language, it safely uses English.
|
||||
- The generated resume content follows the resolved language, but ATS-sensitive headline and job-title wording stays exact so matching and parsing remain safer.
|
||||
|
||||
### Reactive Resume
|
||||
|
||||

|
||||
@ -166,6 +195,19 @@ curl -X POST "http://localhost:3001/api/backups"
|
||||
- Some settings apply only to new runs/actions after save.
|
||||
- Re-run scoring/tailoring/pipeline to validate effect.
|
||||
|
||||
### Resume tailoring used English instead of my resume language
|
||||
|
||||
- Open **Settings → Writing Style & Language** and confirm whether the language mode is set to **Manual** or **Match Resume**.
|
||||
- If you want a specific language every time, switch to **Manual** and select that language explicitly.
|
||||
- If you use **Match Resume**, make sure your resume/profile text has enough content in the target language for detection.
|
||||
- If detection is ambiguous, JobOps falls back to English by design.
|
||||
|
||||
### My headline or target job title did not get translated
|
||||
|
||||
- This is expected during resume tailoring.
|
||||
- JobOps intentionally preserves exact headline and job-title wording for ATS safety, even when other tailored sections are generated in another language.
|
||||
- If you need a different headline or target title, change the source resume/profile text first and then re-run tailoring.
|
||||
|
||||
### RxResume controls are disabled
|
||||
|
||||
- Configure RxResume credentials in Environment & Accounts first.
|
||||
@ -191,9 +233,9 @@ curl -X POST "http://localhost:3001/api/backups"
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Reactive Resume](./reactive-resume)
|
||||
- [Database Backups](../getting-started/database-backups)
|
||||
- [Overview](./overview)
|
||||
- [Orchestrator](./orchestrator)
|
||||
- [Ghostwriter](./ghostwriter)
|
||||
- [Self-Hosting](../getting-started/self-hosting)
|
||||
- [Reactive Resume](/docs/next/features/reactive-resume)
|
||||
- [Database Backups](/docs/next/getting-started/database-backups)
|
||||
- [Overview](/docs/next/features/overview)
|
||||
- [Orchestrator](/docs/next/features/orchestrator)
|
||||
- [Ghostwriter](/docs/next/features/ghostwriter)
|
||||
- [Self-Hosting](/docs/next/getting-started/self-hosting)
|
||||
|
||||
@ -8,6 +8,8 @@ import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
@ -63,9 +65,20 @@ const openModelSection = async () => {
|
||||
fireEvent.click(modelTrigger);
|
||||
};
|
||||
|
||||
const openWritingStyleSection = async () => {
|
||||
const chatTrigger = await screen.findByRole("button", {
|
||||
name: /writing style & language/i,
|
||||
});
|
||||
fireEvent.click(chatTrigger);
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
_resetTracerReadinessCache();
|
||||
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||
status: "ready",
|
||||
@ -82,6 +95,13 @@ describe("SettingsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves trimmed model overrides", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue({
|
||||
@ -256,6 +276,41 @@ describe("SettingsPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("saves the writing language mode through the settings page", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(
|
||||
createAppSettings({
|
||||
chatStyleLanguageMode: {
|
||||
value: "match-resume",
|
||||
default: "manual",
|
||||
override: "match-resume",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await openWritingStyleSection();
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: /output language/i }));
|
||||
fireEvent.click(await screen.findByText("Match current resume language"));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: /specific language/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
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({
|
||||
chatStyleLanguageMode: "match-resume",
|
||||
chatStyleManualLanguage: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("enables save button when basic auth toggle is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
renderPage();
|
||||
|
||||
@ -72,6 +72,8 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
chatStyleFormality: "",
|
||||
chatStyleConstraints: "",
|
||||
chatStyleDoNotUse: "",
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
@ -127,6 +129,8 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
chatStyleFormality: null,
|
||||
chatStyleConstraints: null,
|
||||
chatStyleDoNotUse: null,
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePassword: null,
|
||||
rxresumeApiKey: null,
|
||||
@ -167,6 +171,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
chatStyleFormality: data.chatStyleFormality.override ?? "",
|
||||
chatStyleConstraints: data.chatStyleConstraints.override ?? "",
|
||||
chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "",
|
||||
chatStyleLanguageMode: data.chatStyleLanguageMode.override ?? null,
|
||||
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
rxresumeApiKey: "",
|
||||
@ -284,6 +290,14 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
effective: settings?.chatStyleDoNotUse?.value ?? "",
|
||||
default: settings?.chatStyleDoNotUse?.default ?? "",
|
||||
},
|
||||
languageMode: {
|
||||
effective: settings?.chatStyleLanguageMode?.value ?? "manual",
|
||||
default: settings?.chatStyleLanguageMode?.default ?? "manual",
|
||||
},
|
||||
manualLanguage: {
|
||||
effective: settings?.chatStyleManualLanguage?.value ?? "english",
|
||||
default: settings?.chatStyleManualLanguage?.default ?? "english",
|
||||
},
|
||||
},
|
||||
envSettings: {
|
||||
readable: {
|
||||
@ -780,6 +794,8 @@ export const SettingsPage: React.FC = () => {
|
||||
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
||||
chatStyleConstraints: normalizeString(data.chatStyleConstraints),
|
||||
chatStyleDoNotUse: normalizeString(data.chatStyleDoNotUse),
|
||||
chatStyleLanguageMode: data.chatStyleLanguageMode ?? null,
|
||||
chatStyleManualLanguage: data.chatStyleManualLanguage ?? null,
|
||||
backupEnabled: nullIfSame(
|
||||
data.backupEnabled,
|
||||
backup.backupEnabled.default,
|
||||
|
||||
@ -48,8 +48,13 @@ vi.mock("@/components/ui/select", () => {
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const SelectTrigger = ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
const SelectTrigger = ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" role="combobox" aria-expanded="false" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const SelectValue = () => null;
|
||||
|
||||
@ -69,6 +74,8 @@ const ChatSettingsHarness = () => {
|
||||
chatStyleFormality: "",
|
||||
chatStyleConstraints: "",
|
||||
chatStyleDoNotUse: "",
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
},
|
||||
});
|
||||
|
||||
@ -81,6 +88,8 @@ const ChatSettingsHarness = () => {
|
||||
formality: { effective: "medium", default: "medium" },
|
||||
constraints: { effective: "", default: "" },
|
||||
doNotUse: { effective: "", default: "" },
|
||||
languageMode: { effective: "manual", default: "manual" },
|
||||
manualLanguage: { effective: "english", default: "english" },
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
@ -98,6 +107,8 @@ describe("ChatSettingsSection", () => {
|
||||
0,
|
||||
);
|
||||
expect(screen.getByDisplayValue("medium")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("manual")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("english")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies preset values to the writing style fields", () => {
|
||||
@ -113,4 +124,17 @@ describe("ChatSettingsSection", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the manual language selector when matching the resume language", () => {
|
||||
render(<ChatSettingsHarness />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Match current resume language" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: /specific language/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("english")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,12 @@ import {
|
||||
} from "@client/pages/settings/constants";
|
||||
import type { ChatValues } from "@client/pages/settings/types";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import {
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_LABELS,
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||
type ChatStyleLanguageMode,
|
||||
type ChatStyleManualLanguage,
|
||||
} from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
@ -30,6 +36,11 @@ type ChatSettingsSectionProps = {
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
const LANGUAGE_MODE_LABELS: Record<ChatStyleLanguageMode, string> = {
|
||||
manual: "Choose specific language",
|
||||
"match-resume": "Match current resume language",
|
||||
};
|
||||
|
||||
function parseTokenizedTerms(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]/g)
|
||||
@ -50,25 +61,40 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { tone, formality, constraints, doNotUse } = values;
|
||||
const {
|
||||
tone,
|
||||
formality,
|
||||
constraints,
|
||||
doNotUse,
|
||||
languageMode,
|
||||
manualLanguage,
|
||||
} = values;
|
||||
|
||||
const { control, register, setValue } = useFormContext<UpdateSettingsInput>();
|
||||
const [doNotUseDraft, setDoNotUseDraft] = useState("");
|
||||
const [toneValue, formalityValue, constraintsValue, doNotUseValue] = useWatch(
|
||||
{
|
||||
control,
|
||||
name: [
|
||||
"chatStyleTone",
|
||||
"chatStyleFormality",
|
||||
"chatStyleConstraints",
|
||||
"chatStyleDoNotUse",
|
||||
],
|
||||
},
|
||||
);
|
||||
const [
|
||||
toneValue,
|
||||
formalityValue,
|
||||
constraintsValue,
|
||||
doNotUseValue,
|
||||
languageModeValue,
|
||||
] = useWatch({
|
||||
control,
|
||||
name: [
|
||||
"chatStyleTone",
|
||||
"chatStyleFormality",
|
||||
"chatStyleConstraints",
|
||||
"chatStyleDoNotUse",
|
||||
"chatStyleLanguageMode",
|
||||
],
|
||||
});
|
||||
const toneDraft = normalizeBlank(toneValue);
|
||||
const formalityDraft = normalizeBlank(formalityValue);
|
||||
const constraintsDraft = normalizeBlank(constraintsValue);
|
||||
const doNotUseDraftValue = normalizeBlank(doNotUseValue);
|
||||
const resolvedLanguageMode =
|
||||
normalizeBlank(languageModeValue) ?? languageMode.default;
|
||||
const showManualLanguage = resolvedLanguageMode === "manual";
|
||||
const resolvedStyle = resolveWritingStyleDraft({
|
||||
values: {
|
||||
tone: toneDraft,
|
||||
@ -87,7 +113,9 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
||||
return (
|
||||
<AccordionItem value="chat" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Writing Style</span>
|
||||
<span className="text-base font-semibold">
|
||||
Writing Style & Language
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
@ -146,6 +174,97 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="chatStyleLanguageMode"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Output language
|
||||
</label>
|
||||
<Controller
|
||||
name="chatStyleLanguageMode"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={normalizeBlank(field.value) ?? languageMode.default}
|
||||
onValueChange={(value) => {
|
||||
const nextValue = value as ChatStyleLanguageMode;
|
||||
field.onChange(nextValue);
|
||||
if (nextValue !== "manual") {
|
||||
setValue("chatStyleManualLanguage", null, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="chatStyleLanguageMode"
|
||||
aria-label="Output language"
|
||||
>
|
||||
<SelectValue placeholder="Select output language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="match-resume">
|
||||
Match current resume language
|
||||
</SelectItem>
|
||||
<SelectItem value="manual">
|
||||
Choose specific language
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Choose how AI picks the output language.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showManualLanguage ? (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="chatStyleManualLanguage"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Specific language
|
||||
</label>
|
||||
<Controller
|
||||
name="chatStyleManualLanguage"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={
|
||||
normalizeBlank(field.value) ?? manualLanguage.default
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value as ChatStyleManualLanguage)
|
||||
}
|
||||
disabled={isLoading || isSaving}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="chatStyleManualLanguage"
|
||||
aria-label="Specific language"
|
||||
>
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHAT_STYLE_MANUAL_LANGUAGE_VALUES.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{CHAT_STYLE_MANUAL_LANGUAGE_LABELS[language]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Used when output language is set to a specific language.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="chatStyleTone" className="text-sm font-medium">
|
||||
@ -255,7 +374,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Tone</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
@ -268,6 +387,24 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
|
||||
Effective: {formality.effective} | Default: {formality.default}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Language mode</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
Effective: {LANGUAGE_MODE_LABELS[languageMode.effective]} |
|
||||
Default: {LANGUAGE_MODE_LABELS[languageMode.default]}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Specific language
|
||||
</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
Effective:{" "}
|
||||
{CHAT_STYLE_MANUAL_LANGUAGE_LABELS[manualLanguage.effective]} |
|
||||
Default:{" "}
|
||||
{CHAT_STYLE_MANUAL_LANGUAGE_LABELS[manualLanguage.default]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
@ -22,6 +22,8 @@ describe("settings constants", () => {
|
||||
default: "Keep it warm",
|
||||
},
|
||||
doNotUse: { effective: "", default: "" },
|
||||
languageMode: { effective: "manual", default: "manual" },
|
||||
manualLanguage: { effective: "english", default: "english" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
@ -49,6 +51,8 @@ describe("settings constants", () => {
|
||||
default: "",
|
||||
},
|
||||
doNotUse: { effective: "synergy", default: "" },
|
||||
languageMode: { effective: "manual", default: "manual" },
|
||||
manualLanguage: { effective: "english", default: "english" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import type {
|
||||
ChatStyleLanguageMode,
|
||||
ChatStyleManualLanguage,
|
||||
} from "@shared/types.js";
|
||||
|
||||
export type EffectiveDefault<T> = {
|
||||
effective: T;
|
||||
default: T;
|
||||
@ -19,6 +24,8 @@ export type ChatValues = {
|
||||
formality: EffectiveDefault<string>;
|
||||
constraints: EffectiveDefault<string>;
|
||||
doNotUse: EffectiveDefault<string>;
|
||||
languageMode: EffectiveDefault<ChatStyleLanguageMode>;
|
||||
manualLanguage: EffectiveDefault<ChatStyleManualLanguage>;
|
||||
};
|
||||
|
||||
export type EnvSettingsValues = {
|
||||
|
||||
@ -263,6 +263,69 @@ describe.sequential("Onboarding API routes", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reuse a stored baseUrl when openai-compatible validation is submitted with a blank baseUrl", async () => {
|
||||
await fetch(`${baseUrl}/api/settings`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
llmProvider: "openai_compatible",
|
||||
llmApiKey: "stored-compatible-key",
|
||||
llmBaseUrl: "https://stale.example.com/v1/",
|
||||
}),
|
||||
});
|
||||
|
||||
global.fetch = vi.fn((input, init) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
if (url.startsWith("https://api.openai.com/v1/models")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [] }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.startsWith("https://stale.example.com/v1/models")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { message: "stale endpoint used" } }),
|
||||
} as Response);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "test-compatible-key",
|
||||
baseUrl: " ",
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(true);
|
||||
expect(body.data.message).toBeNull();
|
||||
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
|
||||
const requestInput = call[0];
|
||||
if (typeof requestInput === "string") return requestInput;
|
||||
if (requestInput instanceof URL) return requestInput.href;
|
||||
return requestInput.url;
|
||||
});
|
||||
expect(
|
||||
fetchCalls.some((url) =>
|
||||
url.startsWith("https://api.openai.com/v1/models"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fetchCalls.some((url) =>
|
||||
url.startsWith("https://stale.example.com/v1/models"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/validate/rxresume", () => {
|
||||
|
||||
@ -19,6 +19,15 @@ type ValidationResponse = {
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
function getDefaultValidationBaseUrl(
|
||||
provider: string | undefined,
|
||||
): string | undefined {
|
||||
if (provider === "lmstudio") return "http://localhost:1234";
|
||||
if (provider === "ollama") return "http://localhost:11434";
|
||||
if (provider === "openai_compatible") return "https://api.openai.com";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function validateLlm(options: {
|
||||
apiKey?: string | null;
|
||||
provider?: string | null;
|
||||
@ -37,8 +46,13 @@ async function validateLlm(options: {
|
||||
normalizedProvider === "lmstudio" ||
|
||||
normalizedProvider === "ollama" ||
|
||||
normalizedProvider === "openai_compatible";
|
||||
const hasExplicitBaseUrlOverride =
|
||||
options.baseUrl !== undefined && options.baseUrl !== null;
|
||||
const resolvedBaseUrl = shouldUseBaseUrl
|
||||
? options.baseUrl?.trim() || storedBaseUrl?.trim() || undefined
|
||||
? hasExplicitBaseUrlOverride
|
||||
? options.baseUrl?.trim() ||
|
||||
getDefaultValidationBaseUrl(normalizedProvider)
|
||||
: storedBaseUrl?.trim() || undefined
|
||||
: undefined;
|
||||
const resolvedApiKey = options.apiKey?.trim() || storedApiKey?.trim() || null;
|
||||
|
||||
|
||||
@ -11,9 +11,14 @@ vi.mock("./profile", () => ({
|
||||
getProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./writing-style", () => ({
|
||||
getWritingStyle: vi.fn(),
|
||||
}));
|
||||
vi.mock("./writing-style", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./writing-style")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getWritingStyle: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { getJobById } from "../repositories/jobs";
|
||||
import { getProfile } from "./profile";
|
||||
@ -27,6 +32,8 @@ describe("buildJobChatPromptContext", () => {
|
||||
formality: "medium",
|
||||
constraints: "",
|
||||
doNotUse: "",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "english",
|
||||
});
|
||||
});
|
||||
|
||||
@ -44,6 +51,8 @@ describe("buildJobChatPromptContext", () => {
|
||||
formality: "high",
|
||||
constraints: "Keep responses under 120 words",
|
||||
doNotUse: "synergy, leverage",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "german",
|
||||
});
|
||||
vi.mocked(getProfile).mockResolvedValue({
|
||||
basics: {
|
||||
@ -77,6 +86,8 @@ describe("buildJobChatPromptContext", () => {
|
||||
formality: "high",
|
||||
constraints: "Keep responses under 120 words",
|
||||
doNotUse: "synergy, leverage",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "german",
|
||||
});
|
||||
expect(context.systemPrompt).toContain("Writing style tone: direct.");
|
||||
expect(context.systemPrompt).toContain("Writing style formality: high.");
|
||||
@ -84,10 +95,10 @@ describe("buildJobChatPromptContext", () => {
|
||||
"Follow the user's requested output language exactly when they specify one.",
|
||||
);
|
||||
expect(context.systemPrompt).toContain(
|
||||
"If the global writing constraints specify an output language, follow that when the user has not requested a different language.",
|
||||
"When the user does not request a language, default to writing user-visible resume or application content in German.",
|
||||
);
|
||||
expect(context.systemPrompt).toContain(
|
||||
"If no output language is specified elsewhere, reply in the same language as the most recent user message.",
|
||||
"When suggesting a headline or job title, preserve the original wording instead of translating it.",
|
||||
);
|
||||
expect(context.systemPrompt).toContain(
|
||||
"Writing constraints: Keep responses under 120 words",
|
||||
@ -113,22 +124,60 @@ describe("buildJobChatPromptContext", () => {
|
||||
expect(context.systemPrompt).toContain("Writing style tone: professional.");
|
||||
});
|
||||
|
||||
it("preserves language instructions inside global writing constraints", async () => {
|
||||
it("matches Ghostwriter language to detected resume language when configured", async () => {
|
||||
const job = createJob({ id: "job-ctx-3" });
|
||||
vi.mocked(getJobById).mockResolvedValue(job);
|
||||
vi.mocked(getWritingStyle).mockResolvedValue({
|
||||
tone: "professional",
|
||||
formality: "medium",
|
||||
constraints: "Always respond in French.",
|
||||
constraints: "",
|
||||
doNotUse: "",
|
||||
languageMode: "match-resume",
|
||||
manualLanguage: "english",
|
||||
});
|
||||
vi.mocked(getProfile).mockResolvedValue({
|
||||
basics: {
|
||||
name: "Claire",
|
||||
summary:
|
||||
"Je conçois des plateformes de données et je travaille avec des équipes produit et ingénierie.",
|
||||
},
|
||||
sections: {
|
||||
summary: {
|
||||
content:
|
||||
"Expérience en développement, livraison et accompagnement des équipes.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const context = await buildJobChatPromptContext(job.id);
|
||||
|
||||
expect(context.systemPrompt).toContain(
|
||||
"When the user does not request a language, default to writing user-visible resume or application content in French.",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes language instructions from global writing constraints", async () => {
|
||||
const job = createJob({ id: "job-ctx-4" });
|
||||
vi.mocked(getJobById).mockResolvedValue(job);
|
||||
vi.mocked(getWritingStyle).mockResolvedValue({
|
||||
tone: "professional",
|
||||
formality: "medium",
|
||||
constraints: "Always respond in French. Keep responses under 120 words.",
|
||||
doNotUse: "",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "english",
|
||||
});
|
||||
vi.mocked(getProfile).mockResolvedValue({});
|
||||
|
||||
const context = await buildJobChatPromptContext(job.id);
|
||||
|
||||
expect(context.systemPrompt).toContain(
|
||||
"Writing constraints: Always respond in French.",
|
||||
"When the user does not request a language, default to writing user-visible resume or application content in English.",
|
||||
);
|
||||
expect(context.systemPrompt).toContain(
|
||||
"Writing constraints: Keep responses under 120 words",
|
||||
);
|
||||
expect(context.systemPrompt).not.toContain("Always respond in French");
|
||||
});
|
||||
|
||||
it("throws not found for unknown job", async () => {
|
||||
|
||||
@ -3,8 +3,16 @@ import { logger } from "@infra/logger";
|
||||
import { sanitizeUnknown } from "@infra/sanitize";
|
||||
import type { Job, ResumeProfile } from "@shared/types";
|
||||
import * as jobsRepo from "../repositories/jobs";
|
||||
import {
|
||||
getWritingLanguageLabel,
|
||||
resolveWritingOutputLanguage,
|
||||
} from "./output-language";
|
||||
import { getProfile } from "./profile";
|
||||
import { getWritingStyle, type WritingStyle } from "./writing-style";
|
||||
import {
|
||||
getWritingStyle,
|
||||
stripLanguageDirectivesFromConstraints,
|
||||
type WritingStyle,
|
||||
} from "./writing-style";
|
||||
|
||||
export type JobChatPromptContext = {
|
||||
job: Job;
|
||||
@ -96,7 +104,19 @@ function buildProfileSnapshot(profile: ResumeProfile): string {
|
||||
]);
|
||||
}
|
||||
|
||||
function buildSystemPrompt(style: WritingStyle): string {
|
||||
function buildSystemPrompt(
|
||||
style: WritingStyle,
|
||||
profile: ResumeProfile,
|
||||
): string {
|
||||
const resolvedLanguage = resolveWritingOutputLanguage({
|
||||
style,
|
||||
profile,
|
||||
});
|
||||
const outputLanguage = getWritingLanguageLabel(resolvedLanguage.language);
|
||||
const effectiveConstraints = stripLanguageDirectivesFromConstraints(
|
||||
style.constraints,
|
||||
);
|
||||
|
||||
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.",
|
||||
@ -104,11 +124,13 @@ function buildSystemPrompt(style: WritingStyle): string {
|
||||
"If details are missing, say what is missing before making assumptions.",
|
||||
"Avoid exposing private profile details that are unrelated to the user request.",
|
||||
"Follow the user's requested output language exactly when they specify one.",
|
||||
"If the global writing constraints specify an output language, follow that when the user has not requested a different language.",
|
||||
"If no output language is specified elsewhere, reply in the same language as the most recent user message.",
|
||||
`When the user does not request a language, default to writing user-visible resume or application content in ${outputLanguage}.`,
|
||||
`When suggesting a headline or job title, preserve the original wording instead of translating it.`,
|
||||
`Writing style tone: ${style.tone}.`,
|
||||
`Writing style formality: ${style.formality}.`,
|
||||
style.constraints ? `Writing constraints: ${style.constraints}` : null,
|
||||
effectiveConstraints
|
||||
? `Writing constraints: ${effectiveConstraints}`
|
||||
: null,
|
||||
style.doNotUse ? `Avoid these terms: ${style.doNotUse}` : null,
|
||||
]);
|
||||
}
|
||||
@ -133,9 +155,9 @@ export async function buildJobChatPromptContext(
|
||||
});
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(style);
|
||||
const jobSnapshot = buildJobSnapshot(job);
|
||||
const profileSnapshot = buildProfileSnapshot(profile);
|
||||
const systemPrompt = buildSystemPrompt(style, profile);
|
||||
const jobSnapshot = buildJobSnapshot(job);
|
||||
|
||||
if (!jobSnapshot.trim()) {
|
||||
throw badRequest("Unable to build job context");
|
||||
|
||||
71
orchestrator/src/server/services/output-language.test.ts
Normal file
71
orchestrator/src/server/services/output-language.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectProfileLanguage,
|
||||
resolveWritingOutputLanguage,
|
||||
} from "./output-language";
|
||||
|
||||
describe("resolveWritingOutputLanguage", () => {
|
||||
it("uses the manual language when manual mode is selected", () => {
|
||||
const result = resolveWritingOutputLanguage({
|
||||
style: {
|
||||
languageMode: "manual",
|
||||
manualLanguage: "spanish",
|
||||
},
|
||||
profile: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
language: "spanish",
|
||||
source: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects supported non-english resume language from profile text", () => {
|
||||
const profile: ResumeProfile = {
|
||||
basics: {
|
||||
summary:
|
||||
"Ich entwickle skalierbare Plattformen und arbeite eng mit Produktteams und der Entwicklung zusammen.",
|
||||
},
|
||||
sections: {
|
||||
summary: {
|
||||
content:
|
||||
"Erfahrung mit verteilten Systemen, APIs und verantwortlicher Lieferung für das Team.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(detectProfileLanguage(profile)).toBe("german");
|
||||
expect(
|
||||
resolveWritingOutputLanguage({
|
||||
style: {
|
||||
languageMode: "match-resume",
|
||||
manualLanguage: "english",
|
||||
},
|
||||
profile,
|
||||
}),
|
||||
).toEqual({
|
||||
language: "german",
|
||||
source: "detected",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to english when resume language detection is weak", () => {
|
||||
const result = resolveWritingOutputLanguage({
|
||||
style: {
|
||||
languageMode: "match-resume",
|
||||
manualLanguage: "french",
|
||||
},
|
||||
profile: {
|
||||
basics: {
|
||||
headline: "Senior Engineer",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
language: "english",
|
||||
source: "fallback",
|
||||
});
|
||||
});
|
||||
});
|
||||
194
orchestrator/src/server/services/output-language.ts
Normal file
194
orchestrator/src/server/services/output-language.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import {
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_LABELS,
|
||||
type ChatStyleLanguageMode,
|
||||
type ChatStyleManualLanguage,
|
||||
type ResumeProfile,
|
||||
} from "@shared/types";
|
||||
|
||||
type WritingLanguageConfig = {
|
||||
languageMode: ChatStyleLanguageMode;
|
||||
manualLanguage: ChatStyleManualLanguage;
|
||||
};
|
||||
|
||||
export type ResolvedWritingLanguage = {
|
||||
language: ChatStyleManualLanguage;
|
||||
source: "manual" | "detected" | "fallback";
|
||||
};
|
||||
|
||||
const LANGUAGE_MARKERS: Record<ChatStyleManualLanguage, Set<string>> = {
|
||||
english: new Set([
|
||||
"the",
|
||||
"and",
|
||||
"with",
|
||||
"for",
|
||||
"from",
|
||||
"using",
|
||||
"building",
|
||||
"developed",
|
||||
"delivered",
|
||||
"experience",
|
||||
"led",
|
||||
]),
|
||||
german: new Set([
|
||||
"und",
|
||||
"mit",
|
||||
"für",
|
||||
"der",
|
||||
"die",
|
||||
"das",
|
||||
"ich",
|
||||
"nicht",
|
||||
"entwicklung",
|
||||
"erfahrung",
|
||||
"verantwortlich",
|
||||
]),
|
||||
french: new Set([
|
||||
"et",
|
||||
"avec",
|
||||
"pour",
|
||||
"les",
|
||||
"des",
|
||||
"une",
|
||||
"dans",
|
||||
"sur",
|
||||
"expérience",
|
||||
"développement",
|
||||
"responsable",
|
||||
]),
|
||||
spanish: new Set([
|
||||
"y",
|
||||
"con",
|
||||
"para",
|
||||
"los",
|
||||
"las",
|
||||
"una",
|
||||
"que",
|
||||
"experiencia",
|
||||
"desarrollo",
|
||||
"responsable",
|
||||
"lideré",
|
||||
]),
|
||||
};
|
||||
|
||||
const SPECIAL_CHARACTER_PATTERNS: Partial<
|
||||
Record<ChatStyleManualLanguage, RegExp>
|
||||
> = {
|
||||
german: /[äöüß]/gi,
|
||||
french: /[àâæçéèêëîïôœùûüÿ]/gi,
|
||||
spanish: /[áéíóúñ¿¡]/gi,
|
||||
};
|
||||
|
||||
function collectProfileLanguageSample(profile: ResumeProfile): string {
|
||||
const segments: string[] = [];
|
||||
|
||||
const add = (value: string | null | undefined): void => {
|
||||
if (!value) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
segments.push(trimmed);
|
||||
};
|
||||
|
||||
add(profile.basics?.headline);
|
||||
add(profile.basics?.label);
|
||||
add(profile.basics?.summary);
|
||||
add(profile.sections?.summary?.content);
|
||||
|
||||
for (const item of profile.sections?.projects?.items ?? []) {
|
||||
if (item.visible === false) continue;
|
||||
add(item.description);
|
||||
add(item.summary);
|
||||
}
|
||||
|
||||
for (const item of profile.sections?.experience?.items ?? []) {
|
||||
if (item.visible === false) continue;
|
||||
add(item.position);
|
||||
add(item.summary);
|
||||
}
|
||||
|
||||
return segments.join("\n");
|
||||
}
|
||||
|
||||
function scoreLanguageSample(
|
||||
sample: string,
|
||||
language: ChatStyleManualLanguage,
|
||||
): number {
|
||||
const normalized = sample.toLowerCase();
|
||||
const tokens = normalized.match(/\p{L}+/gu) ?? [];
|
||||
const markers = LANGUAGE_MARKERS[language];
|
||||
|
||||
let score = 0;
|
||||
for (const token of tokens) {
|
||||
if (markers.has(token)) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const specialCharacterPattern = SPECIAL_CHARACTER_PATTERNS[language];
|
||||
if (specialCharacterPattern) {
|
||||
score += (normalized.match(specialCharacterPattern)?.length ?? 0) * 3;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function detectProfileLanguage(
|
||||
profile: ResumeProfile,
|
||||
): ChatStyleManualLanguage | null {
|
||||
const sample = collectProfileLanguageSample(profile);
|
||||
if (!sample.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scoredLanguages = (
|
||||
Object.keys(CHAT_STYLE_MANUAL_LANGUAGE_LABELS) as ChatStyleManualLanguage[]
|
||||
)
|
||||
.map((language) => ({
|
||||
language,
|
||||
score: scoreLanguageSample(sample, language),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
const [best, second] = scoredLanguages;
|
||||
if (!best || best.score <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minimumScore = best.language === "english" ? 4 : 3;
|
||||
const margin = best.score - (second?.score ?? 0);
|
||||
if (best.score < minimumScore || margin < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return best.language;
|
||||
}
|
||||
|
||||
export function resolveWritingOutputLanguage(args: {
|
||||
style: WritingLanguageConfig;
|
||||
profile: ResumeProfile;
|
||||
}): ResolvedWritingLanguage {
|
||||
if (args.style.languageMode === "manual") {
|
||||
return {
|
||||
language: args.style.manualLanguage,
|
||||
source: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
const detectedLanguage = detectProfileLanguage(args.profile);
|
||||
if (detectedLanguage) {
|
||||
return {
|
||||
language: detectedLanguage,
|
||||
source: "detected",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
language: "english",
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
export function getWritingLanguageLabel(
|
||||
language: ChatStyleManualLanguage,
|
||||
): string {
|
||||
return CHAT_STYLE_MANUAL_LANGUAGE_LABELS[language];
|
||||
}
|
||||
@ -17,9 +17,14 @@ vi.mock("./llm/service", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./writing-style", () => ({
|
||||
getWritingStyle: vi.fn(),
|
||||
}));
|
||||
vi.mock("./writing-style", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./writing-style")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getWritingStyle: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { generateTailoring } from "./summary";
|
||||
@ -44,10 +49,12 @@ describe("generateTailoring", () => {
|
||||
formality: "low",
|
||||
constraints: "Keep it under 90 words",
|
||||
doNotUse: "synergy",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "german",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes shared writing-style instructions into tailoring prompts", async () => {
|
||||
it("passes shared writing-style and language instructions into tailoring prompts", async () => {
|
||||
const profile: ResumeProfile = {
|
||||
basics: {
|
||||
name: "Test User",
|
||||
@ -72,5 +79,43 @@ describe("generateTailoring", () => {
|
||||
expect(request?.messages?.[0]?.content).toContain(
|
||||
"Avoid these words or phrases: synergy",
|
||||
);
|
||||
expect(request?.messages?.[0]?.content).toContain(
|
||||
"Output language for summary and skills: German",
|
||||
);
|
||||
expect(request?.messages?.[0]?.content).toContain(
|
||||
"Do NOT translate, localize, or paraphrase the headline, even if the rest of the output is in German.",
|
||||
);
|
||||
expect(request?.messages?.[0]?.content).toContain(
|
||||
'Keep "headline" in the exact original job-title wording from the JD.',
|
||||
);
|
||||
});
|
||||
|
||||
it("removes language directives from constraints so explicit language settings win", async () => {
|
||||
vi.mocked(getWritingStyle).mockResolvedValue({
|
||||
tone: "friendly",
|
||||
formality: "low",
|
||||
constraints: "Always respond in French. Keep it under 90 words.",
|
||||
doNotUse: "synergy",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "german",
|
||||
});
|
||||
|
||||
await generateTailoring("Build APIs", {
|
||||
basics: {
|
||||
name: "Test User",
|
||||
label: "Engineer",
|
||||
},
|
||||
});
|
||||
|
||||
const request = callJsonMock.mock.calls.at(-1)?.[0];
|
||||
expect(request?.messages?.[0]?.content).toContain(
|
||||
"Additional constraints: Keep it under 90 words",
|
||||
);
|
||||
expect(request?.messages?.[0]?.content).not.toContain(
|
||||
"Always respond in French",
|
||||
);
|
||||
expect(request?.messages?.[0]?.content).toContain(
|
||||
"Output language for summary and skills: German",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,7 +7,14 @@ 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";
|
||||
import {
|
||||
getWritingLanguageLabel,
|
||||
resolveWritingOutputLanguage,
|
||||
} from "./output-language";
|
||||
import {
|
||||
getWritingStyle,
|
||||
stripLanguageDirectivesFromConstraints,
|
||||
} from "./writing-style";
|
||||
|
||||
export interface TailoredData {
|
||||
summary: string;
|
||||
@ -140,6 +147,15 @@ function buildTailoringPrompt(
|
||||
jd: string,
|
||||
writingStyle: Awaited<ReturnType<typeof getWritingStyle>>,
|
||||
): string {
|
||||
const resolvedLanguage = resolveWritingOutputLanguage({
|
||||
style: writingStyle,
|
||||
profile,
|
||||
});
|
||||
const outputLanguage = getWritingLanguageLabel(resolvedLanguage.language);
|
||||
const effectiveConstraints = stripLanguageDirectivesFromConstraints(
|
||||
writingStyle.constraints,
|
||||
);
|
||||
|
||||
// Extract only needed parts of profile to save tokens
|
||||
const relevantProfile = {
|
||||
basics: {
|
||||
@ -175,26 +191,33 @@ INSTRUCTIONS:
|
||||
1. "headline" (String):
|
||||
- CRITICAL: This is the #1 ATS factor.
|
||||
- It must match the Job Title from the JD exactly (e.g., if JD says "Senior React Dev", use "Senior React Dev").
|
||||
- If the JD title is very generic, you may add one specialty, but keep it matching the role.
|
||||
- Do NOT translate, localize, or paraphrase the headline, even if the rest of the output is in ${outputLanguage}.
|
||||
|
||||
2. "summary" (String):
|
||||
- The Hook. This needs to mirror the company's "About You" / "What we're looking for" section.
|
||||
- Keep it concise, warm, and confident.
|
||||
- Do NOT invent experience.
|
||||
- Use the profile to add context.
|
||||
- Write the summary in ${outputLanguage}.
|
||||
|
||||
3. "skills" (Array of Objects):
|
||||
- Review my existing skills section structure.
|
||||
- Keyword Stuffing: Swap synonyms to match the JD exactly (e.g. "TDD" -> "Unit Testing", "ReactJS" -> "React").
|
||||
- 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": [...] }.
|
||||
- Write user-visible skill text in ${outputLanguage} when natural, but keep exact JD terms, acronyms, and technology names when that helps ATS matching.
|
||||
|
||||
WRITING STYLE PREFERENCES:
|
||||
- Tone: ${writingStyle.tone}
|
||||
- Formality: ${writingStyle.formality}
|
||||
${writingStyle.constraints ? `- Additional constraints: ${writingStyle.constraints}` : ""}
|
||||
- Output language for summary and skills: ${outputLanguage}
|
||||
${effectiveConstraints ? `- Additional constraints: ${effectiveConstraints}` : ""}
|
||||
${writingStyle.doNotUse ? `- Avoid these words or phrases: ${writingStyle.doNotUse}` : ""}
|
||||
|
||||
ATS SAFETY:
|
||||
- Keep "headline" in the exact original job-title wording from the JD.
|
||||
- Do not translate the headline, even when summary and skills are written in ${outputLanguage}.
|
||||
|
||||
OUTPUT FORMAT (JSON):
|
||||
{
|
||||
"headline": "...",
|
||||
|
||||
@ -5,11 +5,23 @@ vi.mock("@server/repositories/settings", () => ({
|
||||
}));
|
||||
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { getWritingStyle } from "./writing-style";
|
||||
import {
|
||||
getWritingStyle,
|
||||
stripLanguageDirectivesFromConstraints,
|
||||
} from "./writing-style";
|
||||
|
||||
describe("getWritingStyle", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.CHAT_STYLE_TONE;
|
||||
delete process.env.CHAT_STYLE_FORMALITY;
|
||||
delete process.env.CHAT_STYLE_CONSTRAINTS;
|
||||
delete process.env.CHAT_STYLE_DO_NOT_USE;
|
||||
delete process.env.CHAT_STYLE_LANGUAGE_MODE;
|
||||
delete process.env.CHAT_STYLE_MANUAL_LANGUAGE;
|
||||
});
|
||||
|
||||
it("uses defaults when no overrides are stored", async () => {
|
||||
@ -20,6 +32,8 @@ describe("getWritingStyle", () => {
|
||||
formality: "medium",
|
||||
constraints: "",
|
||||
doNotUse: "",
|
||||
languageMode: "manual",
|
||||
manualLanguage: "english",
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,6 +48,10 @@ describe("getWritingStyle", () => {
|
||||
return "Keep it short";
|
||||
case "chatStyleDoNotUse":
|
||||
return "synergy";
|
||||
case "chatStyleLanguageMode":
|
||||
return "match-resume";
|
||||
case "chatStyleManualLanguage":
|
||||
return "german";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@ -44,6 +62,16 @@ describe("getWritingStyle", () => {
|
||||
formality: "low",
|
||||
constraints: "Keep it short",
|
||||
doNotUse: "synergy",
|
||||
languageMode: "match-resume",
|
||||
manualLanguage: "german",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips language directives from constraints while keeping other guidance", () => {
|
||||
expect(
|
||||
stripLanguageDirectivesFromConstraints(
|
||||
"Always respond in French. Keep it under 90 words. Output language: German.",
|
||||
),
|
||||
).toBe("Keep it under 90 words");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,21 +1,78 @@
|
||||
import * as settingsRepo from "@server/repositories/settings";
|
||||
import { settingsRegistry } from "@shared/settings-registry";
|
||||
import type {
|
||||
ChatStyleLanguageMode,
|
||||
ChatStyleManualLanguage,
|
||||
} from "@shared/types";
|
||||
|
||||
export type WritingStyle = {
|
||||
tone: string;
|
||||
formality: string;
|
||||
constraints: string;
|
||||
doNotUse: string;
|
||||
languageMode: ChatStyleLanguageMode;
|
||||
manualLanguage: ChatStyleManualLanguage;
|
||||
};
|
||||
|
||||
const LANGUAGE_NAMES_PATTERN = "english|german|french|spanish";
|
||||
|
||||
const LANGUAGE_DIRECTIVE_PATTERNS = [
|
||||
new RegExp(
|
||||
String.raw`\b(?:always\s+)?(?:respond|reply|write|generate|output)(?:\s+\w+){0,3}\s+(?:in|using)\s+(?:${LANGUAGE_NAMES_PATTERN})\b[.!]?`,
|
||||
"gi",
|
||||
),
|
||||
new RegExp(
|
||||
String.raw`\b(?:set|use|choose|default\s+to)\s+(?:the\s+)?(?:output\s+)?language(?:\s+to)?\s+(?:${LANGUAGE_NAMES_PATTERN})\b[.!]?`,
|
||||
"gi",
|
||||
),
|
||||
new RegExp(
|
||||
String.raw`\b(?:output|response)\s+language\s*[:=]?\s*(?:${LANGUAGE_NAMES_PATTERN})\b[.!]?`,
|
||||
"gi",
|
||||
),
|
||||
];
|
||||
|
||||
export function stripLanguageDirectivesFromConstraints(
|
||||
constraints: string,
|
||||
): string {
|
||||
if (!constraints.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return constraints
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => {
|
||||
let nextLine = line;
|
||||
|
||||
for (const pattern of LANGUAGE_DIRECTIVE_PATTERNS) {
|
||||
nextLine = nextLine.replace(pattern, "");
|
||||
}
|
||||
|
||||
return nextLine
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.replace(/\s+([,.;:!?])/g, "$1")
|
||||
.replace(/^[,.;:!?\s-]+|[,.;:!?\s-]+$/g, "")
|
||||
.trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
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"),
|
||||
]);
|
||||
const [
|
||||
toneRaw,
|
||||
formalityRaw,
|
||||
constraintsRaw,
|
||||
doNotUseRaw,
|
||||
languageModeRaw,
|
||||
manualLanguageRaw,
|
||||
] = await Promise.all([
|
||||
settingsRepo.getSetting("chatStyleTone"),
|
||||
settingsRepo.getSetting("chatStyleFormality"),
|
||||
settingsRepo.getSetting("chatStyleConstraints"),
|
||||
settingsRepo.getSetting("chatStyleDoNotUse"),
|
||||
settingsRepo.getSetting("chatStyleLanguageMode"),
|
||||
settingsRepo.getSetting("chatStyleManualLanguage"),
|
||||
]);
|
||||
|
||||
return {
|
||||
tone:
|
||||
@ -31,5 +88,13 @@ export async function getWritingStyle(): Promise<WritingStyle> {
|
||||
doNotUse:
|
||||
settingsRegistry.chatStyleDoNotUse.parse(doNotUseRaw ?? undefined) ??
|
||||
settingsRegistry.chatStyleDoNotUse.default(),
|
||||
languageMode:
|
||||
settingsRegistry.chatStyleLanguageMode.parse(
|
||||
languageModeRaw ?? undefined,
|
||||
) ?? settingsRegistry.chatStyleLanguageMode.default(),
|
||||
manualLanguage:
|
||||
settingsRegistry.chatStyleManualLanguage.parse(
|
||||
manualLanguageRaw ?? undefined,
|
||||
) ?? settingsRegistry.chatStyleManualLanguage.default(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -118,6 +118,67 @@ describe("settingsRegistry helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("writing-style language settings", () => {
|
||||
it("defaults to manual english", () => {
|
||||
const previousLanguageMode = process.env.CHAT_STYLE_LANGUAGE_MODE;
|
||||
const previousManualLanguage = process.env.CHAT_STYLE_MANUAL_LANGUAGE;
|
||||
|
||||
delete process.env.CHAT_STYLE_LANGUAGE_MODE;
|
||||
delete process.env.CHAT_STYLE_MANUAL_LANGUAGE;
|
||||
|
||||
try {
|
||||
expect(settingsRegistry.chatStyleLanguageMode.default()).toBe("manual");
|
||||
expect(settingsRegistry.chatStyleManualLanguage.default()).toBe(
|
||||
"english",
|
||||
);
|
||||
} finally {
|
||||
if (previousLanguageMode === undefined) {
|
||||
delete process.env.CHAT_STYLE_LANGUAGE_MODE;
|
||||
} else {
|
||||
process.env.CHAT_STYLE_LANGUAGE_MODE = previousLanguageMode;
|
||||
}
|
||||
|
||||
if (previousManualLanguage === undefined) {
|
||||
delete process.env.CHAT_STYLE_MANUAL_LANGUAGE;
|
||||
} else {
|
||||
process.env.CHAT_STYLE_MANUAL_LANGUAGE = previousManualLanguage;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parses and serializes supported language settings", () => {
|
||||
expect(settingsRegistry.chatStyleLanguageMode.parse("manual")).toBe(
|
||||
"manual",
|
||||
);
|
||||
expect(settingsRegistry.chatStyleLanguageMode.parse("match-resume")).toBe(
|
||||
"match-resume",
|
||||
);
|
||||
expect(settingsRegistry.chatStyleLanguageMode.parse("auto")).toBeNull();
|
||||
expect(settingsRegistry.chatStyleLanguageMode.parse("")).toBeNull();
|
||||
expect(
|
||||
settingsRegistry.chatStyleLanguageMode.serialize("match-resume"),
|
||||
).toBe("match-resume");
|
||||
expect(settingsRegistry.chatStyleLanguageMode.serialize(null)).toBeNull();
|
||||
|
||||
expect(settingsRegistry.chatStyleManualLanguage.parse("english")).toBe(
|
||||
"english",
|
||||
);
|
||||
expect(settingsRegistry.chatStyleManualLanguage.parse("german")).toBe(
|
||||
"german",
|
||||
);
|
||||
expect(
|
||||
settingsRegistry.chatStyleManualLanguage.parse("italian"),
|
||||
).toBeNull();
|
||||
expect(settingsRegistry.chatStyleManualLanguage.parse("")).toBeNull();
|
||||
expect(
|
||||
settingsRegistry.chatStyleManualLanguage.serialize("spanish"),
|
||||
).toBe("spanish");
|
||||
expect(
|
||||
settingsRegistry.chatStyleManualLanguage.serialize(null),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LLM provider parsing", () => {
|
||||
it("normalizes the documented openai-compatible alias", () => {
|
||||
expect(settingsRegistry.llmProvider.parse("openai-compatible")).toBe(
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import type { ResumeProjectsSettings } from "./types/settings";
|
||||
import {
|
||||
CHAT_STYLE_LANGUAGE_MODE_VALUES,
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||
type ChatStyleLanguageMode,
|
||||
type ChatStyleManualLanguage,
|
||||
type ResumeProjectsSettings,
|
||||
} from "./types/settings";
|
||||
|
||||
function parseNonEmptyStringOrNull(raw: string | undefined): string | null {
|
||||
return raw === undefined || raw === "" ? null : raw;
|
||||
@ -49,6 +55,25 @@ function serializeBitBool(value: boolean | null | undefined): string | null {
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
|
||||
function createEnumParser<const TValues extends readonly [string, ...string[]]>(
|
||||
values: TValues,
|
||||
): (raw: string | undefined) => TValues[number] | null {
|
||||
const allowedValues = new Set<string>(values);
|
||||
|
||||
return (raw: string | undefined): TValues[number] | null => {
|
||||
if (!raw) return null;
|
||||
return allowedValues.has(raw) ? (raw as TValues[number]) : null;
|
||||
};
|
||||
}
|
||||
|
||||
const parseChatStyleLanguageModeOrNull = createEnumParser(
|
||||
CHAT_STYLE_LANGUAGE_MODE_VALUES,
|
||||
);
|
||||
|
||||
const parseChatStyleManualLanguageOrNull = createEnumParser(
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||
);
|
||||
|
||||
export const resumeProjectsSchema = z.object({
|
||||
maxProjects: z.number().int().min(0).max(100),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
@ -307,6 +332,34 @@ export const settingsRegistry = {
|
||||
serialize: (value: string | null | undefined): string | null =>
|
||||
value ?? null,
|
||||
},
|
||||
chatStyleLanguageMode: {
|
||||
kind: "typed" as const,
|
||||
schema: z.enum(CHAT_STYLE_LANGUAGE_MODE_VALUES),
|
||||
default: (): ChatStyleLanguageMode =>
|
||||
parseChatStyleLanguageModeOrNull(
|
||||
typeof process !== "undefined"
|
||||
? process.env.CHAT_STYLE_LANGUAGE_MODE
|
||||
: undefined,
|
||||
) ?? "manual",
|
||||
parse: parseChatStyleLanguageModeOrNull,
|
||||
serialize: (
|
||||
value: ChatStyleLanguageMode | null | undefined,
|
||||
): string | null => value ?? null,
|
||||
},
|
||||
chatStyleManualLanguage: {
|
||||
kind: "typed" as const,
|
||||
schema: z.enum(CHAT_STYLE_MANUAL_LANGUAGE_VALUES),
|
||||
default: (): ChatStyleManualLanguage =>
|
||||
parseChatStyleManualLanguageOrNull(
|
||||
typeof process !== "undefined"
|
||||
? process.env.CHAT_STYLE_MANUAL_LANGUAGE
|
||||
: undefined,
|
||||
) ?? "english",
|
||||
parse: parseChatStyleManualLanguageOrNull,
|
||||
serialize: (
|
||||
value: ChatStyleManualLanguage | null | undefined,
|
||||
): string | null => value ?? null,
|
||||
},
|
||||
backupEnabled: {
|
||||
kind: "typed" as const,
|
||||
schema: z.boolean(),
|
||||
|
||||
46
shared/src/settings-schema.test.ts
Normal file
46
shared/src/settings-schema.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { updateSettingsSchema } from "./settings-schema";
|
||||
|
||||
describe("updateSettingsSchema language settings", () => {
|
||||
it("accepts supported language mode and manual language values", () => {
|
||||
expect(
|
||||
updateSettingsSchema.parse({
|
||||
chatStyleLanguageMode: "manual",
|
||||
chatStyleManualLanguage: "german",
|
||||
}),
|
||||
).toEqual({
|
||||
chatStyleLanguageMode: "manual",
|
||||
chatStyleManualLanguage: "german",
|
||||
});
|
||||
|
||||
expect(
|
||||
updateSettingsSchema.parse({
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
}),
|
||||
).toEqual({
|
||||
chatStyleLanguageMode: null,
|
||||
chatStyleManualLanguage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported language mode and manual language values", () => {
|
||||
const result = updateSettingsSchema.safeParse({
|
||||
chatStyleLanguageMode: "auto",
|
||||
chatStyleManualLanguage: "italian",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
result.error.flatten().fieldErrors.chatStyleLanguageMode,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
result.error.flatten().fieldErrors.chatStyleManualLanguage,
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -188,6 +188,16 @@ export const createAppSettings = (
|
||||
chatStyleFormality: { value: "medium", default: "medium", override: null },
|
||||
chatStyleConstraints: { value: "", default: "", override: null },
|
||||
chatStyleDoNotUse: { value: "", default: "", override: null },
|
||||
chatStyleLanguageMode: {
|
||||
value: "manual",
|
||||
default: "manual",
|
||||
override: null,
|
||||
},
|
||||
chatStyleManualLanguage: {
|
||||
value: "english",
|
||||
default: "english",
|
||||
override: null,
|
||||
},
|
||||
llmApiKeyHint: null,
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
|
||||
@ -14,6 +14,34 @@ export interface ResumeProjectsSettings {
|
||||
|
||||
export type RxResumeMode = "v4" | "v5";
|
||||
|
||||
export const CHAT_STYLE_LANGUAGE_MODE_VALUES = [
|
||||
"manual",
|
||||
"match-resume",
|
||||
] as const;
|
||||
|
||||
export type ChatStyleLanguageMode =
|
||||
(typeof CHAT_STYLE_LANGUAGE_MODE_VALUES)[number];
|
||||
|
||||
export const CHAT_STYLE_MANUAL_LANGUAGE_VALUES = [
|
||||
"english",
|
||||
"german",
|
||||
"french",
|
||||
"spanish",
|
||||
] as const;
|
||||
|
||||
export type ChatStyleManualLanguage =
|
||||
(typeof CHAT_STYLE_MANUAL_LANGUAGE_VALUES)[number];
|
||||
|
||||
export const CHAT_STYLE_MANUAL_LANGUAGE_LABELS: Record<
|
||||
ChatStyleManualLanguage,
|
||||
string
|
||||
> = {
|
||||
english: "English",
|
||||
german: "German",
|
||||
french: "French",
|
||||
spanish: "Spanish",
|
||||
};
|
||||
|
||||
export interface ResumeProfile {
|
||||
basics?: {
|
||||
name?: string;
|
||||
@ -135,6 +163,8 @@ export interface AppSettings {
|
||||
chatStyleFormality: Resolved<string>;
|
||||
chatStyleConstraints: Resolved<string>;
|
||||
chatStyleDoNotUse: Resolved<string>;
|
||||
chatStyleLanguageMode: Resolved<ChatStyleLanguageMode>;
|
||||
chatStyleManualLanguage: Resolved<ChatStyleManualLanguage>;
|
||||
backupEnabled: Resolved<boolean>;
|
||||
backupHour: Resolved<number>;
|
||||
backupMaxCount: Resolved<number>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user