From f92b80dfe291a35fd7f3f8abeebec848c7d7b7d7 Mon Sep 17 00:00:00 2001 From: Saad Date: Wed, 11 Mar 2026 19:24:01 +0100 Subject: [PATCH] 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 --- docs-site/docs/features/ghostwriter.md | 4 +- docs-site/docs/features/settings.md | 58 +++++- .../src/client/pages/SettingsPage.test.tsx | 55 +++++ .../src/client/pages/SettingsPage.tsx | 16 ++ .../components/ChatSettingsSection.test.tsx | 28 ++- .../components/ChatSettingsSection.tsx | 165 +++++++++++++-- .../client/pages/settings/constants.test.ts | 4 + .../src/client/pages/settings/types.ts | 7 + .../src/server/api/routes/onboarding.test.ts | 63 ++++++ .../src/server/api/routes/onboarding.ts | 16 +- .../services/ghostwriter-context.test.ts | 65 +++++- .../server/services/ghostwriter-context.ts | 36 +++- .../server/services/output-language.test.ts | 71 +++++++ .../src/server/services/output-language.ts | 194 ++++++++++++++++++ .../src/server/services/summary.test.ts | 53 ++++- orchestrator/src/server/services/summary.ts | 29 ++- .../src/server/services/writing-style.test.ts | 30 ++- .../src/server/services/writing-style.ts | 79 ++++++- shared/src/settings-registry.test.ts | 61 ++++++ shared/src/settings-registry.ts | 55 ++++- shared/src/settings-schema.test.ts | 46 +++++ shared/src/testing/factories.ts | 10 + shared/src/types/settings.ts | 30 +++ 23 files changed, 1117 insertions(+), 58 deletions(-) create mode 100644 orchestrator/src/server/services/output-language.test.ts create mode 100644 orchestrator/src/server/services/output-language.ts create mode 100644 shared/src/settings-schema.test.ts diff --git a/docs-site/docs/features/ghostwriter.md b/docs-site/docs/features/ghostwriter.md index 1b98d31..a257c8b 100644 --- a/docs-site/docs/features/ghostwriter.md +++ b/docs-site/docs/features/ghostwriter.md @@ -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 diff --git a/docs-site/docs/features/settings.md b/docs-site/docs/features/settings.md index 1431158..36eebe1 100644 --- a/docs-site/docs/features/settings.md +++ b/docs-site/docs/features/settings.md @@ -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 ![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: - 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 ![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. - 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) diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index ce59d97..968ca90 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -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[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(); diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 73d30b9..3a68a7c 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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, diff --git a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx index 8b19d95..e2fc2a8 100644 --- a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx @@ -48,8 +48,13 @@ vi.mock("@/components/ui/select", () => { ); }; - const SelectTrigger = ({ children }: { children: React.ReactNode }) => ( - <>{children} + const SelectTrigger = ({ + children, + ...props + }: React.ButtonHTMLAttributes) => ( + ); 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(); + + 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(); + }); }); diff --git a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx index 2c0ffb6..2eddd39 100644 --- a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx @@ -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 = { + 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 = ({ isLoading, isSaving, }) => { - const { tone, formality, constraints, doNotUse } = values; + const { + tone, + formality, + constraints, + doNotUse, + languageMode, + manualLanguage, + } = values; const { control, register, setValue } = useFormContext(); 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 = ({ return ( - Writing Style + + Writing Style & Language +
@@ -146,6 +174,97 @@ export const ChatSettingsSection: React.FC = ({
+
+
+ + ( + + )} + /> +
+ Choose how AI picks the output language. +
+
+ + {showManualLanguage ? ( +
+ + ( + + )} + /> +
+ Used when output language is set to a specific language. +
+
+ ) : null} +
+