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:
parent
87dbda3df1
commit
77179b2b94
@ -19,7 +19,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
||||
import {
|
||||
CoverLetterDisplay,
|
||||
FitAssessment,
|
||||
JobHeader,
|
||||
TailoredSummary,
|
||||
} from "..";
|
||||
import { KbdHint } from "../KbdHint";
|
||||
import { OpenJobListingButton } from "../OpenJobListingButton";
|
||||
import { CollapsibleSection } from "./CollapsibleSection";
|
||||
@ -32,6 +37,8 @@ interface DecideModeProps {
|
||||
isSkipping: boolean;
|
||||
onRescore: () => void;
|
||||
isRescoring: boolean;
|
||||
onRequestCoverLetter: () => void;
|
||||
coverLetterGenerating: boolean;
|
||||
onEditDetails: () => void;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
@ -43,6 +50,8 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
isSkipping,
|
||||
onRescore,
|
||||
isRescoring,
|
||||
onRequestCoverLetter,
|
||||
coverLetterGenerating,
|
||||
onEditDetails,
|
||||
onCheckSponsor,
|
||||
}) => {
|
||||
@ -98,6 +107,11 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
|
||||
<div className="flex-1 py-6 space-y-6 overflow-y-auto">
|
||||
<FitAssessment job={job} />
|
||||
<CoverLetterDisplay
|
||||
job={job}
|
||||
onRequestGenerate={onRequestCoverLetter}
|
||||
generateInFlight={coverLetterGenerating}
|
||||
/>
|
||||
<TailoredSummary job={job} />
|
||||
|
||||
<CollapsibleSection
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as api from "@client/api";
|
||||
import { useSkipJobMutation } from "@client/hooks/queries/useJobMutations";
|
||||
import { useCoverLetterGeneration } from "@client/hooks/useCoverLetterGeneration";
|
||||
import { useRescoreJob } from "@client/hooks/useRescoreJob";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
@ -34,6 +35,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
const previousJobIdRef = useRef<string | null>(null);
|
||||
const skipJobMutation = useSkipJobMutation();
|
||||
const { isRescoring, rescoreJob } = useRescoreJob(onJobUpdated);
|
||||
const { generateForJob, inFlight: coverLetterGenerating } =
|
||||
useCoverLetterGeneration(onJobUpdated);
|
||||
|
||||
useEffect(() => {
|
||||
const currentJobId = job?.id ?? null;
|
||||
@ -138,6 +141,8 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
isSkipping={isSkipping}
|
||||
onRescore={handleRescore}
|
||||
isRescoring={isRescoring}
|
||||
onRequestCoverLetter={() => void generateForJob(job.id)}
|
||||
coverLetterGenerating={coverLetterGenerating}
|
||||
onEditDetails={() => setIsEditDetailsOpen(true)}
|
||||
onCheckSponsor={async () => {
|
||||
try {
|
||||
|
||||
@ -18,6 +18,7 @@ vi.mock("@server/services/rxresume", () => ({
|
||||
vi.mock("@server/services/profile", () => ({
|
||||
getProfile: vi.fn(),
|
||||
clearProfileCache: vi.fn(),
|
||||
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// Mock the settings repository
|
||||
@ -30,7 +31,10 @@ vi.mock("@server/repositories/settings", async (importOriginal) => {
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
describe.sequential("Profile API routes", () => {
|
||||
@ -41,6 +45,7 @@ describe.sequential("Profile API routes", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
|
||||
({ server, baseUrl, closeDb, tempDir } = await startServer());
|
||||
});
|
||||
|
||||
@ -159,6 +164,23 @@ describe.sequential("Profile API routes", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.mocked(getSetting).mockResolvedValue(null);
|
||||
|
||||
|
||||
@ -2,7 +2,11 @@ import { toAppError } from "@infra/errors";
|
||||
import { fail, ok } from "@infra/http";
|
||||
import { isDemoMode } from "@server/config/demo";
|
||||
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 {
|
||||
clearRxResumeResumeCache,
|
||||
@ -48,6 +52,24 @@ profileRouter.get("/", async (_req: Request, res: Response) => {
|
||||
*/
|
||||
profileRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
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 } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ vi.mock("@server/services/scorer", () => ({
|
||||
vi.mock("@server/services/profile", () => ({
|
||||
getProfile: vi.fn().mockResolvedValue({}),
|
||||
clearProfileCache: vi.fn(),
|
||||
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@server/services/visa-sponsors/index", () => ({
|
||||
|
||||
@ -126,6 +126,7 @@ vi.mock("../repositories/settings", () => ({
|
||||
// Mock the profile service - getProfile now fetches from v4 API
|
||||
vi.mock("./profile", () => ({
|
||||
getProfile: vi.fn().mockResolvedValue(mockProfile),
|
||||
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("./projectSelection", () => ({
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { generatePdf } from "./pdf";
|
||||
import { resolveLocalResumeFilePath } from "./profile";
|
||||
import * as projectSelection from "./projectSelection";
|
||||
import { getResume } from "./rxresume";
|
||||
|
||||
// Define mock data in hoisted block
|
||||
const { mocks, mockProfile, mockRxResume } = vi.hoisted(() => {
|
||||
@ -115,6 +117,7 @@ vi.mock("../repositories/settings", () => ({
|
||||
// Mock the profile service - getProfile now fetches from v4 API
|
||||
vi.mock("./profile", () => ({
|
||||
getProfile: vi.fn().mockResolvedValue(mockProfile),
|
||||
resolveLocalResumeFilePath: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("./projectSelection", () => ({
|
||||
@ -282,6 +285,7 @@ vi.stubGlobal(
|
||||
describe("PDF Service Tailoring Logic", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveLocalResumeFilePath).mockResolvedValue(null);
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
mockRxResume.clearLastCreateData();
|
||||
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 () => {
|
||||
const tailoredContent = {
|
||||
summary: "New Sum",
|
||||
|
||||
@ -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 { access, mkdir } from "node:fs/promises";
|
||||
import { access, mkdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { logger } from "@infra/logger";
|
||||
import type { RxResumeMode } from "@shared/types";
|
||||
import { getDataDir } from "../config/dataDir";
|
||||
import { resolveLocalResumeFilePath } from "./profile";
|
||||
import {
|
||||
deleteResume as deleteRemoteResume,
|
||||
exportResumePdf,
|
||||
@ -17,6 +21,7 @@ import {
|
||||
prepareTailoredResumeForPdf,
|
||||
} from "./rxresume";
|
||||
import { getConfiguredRxResumeBaseResumeId } from "./rxresume/baseResumeId";
|
||||
import { inferRxResumeModeFromData } from "./rxresume/tailoring";
|
||||
|
||||
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.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Prepare resume data with tailored content and project selection
|
||||
* 2. Import/create resume on Reactive Resume
|
||||
* 3. Request print to get PDF URL
|
||||
* 4. Download PDF locally
|
||||
* 5. Delete temporary resume from Reactive Resume
|
||||
* 1. Load base resume JSON (local file if JOBOPS_LOCAL_RESUME_PATH / Settings, else Rx template)
|
||||
* 2. Prepare resume data with tailored content and project selection
|
||||
* 3. Import/create resume on Reactive Resume
|
||||
* 4. Request print to get PDF URL
|
||||
* 5. Download PDF locally
|
||||
* 6. Delete temporary resume from Reactive Resume
|
||||
*/
|
||||
export async function generatePdf(
|
||||
jobId: string,
|
||||
tailoredContent: TailoredPdfContent,
|
||||
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,
|
||||
options?: GeneratePdfOptions,
|
||||
): Promise<PdfResult> {
|
||||
@ -87,23 +93,60 @@ export async function generatePdf(
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const { resumeId: baseResumeId } =
|
||||
await getConfiguredRxResumeBaseResumeId();
|
||||
if (!baseResumeId) {
|
||||
throw new Error(
|
||||
"Base resume not configured. Please select a base resume from your Reactive Resume account in Settings.",
|
||||
);
|
||||
}
|
||||
const baseResume = await getRxResume(baseResumeId);
|
||||
if (!baseResume.data || typeof baseResume.data !== "object") {
|
||||
throw new Error("Reactive Resume base resume is empty or invalid.");
|
||||
let baseResumeData: Record<string, unknown>;
|
||||
let prepareMode: RxResumeMode | undefined;
|
||||
|
||||
const localPath = await resolveLocalResumeFilePath();
|
||||
if (localPath) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(localPath, "utf8");
|
||||
} catch (error) {
|
||||
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>;
|
||||
try {
|
||||
const prepared = await prepareTailoredResumeForPdf({
|
||||
resumeData: baseResume.data,
|
||||
mode: baseResume.mode,
|
||||
resumeData: baseResumeData,
|
||||
mode: prepareMode,
|
||||
tailoredContent,
|
||||
jobDescription,
|
||||
selectedProjectIds,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user