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.
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -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
|
||||||
|
|
||||||

|

|
||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 & 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>
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
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", () => ({
|
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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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": "...",
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
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 },
|
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,
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user