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:
Saad 2026-03-11 19:24:01 +01:00 committed by GitHub
parent faea61a249
commit f92b80dfe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1117 additions and 58 deletions

View File

@ -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. 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. `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. - Check model/provider configuration in Settings.
- Tighten prompts with explicit output intent (for example, "3 bullet points for recruiter outreach"). - 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 ### Missing context in answers

View File

@ -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 - Toggle visa sponsor badge visibility in job lists/details
### Writing Style ### Writing Style & Language
![Ghostwriter settings section](/img/features/settings-ghostwriter-section.png) ![Ghostwriter settings section](/img/features/settings-ghostwriter-section.png)
@ -70,12 +70,41 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
- Set global writing defaults: - Set global writing defaults:
- Tone - Tone
- Formality - Formality
- Output language mode
- Manual output language
- Constraints - Constraints
- Do-not-use terms - Do-not-use terms
- These settings apply to Ghostwriter and resume tailoring - 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 - 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 ### Reactive Resume
![Reactive Resume settings section](/img/features/settings-reactive-resume-section.png) ![Reactive Resume settings section](/img/features/settings-reactive-resume-section.png)
@ -166,6 +195,19 @@ curl -X POST "http://localhost:3001/api/backups"
- Some settings apply only to new runs/actions after save. - Some settings apply only to new runs/actions after save.
- Re-run scoring/tailoring/pipeline to validate effect. - 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 ### RxResume controls are disabled
- Configure RxResume credentials in Environment & Accounts first. - Configure RxResume credentials in Environment & Accounts first.
@ -191,9 +233,9 @@ curl -X POST "http://localhost:3001/api/backups"
## Related pages ## Related pages
- [Reactive Resume](./reactive-resume) - [Reactive Resume](/docs/next/features/reactive-resume)
- [Database Backups](../getting-started/database-backups) - [Database Backups](/docs/next/getting-started/database-backups)
- [Overview](./overview) - [Overview](/docs/next/features/overview)
- [Orchestrator](./orchestrator) - [Orchestrator](/docs/next/features/orchestrator)
- [Ghostwriter](./ghostwriter) - [Ghostwriter](/docs/next/features/ghostwriter)
- [Self-Hosting](../getting-started/self-hosting) - [Self-Hosting](/docs/next/getting-started/self-hosting)

View File

@ -8,6 +8,8 @@ import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../test/renderWithQueryClient"; import { renderWithQueryClient } from "../test/renderWithQueryClient";
import { SettingsPage } from "./SettingsPage"; import { SettingsPage } from "./SettingsPage";
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui); renderWithQueryClient(ui);
@ -63,9 +65,20 @@ const openModelSection = async () => {
fireEvent.click(modelTrigger); fireEvent.click(modelTrigger);
}; };
const openWritingStyleSection = async () => {
const chatTrigger = await screen.findByRole("button", {
name: /writing style & language/i,
});
fireEvent.click(chatTrigger);
};
describe("SettingsPage", () => { describe("SettingsPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
_resetTracerReadinessCache(); _resetTracerReadinessCache();
vi.mocked(api.getTracerReadiness).mockResolvedValue({ vi.mocked(api.getTracerReadiness).mockResolvedValue({
status: "ready", status: "ready",
@ -82,6 +95,13 @@ describe("SettingsPage", () => {
}); });
}); });
afterAll(() => {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView,
});
});
it("saves trimmed model overrides", async () => { it("saves trimmed model overrides", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings); vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
vi.mocked(api.updateSettings).mockResolvedValue({ 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 () => { it("enables save button when basic auth toggle is changed", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings); vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
renderPage(); renderPage();

View File

@ -72,6 +72,8 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
chatStyleFormality: "", chatStyleFormality: "",
chatStyleConstraints: "", chatStyleConstraints: "",
chatStyleDoNotUse: "", chatStyleDoNotUse: "",
chatStyleLanguageMode: null,
chatStyleManualLanguage: null,
rxresumeEmail: "", rxresumeEmail: "",
rxresumePassword: "", rxresumePassword: "",
rxresumeApiKey: "", rxresumeApiKey: "",
@ -127,6 +129,8 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
chatStyleFormality: null, chatStyleFormality: null,
chatStyleConstraints: null, chatStyleConstraints: null,
chatStyleDoNotUse: null, chatStyleDoNotUse: null,
chatStyleLanguageMode: null,
chatStyleManualLanguage: null,
rxresumeEmail: null, rxresumeEmail: null,
rxresumePassword: null, rxresumePassword: null,
rxresumeApiKey: null, rxresumeApiKey: null,
@ -167,6 +171,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
chatStyleFormality: data.chatStyleFormality.override ?? "", chatStyleFormality: data.chatStyleFormality.override ?? "",
chatStyleConstraints: data.chatStyleConstraints.override ?? "", chatStyleConstraints: data.chatStyleConstraints.override ?? "",
chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "", chatStyleDoNotUse: data.chatStyleDoNotUse.override ?? "",
chatStyleLanguageMode: data.chatStyleLanguageMode.override ?? null,
chatStyleManualLanguage: data.chatStyleManualLanguage.override ?? null,
rxresumeEmail: data.rxresumeEmail ?? "", rxresumeEmail: data.rxresumeEmail ?? "",
rxresumePassword: "", rxresumePassword: "",
rxresumeApiKey: "", rxresumeApiKey: "",
@ -284,6 +290,14 @@ const getDerivedSettings = (settings: AppSettings | null) => {
effective: settings?.chatStyleDoNotUse?.value ?? "", effective: settings?.chatStyleDoNotUse?.value ?? "",
default: settings?.chatStyleDoNotUse?.default ?? "", 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: { envSettings: {
readable: { readable: {
@ -780,6 +794,8 @@ export const SettingsPage: React.FC = () => {
chatStyleFormality: normalizeString(data.chatStyleFormality), chatStyleFormality: normalizeString(data.chatStyleFormality),
chatStyleConstraints: normalizeString(data.chatStyleConstraints), chatStyleConstraints: normalizeString(data.chatStyleConstraints),
chatStyleDoNotUse: normalizeString(data.chatStyleDoNotUse), chatStyleDoNotUse: normalizeString(data.chatStyleDoNotUse),
chatStyleLanguageMode: data.chatStyleLanguageMode ?? null,
chatStyleManualLanguage: data.chatStyleManualLanguage ?? null,
backupEnabled: nullIfSame( backupEnabled: nullIfSame(
data.backupEnabled, data.backupEnabled,
backup.backupEnabled.default, backup.backupEnabled.default,

View File

@ -48,8 +48,13 @@ vi.mock("@/components/ui/select", () => {
</button> </button>
); );
}; };
const SelectTrigger = ({ children }: { children: React.ReactNode }) => ( const SelectTrigger = ({
<>{children}</> children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" role="combobox" aria-expanded="false" {...props}>
{children}
</button>
); );
const SelectValue = () => null; const SelectValue = () => null;
@ -69,6 +74,8 @@ const ChatSettingsHarness = () => {
chatStyleFormality: "", chatStyleFormality: "",
chatStyleConstraints: "", chatStyleConstraints: "",
chatStyleDoNotUse: "", chatStyleDoNotUse: "",
chatStyleLanguageMode: null,
chatStyleManualLanguage: null,
}, },
}); });
@ -81,6 +88,8 @@ const ChatSettingsHarness = () => {
formality: { effective: "medium", default: "medium" }, formality: { effective: "medium", default: "medium" },
constraints: { effective: "", default: "" }, constraints: { effective: "", default: "" },
doNotUse: { effective: "", default: "" }, doNotUse: { effective: "", default: "" },
languageMode: { effective: "manual", default: "manual" },
manualLanguage: { effective: "english", default: "english" },
}} }}
isLoading={false} isLoading={false}
isSaving={false} isSaving={false}
@ -98,6 +107,8 @@ describe("ChatSettingsSection", () => {
0, 0,
); );
expect(screen.getByDisplayValue("medium")).toBeInTheDocument(); expect(screen.getByDisplayValue("medium")).toBeInTheDocument();
expect(screen.getByDisplayValue("manual")).toBeInTheDocument();
expect(screen.getByDisplayValue("english")).toBeInTheDocument();
}); });
it("applies preset values to the writing style fields", () => { it("applies preset values to the writing style fields", () => {
@ -113,4 +124,17 @@ describe("ChatSettingsSection", () => {
), ),
).toBeInTheDocument(); ).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();
});
}); });

View File

@ -6,6 +6,12 @@ import {
} from "@client/pages/settings/constants"; } 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 {
CHAT_STYLE_MANUAL_LANGUAGE_LABELS,
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
type ChatStyleLanguageMode,
type ChatStyleManualLanguage,
} from "@shared/types.js";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form"; import { Controller, useFormContext, useWatch } from "react-hook-form";
@ -30,6 +36,11 @@ type ChatSettingsSectionProps = {
isSaving: boolean; isSaving: boolean;
}; };
const LANGUAGE_MODE_LABELS: Record<ChatStyleLanguageMode, string> = {
manual: "Choose specific language",
"match-resume": "Match current resume language",
};
function parseTokenizedTerms(input: string): string[] { function parseTokenizedTerms(input: string): string[] {
return input return input
.split(/[\n,]/g) .split(/[\n,]/g)
@ -50,25 +61,40 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
isLoading, isLoading,
isSaving, isSaving,
}) => { }) => {
const { tone, formality, constraints, doNotUse } = values; const {
tone,
formality,
constraints,
doNotUse,
languageMode,
manualLanguage,
} = values;
const { control, register, setValue } = useFormContext<UpdateSettingsInput>(); const { control, register, setValue } = useFormContext<UpdateSettingsInput>();
const [doNotUseDraft, setDoNotUseDraft] = useState(""); const [doNotUseDraft, setDoNotUseDraft] = useState("");
const [toneValue, formalityValue, constraintsValue, doNotUseValue] = useWatch( const [
{ toneValue,
control, formalityValue,
name: [ constraintsValue,
"chatStyleTone", doNotUseValue,
"chatStyleFormality", languageModeValue,
"chatStyleConstraints", ] = useWatch({
"chatStyleDoNotUse", control,
], name: [
}, "chatStyleTone",
); "chatStyleFormality",
"chatStyleConstraints",
"chatStyleDoNotUse",
"chatStyleLanguageMode",
],
});
const toneDraft = normalizeBlank(toneValue); const toneDraft = normalizeBlank(toneValue);
const formalityDraft = normalizeBlank(formalityValue); const formalityDraft = normalizeBlank(formalityValue);
const constraintsDraft = normalizeBlank(constraintsValue); const constraintsDraft = normalizeBlank(constraintsValue);
const doNotUseDraftValue = normalizeBlank(doNotUseValue); const doNotUseDraftValue = normalizeBlank(doNotUseValue);
const resolvedLanguageMode =
normalizeBlank(languageModeValue) ?? languageMode.default;
const showManualLanguage = resolvedLanguageMode === "manual";
const resolvedStyle = resolveWritingStyleDraft({ const resolvedStyle = resolveWritingStyleDraft({
values: { values: {
tone: toneDraft, tone: toneDraft,
@ -87,7 +113,9 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
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">Writing Style</span> <span className="text-base font-semibold">
Writing Style &amp; Language
</span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-4"> <AccordionContent className="pb-4">
<div className="space-y-4"> <div className="space-y-4">
@ -146,6 +174,97 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
</div> </div>
</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="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">
@ -255,7 +374,7 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
<Separator /> <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>
<div className="text-xs text-muted-foreground">Tone</div> <div className="text-xs text-muted-foreground">Tone</div>
<div className="break-words font-mono text-xs"> <div className="break-words font-mono text-xs">
@ -268,6 +387,24 @@ export const ChatSettingsSection: React.FC<ChatSettingsSectionProps> = ({
Effective: {formality.effective} | Default: {formality.default} Effective: {formality.effective} | Default: {formality.default}
</div> </div>
</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>
</div> </div>
</AccordionContent> </AccordionContent>

View File

@ -22,6 +22,8 @@ describe("settings constants", () => {
default: "Keep it warm", default: "Keep it warm",
}, },
doNotUse: { effective: "", default: "" }, doNotUse: { effective: "", default: "" },
languageMode: { effective: "manual", default: "manual" },
manualLanguage: { effective: "english", default: "english" },
}, },
}), }),
).toEqual({ ).toEqual({
@ -49,6 +51,8 @@ describe("settings constants", () => {
default: "", default: "",
}, },
doNotUse: { effective: "synergy", default: "" }, doNotUse: { effective: "synergy", default: "" },
languageMode: { effective: "manual", default: "manual" },
manualLanguage: { effective: "english", default: "english" },
}, },
}), }),
).toEqual({ ).toEqual({

View File

@ -1,3 +1,8 @@
import type {
ChatStyleLanguageMode,
ChatStyleManualLanguage,
} from "@shared/types.js";
export type EffectiveDefault<T> = { export type EffectiveDefault<T> = {
effective: T; effective: T;
default: T; default: T;
@ -19,6 +24,8 @@ export type ChatValues = {
formality: EffectiveDefault<string>; formality: EffectiveDefault<string>;
constraints: EffectiveDefault<string>; constraints: EffectiveDefault<string>;
doNotUse: EffectiveDefault<string>; doNotUse: EffectiveDefault<string>;
languageMode: EffectiveDefault<ChatStyleLanguageMode>;
manualLanguage: EffectiveDefault<ChatStyleManualLanguage>;
}; };
export type EnvSettingsValues = { export type EnvSettingsValues = {

View File

@ -263,6 +263,69 @@ describe.sequential("Onboarding API routes", () => {
), ),
).toBe(false); ).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", () => { describe("POST /api/onboarding/validate/rxresume", () => {

View File

@ -19,6 +19,15 @@ type ValidationResponse = {
message: string | null; 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: { async function validateLlm(options: {
apiKey?: string | null; apiKey?: string | null;
provider?: string | null; provider?: string | null;
@ -37,8 +46,13 @@ async function validateLlm(options: {
normalizedProvider === "lmstudio" || normalizedProvider === "lmstudio" ||
normalizedProvider === "ollama" || normalizedProvider === "ollama" ||
normalizedProvider === "openai_compatible"; normalizedProvider === "openai_compatible";
const hasExplicitBaseUrlOverride =
options.baseUrl !== undefined && options.baseUrl !== null;
const resolvedBaseUrl = shouldUseBaseUrl const resolvedBaseUrl = shouldUseBaseUrl
? options.baseUrl?.trim() || storedBaseUrl?.trim() || undefined ? hasExplicitBaseUrlOverride
? options.baseUrl?.trim() ||
getDefaultValidationBaseUrl(normalizedProvider)
: storedBaseUrl?.trim() || undefined
: undefined; : undefined;
const resolvedApiKey = options.apiKey?.trim() || storedApiKey?.trim() || null; const resolvedApiKey = options.apiKey?.trim() || storedApiKey?.trim() || null;

View File

@ -11,9 +11,14 @@ vi.mock("./profile", () => ({
getProfile: vi.fn(), getProfile: vi.fn(),
})); }));
vi.mock("./writing-style", () => ({ vi.mock("./writing-style", async (importOriginal) => {
getWritingStyle: vi.fn(), const actual = await importOriginal<typeof import("./writing-style")>();
}));
return {
...actual,
getWritingStyle: vi.fn(),
};
});
import { getJobById } from "../repositories/jobs"; import { getJobById } from "../repositories/jobs";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
@ -27,6 +32,8 @@ describe("buildJobChatPromptContext", () => {
formality: "medium", formality: "medium",
constraints: "", constraints: "",
doNotUse: "", doNotUse: "",
languageMode: "manual",
manualLanguage: "english",
}); });
}); });
@ -44,6 +51,8 @@ describe("buildJobChatPromptContext", () => {
formality: "high", formality: "high",
constraints: "Keep responses under 120 words", constraints: "Keep responses under 120 words",
doNotUse: "synergy, leverage", doNotUse: "synergy, leverage",
languageMode: "manual",
manualLanguage: "german",
}); });
vi.mocked(getProfile).mockResolvedValue({ vi.mocked(getProfile).mockResolvedValue({
basics: { basics: {
@ -77,6 +86,8 @@ describe("buildJobChatPromptContext", () => {
formality: "high", formality: "high",
constraints: "Keep responses under 120 words", constraints: "Keep responses under 120 words",
doNotUse: "synergy, leverage", doNotUse: "synergy, leverage",
languageMode: "manual",
manualLanguage: "german",
}); });
expect(context.systemPrompt).toContain("Writing style tone: direct."); expect(context.systemPrompt).toContain("Writing style tone: direct.");
expect(context.systemPrompt).toContain("Writing style formality: high."); 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.", "Follow the user's requested output language exactly when they specify one.",
); );
expect(context.systemPrompt).toContain( 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( 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( expect(context.systemPrompt).toContain(
"Writing constraints: Keep responses under 120 words", "Writing constraints: Keep responses under 120 words",
@ -113,22 +124,60 @@ describe("buildJobChatPromptContext", () => {
expect(context.systemPrompt).toContain("Writing style tone: professional."); 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" }); const job = createJob({ id: "job-ctx-3" });
vi.mocked(getJobById).mockResolvedValue(job); vi.mocked(getJobById).mockResolvedValue(job);
vi.mocked(getWritingStyle).mockResolvedValue({ vi.mocked(getWritingStyle).mockResolvedValue({
tone: "professional", tone: "professional",
formality: "medium", formality: "medium",
constraints: "Always respond in French.", constraints: "",
doNotUse: "", 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({}); vi.mocked(getProfile).mockResolvedValue({});
const context = await buildJobChatPromptContext(job.id); const context = await buildJobChatPromptContext(job.id);
expect(context.systemPrompt).toContain( 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 () => { it("throws not found for unknown job", async () => {

View File

@ -3,8 +3,16 @@ import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize"; 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 {
getWritingLanguageLabel,
resolveWritingOutputLanguage,
} from "./output-language";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
import { getWritingStyle, type WritingStyle } from "./writing-style"; import {
getWritingStyle,
stripLanguageDirectivesFromConstraints,
type WritingStyle,
} from "./writing-style";
export type JobChatPromptContext = { export type JobChatPromptContext = {
job: Job; 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([ 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.",
@ -104,11 +124,13 @@ function buildSystemPrompt(style: WritingStyle): string {
"If details are missing, say what is missing before making assumptions.", "If details are missing, say what is missing before making assumptions.",
"Avoid exposing private profile details that are unrelated to the user request.", "Avoid exposing private profile details that are unrelated to the user request.",
"Follow the user's requested output language exactly when they specify one.", "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.", `When the user does not request a language, default to writing user-visible resume or application content in ${outputLanguage}.`,
"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.`,
`Writing style tone: ${style.tone}.`, `Writing style tone: ${style.tone}.`,
`Writing style formality: ${style.formality}.`, `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, 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 profileSnapshot = buildProfileSnapshot(profile);
const systemPrompt = buildSystemPrompt(style, profile);
const jobSnapshot = buildJobSnapshot(job);
if (!jobSnapshot.trim()) { if (!jobSnapshot.trim()) {
throw badRequest("Unable to build job context"); throw badRequest("Unable to build job context");

View 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",
});
});
});

View 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];
}

View File

@ -17,9 +17,14 @@ vi.mock("./llm/service", () => ({
}, },
})); }));
vi.mock("./writing-style", () => ({ vi.mock("./writing-style", async (importOriginal) => {
getWritingStyle: vi.fn(), const actual = await importOriginal<typeof import("./writing-style")>();
}));
return {
...actual,
getWritingStyle: vi.fn(),
};
});
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { generateTailoring } from "./summary"; import { generateTailoring } from "./summary";
@ -44,10 +49,12 @@ describe("generateTailoring", () => {
formality: "low", formality: "low",
constraints: "Keep it under 90 words", constraints: "Keep it under 90 words",
doNotUse: "synergy", 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 = { const profile: ResumeProfile = {
basics: { basics: {
name: "Test User", name: "Test User",
@ -72,5 +79,43 @@ describe("generateTailoring", () => {
expect(request?.messages?.[0]?.content).toContain( expect(request?.messages?.[0]?.content).toContain(
"Avoid these words or phrases: synergy", "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",
);
}); });
}); });

View File

@ -7,7 +7,14 @@ 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"; import {
getWritingLanguageLabel,
resolveWritingOutputLanguage,
} from "./output-language";
import {
getWritingStyle,
stripLanguageDirectivesFromConstraints,
} from "./writing-style";
export interface TailoredData { export interface TailoredData {
summary: string; summary: string;
@ -140,6 +147,15 @@ function buildTailoringPrompt(
jd: string, jd: string,
writingStyle: Awaited<ReturnType<typeof getWritingStyle>>, writingStyle: Awaited<ReturnType<typeof getWritingStyle>>,
): string { ): 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 // Extract only needed parts of profile to save tokens
const relevantProfile = { const relevantProfile = {
basics: { basics: {
@ -175,26 +191,33 @@ INSTRUCTIONS:
1. "headline" (String): 1. "headline" (String):
- CRITICAL: This is the #1 ATS factor. - 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"). - 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): 2. "summary" (String):
- The Hook. This needs to mirror the company's "About You" / "What we're looking for" section. - The Hook. This needs to mirror the company's "About You" / "What we're looking for" section.
- Keep it concise, warm, and confident. - Keep it concise, warm, and confident.
- Do NOT invent experience. - Do NOT invent experience.
- Use the profile to add context. - Use the profile to add context.
- Write the summary in ${outputLanguage}.
3. "skills" (Array of Objects): 3. "skills" (Array of Objects):
- Review my existing skills section structure. - Review my existing skills section structure.
- Keyword Stuffing: Swap synonyms to match the JD exactly (e.g. "TDD" -> "Unit Testing", "ReactJS" -> "React"). - 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. - 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": [...] }.
- 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: WRITING STYLE PREFERENCES:
- Tone: ${writingStyle.tone} - Tone: ${writingStyle.tone}
- Formality: ${writingStyle.formality} - 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}` : ""} ${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): OUTPUT FORMAT (JSON):
{ {
"headline": "...", "headline": "...",

View File

@ -5,11 +5,23 @@ vi.mock("@server/repositories/settings", () => ({
})); }));
import { getSetting } from "@server/repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { getWritingStyle } from "./writing-style"; import {
getWritingStyle,
stripLanguageDirectivesFromConstraints,
} from "./writing-style";
describe("getWritingStyle", () => { describe("getWritingStyle", () => {
const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); 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 () => { it("uses defaults when no overrides are stored", async () => {
@ -20,6 +32,8 @@ describe("getWritingStyle", () => {
formality: "medium", formality: "medium",
constraints: "", constraints: "",
doNotUse: "", doNotUse: "",
languageMode: "manual",
manualLanguage: "english",
}); });
}); });
@ -34,6 +48,10 @@ describe("getWritingStyle", () => {
return "Keep it short"; return "Keep it short";
case "chatStyleDoNotUse": case "chatStyleDoNotUse":
return "synergy"; return "synergy";
case "chatStyleLanguageMode":
return "match-resume";
case "chatStyleManualLanguage":
return "german";
default: default:
return null; return null;
} }
@ -44,6 +62,16 @@ describe("getWritingStyle", () => {
formality: "low", formality: "low",
constraints: "Keep it short", constraints: "Keep it short",
doNotUse: "synergy", 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");
});
}); });

View File

@ -1,21 +1,78 @@
import * as settingsRepo from "@server/repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
import { settingsRegistry } from "@shared/settings-registry"; import { settingsRegistry } from "@shared/settings-registry";
import type {
ChatStyleLanguageMode,
ChatStyleManualLanguage,
} from "@shared/types";
export type WritingStyle = { export type WritingStyle = {
tone: string; tone: string;
formality: string; formality: string;
constraints: string; constraints: string;
doNotUse: 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> { export async function getWritingStyle(): Promise<WritingStyle> {
const [toneRaw, formalityRaw, constraintsRaw, doNotUseRaw] = const [
await Promise.all([ toneRaw,
settingsRepo.getSetting("chatStyleTone"), formalityRaw,
settingsRepo.getSetting("chatStyleFormality"), constraintsRaw,
settingsRepo.getSetting("chatStyleConstraints"), doNotUseRaw,
settingsRepo.getSetting("chatStyleDoNotUse"), languageModeRaw,
]); manualLanguageRaw,
] = await Promise.all([
settingsRepo.getSetting("chatStyleTone"),
settingsRepo.getSetting("chatStyleFormality"),
settingsRepo.getSetting("chatStyleConstraints"),
settingsRepo.getSetting("chatStyleDoNotUse"),
settingsRepo.getSetting("chatStyleLanguageMode"),
settingsRepo.getSetting("chatStyleManualLanguage"),
]);
return { return {
tone: tone:
@ -31,5 +88,13 @@ export async function getWritingStyle(): Promise<WritingStyle> {
doNotUse: doNotUse:
settingsRegistry.chatStyleDoNotUse.parse(doNotUseRaw ?? undefined) ?? settingsRegistry.chatStyleDoNotUse.parse(doNotUseRaw ?? undefined) ??
settingsRegistry.chatStyleDoNotUse.default(), settingsRegistry.chatStyleDoNotUse.default(),
languageMode:
settingsRegistry.chatStyleLanguageMode.parse(
languageModeRaw ?? undefined,
) ?? settingsRegistry.chatStyleLanguageMode.default(),
manualLanguage:
settingsRegistry.chatStyleManualLanguage.parse(
manualLanguageRaw ?? undefined,
) ?? settingsRegistry.chatStyleManualLanguage.default(),
}; };
} }

View File

@ -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", () => { describe("LLM provider parsing", () => {
it("normalizes the documented openai-compatible alias", () => { it("normalizes the documented openai-compatible alias", () => {
expect(settingsRegistry.llmProvider.parse("openai-compatible")).toBe( expect(settingsRegistry.llmProvider.parse("openai-compatible")).toBe(

View File

@ -1,5 +1,11 @@
import { z } from "zod"; 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 { function parseNonEmptyStringOrNull(raw: string | undefined): string | null {
return raw === undefined || raw === "" ? null : raw; return raw === undefined || raw === "" ? null : raw;
@ -49,6 +55,25 @@ function serializeBitBool(value: boolean | null | undefined): string | null {
return value ? "1" : "0"; 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({ export const resumeProjectsSchema = z.object({
maxProjects: z.number().int().min(0).max(100), maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200), lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
@ -307,6 +332,34 @@ export const settingsRegistry = {
serialize: (value: string | null | undefined): string | null => serialize: (value: string | null | undefined): string | null =>
value ?? 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: { backupEnabled: {
kind: "typed" as const, kind: "typed" as const,
schema: z.boolean(), schema: z.boolean(),

View 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();
});
});

View File

@ -188,6 +188,16 @@ export const createAppSettings = (
chatStyleFormality: { value: "medium", default: "medium", override: null }, chatStyleFormality: { value: "medium", default: "medium", override: null },
chatStyleConstraints: { value: "", default: "", override: null }, chatStyleConstraints: { value: "", default: "", override: null },
chatStyleDoNotUse: { 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, llmApiKeyHint: null,
rxresumeApiKeyHint: null, rxresumeApiKeyHint: null,
rxresumeEmail: null, rxresumeEmail: null,

View File

@ -14,6 +14,34 @@ export interface ResumeProjectsSettings {
export type RxResumeMode = "v4" | "v5"; 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 { export interface ResumeProfile {
basics?: { basics?: {
name?: string; name?: string;
@ -135,6 +163,8 @@ export interface AppSettings {
chatStyleFormality: Resolved<string>; chatStyleFormality: Resolved<string>;
chatStyleConstraints: Resolved<string>; chatStyleConstraints: Resolved<string>;
chatStyleDoNotUse: Resolved<string>; chatStyleDoNotUse: Resolved<string>;
chatStyleLanguageMode: Resolved<ChatStyleLanguageMode>;
chatStyleManualLanguage: Resolved<ChatStyleManualLanguage>;
backupEnabled: Resolved<boolean>; backupEnabled: Resolved<boolean>;
backupHour: Resolved<number>; backupHour: Resolved<number>;
backupMaxCount: Resolved<number>; backupMaxCount: Resolved<number>;