feat(orchestrator): local JSON base resume for PDF generation

Use JOBOPS_LOCAL_RESUME_PATH or Settings local path as the tailored PDF
source when Reactive Resume template is not selected. Align GET
/api/profile/status with local resume configuration. Update route test
mocks for resolveLocalResumeFilePath.

Includes discovered-panel UI tweaks in DecideMode and DiscoveredPanel.

Made-with: Cursor
This commit is contained in:
ilia 2026-04-05 23:47:25 -04:00
parent 87dbda3df1
commit 77179b2b94
8 changed files with 147 additions and 23 deletions

View File

@ -19,7 +19,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { FitAssessment, JobHeader, TailoredSummary } from ".."; import {
CoverLetterDisplay,
FitAssessment,
JobHeader,
TailoredSummary,
} from "..";
import { KbdHint } from "../KbdHint"; import { KbdHint } from "../KbdHint";
import { OpenJobListingButton } from "../OpenJobListingButton"; import { OpenJobListingButton } from "../OpenJobListingButton";
import { CollapsibleSection } from "./CollapsibleSection"; import { CollapsibleSection } from "./CollapsibleSection";
@ -32,6 +37,8 @@ interface DecideModeProps {
isSkipping: boolean; isSkipping: boolean;
onRescore: () => void; onRescore: () => void;
isRescoring: boolean; isRescoring: boolean;
onRequestCoverLetter: () => void;
coverLetterGenerating: boolean;
onEditDetails: () => void; onEditDetails: () => void;
onCheckSponsor?: () => Promise<void>; onCheckSponsor?: () => Promise<void>;
} }
@ -43,6 +50,8 @@ export const DecideMode: React.FC<DecideModeProps> = ({
isSkipping, isSkipping,
onRescore, onRescore,
isRescoring, isRescoring,
onRequestCoverLetter,
coverLetterGenerating,
onEditDetails, onEditDetails,
onCheckSponsor, onCheckSponsor,
}) => { }) => {
@ -98,6 +107,11 @@ export const DecideMode: React.FC<DecideModeProps> = ({
<div className="flex-1 py-6 space-y-6 overflow-y-auto"> <div className="flex-1 py-6 space-y-6 overflow-y-auto">
<FitAssessment job={job} /> <FitAssessment job={job} />
<CoverLetterDisplay
job={job}
onRequestGenerate={onRequestCoverLetter}
generateInFlight={coverLetterGenerating}
/>
<TailoredSummary job={job} /> <TailoredSummary job={job} />
<CollapsibleSection <CollapsibleSection

View File

@ -1,5 +1,6 @@
import * as api from "@client/api"; import * as api from "@client/api";
import { useSkipJobMutation } from "@client/hooks/queries/useJobMutations"; import { useSkipJobMutation } from "@client/hooks/queries/useJobMutations";
import { useCoverLetterGeneration } from "@client/hooks/useCoverLetterGeneration";
import { useRescoreJob } from "@client/hooks/useRescoreJob"; import { useRescoreJob } from "@client/hooks/useRescoreJob";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import type React from "react"; import type React from "react";
@ -34,6 +35,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
const previousJobIdRef = useRef<string | null>(null); const previousJobIdRef = useRef<string | null>(null);
const skipJobMutation = useSkipJobMutation(); const skipJobMutation = useSkipJobMutation();
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated); const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
const { generateForJob, inFlight: coverLetterGenerating } =
useCoverLetterGeneration(onJobUpdated);
useEffect(() => { useEffect(() => {
const currentJobId = job?.id ?? null; const currentJobId = job?.id ?? null;
@ -138,6 +141,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
isSkipping={isSkipping} isSkipping={isSkipping}
onRescore={handleRescore} onRescore={handleRescore}
isRescoring={isRescoring} isRescoring={isRescoring}
onRequestCoverLetter={() => void generateForJob(job.id)}
coverLetterGenerating={coverLetterGenerating}
onEditDetails={() => setIsEditDetailsOpen(true)} onEditDetails={() => setIsEditDetailsOpen(true)}
onCheckSponsor={async () => { onCheckSponsor={async () => {
try { try {

View File

@ -18,6 +18,7 @@ vi.mock("@server/services/rxresume", () => ({
vi.mock("@server/services/profile", () => ({ vi.mock("@server/services/profile", () => ({
getProfile: vi.fn(), getProfile: vi.fn(),
clearProfileCache: vi.fn(), clearProfileCache: vi.fn(),
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
})); }));
// Mock the settings repository // Mock the settings repository
@ -30,7 +31,10 @@ vi.mock("@server/repositories/settings", async (importOriginal) => {
}); });
import { getSetting } from "@server/repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { getProfile } from "@server/services/profile"; import {
getProfile,
resolveLocalResumeFilePath,
} from "@server/services/profile";
import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume"; import { getResume, RxResumeAuthConfigError } from "@server/services/rxresume";
describe.sequential("Profile API routes", () => { describe.sequential("Profile API routes", () => {
@ -41,6 +45,7 @@ describe.sequential("Profile API routes", () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
({ server, baseUrl, closeDb, tempDir } = await startServer()); ({ server, baseUrl, closeDb, tempDir } = await startServer());
}); });
@ -159,6 +164,23 @@ describe.sequential("Profile API routes", () => {
}); });
describe("GET /api/profile/status", () => { describe("GET /api/profile/status", () => {
it("returns exists: true when a local resume path is configured and readable", async () => {
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(
"/data/resumes/local.json",
);
vi.mocked(getProfile).mockResolvedValue({
basics: { name: "Local User" },
} as any);
const res = await fetch(`${baseUrl}/api/profile/status`);
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
});
it("returns exists: false when rxresumeBaseResumeId is not configured", async () => { it("returns exists: false when rxresumeBaseResumeId is not configured", async () => {
vi.mocked(getSetting).mockResolvedValue(null); vi.mocked(getSetting).mockResolvedValue(null);

View File

@ -2,7 +2,11 @@ import { toAppError } from "@infra/errors";
import { fail, ok } from "@infra/http"; import { fail, ok } from "@infra/http";
import { isDemoMode } from "@server/config/demo"; import { isDemoMode } from "@server/config/demo";
import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults"; import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
import { clearProfileCache, getProfile } from "@server/services/profile"; import {
clearProfileCache,
getProfile,
resolveLocalResumeFilePath,
} from "@server/services/profile";
import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import { import {
clearRxResumeResumeCache, clearRxResumeResumeCache,
@ -48,6 +52,24 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
*/ */
profileRouter.get("/status", async (_req: Request, res: Response) => { profileRouter.get("/status", async (_req: Request, res: Response) => {
try { try {
const localPath = await resolveLocalResumeFilePath();
if (localPath) {
try {
await getProfile();
ok(res, { exists: true, error: null });
return;
} catch (error) {
if (error instanceof RxResumeAuthConfigError) {
ok(res, { exists: false, error: error.message });
return;
}
const message =
error instanceof Error ? error.message : "Unknown error";
ok(res, { exists: false, error: message });
return;
}
}
const { resumeId: rxresumeBaseResumeId } = const { resumeId: rxresumeBaseResumeId } =
await getConfiguredRxResumeBaseResumeId(); await getConfiguredRxResumeBaseResumeId();

View File

@ -62,6 +62,7 @@ vi.mock("@server/services/scorer", () => ({
vi.mock("@server/services/profile", () => ({ vi.mock("@server/services/profile", () => ({
getProfile: vi.fn().mockResolvedValue({}), getProfile: vi.fn().mockResolvedValue({}),
clearProfileCache: vi.fn(), clearProfileCache: vi.fn(),
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
})); }));
vi.mock("@server/services/visa-sponsors/index", () => ({ vi.mock("@server/services/visa-sponsors/index", () => ({

View File

@ -126,6 +126,7 @@ vi.mock("../repositories/settings", () => ({
// Mock the profile service - getProfile now fetches from v4 API // Mock the profile service - getProfile now fetches from v4 API
vi.mock("./profile", () => ({ vi.mock("./profile", () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile), getProfile: vi.fn().mockResolvedValue(mockProfile),
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
})); }));
vi.mock("./projectSelection", () => ({ vi.mock("./projectSelection", () => ({

View File

@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { generatePdf } from "./pdf"; import { generatePdf } from "./pdf";
import { resolveLocalResumeFilePath } from "./profile";
import * as projectSelection from "./projectSelection"; import * as projectSelection from "./projectSelection";
import { getResume } from "./rxresume";
// Define mock data in hoisted block // Define mock data in hoisted block
const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => { const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
@ -115,6 +117,7 @@ vi.mock("../repositories/settings", () => ({
// Mock the profile service - getProfile now fetches from v4 API // Mock the profile service - getProfile now fetches from v4 API
vi.mock("./profile", () => ({ vi.mock("./profile", () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile), getProfile: vi.fn().mockResolvedValue(mockProfile),
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
})); }));
vi.mock("./projectSelection", () => ({ vi.mock("./projectSelection", () => ({
@ -282,6 +285,7 @@ vi.stubGlobal(
describe("PDF Service Tailoring Logic", () => { describe("PDF Service Tailoring Logic", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mockRxResume.clearLastCreateData(); mockRxResume.clearLastCreateData();
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue( mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
@ -292,6 +296,18 @@ describe("PDF Service Tailoring Logic", () => {
}); });
}); });
it("loads base resume from local file when a local path is configured", async () => {
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(
"/tmp/jobops-base.json",
);
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
await generatePdf("job-local-base", {}, "desc", undefined, "p1");
expect(getResume).not.toHaveBeenCalled();
expect(mockRxResume.importResume).toHaveBeenCalled();
});
it("should use provided selectedProjectIds and BYPASS AI selection", async () => { it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
const tailoredContent = { const tailoredContent = {
summary: "New Sum", summary: "New Sum",

View File

@ -1,14 +1,18 @@
/** /**
* Service for generating PDF resumes using Reactive Resume. * Service for generating tailored PDF resumes via Reactive Resume print API.
* Base resume JSON is loaded from a local file when configured (same as profile),
* otherwise from the selected Reactive Resume template.
*/ */
import { createWriteStream, existsSync } from "node:fs"; import { createWriteStream, existsSync } from "node:fs";
import { access, mkdir } from "node:fs/promises"; import { access, mkdir, readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import type { RxResumeMode } from "@shared/types";
import { getDataDir } from "../config/dataDir"; import { getDataDir } from "../config/dataDir";
import { resolveLocalResumeFilePath } from "./profile";
import { import {
deleteResume as deleteRemoteResume, deleteResume as deleteRemoteResume,
exportResumePdf, exportResumePdf,
@ -17,6 +21,7 @@ import {
prepareTailoredResumeForPdf, prepareTailoredResumeForPdf,
} from "./rxresume"; } from "./rxresume";
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId"; import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
import { inferRxResumeModeFromData } from "./rxresume/tailoring";
const OUTPUT_DIR = join(getDataDir(), "pdfs"); const OUTPUT_DIR = join(getDataDir(), "pdfs");
@ -65,17 +70,18 @@ async function downloadFile(url: string, outputPath: string): Promise<void> {
* Generate a tailored PDF resume for a job using Reactive Resume. * Generate a tailored PDF resume for a job using Reactive Resume.
* *
* Flow: * Flow:
* 1. Prepare resume data with tailored content and project selection * 1. Load base resume JSON (local file if JOBOPS_LOCAL_RESUME_PATH / Settings, else Rx template)
* 2. Import/create resume on Reactive Resume * 2. Prepare resume data with tailored content and project selection
* 3. Request print to get PDF URL * 3. Import/create resume on Reactive Resume
* 4. Download PDF locally * 4. Request print to get PDF URL
* 5. Delete temporary resume from Reactive Resume * 5. Download PDF locally
* 6. Delete temporary resume from Reactive Resume
*/ */
export async function generatePdf( export async function generatePdf(
jobId: string, jobId: string,
tailoredContent: TailoredPdfContent, tailoredContent: TailoredPdfContent,
jobDescription: string, jobDescription: string,
_baseResumePath?: string, // Deprecated: now always uses configured Reactive Resume base resume _baseResumePath?: string, // Deprecated: uses local path or configured Reactive Resume base resume
selectedProjectIds?: string | null, selectedProjectIds?: string | null,
options?: GeneratePdfOptions, options?: GeneratePdfOptions,
): Promise<PdfResult> { ): Promise<PdfResult> {
@ -87,23 +93,60 @@ export async function generatePdf(
await mkdir(OUTPUT_DIR, { recursive: true }); await mkdir(OUTPUT_DIR, { recursive: true });
} }
const { resumeId: baseResumeId } = let baseResumeData: Record<string, unknown>;
await getConfiguredRxResumeBaseResumeId(); let prepareMode: RxResumeMode | undefined;
if (!baseResumeId) {
throw new Error( const localPath = await resolveLocalResumeFilePath();
"Base resume not configured. Please select a base resume from your Reactive Resume account in Settings.", if (localPath) {
); let raw: string;
} try {
const baseResume = await getRxResume(baseResumeId); raw = await readFile(localPath, "utf8");
if (!baseResume.data || typeof baseResume.data !== "object") { } catch (error) {
throw new Error("Reactive Resume base resume is empty or invalid."); const detail = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Cannot read local resume file at ${localPath}. ${detail}`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(
`Local resume file at ${localPath} is not valid JSON (Reactive Resume export shape expected).`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(
`Local resume file at ${localPath} must be a single JSON object.`,
);
}
baseResumeData = parsed as Record<string, unknown>;
prepareMode = inferRxResumeModeFromData(baseResumeData) ?? undefined;
if (!prepareMode) {
const { mode } = await getConfiguredRxResumeBaseResumeId();
prepareMode = mode;
}
} else {
const { resumeId: baseResumeId, mode } =
await getConfiguredRxResumeBaseResumeId();
if (!baseResumeId) {
throw new Error(
"Base resume not configured. Set JOBOPS_LOCAL_RESUME_PATH or local resume path in Settings, or select a base resume from Reactive Resume.",
);
}
const baseResume = await getRxResume(baseResumeId);
if (!baseResume.data || typeof baseResume.data !== "object") {
throw new Error("Reactive Resume base resume is empty or invalid.");
}
baseResumeData = baseResume.data as Record<string, unknown>;
prepareMode = baseResume.mode ?? mode;
} }
let preparedResumeData: Record<string, unknown>; let preparedResumeData: Record<string, unknown>;
try { try {
const prepared = await prepareTailoredResumeForPdf({ const prepared = await prepareTailoredResumeForPdf({
resumeData: baseResume.data, resumeData: baseResumeData,
mode: baseResume.mode, mode: prepareMode,
tailoredContent, tailoredContent,
jobDescription, jobDescription,
selectedProjectIds, selectedProjectIds,