* 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)
177 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|