Jobber/orchestrator/src/server/services/ghostwriter-context.ts
Shaheer Sarfaraz b18c2eccbb
Code cleanup (#218)
* chore: move @types/canvas-confetti to devDependencies, remove unused get-tsconfig direct dep

* chore: configure knip with workspace entry points for all packages

* refactor(shared): split 1119-line types.ts into domain modules under types/

* refactor: remove llm-service.ts shim, migrate all import sites to llm/service directly

* refactor(settings): migrate 4 manually-resolved settings into conversion registry

* refactor: split gmail-sync.ts into gmail-api, email-router, and thin orchestrator

* refactor(orchestrator): extract useKeyboardShortcuts and usePipelineControls from OrchestratorPage

Splits the 840-line OrchestratorPage into a thin orchestration shell (~480 lines) by
extracting keyboard shortcut handling into useKeyboardShortcuts.ts and pipeline
control logic into usePipelineControls.ts. Net negative line count across all files.

* feat: create settings registry (Step 1)

Introduces a single source of truth for all settings, combining schema definitions, default logic, parsing, and serialization into a single configuration object.

* feat: derive schema, keys, and types from settings registry (Step 2)

Derives AppSettings nested shape, SettingKey DB union, and updateSettingsSchema Zod shape automatically from the settings registry.

* refactor: gut envSettings and remove settings-conversion (Step 3)

Replaces manual env arrays with registry-driven maps in envSettings.ts.
Deletes settings-conversion.ts since all parsing/defaults now live in the registry.

* refactor: simplify getEffectiveSettings with generic loop (Step 4)

Replaces ~334 lines of manual key-by-key unpacking with a generic registry-driven iteration loop (~40 lines). Models, typed, string, and virtual kinds are automatically derived.

* refactor: simplify settingsUpdateRegistry (Step 5)

Replaces ~350 lines of explicit per-key update handlers with a dynamic generic loop over the settings registry, properly routing persistence and side effects.

* refactor(settings): implement nested settings registry and clean up tests

- Migrate settings system to use a centralized nested registry (`settings-schema.ts`, `registry.ts`)
- Remove obsolete flat-to-nested conversion logic (`settings-conversion.ts`)
- Address Biome warnings by explicitly ignoring intentional `any` usage in generic runtime schema builder and registry logic
- Clean up unused variables in test files (`SettingsPage.test.tsx`) to achieve a 100% green CI pipeline

* refactor(settings): address PR comments on env data and registry parsing

- Narrow `getEnvSettingsData` return type to `Partial<AppSettings>` to satisfy strict typing and omit 'typed' registry entries
- Introduce `parseNonEmptyStringOrNull` for typed string settings so empty-string overrides cleanly fall back to defaults (matching original `||` logic)
- Add missing unit tests for registry parse/serialize helpers (JSON, bools, numeric clamping)
2026-02-21 03:07:51 +00:00

177 lines
5.4 KiB
TypeScript

import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize";
import type { Job, ResumeProfile } from "@shared/types";
import { badRequest, notFound } from "../infra/errors";
import * as jobsRepo from "../repositories/jobs";
import { getProfile } from "./profile";
import { getEffectiveSettings } from "./settings";
type JobChatStyle = {
tone: string;
formality: string;
constraints: string;
doNotUse: string;
};
export type JobChatPromptContext = {
job: Job;
style: JobChatStyle;
systemPrompt: string;
jobSnapshot: string;
profileSnapshot: string;
};
const MAX_JOB_DESCRIPTION = 4000;
const MAX_PROFILE_SUMMARY = 1200;
const MAX_SKILLS = 18;
const MAX_PROJECTS = 6;
const MAX_EXPERIENCE = 5;
const MAX_ITEM_TEXT = 320;
function truncate(value: string | null | undefined, max: number): string {
if (!value) return "";
const trimmed = value.trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max)}...`;
}
function compactJoin(parts: Array<string | null | undefined>): string {
return parts.filter(Boolean).join("\n");
}
function buildJobSnapshot(job: Job): string {
const snapshot = {
event: "job.completed",
sentAt: new Date().toISOString(),
job: {
id: job.id,
source: job.source,
title: job.title,
employer: job.employer,
location: job.location,
salary: job.salary,
status: job.status,
jobUrl: job.jobUrl,
applicationLink: job.applicationLink,
suitabilityScore: job.suitabilityScore,
suitabilityReason: truncate(job.suitabilityReason, 600),
tailoredSummary: truncate(job.tailoredSummary, 1200),
tailoredHeadline: truncate(job.tailoredHeadline, 300),
tailoredSkills: truncate(job.tailoredSkills, 1200),
jobDescription: truncate(job.jobDescription, MAX_JOB_DESCRIPTION),
},
};
return JSON.stringify(snapshot, null, 2);
}
function buildProfileSnapshot(profile: ResumeProfile): string {
const summary =
truncate(profile?.sections?.summary?.content, MAX_PROFILE_SUMMARY) ||
truncate(profile?.basics?.summary, MAX_PROFILE_SUMMARY);
const skills = (profile?.sections?.skills?.items ?? [])
.slice(0, MAX_SKILLS)
.map((item) => {
const keywords = (item.keywords ?? []).slice(0, 8).join(", ");
return `${item.name}${keywords ? `: ${keywords}` : ""}`;
});
const projects = (profile?.sections?.projects?.items ?? [])
.filter((item) => item.visible !== false)
.slice(0, MAX_PROJECTS)
.map(
(item) =>
`${item.name} (${item.date || "n/a"}): ${truncate(item.summary, MAX_ITEM_TEXT)}`,
);
const experience = (profile?.sections?.experience?.items ?? [])
.filter((item) => item.visible !== false)
.slice(0, MAX_EXPERIENCE)
.map(
(item) =>
`${item.position} @ ${item.company} (${item.date || "n/a"}): ${truncate(item.summary, MAX_ITEM_TEXT)}`,
);
return compactJoin([
`Name: ${profile?.basics?.name || "Unknown"}`,
`Headline: ${truncate(profile?.basics?.headline || profile?.basics?.label, 200) || ""}`,
summary ? `Summary:\n${summary}` : null,
skills.length > 0 ? `Skills:\n- ${skills.join("\n- ")}` : null,
projects.length > 0 ? `Projects:\n- ${projects.join("\n- ")}` : null,
experience.length > 0 ? `Experience:\n- ${experience.join("\n- ")}` : null,
]);
}
function buildSystemPrompt(style: JobChatStyle): string {
return compactJoin([
"You are Ghostwriter, a job-application writing assistant for a single job.",
"Use only the provided job and profile context unless the user gives extra details.",
"Do not claim actions were executed. You are read-only and advisory.",
"If details are missing, say what is missing before making assumptions.",
"Avoid exposing private profile details that are unrelated to the user request.",
`Writing style tone: ${style.tone}.`,
`Writing style formality: ${style.formality}.`,
style.constraints ? `Writing constraints: ${style.constraints}` : null,
style.doNotUse ? `Avoid these terms: ${style.doNotUse}` : null,
]);
}
async function resolveStyle(): Promise<JobChatStyle> {
const settings = await getEffectiveSettings();
return {
tone: settings.chatStyleTone.value,
formality: settings.chatStyleFormality.value,
constraints: settings.chatStyleConstraints.value,
doNotUse: settings.chatStyleDoNotUse.value,
};
}
export async function buildJobChatPromptContext(
jobId: string,
): Promise<JobChatPromptContext> {
const job = await jobsRepo.getJobById(jobId);
if (!job) {
throw notFound("Job not found");
}
const style = await resolveStyle();
let profile: ResumeProfile = {};
try {
profile = await getProfile();
} catch (error) {
logger.warn("Failed to load profile for job chat context", {
jobId,
error: sanitizeUnknown(error),
});
}
const systemPrompt = buildSystemPrompt(style);
const jobSnapshot = buildJobSnapshot(job);
const profileSnapshot = buildProfileSnapshot(profile);
if (!jobSnapshot.trim()) {
throw badRequest("Unable to build job context");
}
logger.info("Built job chat context", {
jobId,
includesProfile: Boolean(profileSnapshot),
contextStats: sanitizeUnknown({
systemChars: systemPrompt.length,
jobChars: jobSnapshot.length,
profileChars: profileSnapshot.length,
}),
});
return {
job,
style,
systemPrompt,
jobSnapshot,
profileSnapshot,
};
}