From 3fee6e0befdaabe64aafe9b414e83bdeaae158ff Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:31:11 +0000 Subject: [PATCH] Add shared writing style preferences (#240) * Add shared writing style preferences * Address PR review feedback * Add scoring instructions settings * Polish writing style inputs * Clarify do-not-use terms guidance * Normalize blank writing style overrides * Refactor writing style resolution to use effective values and enhance blank value handling --- .codex/environments/environment.toml | 11 ++ docs-site/docs/features/ghostwriter.md | 2 + docs-site/docs/features/settings.md | 8 +- .../src/client/pages/SettingsPage.test.tsx | 41 ++++ .../src/client/pages/SettingsPage.tsx | 11 ++ .../components/ChatSettingsSection.test.tsx | 116 +++++++++++ .../components/ChatSettingsSection.tsx | 182 +++++++++++++++--- .../components/ScoringSettingsSection.tsx | 49 +++++ .../client/pages/settings/constants.test.ts | 81 ++++++++ .../src/client/pages/settings/constants.ts | 96 +++++++++ .../src/client/pages/settings/types.ts | 1 + orchestrator/src/lib/analytics.ts | 2 +- .../src/server/api/routes/settings.ts | 28 ++- orchestrator/src/server/infra/errors.test.ts | 33 ++++ orchestrator/src/server/infra/errors.ts | 19 +- .../services/ghostwriter-context.test.ts | 58 ++---- .../server/services/ghostwriter-context.ts | 26 +-- .../src/server/services/scorer.test.ts | 44 +++++ orchestrator/src/server/services/scorer.ts | 16 +- .../src/server/services/summary.test.ts | 76 ++++++++ orchestrator/src/server/services/summary.ts | 25 ++- .../src/server/services/writing-style.test.ts | 49 +++++ .../src/server/services/writing-style.ts | 35 ++++ shared/src/settings-registry.ts | 8 + shared/src/testing/factories.ts | 5 + shared/src/types/settings.ts | 1 + 26 files changed, 910 insertions(+), 113 deletions(-) create mode 100644 .codex/environments/environment.toml create mode 100644 orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx create mode 100644 orchestrator/src/client/pages/settings/constants.test.ts create mode 100644 orchestrator/src/server/infra/errors.test.ts create mode 100644 orchestrator/src/server/services/summary.test.ts create mode 100644 orchestrator/src/server/services/writing-style.test.ts create mode 100644 orchestrator/src/server/services/writing-style.ts diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..e434989 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "job-ops" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "cd orchestrator && npm rebuild && npm run db:migrate && csex" diff --git a/docs-site/docs/features/ghostwriter.md b/docs-site/docs/features/ghostwriter.md index 48e53b2..024197b 100644 --- a/docs-site/docs/features/ghostwriter.md +++ b/docs-site/docs/features/ghostwriter.md @@ -44,6 +44,8 @@ Global settings affecting generations: - `Constraints` - `Do-not-use terms` +`Do-not-use terms` are passed as guidance in the prompt. They are not enforced by a hard post-generation filter, so the model should avoid them but may still use them occasionally. + Defaults: - Tone: `professional` diff --git a/docs-site/docs/features/settings.md b/docs-site/docs/features/settings.md index ffd9025..73bf54e 100644 --- a/docs-site/docs/features/settings.md +++ b/docs-site/docs/features/settings.md @@ -15,7 +15,7 @@ It lets you configure: - LLM provider and models - Webhook destinations and secret -- Display and Ghostwriter defaults +- Display and writing-style defaults - Service credentials and basic auth - Reactive Resume project selection - Tracer Links readiness verification @@ -62,15 +62,18 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta - Toggle visa sponsor badge visibility in job lists/details -### Ghostwriter +### Writing Style ![Ghostwriter settings section](/img/features/settings-ghostwriter-section.png) +- Pick a preset for a quick starting point - Set global writing defaults: - Tone - Formality - Constraints - Do-not-use terms +- These settings apply to Ghostwriter and resume tailoring +- Do-not-use terms are model guidance, not a guaranteed output filter ### Reactive Resume @@ -120,6 +123,7 @@ Readiness requires: - Set penalty amount - Optional auto-skip threshold for low-score jobs - Block jobs from companies that match configured keyword tokens +- Add custom scoring instructions to tell the AI what to weigh more or less ### Danger Zone diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index fbf6524..ce59d97 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -341,4 +341,45 @@ describe("SettingsPage", () => { }), ); }); + + it("saves scoring instructions from scoring settings", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings); + vi.mocked(api.updateSettings).mockResolvedValue({ + ...baseSettings, + scoringInstructions: { + value: + "Open to relocating, so do not mark down for location discrepancies.", + default: "", + override: + "Open to relocating, so do not mark down for location discrepancies.", + }, + }); + + renderPage(); + + const scoringTrigger = await screen.findByRole("button", { + name: /scoring settings/i, + }); + fireEvent.click(scoringTrigger); + + const textarea = screen.getByLabelText(/scoring instructions/i); + fireEvent.change(textarea, { + target: { + value: + "Open to relocating, so do not mark down for location discrepancies.", + }, + }); + + const saveButton = screen.getByRole("button", { name: /^save$/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()); + expect(api.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + scoringInstructions: + "Open to relocating, so do not mark down for location discrepancies.", + }), + ); + }); }); diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 10cf3e6..73d30b9 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -90,6 +90,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { missingSalaryPenalty: null, autoSkipScoreThreshold: null, blockedCompanyKeywords: [], + scoringInstructions: "", }; type LlmProviderValue = LlmProviderId | null; @@ -145,6 +146,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { missingSalaryPenalty: null, autoSkipScoreThreshold: null, blockedCompanyKeywords: null, + scoringInstructions: null, }; const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ @@ -183,6 +185,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ missingSalaryPenalty: data.missingSalaryPenalty.override, autoSkipScoreThreshold: data.autoSkipScoreThreshold.override, blockedCompanyKeywords: data.blockedCompanyKeywords.override ?? [], + scoringInstructions: data.scoringInstructions.override ?? "", }); const normalizeString = (value: string | null | undefined) => { @@ -334,6 +337,10 @@ const getDerivedSettings = (settings: AppSettings | null) => { effective: settings?.blockedCompanyKeywords?.value ?? [], default: settings?.blockedCompanyKeywords?.default ?? [], }, + scoringInstructions: { + effective: settings?.scoringInstructions?.value ?? "", + default: settings?.scoringInstructions?.default ?? "", + }, }, }; }; @@ -799,6 +806,10 @@ export const SettingsPage: React.FC = () => { ? null : normalized; })(), + scoringInstructions: nullIfSame( + normalizeString(data.scoringInstructions), + scoring.scoringInstructions.default, + ), ...envPayload, }; diff --git a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx new file mode 100644 index 0000000..8b19d95 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.test.tsx @@ -0,0 +1,116 @@ +import type { UpdateSettingsInput } from "@shared/settings-schema.js"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; +import { Accordion } from "@/components/ui/accordion"; +import { ChatSettingsSection } from "./ChatSettingsSection"; + +vi.mock("@/components/ui/select", () => { + const SelectContext = React.createContext<{ + onValueChange?: (value: string) => void; + } | null>(null); + + const Select = ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode; + value?: string; + onValueChange?: (value: string) => void; + }) => { + return ( + +
+ + {children} +
+
+ ); + }; + + const SelectContent = ({ children }: { children: React.ReactNode }) => ( + <>{children} + ); + const SelectItem = ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => { + const context = React.useContext(SelectContext); + + return ( + + ); + }; + const SelectTrigger = ({ children }: { children: React.ReactNode }) => ( + <>{children} + ); + const SelectValue = () => null; + + return { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + }; +}); + +const ChatSettingsHarness = () => { + const methods = useForm({ + defaultValues: { + chatStyleTone: "", + chatStyleFormality: "", + chatStyleConstraints: "", + chatStyleDoNotUse: "", + }, + }); + + return ( + + + + + + ); +}; + +describe("ChatSettingsSection", () => { + it("treats blank overrides as unset so preset and selects stay aligned", () => { + render(); + + expect(screen.getAllByDisplayValue("professional").length).toBeGreaterThan( + 0, + ); + expect(screen.getByDisplayValue("medium")).toBeInTheDocument(); + }); + + it("applies preset values to the writing style fields", () => { + render(); + + fireEvent.click(screen.getAllByRole("button", { name: "Friendly" })[0]); + + expect(screen.getAllByDisplayValue("friendly").length).toBeGreaterThan(0); + expect(screen.getByDisplayValue("low")).toBeInTheDocument(); + expect( + screen.getByDisplayValue( + "Keep the response warm, approachable, and confident.", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx index 2a3262e..2c0ffb6 100644 --- a/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ChatSettingsSection.tsx @@ -1,8 +1,14 @@ -import { SettingsInput } from "@client/pages/settings/components/SettingsInput"; +import { TokenizedInput } from "@client/pages/orchestrator/TokenizedInput"; +import { + getMatchingWritingStylePresetId, + resolveWritingStyleDraft, + WRITING_STYLE_PRESETS, +} from "@client/pages/settings/constants"; import type { ChatValues } from "@client/pages/settings/types"; import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type React from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { useState } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; import { AccordionContent, AccordionItem, @@ -16,6 +22,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; type ChatSettingsSectionProps = { values: ChatValues; @@ -23,6 +30,21 @@ type ChatSettingsSectionProps = { isSaving: boolean; }; +function parseTokenizedTerms(input: string): string[] { + return input + .split(/[\n,]/g) + .map((value) => value.trim()) + .filter(Boolean); +} + +function parseStoredTerms(value: string | null | undefined): string[] { + return parseTokenizedTerms(value ?? ""); +} + +function normalizeBlank(value: string | null | undefined): string | undefined { + return value == null || value === "" ? undefined : value; +} + export const ChatSettingsSection: React.FC = ({ values, isLoading, @@ -30,15 +52,100 @@ export const ChatSettingsSection: React.FC = ({ }) => { const { tone, formality, constraints, doNotUse } = values; - const { control, register } = useFormContext(); + const { control, register, setValue } = useFormContext(); + const [doNotUseDraft, setDoNotUseDraft] = useState(""); + const [toneValue, formalityValue, constraintsValue, doNotUseValue] = useWatch( + { + control, + name: [ + "chatStyleTone", + "chatStyleFormality", + "chatStyleConstraints", + "chatStyleDoNotUse", + ], + }, + ); + const toneDraft = normalizeBlank(toneValue); + const formalityDraft = normalizeBlank(formalityValue); + const constraintsDraft = normalizeBlank(constraintsValue); + const doNotUseDraftValue = normalizeBlank(doNotUseValue); + const resolvedStyle = resolveWritingStyleDraft({ + values: { + tone: toneDraft, + formality: formalityDraft, + constraints: constraintsDraft, + doNotUse: doNotUseDraftValue, + }, + defaults: values, + }); + const selectedPresetId = + getMatchingWritingStylePresetId(resolvedStyle) ?? "custom"; + const doNotUseTokens = parseStoredTerms( + doNotUseDraftValue ?? doNotUse.default, + ); return ( - Ghostwriter + Writing Style
+

+ These defaults shape AI-generated writing across Ghostwriter and + resume tailoring. +

+ +
+ + +
+ {selectedPresetId === "custom" + ? "Your current values are custom." + : (WRITING_STYLE_PRESETS.find( + (preset) => preset.id === selectedPresetId, + )?.description ?? "")} +
+
+
- +
+ +