Use logger! add shim to convert backend responses to same format (#84)
* chore(orchestrator): add @infra import alias * feat(server): add error/http/context/logger/sanitize infrastructure * refactor(core): propagate request context, structured logs, and sanitization * test/docs: update API contract assertions and contributor standards * all pages working * normalizing
This commit is contained in:
parent
82b261c7bc
commit
16a8f1d15a
58
AGENTS.md
Normal file
58
AGENTS.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Error/Logging/Sanitization Standards
|
||||
|
||||
This project uses strict operability and privacy defaults for server-side code.
|
||||
|
||||
## API Response Contract
|
||||
|
||||
For all `/api/*` routes, return:
|
||||
|
||||
- Success: `{ ok: true, data, meta?: { requestId } }`
|
||||
- Error: `{ ok: false, error: { code, message, details? }, meta: { requestId } }`
|
||||
|
||||
Use consistent status/code mapping:
|
||||
|
||||
- `400 INVALID_REQUEST`
|
||||
- `401 UNAUTHORIZED`
|
||||
- `403 FORBIDDEN`
|
||||
- `404 NOT_FOUND`
|
||||
- `408 REQUEST_TIMEOUT`
|
||||
- `409 CONFLICT`
|
||||
- `422 UNPROCESSABLE_ENTITY`
|
||||
- `500 INTERNAL_ERROR`
|
||||
- `502 UPSTREAM_ERROR`
|
||||
- `503 SERVICE_UNAVAILABLE`
|
||||
|
||||
## Correlation IDs
|
||||
|
||||
- Honor inbound `x-request-id` when present; otherwise generate one.
|
||||
- Always return `x-request-id` header.
|
||||
- Include request ID in API responses (`meta.requestId`) and logs.
|
||||
- Propagate context into async flows (especially pipeline run and per-job work) so logs include `pipelineRunId` / `jobId` when available.
|
||||
|
||||
## Logging Rules
|
||||
|
||||
- Use the shared logger wrapper (`infra/logger.ts`) in core server paths.
|
||||
- Do not add direct `console.log`, `console.warn`, or `console.error` in core paths.
|
||||
- Log structured objects, not free-form dumps.
|
||||
- Include useful context fields (e.g. `requestId`, `pipelineRunId`, `jobId`, `route`, `status`).
|
||||
|
||||
## Redaction and Sanitization
|
||||
|
||||
- Always sanitize objects before logging or returning in error `details`.
|
||||
- Redact sensitive keys by default (`authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, etc.).
|
||||
- Truncate large payloads and long strings.
|
||||
- Do not throw/log raw upstream response bodies, full webhook bodies, or large `JSON.stringify(...)` blobs.
|
||||
|
||||
## Webhook and LLM Payload Defaults
|
||||
|
||||
- Webhooks: send minimal whitelisted payloads by default.
|
||||
- LLM prompts: send only required profile/job fields; avoid unnecessary PII.
|
||||
- Document external payload behavior when adding new integrations.
|
||||
|
||||
## PR Checklist (Routes/Services)
|
||||
|
||||
- API responses follow `{ ok, data/error, meta.requestId }`.
|
||||
- Status/code mapping is correct and consistent.
|
||||
- Request/correlation IDs appear in logs and async workflows.
|
||||
- No raw sensitive payload logging or raw upstream body throws.
|
||||
- New/changed webhook or LLM payloads are sanitized and documented.
|
||||
@ -92,3 +92,11 @@ POST /api/jobs/:id/generate-pdf
|
||||
- `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`.
|
||||
- The PDF is served at `/pdfs/resume_<jobId>.pdf` and cache-busted with the job?s `updatedAt` timestamp.
|
||||
- If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`.
|
||||
|
||||
## External payload and sanitization defaults
|
||||
|
||||
- **LLM providers** receive only prompt inputs required for scoring/tailoring/project selection/manual extraction tasks.
|
||||
- By default, prompt construction uses minimized profile/job fields and avoids sending unnecessary sensitive data.
|
||||
- **Webhook payloads** are sanitized and whitelisted by default; large/sensitive blobs are not sent.
|
||||
- Server logs and error details are redacted/truncated by default (secrets, tokens, cookies, passwords, API keys, and oversized payload fields).
|
||||
- Correlation data is included in logs (`requestId`, and when available `pipelineRunId` / `jobId`) to improve traceability without exposing raw payloads.
|
||||
|
||||
@ -35,6 +35,45 @@ import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
class ApiClientError extends Error {
|
||||
requestId?: string;
|
||||
|
||||
constructor(message: string, requestId?: string) {
|
||||
super(requestId ? `${message} (requestId: ${requestId})` : message);
|
||||
this.name = "ApiClientError";
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
type LegacyApiResponse<T> =
|
||||
| {
|
||||
success: true;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
function normalizeApiResponse<T>(
|
||||
payload: unknown,
|
||||
): ApiResponse<T> | LegacyApiResponse<T> {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new ApiClientError("API request failed: malformed JSON response");
|
||||
}
|
||||
const response = payload as Record<string, unknown>;
|
||||
if (typeof response.ok === "boolean") {
|
||||
return payload as ApiResponse<T>;
|
||||
}
|
||||
if (typeof response.success === "boolean") {
|
||||
return payload as LegacyApiResponse<T>;
|
||||
}
|
||||
throw new ApiClientError("API request failed: unexpected response shape");
|
||||
}
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit,
|
||||
@ -49,22 +88,38 @@ async function fetchApi<T>(
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
let data: ApiResponse<T>;
|
||||
let payload: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
// If the response is not JSON, it's likely an HTML error page
|
||||
console.error("API returned non-JSON response:", text.substring(0, 500));
|
||||
throw new Error(
|
||||
throw new ApiClientError(
|
||||
`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`,
|
||||
);
|
||||
}
|
||||
const parsed = normalizeApiResponse<T>(payload);
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "API request failed");
|
||||
if ("ok" in parsed) {
|
||||
if (!parsed.ok) {
|
||||
throw new ApiClientError(
|
||||
parsed.error.message || "API request failed",
|
||||
parsed.meta?.requestId,
|
||||
);
|
||||
}
|
||||
return parsed.data as T;
|
||||
}
|
||||
|
||||
return data.data as T;
|
||||
if (!parsed.success) {
|
||||
throw new ApiClientError(
|
||||
parsed.error || parsed.message || "API request failed",
|
||||
);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
if (data !== undefined) return data as T;
|
||||
if (parsed.message !== undefined) return { message: parsed.message } as T;
|
||||
return null as T;
|
||||
}
|
||||
|
||||
// Jobs API
|
||||
|
||||
@ -23,7 +23,7 @@ describe.sequential("Backup API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.backups).toEqual([]);
|
||||
expect(body.data.nextScheduled).toBeNull();
|
||||
});
|
||||
@ -36,7 +36,7 @@ describe.sequential("Backup API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.backups).toHaveLength(1);
|
||||
expect(body.data.backups[0]).toHaveProperty("filename");
|
||||
expect(body.data.backups[0]).toHaveProperty("type", "manual");
|
||||
@ -51,7 +51,7 @@ describe.sequential("Backup API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.type).toBe("manual");
|
||||
expect(body.data.filename).toMatch(
|
||||
/^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}\.db$/,
|
||||
@ -67,8 +67,8 @@ describe.sequential("Backup API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("Database file not found");
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.message).toContain("Database file not found");
|
||||
});
|
||||
});
|
||||
|
||||
@ -88,8 +88,8 @@ describe.sequential("Backup API routes", () => {
|
||||
const deleteBody = await deleteRes.json();
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteBody.success).toBe(true);
|
||||
expect(deleteBody.message).toContain("deleted successfully");
|
||||
expect(deleteBody.ok).toBe(true);
|
||||
expect(deleteBody.data.message).toContain("deleted successfully");
|
||||
|
||||
// Verify it's gone
|
||||
const listRes = await fetch(`${baseUrl}/api/backups`);
|
||||
@ -104,8 +104,8 @@ describe.sequential("Backup API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("not found");
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.message).toContain("not found");
|
||||
});
|
||||
|
||||
it("should return 400 for invalid filename", async () => {
|
||||
@ -115,8 +115,8 @@ describe.sequential("Backup API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("Invalid");
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.message).toContain("Invalid");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import {
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
@ -25,7 +26,7 @@ backupRouter.get("/", async (_req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("❌ [backup-api] Failed to list backups:", error);
|
||||
logger.error("Failed to list backups", error);
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
@ -49,7 +50,7 @@ backupRouter.post("/", async (_req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("❌ [backup-api] Failed to create backup:", error);
|
||||
logger.error("Failed to create backup", error);
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
@ -77,10 +78,10 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(
|
||||
`❌ [backup-api] Failed to delete backup ${req.params.filename}:`,
|
||||
logger.error("Failed to delete backup", {
|
||||
filename: req.params.filename,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
if (message.includes("not found")) {
|
||||
res.status(404).json({ success: false, error: message });
|
||||
|
||||
@ -28,7 +28,7 @@ describe.sequential("Database API routes", () => {
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/database`, { method: "DELETE" });
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.jobsDeleted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -28,7 +28,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/jobs`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.success).toBe(true);
|
||||
expect(listBody.ok).toBe(true);
|
||||
expect(listBody.data.total).toBe(1);
|
||||
expect(listBody.data.jobs[0].id).toBe(job.id);
|
||||
|
||||
@ -92,7 +92,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
method: "POST",
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.status).toBe("applied");
|
||||
expect(body.data.notionPageId).toBe("page-123");
|
||||
expect(body.data.appliedAt).toBeTruthy();
|
||||
@ -135,7 +135,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.suitabilityScore).toBe(77);
|
||||
expect(body.data.suitabilityReason).toBe("Updated fit");
|
||||
});
|
||||
@ -165,7 +165,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.sponsorMatchScore).toBe(100);
|
||||
expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR");
|
||||
});
|
||||
@ -192,7 +192,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
body: JSON.stringify({ toStage: "applied" }),
|
||||
});
|
||||
const body1 = await trans1.json();
|
||||
expect(body1.success).toBe(true);
|
||||
expect(body1.ok).toBe(true);
|
||||
expect(body1.data.toStage).toBe("applied");
|
||||
const eventId = body1.data.id;
|
||||
|
||||
@ -209,7 +209,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
// 3. Get events
|
||||
const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
|
||||
const eventsBody = await eventsRes.json();
|
||||
expect(eventsBody.success).toBe(true);
|
||||
expect(eventsBody.ok).toBe(true);
|
||||
expect(eventsBody.data).toHaveLength(2);
|
||||
expect(eventsBody.data[0].toStage).toBe("applied");
|
||||
expect(eventsBody.data[1].toStage).toBe("recruiter_screen");
|
||||
@ -252,7 +252,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
// 1. Initial state
|
||||
const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
|
||||
const body1 = await res1.json();
|
||||
expect(body1.success).toBe(true);
|
||||
expect(body1.ok).toBe(true);
|
||||
expect(body1.data).toEqual([]);
|
||||
|
||||
// 2. Insert a task
|
||||
@ -297,7 +297,7 @@ describe.sequential("Jobs API routes", () => {
|
||||
body: JSON.stringify({ outcome: "rejected" }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.outcome).toBe("rejected");
|
||||
expect(body.data.closedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { sanitizeWebhookPayload } from "@infra/sanitize";
|
||||
import {
|
||||
APPLICATION_OUTCOMES,
|
||||
APPLICATION_STAGES,
|
||||
@ -8,7 +10,6 @@ import {
|
||||
} from "@shared/types";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
generateFinalPdf,
|
||||
processJob,
|
||||
@ -49,23 +50,35 @@ async function notifyJobCompleteWebhook(job: Job) {
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
if (secret) headers.Authorization = `Bearer ${secret}`;
|
||||
|
||||
const payload = sanitizeWebhookPayload({
|
||||
event: "job.completed",
|
||||
sentAt: new Date().toISOString(),
|
||||
job: {
|
||||
id: job.id,
|
||||
source: job.source,
|
||||
title: job.title,
|
||||
employer: job.employer,
|
||||
status: job.status,
|
||||
suitabilityScore: job.suitabilityScore,
|
||||
sponsorMatchScore: job.sponsorMatchScore,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
event: "job.completed",
|
||||
sentAt: new Date().toISOString(),
|
||||
job,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`ƒsÿ‹,? Job complete webhook POST failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
logger.warn("Job complete webhook POST failed", {
|
||||
status: response.status,
|
||||
response: (await response.text().catch(() => "")).slice(0, 200),
|
||||
jobId: job.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("ƒsÿ‹,? Job complete webhook POST failed:", error);
|
||||
logger.warn("Job complete webhook POST failed", { jobId: job.id, error });
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +142,7 @@ jobsRouter.get("/", async (req: Request, res: Response) => {
|
||||
const stats = await jobsRepo.getJobStats();
|
||||
|
||||
const response: ApiResponse<JobsListResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
jobs,
|
||||
total: jobs.length,
|
||||
@ -493,7 +506,9 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
if (updatedJob) {
|
||||
notifyJobCompleteWebhook(updatedJob).catch(console.warn);
|
||||
notifyJobCompleteWebhook(updatedJob).catch((error) => {
|
||||
logger.warn("Job complete webhook dispatch failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (!updatedJob) {
|
||||
|
||||
@ -58,7 +58,7 @@ describe.sequential("Manual jobs API routes", () => {
|
||||
body: JSON.stringify({ jobDescription: "Role description" }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.job.title).toBe("Backend Engineer");
|
||||
});
|
||||
|
||||
@ -81,7 +81,7 @@ describe.sequential("Manual jobs API routes", () => {
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.source).toBe("manual");
|
||||
expect(body.data.jobUrl).toMatch(/^manual:\/\//);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { logger } from "@infra/logger";
|
||||
import type {
|
||||
ApiResponse,
|
||||
ManualJobFetchResponse,
|
||||
@ -154,7 +155,7 @@ manualJobsRouter.post("/fetch", async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const result: ApiResponse<ManualJobFetchResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
content: enrichedContent,
|
||||
url: input.url,
|
||||
@ -185,7 +186,7 @@ manualJobsRouter.post("/infer", async (req: Request, res: Response) => {
|
||||
const result = await inferManualJobDetails(input.jobDescription);
|
||||
|
||||
const response: ApiResponse<ManualJobInferenceResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
job: result.job,
|
||||
warning: result.warning ?? null,
|
||||
@ -254,10 +255,10 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => {
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Manual job scoring failed:", error);
|
||||
logger.warn("Manual job scoring failed", error);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.warn("Manual job scoring task failed to start:", error);
|
||||
logger.warn("Manual job scoring task failed to start", error);
|
||||
});
|
||||
|
||||
res.json({ success: true, data: createdJob });
|
||||
|
||||
@ -30,7 +30,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("missing");
|
||||
});
|
||||
@ -68,7 +68,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
// Should be invalid because the key is fake
|
||||
expect(body.data.valid).toBe(false);
|
||||
});
|
||||
@ -84,7 +84,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("missing");
|
||||
});
|
||||
@ -132,7 +132,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
// Should be invalid because credentials are fake
|
||||
expect(body.data.valid).toBe(false);
|
||||
});
|
||||
@ -157,7 +157,7 @@ describe.sequential("Onboarding API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.valid).toBe(false);
|
||||
expect(body.data.message).toContain("No base resume selected");
|
||||
});
|
||||
|
||||
@ -19,7 +19,7 @@ describe.sequential("Pipeline API routes", () => {
|
||||
it("reports pipeline status", async () => {
|
||||
const res = await fetch(`${baseUrl}/api/pipeline/status`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.isRunning).toBe(false);
|
||||
expect(body.data.lastRun).toBeNull();
|
||||
});
|
||||
@ -39,7 +39,7 @@ describe.sequential("Pipeline API routes", () => {
|
||||
body: JSON.stringify({ topN: 5, sources: ["gradcracker"] }),
|
||||
});
|
||||
const runBody = await runRes.json();
|
||||
expect(runBody.success).toBe(true);
|
||||
expect(runBody.ok).toBe(true);
|
||||
expect(runPipeline).toHaveBeenCalledWith({
|
||||
topN: 5,
|
||||
sources: ["gradcracker"],
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { runWithRequestContext } from "@infra/request-context";
|
||||
import type { ApiResponse, PipelineStatusResponse } from "@shared/types";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
import { z } from "zod";
|
||||
@ -19,7 +21,7 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
const lastRun = await pipelineRepo.getLatestPipelineRun();
|
||||
|
||||
const response: ApiResponse<PipelineStatusResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
isRunning,
|
||||
lastRun,
|
||||
@ -30,7 +32,9 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
}
|
||||
});
|
||||
|
||||
@ -70,10 +74,12 @@ pipelineRouter.get("/progress", (req: Request, res: Response) => {
|
||||
pipelineRouter.get("/runs", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const runs = await pipelineRepo.getRecentPipelineRuns(20);
|
||||
res.json({ success: true, data: runs });
|
||||
res.json({ ok: true, data: runs });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
}
|
||||
});
|
||||
|
||||
@ -94,17 +100,26 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
|
||||
const config = runPipelineSchema.parse(req.body);
|
||||
|
||||
// Start pipeline in background
|
||||
runPipeline(config).catch(console.error);
|
||||
runWithRequestContext({}, () => {
|
||||
runPipeline(config).catch((error) => {
|
||||
logger.error("Background pipeline run failed", error);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok: true,
|
||||
data: { message: "Pipeline started" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ success: false, error: error.message });
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: error.message },
|
||||
});
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
}
|
||||
});
|
||||
|
||||
@ -83,7 +83,7 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data.length).toBe(2);
|
||||
});
|
||||
@ -97,8 +97,8 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("Base resume not configured");
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.message).toContain("Base resume not configured");
|
||||
});
|
||||
});
|
||||
|
||||
@ -114,7 +114,7 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data).toEqual(mockProfile);
|
||||
});
|
||||
|
||||
@ -127,8 +127,8 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("Base resume not configured");
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.message).toContain("Base resume not configured");
|
||||
});
|
||||
});
|
||||
|
||||
@ -140,7 +140,7 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.exists).toBe(false);
|
||||
expect(body.data.error).toContain("No base resume selected");
|
||||
});
|
||||
@ -156,7 +156,7 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.exists).toBe(true);
|
||||
expect(body.data.error).toBeNull();
|
||||
});
|
||||
@ -169,7 +169,7 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.exists).toBe(false);
|
||||
expect(body.data.error).toContain("credentials not configured");
|
||||
});
|
||||
@ -185,7 +185,7 @@ describe.sequential("Profile API routes", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.exists).toBe(false);
|
||||
expect(body.data.error).toContain("empty or invalid");
|
||||
});
|
||||
|
||||
@ -24,7 +24,7 @@ describe.sequential("Settings API routes", () => {
|
||||
it("returns settings with defaults", async () => {
|
||||
const res = await fetch(`${baseUrl}/api/settings`);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.defaultModel).toBe("test-model");
|
||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
||||
expect(body.data.rxresumeEmail).toBe("resume@example.com");
|
||||
@ -51,7 +51,7 @@ describe.sequential("Settings API routes", () => {
|
||||
}),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
expect(patchBody.success).toBe(true);
|
||||
expect(patchBody.ok).toBe(true);
|
||||
expect(patchBody.data.searchTerms).toEqual(["engineer"]);
|
||||
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]);
|
||||
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
|
||||
@ -70,7 +70,7 @@ describe.sequential("Settings API routes", () => {
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain("Username is required");
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error.message).toContain("Username is required");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { setBackupSettings } from "@server/services/backup/index";
|
||||
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
||||
import {
|
||||
@ -72,7 +73,7 @@ settingsRouter.get("/rx-resumes", async (_req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`❌ Failed to fetch Reactive Resumes: ${message}`);
|
||||
logger.error("Failed to fetch Reactive Resumes", { message });
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
@ -103,7 +104,7 @@ settingsRouter.get(
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`❌ Failed to fetch RxResume projects: ${message}`);
|
||||
logger.error("Failed to fetch RxResume projects", { message });
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
|
||||
@ -47,7 +47,7 @@ describe.sequential("UK Visa Jobs API routes", () => {
|
||||
body: JSON.stringify({ query: "engineer" }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.totalPages).toBe(2);
|
||||
expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({
|
||||
searchKeyword: "engineer",
|
||||
@ -87,7 +87,7 @@ describe.sequential("UK Visa Jobs API routes", () => {
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.created).toBe(1);
|
||||
expect(body.data.skipped).toBe(1);
|
||||
});
|
||||
|
||||
@ -70,7 +70,7 @@ ukVisaJobsRouter.post("/search", async (req: Request, res: Response) => {
|
||||
);
|
||||
|
||||
const response: ApiResponse<UkVisaJobsSearchResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
jobs: result.jobs,
|
||||
totalJobs: result.totalJobs,
|
||||
@ -133,7 +133,7 @@ ukVisaJobsRouter.post("/import", async (req: Request, res: Response) => {
|
||||
const result = await jobsRepo.bulkCreateJobs(jobs);
|
||||
|
||||
const response: ApiResponse<UkVisaJobsImportResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
created: result.created,
|
||||
skipped: result.skipped,
|
||||
|
||||
@ -35,7 +35,7 @@ describe.sequential("Visa sponsors API routes", () => {
|
||||
|
||||
const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`);
|
||||
const statusBody = await statusRes.json();
|
||||
expect(statusBody.success).toBe(true);
|
||||
expect(statusBody.ok).toBe(true);
|
||||
expect(statusBody.data.totalSponsors).toBe(0);
|
||||
|
||||
const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, {
|
||||
@ -76,7 +76,7 @@ describe.sequential("Visa sponsors API routes", () => {
|
||||
body: JSON.stringify({ query: "Acme" }),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.total).toBe(1);
|
||||
|
||||
const orgRes = await fetch(
|
||||
|
||||
@ -17,7 +17,7 @@ visaSponsorsRouter.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const status = visaSponsors.getStatus();
|
||||
const response: ApiResponse<VisaSponsorStatusResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: status,
|
||||
};
|
||||
res.json(response);
|
||||
@ -46,7 +46,7 @@ visaSponsorsRouter.post("/search", async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
const response: ApiResponse<VisaSponsorSearchResponse> = {
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
results,
|
||||
query: input.query,
|
||||
|
||||
@ -29,7 +29,7 @@ describe.sequential("Webhook API routes", () => {
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
});
|
||||
const goodBody = await goodRes.json();
|
||||
expect(goodBody.success).toBe(true);
|
||||
expect(goodBody.ok).toBe(true);
|
||||
expect(goodBody.data.message).toBe("Pipeline triggered");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { runWithRequestContext } from "@infra/request-context";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
import { runPipeline } from "../../pipeline/index";
|
||||
|
||||
@ -12,15 +14,22 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => {
|
||||
const expectedToken = process.env.WEBHOOK_SECRET;
|
||||
|
||||
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
|
||||
return res.status(401).json({ success: false, error: "Unauthorized" });
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Start pipeline in background
|
||||
runPipeline().catch(console.error);
|
||||
runWithRequestContext({}, () => {
|
||||
runPipeline().catch((error) => {
|
||||
logger.error("Webhook-triggered pipeline run failed", error);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
ok: true,
|
||||
data: {
|
||||
message: "Pipeline triggered",
|
||||
triggeredAt: new Date().toISOString(),
|
||||
@ -28,6 +37,8 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
res
|
||||
.status(500)
|
||||
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
|
||||
}
|
||||
});
|
||||
|
||||
@ -5,6 +5,15 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { unauthorized } from "@infra/errors";
|
||||
import {
|
||||
apiErrorHandler,
|
||||
fail,
|
||||
legacyApiResponseShim,
|
||||
notFoundApiHandler,
|
||||
requestContextMiddleware,
|
||||
} from "@infra/http";
|
||||
import { logger } from "@infra/logger";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { apiRouter } from "./api/index";
|
||||
@ -67,7 +76,7 @@ function createBasicAuthGuard() {
|
||||
if (!enabled || !requiresAuth(req.method, req.path)) return next();
|
||||
if (isAuthorized(req)) return next();
|
||||
res.setHeader("WWW-Authenticate", 'Basic realm="Job Ops"');
|
||||
res.status(401).send("Authentication required");
|
||||
fail(res, unauthorized("Authentication required"));
|
||||
};
|
||||
|
||||
return {
|
||||
@ -82,16 +91,21 @@ export function createApp() {
|
||||
const authGuard = createBasicAuthGuard();
|
||||
|
||||
app.use(cors());
|
||||
app.use(requestContextMiddleware());
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
app.use(legacyApiResponseShim());
|
||||
|
||||
// Logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(
|
||||
`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`,
|
||||
);
|
||||
logger.info("HTTP request completed", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
status: res.statusCode,
|
||||
durationMs: duration,
|
||||
});
|
||||
});
|
||||
next();
|
||||
});
|
||||
@ -101,6 +115,7 @@ export function createApp() {
|
||||
|
||||
// API routes
|
||||
app.use("/api", apiRouter);
|
||||
app.use(notFoundApiHandler());
|
||||
|
||||
// Serve static files for generated PDFs
|
||||
const pdfDir = join(getDataDir(), "pdfs");
|
||||
@ -132,5 +147,7 @@ export function createApp() {
|
||||
});
|
||||
}
|
||||
|
||||
app.use(apiErrorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
128
orchestrator/src/server/infra/errors.ts
Normal file
128
orchestrator/src/server/infra/errors.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export type AppErrorCode =
|
||||
| "INVALID_REQUEST"
|
||||
| "UNAUTHORIZED"
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "REQUEST_TIMEOUT"
|
||||
| "CONFLICT"
|
||||
| "UNPROCESSABLE_ENTITY"
|
||||
| "UPSTREAM_ERROR"
|
||||
| "SERVICE_UNAVAILABLE"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
const DEFAULT_CODE_BY_STATUS: Record<number, AppErrorCode> = {
|
||||
400: "INVALID_REQUEST",
|
||||
401: "UNAUTHORIZED",
|
||||
403: "FORBIDDEN",
|
||||
404: "NOT_FOUND",
|
||||
408: "REQUEST_TIMEOUT",
|
||||
409: "CONFLICT",
|
||||
422: "UNPROCESSABLE_ENTITY",
|
||||
500: "INTERNAL_ERROR",
|
||||
502: "UPSTREAM_ERROR",
|
||||
503: "SERVICE_UNAVAILABLE",
|
||||
};
|
||||
|
||||
export class AppError extends Error {
|
||||
status: number;
|
||||
code: AppErrorCode;
|
||||
details?: unknown;
|
||||
|
||||
constructor(args: {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: AppErrorCode;
|
||||
details?: unknown;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(args.message, { cause: args.cause });
|
||||
this.name = "AppError";
|
||||
this.status = args.status ?? 500;
|
||||
this.code = args.code ?? statusToCode(this.status);
|
||||
this.details = args.details;
|
||||
}
|
||||
}
|
||||
|
||||
export function statusToCode(status: number): AppErrorCode {
|
||||
return DEFAULT_CODE_BY_STATUS[status] ?? "INTERNAL_ERROR";
|
||||
}
|
||||
|
||||
export function badRequest(message: string, details?: unknown): AppError {
|
||||
return new AppError({
|
||||
status: 400,
|
||||
code: "INVALID_REQUEST",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
export function unauthorized(message = "Unauthorized"): AppError {
|
||||
return new AppError({ status: 401, code: "UNAUTHORIZED", message });
|
||||
}
|
||||
|
||||
export function forbidden(message = "Forbidden"): AppError {
|
||||
return new AppError({ status: 403, code: "FORBIDDEN", message });
|
||||
}
|
||||
|
||||
export function notFound(message = "Not found"): AppError {
|
||||
return new AppError({ status: 404, code: "NOT_FOUND", message });
|
||||
}
|
||||
|
||||
export function requestTimeout(message = "Request timed out"): AppError {
|
||||
return new AppError({ status: 408, code: "REQUEST_TIMEOUT", message });
|
||||
}
|
||||
|
||||
export function conflict(message: string): AppError {
|
||||
return new AppError({ status: 409, code: "CONFLICT", message });
|
||||
}
|
||||
|
||||
export function unprocessableEntity(
|
||||
message: string,
|
||||
details?: unknown,
|
||||
): AppError {
|
||||
return new AppError({
|
||||
status: 422,
|
||||
code: "UNPROCESSABLE_ENTITY",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
export function upstreamError(message: string, details?: unknown): AppError {
|
||||
return new AppError({
|
||||
status: 502,
|
||||
code: "UPSTREAM_ERROR",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceUnavailable(message: string): AppError {
|
||||
return new AppError({ status: 503, code: "SERVICE_UNAVAILABLE", message });
|
||||
}
|
||||
|
||||
export function toAppError(error: unknown): AppError {
|
||||
if (error instanceof AppError) return error;
|
||||
if (error instanceof ZodError) {
|
||||
return badRequest(error.message, error.flatten());
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return requestTimeout("Request timed out");
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return new AppError({
|
||||
status: 500,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error.message || "Internal server error",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return new AppError({
|
||||
status: 500,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Internal server error",
|
||||
details: error,
|
||||
});
|
||||
}
|
||||
154
orchestrator/src/server/infra/http.ts
Normal file
154
orchestrator/src/server/infra/http.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ApiResponse } from "@shared/types";
|
||||
import type {
|
||||
ErrorRequestHandler,
|
||||
NextFunction,
|
||||
Request,
|
||||
RequestHandler,
|
||||
Response,
|
||||
} from "express";
|
||||
import type { AppError } from "./errors";
|
||||
import { notFound, statusToCode, toAppError } from "./errors";
|
||||
import { logger } from "./logger";
|
||||
import { getRequestId, runWithRequestContext } from "./request-context";
|
||||
import { sanitizeUnknown } from "./sanitize";
|
||||
|
||||
function getResponseRequestId(res: Response): string {
|
||||
return (
|
||||
(res.getHeader("x-request-id") as string | undefined) ??
|
||||
getRequestId() ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
export function ok<T>(res: Response, data: T, status = 200): void {
|
||||
const payload: ApiResponse<T> = {
|
||||
ok: true,
|
||||
data,
|
||||
meta: { requestId: getResponseRequestId(res) },
|
||||
};
|
||||
res.status(status).json(payload);
|
||||
}
|
||||
|
||||
export function fail(res: Response, error: AppError): void {
|
||||
const payload: ApiResponse<never> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.details !== undefined
|
||||
? { details: sanitizeUnknown(error.details) }
|
||||
: {}),
|
||||
},
|
||||
meta: { requestId: getResponseRequestId(res) },
|
||||
};
|
||||
res.status(error.status).json(payload);
|
||||
}
|
||||
|
||||
export function asyncRoute(
|
||||
handler: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => Promise<unknown>,
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export function requestContextMiddleware(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const requestIdHeader = req.header("x-request-id")?.trim();
|
||||
const requestId =
|
||||
requestIdHeader && requestIdHeader.length > 0
|
||||
? requestIdHeader
|
||||
: crypto.randomUUID();
|
||||
|
||||
res.setHeader("x-request-id", requestId);
|
||||
runWithRequestContext({ requestId }, () => next());
|
||||
};
|
||||
}
|
||||
|
||||
export function legacyApiResponseShim(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
if (!req.path.startsWith("/api")) return next();
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = ((body: unknown) => {
|
||||
if (!body || typeof body !== "object") return originalJson(body);
|
||||
const payload = body as Record<string, unknown>;
|
||||
if ("ok" in payload) {
|
||||
if (!("meta" in payload)) {
|
||||
return originalJson({
|
||||
...payload,
|
||||
meta: { requestId: getResponseRequestId(res) },
|
||||
});
|
||||
}
|
||||
return originalJson(body);
|
||||
}
|
||||
|
||||
if (typeof payload.success === "boolean") {
|
||||
const requestId = getResponseRequestId(res);
|
||||
if (payload.success) {
|
||||
let data: unknown = payload.data;
|
||||
if (data === undefined && payload.message !== undefined) {
|
||||
data = { message: payload.message };
|
||||
}
|
||||
return originalJson({
|
||||
ok: true,
|
||||
data: data ?? null,
|
||||
meta: { requestId },
|
||||
} satisfies ApiResponse<unknown>);
|
||||
}
|
||||
|
||||
const status = res.statusCode >= 400 ? res.statusCode : 500;
|
||||
const rawError = payload.error;
|
||||
const message =
|
||||
typeof rawError === "string"
|
||||
? rawError
|
||||
: typeof payload.message === "string"
|
||||
? payload.message
|
||||
: "Request failed";
|
||||
const details =
|
||||
rawError && typeof rawError === "object"
|
||||
? (rawError as { details?: unknown }).details
|
||||
: payload.details;
|
||||
|
||||
return originalJson({
|
||||
ok: false,
|
||||
error: {
|
||||
code: statusToCode(status),
|
||||
message,
|
||||
...(details !== undefined
|
||||
? { details: sanitizeUnknown(details) }
|
||||
: {}),
|
||||
},
|
||||
meta: { requestId },
|
||||
} satisfies ApiResponse<never>);
|
||||
}
|
||||
|
||||
return originalJson(body);
|
||||
}) as Response["json"];
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function notFoundApiHandler(): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
if (!req.path.startsWith("/api")) return next();
|
||||
next(notFound(`Route not found: ${req.method} ${req.path}`));
|
||||
};
|
||||
}
|
||||
|
||||
export const apiErrorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||
const appError = toAppError(err);
|
||||
logger.error(appError.message, {
|
||||
status: appError.status,
|
||||
code: appError.code,
|
||||
details: appError.details,
|
||||
cause: appError.cause,
|
||||
});
|
||||
fail(res, appError);
|
||||
};
|
||||
74
orchestrator/src/server/infra/logger.ts
Normal file
74
orchestrator/src/server/infra/logger.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { getRequestContext } from "./request-context";
|
||||
import { sanitizeError, sanitizeUnknown } from "./sanitize";
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const levelPriority: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function resolveMinLevel(): LogLevel {
|
||||
const raw = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
|
||||
return raw;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
const minLevel = resolveMinLevel();
|
||||
|
||||
export class Logger {
|
||||
constructor(private readonly context: Record<string, unknown> = {}) {}
|
||||
|
||||
child(context: Record<string, unknown>): Logger {
|
||||
return new Logger({ ...this.context, ...context });
|
||||
}
|
||||
|
||||
debug(message: string, meta?: unknown): void {
|
||||
this.log("debug", message, meta);
|
||||
}
|
||||
|
||||
info(message: string, meta?: unknown): void {
|
||||
this.log("info", message, meta);
|
||||
}
|
||||
|
||||
warn(message: string, meta?: unknown): void {
|
||||
this.log("warn", message, meta);
|
||||
}
|
||||
|
||||
error(message: string, meta?: unknown): void {
|
||||
this.log("error", message, meta);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, meta?: unknown): void {
|
||||
if (levelPriority[level] < levelPriority[minLevel]) return;
|
||||
|
||||
const requestContext = getRequestContext();
|
||||
const payload: Record<string, unknown> = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
msg: message,
|
||||
...this.context,
|
||||
...(requestContext ?? {}),
|
||||
};
|
||||
|
||||
if (meta !== undefined) {
|
||||
payload.meta =
|
||||
meta instanceof Error ? sanitizeError(meta) : sanitizeUnknown(meta);
|
||||
}
|
||||
|
||||
const line = JSON.stringify(payload);
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else if (level === "warn") {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
30
orchestrator/src/server/infra/request-context.ts
Normal file
30
orchestrator/src/server/infra/request-context.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export type RequestContext = {
|
||||
requestId: string;
|
||||
pipelineRunId?: string;
|
||||
jobId?: string;
|
||||
};
|
||||
|
||||
const storage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
||||
export function runWithRequestContext<T>(
|
||||
context: Partial<RequestContext>,
|
||||
fn: () => T,
|
||||
): T {
|
||||
const current = storage.getStore();
|
||||
const merged: RequestContext = {
|
||||
requestId: context.requestId ?? current?.requestId ?? "unknown",
|
||||
...(current ?? {}),
|
||||
...context,
|
||||
};
|
||||
return storage.run(merged, fn);
|
||||
}
|
||||
|
||||
export function getRequestId(): string | undefined {
|
||||
return storage.getStore()?.requestId;
|
||||
}
|
||||
114
orchestrator/src/server/infra/sanitize.ts
Normal file
114
orchestrator/src/server/infra/sanitize.ts
Normal file
@ -0,0 +1,114 @@
|
||||
const REDACTED = "[REDACTED]";
|
||||
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/(authorization|cookie|password|pass|secret|token|api.?key|credential|set-cookie|proxy-authorization|x-api-key)/i;
|
||||
|
||||
const DEFAULT_MAX_STRING = 800;
|
||||
const DEFAULT_MAX_DEPTH = 5;
|
||||
const DEFAULT_MAX_ITEMS = 30;
|
||||
|
||||
export function redactString(value: string, max = DEFAULT_MAX_STRING): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}…(truncated ${value.length - max} chars)`;
|
||||
}
|
||||
|
||||
export function sanitizeUnknown(
|
||||
value: unknown,
|
||||
options: { depth?: number; maxItems?: number; maxString?: number } = {},
|
||||
): unknown {
|
||||
const depth = options.depth ?? DEFAULT_MAX_DEPTH;
|
||||
const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||
const maxString = options.maxString ?? DEFAULT_MAX_STRING;
|
||||
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === "string") return redactString(value, maxString);
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "bigint"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return sanitizeError(value);
|
||||
}
|
||||
|
||||
if (depth <= 0) {
|
||||
return "[TRUNCATED_DEPTH]";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const limited = value.slice(0, maxItems);
|
||||
const out = limited.map((item) =>
|
||||
sanitizeUnknown(item, {
|
||||
depth: depth - 1,
|
||||
maxItems,
|
||||
maxString,
|
||||
}),
|
||||
);
|
||||
if (value.length > maxItems) {
|
||||
out.push(`[TRUNCATED_ITEMS ${value.length - maxItems}]`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [index, [key, entryValue]] of entries.entries()) {
|
||||
if (index >= maxItems) {
|
||||
out.__truncatedKeys = entries.length - maxItems;
|
||||
break;
|
||||
}
|
||||
|
||||
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
||||
out[key] = REDACTED;
|
||||
continue;
|
||||
}
|
||||
|
||||
out[key] = sanitizeUnknown(entryValue, {
|
||||
depth: depth - 1,
|
||||
maxItems,
|
||||
maxString,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function sanitizeError(error: Error): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
name: error.name,
|
||||
message: redactString(error.message),
|
||||
};
|
||||
|
||||
const maybe = error as Error & {
|
||||
status?: number;
|
||||
body?: string;
|
||||
details?: unknown;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (typeof maybe.status === "number") out.status = maybe.status;
|
||||
if (maybe.details !== undefined) out.details = sanitizeUnknown(maybe.details);
|
||||
if (maybe.cause !== undefined) out.cause = sanitizeUnknown(maybe.cause);
|
||||
if (maybe.body !== undefined) out.body = REDACTED;
|
||||
if (error.stack) out.stack = redactString(error.stack, 1200);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sanitizeWebhookPayload(
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
const raw = sanitizeUnknown(payload, {
|
||||
depth: 4,
|
||||
maxItems: 20,
|
||||
maxString: 300,
|
||||
});
|
||||
return (raw && typeof raw === "object" ? raw : { value: raw }) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
@ -8,6 +8,8 @@
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { logger } from "@infra/logger";
|
||||
import { runWithRequestContext } from "@infra/request-context";
|
||||
import type { PipelineConfig } from "@shared/types";
|
||||
import { getDataDir } from "../config/dataDir";
|
||||
import * as jobsRepo from "../repositories/jobs";
|
||||
@ -71,91 +73,92 @@ export async function runPipeline(
|
||||
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
const pipelineRun = await pipelineRepo.createPipelineRun();
|
||||
|
||||
console.log("🚀 Starting job pipeline...");
|
||||
console.log(
|
||||
` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore} (manual processing)`,
|
||||
);
|
||||
|
||||
try {
|
||||
const profile = await loadProfileStep();
|
||||
|
||||
const { discoveredJobs } = await discoverJobsStep({ mergedConfig });
|
||||
|
||||
const { created } = await importJobsStep({ discoveredJobs });
|
||||
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
jobsDiscovered: created,
|
||||
return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => {
|
||||
const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id });
|
||||
pipelineLogger.info("Starting pipeline run", {
|
||||
topN: mergedConfig.topN,
|
||||
minSuitabilityScore: mergedConfig.minSuitabilityScore,
|
||||
sources: mergedConfig.sources,
|
||||
});
|
||||
|
||||
const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile });
|
||||
try {
|
||||
const profile = await loadProfileStep();
|
||||
|
||||
const jobsToProcess = selectJobsStep({
|
||||
scoredJobs,
|
||||
mergedConfig,
|
||||
});
|
||||
const { discoveredJobs } = await discoverJobsStep({ mergedConfig });
|
||||
|
||||
console.log("\n🏭 Auto-processing top jobs...");
|
||||
console.log(
|
||||
` Found ${jobsToProcess.length} candidates (score >= ${mergedConfig.minSuitabilityScore}, top ${mergedConfig.topN})`,
|
||||
);
|
||||
const { created } = await importJobsStep({ discoveredJobs });
|
||||
|
||||
const { processedCount } = await processJobsStep({
|
||||
jobsToProcess,
|
||||
processJob,
|
||||
});
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
jobsDiscovered: created,
|
||||
});
|
||||
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString(),
|
||||
jobsProcessed: processedCount,
|
||||
});
|
||||
const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile });
|
||||
|
||||
console.log("\n🎉 Pipeline completed!");
|
||||
console.log(` Jobs discovered: ${created}`);
|
||||
console.log(` Jobs processed: ${processedCount}`);
|
||||
const jobsToProcess = selectJobsStep({
|
||||
scoredJobs,
|
||||
mergedConfig,
|
||||
});
|
||||
|
||||
progressHelpers.complete(created, processedCount);
|
||||
pipelineLogger.info("Selected jobs for processing", {
|
||||
candidates: jobsToProcess.length,
|
||||
});
|
||||
|
||||
await notifyPipelineWebhookStep("pipeline.completed", {
|
||||
pipelineRunId: pipelineRun.id,
|
||||
jobsDiscovered: created,
|
||||
jobsScored: unprocessedJobs.length,
|
||||
jobsProcessed: processedCount,
|
||||
});
|
||||
isPipelineRunning = false;
|
||||
const { processedCount } = await processJobsStep({
|
||||
jobsToProcess,
|
||||
processJob,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
jobsDiscovered: created,
|
||||
jobsProcessed: processedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString(),
|
||||
jobsProcessed: processedCount,
|
||||
});
|
||||
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
status: "failed",
|
||||
completedAt: new Date().toISOString(),
|
||||
errorMessage: message,
|
||||
});
|
||||
progressHelpers.complete(created, processedCount);
|
||||
pipelineLogger.info("Pipeline run completed", {
|
||||
jobsDiscovered: created,
|
||||
jobsProcessed: processedCount,
|
||||
});
|
||||
|
||||
progressHelpers.failed(message);
|
||||
await notifyPipelineWebhookStep("pipeline.completed", {
|
||||
pipelineRunId: pipelineRun.id,
|
||||
jobsDiscovered: created,
|
||||
jobsScored: unprocessedJobs.length,
|
||||
jobsProcessed: processedCount,
|
||||
});
|
||||
|
||||
await notifyPipelineWebhookStep("pipeline.failed", {
|
||||
pipelineRunId: pipelineRun.id,
|
||||
error: message,
|
||||
});
|
||||
isPipelineRunning = false;
|
||||
return {
|
||||
success: true,
|
||||
jobsDiscovered: created,
|
||||
jobsProcessed: processedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
console.error("\n❌ Pipeline failed:", message);
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
status: "failed",
|
||||
completedAt: new Date().toISOString(),
|
||||
errorMessage: message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
jobsDiscovered: 0,
|
||||
jobsProcessed: 0,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
progressHelpers.failed(message);
|
||||
pipelineLogger.error("Pipeline run failed", error);
|
||||
|
||||
await notifyPipelineWebhookStep("pipeline.failed", {
|
||||
pipelineRunId: pipelineRun.id,
|
||||
error: message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
jobsDiscovered: 0,
|
||||
jobsProcessed: 0,
|
||||
error: message,
|
||||
};
|
||||
} finally {
|
||||
isPipelineRunning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type ProcessJobOptions = {
|
||||
@ -172,83 +175,87 @@ export async function summarizeJob(
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
console.log(`📝 Summarizing job ${jobId}...`);
|
||||
return runWithRequestContext({ jobId }, async () => {
|
||||
const jobLogger = logger.child({ jobId });
|
||||
jobLogger.info("Summarizing job");
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: "Job not found" };
|
||||
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: "Job not found" };
|
||||
const profile = await getProfile();
|
||||
|
||||
const profile = await getProfile();
|
||||
// 1. Generate Summary & Tailoring
|
||||
let tailoredSummary = job.tailoredSummary;
|
||||
let tailoredHeadline = job.tailoredHeadline;
|
||||
let tailoredSkills = job.tailoredSkills;
|
||||
|
||||
// 1. Generate Summary & Tailoring
|
||||
let tailoredSummary = job.tailoredSummary;
|
||||
let tailoredHeadline = job.tailoredHeadline;
|
||||
let tailoredSkills = job.tailoredSkills;
|
||||
|
||||
if (!tailoredSummary || !tailoredHeadline || options?.force) {
|
||||
console.log(" Generating tailoring (summary, headline, skills)...");
|
||||
const tailoringResult = await generateTailoring(
|
||||
job.jobDescription || "",
|
||||
profile,
|
||||
);
|
||||
if (tailoringResult.success && tailoringResult.data) {
|
||||
tailoredSummary = tailoringResult.data.summary;
|
||||
tailoredHeadline = tailoringResult.data.headline;
|
||||
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
|
||||
} else if (options?.force || !tailoredSummary || !tailoredHeadline) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Suggest Projects
|
||||
let selectedProjectIds = job.selectedProjectIds;
|
||||
if (!selectedProjectIds || options?.force) {
|
||||
console.log(" Suggesting projects...");
|
||||
try {
|
||||
const { catalog, selectionItems } = extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await getSetting("resumeProjects");
|
||||
const { resumeProjects } = resolveResumeProjectsSettings({
|
||||
catalog,
|
||||
overrideRaw: overrideResumeProjectsRaw,
|
||||
});
|
||||
|
||||
const locked = resumeProjects.lockedProjectIds;
|
||||
const desiredCount = Math.max(
|
||||
0,
|
||||
resumeProjects.maxProjects - locked.length,
|
||||
if (!tailoredSummary || !tailoredHeadline || options?.force) {
|
||||
jobLogger.info("Generating tailoring content");
|
||||
const tailoringResult = await generateTailoring(
|
||||
job.jobDescription || "",
|
||||
profile,
|
||||
);
|
||||
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
||||
const eligibleProjects = selectionItems.filter((p) =>
|
||||
eligibleSet.has(p.id),
|
||||
);
|
||||
|
||||
const picked = await pickProjectIdsForJob({
|
||||
jobDescription: job.jobDescription || "",
|
||||
eligibleProjects,
|
||||
desiredCount,
|
||||
});
|
||||
|
||||
selectedProjectIds = [...locked, ...picked].join(",");
|
||||
} catch (_err) {
|
||||
console.warn(" ⚠️ Failed to suggest projects, leaving empty");
|
||||
if (tailoringResult.success && tailoringResult.data) {
|
||||
tailoredSummary = tailoringResult.data.summary;
|
||||
tailoredHeadline = tailoringResult.data.headline;
|
||||
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
|
||||
} else if (options?.force || !tailoredSummary || !tailoredHeadline) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Suggest Projects
|
||||
let selectedProjectIds = job.selectedProjectIds;
|
||||
if (!selectedProjectIds || options?.force) {
|
||||
jobLogger.info("Selecting projects");
|
||||
try {
|
||||
const { catalog, selectionItems } =
|
||||
extractProjectsFromProfile(profile);
|
||||
const overrideResumeProjectsRaw = await getSetting("resumeProjects");
|
||||
const { resumeProjects } = resolveResumeProjectsSettings({
|
||||
catalog,
|
||||
overrideRaw: overrideResumeProjectsRaw,
|
||||
});
|
||||
|
||||
const locked = resumeProjects.lockedProjectIds;
|
||||
const desiredCount = Math.max(
|
||||
0,
|
||||
resumeProjects.maxProjects - locked.length,
|
||||
);
|
||||
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
|
||||
const eligibleProjects = selectionItems.filter((p) =>
|
||||
eligibleSet.has(p.id),
|
||||
);
|
||||
|
||||
const picked = await pickProjectIdsForJob({
|
||||
jobDescription: job.jobDescription || "",
|
||||
eligibleProjects,
|
||||
desiredCount,
|
||||
});
|
||||
|
||||
selectedProjectIds = [...locked, ...picked].join(",");
|
||||
} catch (error) {
|
||||
jobLogger.warn("Failed to suggest projects", error);
|
||||
}
|
||||
}
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
tailoredSummary: tailoredSummary ?? undefined,
|
||||
tailoredHeadline: tailoredHeadline ?? undefined,
|
||||
tailoredSkills: tailoredSkills ?? undefined,
|
||||
selectedProjectIds: selectedProjectIds ?? undefined,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
jobLogger.error("Summarization failed", error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
tailoredSummary: tailoredSummary ?? undefined,
|
||||
tailoredHeadline: tailoredHeadline ?? undefined,
|
||||
tailoredSkills: tailoredSkills ?? undefined,
|
||||
selectedProjectIds: selectedProjectIds ?? undefined,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,43 +268,46 @@ export async function generateFinalPdf(
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
console.log(`📄 Generating final PDF for job ${jobId}...`);
|
||||
return runWithRequestContext({ jobId }, async () => {
|
||||
const jobLogger = logger.child({ jobId });
|
||||
jobLogger.info("Generating final PDF");
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: "Job not found" };
|
||||
|
||||
try {
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: "Job not found" };
|
||||
// Mark as processing
|
||||
await jobsRepo.updateJob(job.id, { status: "processing" });
|
||||
|
||||
// Mark as processing
|
||||
await jobsRepo.updateJob(job.id, { status: "processing" });
|
||||
const pdfResult = await generatePdf(
|
||||
job.id,
|
||||
{
|
||||
summary: job.tailoredSummary || "",
|
||||
headline: job.tailoredHeadline || "",
|
||||
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [],
|
||||
},
|
||||
job.jobDescription || "",
|
||||
undefined, // deprecated baseResumePath parameter
|
||||
job.selectedProjectIds,
|
||||
);
|
||||
|
||||
const pdfResult = await generatePdf(
|
||||
job.id,
|
||||
{
|
||||
summary: job.tailoredSummary || "",
|
||||
headline: job.tailoredHeadline || "",
|
||||
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [],
|
||||
},
|
||||
job.jobDescription || "",
|
||||
undefined, // deprecated baseResumePath parameter
|
||||
job.selectedProjectIds,
|
||||
);
|
||||
if (!pdfResult.success) {
|
||||
// Revert status if failed
|
||||
await jobsRepo.updateJob(job.id, { status: "discovered" });
|
||||
return { success: false, error: pdfResult.error };
|
||||
}
|
||||
|
||||
if (!pdfResult.success) {
|
||||
// Revert status if failed
|
||||
await jobsRepo.updateJob(job.id, { status: "discovered" });
|
||||
return { success: false, error: pdfResult.error };
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
status: "ready",
|
||||
pdfPath: pdfResult.pdfPath,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
jobLogger.error("PDF generation failed", error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
status: "ready",
|
||||
pdfPath: pdfResult.pdfPath,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from "@infra/logger";
|
||||
|
||||
/**
|
||||
* Pipeline progress tracking with Server-Sent Events.
|
||||
*/
|
||||
@ -67,7 +69,7 @@ export function updateProgress(update: Partial<PipelineProgress>): void {
|
||||
try {
|
||||
listener(currentProgress);
|
||||
} catch (error) {
|
||||
console.error("Error in progress listener:", error);
|
||||
logger.error("Error in progress listener", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
@ -12,7 +13,7 @@ export async function discoverJobsStep(args: {
|
||||
discoveredJobs: CreateJobInput[];
|
||||
sourceErrors: string[];
|
||||
}> {
|
||||
console.log("\n🕷️ Running crawler...");
|
||||
logger.info("Running discovery step");
|
||||
progressHelpers.startCrawling();
|
||||
|
||||
const discoveredJobs: CreateJobInput[] = [];
|
||||
@ -149,7 +150,7 @@ export async function discoverJobsStep(args: {
|
||||
}
|
||||
|
||||
if (sourceErrors.length > 0) {
|
||||
console.warn(`⚠️ Some sources failed: ${sourceErrors.join("; ")}`);
|
||||
logger.warn("Some discovery sources failed", { sourceErrors });
|
||||
}
|
||||
|
||||
progressHelpers.crawlingComplete(discoveredJobs.length);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import type { CreateJobInput } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import { progressHelpers } from "../progress";
|
||||
@ -5,11 +6,11 @@ import { progressHelpers } from "../progress";
|
||||
export async function importJobsStep(args: {
|
||||
discoveredJobs: CreateJobInput[];
|
||||
}): Promise<{ created: number; skipped: number }> {
|
||||
console.log("\n💾 Importing jobs to database...");
|
||||
logger.info("Importing discovered jobs");
|
||||
const { created, skipped } = await jobsRepo.bulkCreateJobs(
|
||||
args.discoveredJobs,
|
||||
);
|
||||
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`);
|
||||
logger.info("Import step complete", { created, skipped });
|
||||
|
||||
progressHelpers.importComplete(created, skipped);
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { getProfile } from "../../services/profile";
|
||||
|
||||
export async function loadProfileStep(): Promise<Record<string, unknown>> {
|
||||
console.log("\n📋 Loading profile...");
|
||||
logger.info("Loading profile");
|
||||
return getProfile().catch((error) => {
|
||||
console.warn(
|
||||
"⚠️ Failed to load profile for scoring, using empty profile:",
|
||||
logger.warn(
|
||||
"Failed to load profile for scoring, using empty profile",
|
||||
error,
|
||||
);
|
||||
return {} as Record<string, unknown>;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { sanitizeWebhookPayload } from "@infra/sanitize";
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
|
||||
export async function notifyPipelineWebhookStep(
|
||||
@ -22,22 +24,30 @@ export async function notifyPipelineWebhookStep(
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
if (secret) headers.Authorization = `Bearer ${secret}`;
|
||||
|
||||
const sanitizedPayload = sanitizeWebhookPayload({
|
||||
event,
|
||||
sentAt: new Date().toISOString(),
|
||||
pipelineRunId: payload.pipelineRunId,
|
||||
jobsDiscovered: payload.jobsDiscovered,
|
||||
jobsScored: payload.jobsScored,
|
||||
jobsProcessed: payload.jobsProcessed,
|
||||
error: payload.error,
|
||||
});
|
||||
|
||||
const response = await fetch(pipelineWebhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
event,
|
||||
sentAt: new Date().toISOString(),
|
||||
...payload,
|
||||
}),
|
||||
body: JSON.stringify(sanitizedPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
const responseText = await response.text().catch(() => "");
|
||||
logger.warn("Pipeline webhook POST failed", {
|
||||
status: response.status,
|
||||
error: responseText.slice(0, 200),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Pipeline webhook POST failed:", error);
|
||||
logger.warn("Pipeline webhook POST failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { progressHelpers, updateProgress } from "../progress";
|
||||
import type { ScoredJob } from "./types";
|
||||
|
||||
@ -28,7 +29,10 @@ export async function processJobsStep(args: {
|
||||
if (result.success) {
|
||||
processedCount++;
|
||||
} else {
|
||||
console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`);
|
||||
logger.warn("Failed to process job", {
|
||||
jobId: job.id,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
progressHelpers.jobComplete(i + 1, args.jobsToProcess.length);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import type { Job } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import { scoreJobSuitability } from "../../services/scorer";
|
||||
@ -8,7 +9,7 @@ import type { ScoredJob } from "./types";
|
||||
export async function scoreJobsStep(args: {
|
||||
profile: Record<string, unknown>;
|
||||
}): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> {
|
||||
console.log("\n🎯 Scoring jobs for suitability...");
|
||||
logger.info("Running scoring step");
|
||||
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
|
||||
|
||||
updateProgress({
|
||||
@ -73,7 +74,7 @@ export async function scoreJobsStep(args: {
|
||||
}
|
||||
|
||||
progressHelpers.scoringComplete(scoredJobs.length);
|
||||
console.log(`\n📊 Scored ${scoredJobs.length} jobs.`);
|
||||
logger.info("Scoring step completed", { scoredJobs: scoredJobs.length });
|
||||
|
||||
return { unprocessedJobs, scoredJobs };
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import { toStringOrNull } from "@shared/utils/type-conversion";
|
||||
import {
|
||||
buildModeCacheKey,
|
||||
@ -51,7 +52,7 @@ export class LlmService {
|
||||
resolvedProvider === "openrouter" &&
|
||||
toStringOrNull(process.env.OPENROUTER_API_KEY)
|
||||
) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.",
|
||||
);
|
||||
const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY);
|
||||
@ -180,9 +181,11 @@ export class LlmService {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
console.log(
|
||||
`🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`,
|
||||
);
|
||||
logger.info("LLM retry attempt", {
|
||||
jobId: jobId ?? "unknown",
|
||||
attempt,
|
||||
maxRetries,
|
||||
});
|
||||
await sleep(getRetryDelayMs(retryDelayMs, attempt));
|
||||
}
|
||||
|
||||
@ -209,7 +212,7 @@ export class LlmService {
|
||||
`LLM API error: ${response.status}${detail}`,
|
||||
) as LlmApiError;
|
||||
err.status = response.status;
|
||||
err.body = errorBody;
|
||||
err.body = truncate(errorBody, 600);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@ -238,9 +241,13 @@ export class LlmService {
|
||||
}
|
||||
|
||||
if (attempt < maxRetries && shouldRetryAttempt({ message, status })) {
|
||||
console.warn(
|
||||
`⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`,
|
||||
);
|
||||
logger.warn("LLM attempt failed, retrying", {
|
||||
jobId: jobId ?? "unknown",
|
||||
attempt: attempt + 1,
|
||||
maxRetries,
|
||||
status: status ?? "no-status",
|
||||
message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -271,9 +278,9 @@ function normalizeProvider(
|
||||
if (normalized === "lmstudio") return "lmstudio";
|
||||
if (normalized === "ollama") return "ollama";
|
||||
if (normalized && normalized !== "openrouter") {
|
||||
console.warn(
|
||||
`⚠️ Unknown LLM provider "${normalized}", defaulting to openrouter`,
|
||||
);
|
||||
logger.warn("Unknown LLM provider, defaulting to openrouter", {
|
||||
normalized,
|
||||
});
|
||||
}
|
||||
return "openrouter";
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { logger } from "@infra/logger";
|
||||
|
||||
export function parseJsonContent<T>(content: string, jobId?: string): T {
|
||||
let candidate = content.trim();
|
||||
|
||||
@ -16,10 +18,10 @@ export function parseJsonContent<T>(content: string, jobId?: string): T {
|
||||
try {
|
||||
return JSON.parse(candidate) as T;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ [${jobId ?? "unknown"}] Failed to parse JSON:`,
|
||||
candidate.substring(0, 200),
|
||||
);
|
||||
logger.error("Failed to parse LLM JSON content", {
|
||||
jobId: jobId ?? "unknown",
|
||||
sample: candidate.substring(0, 200),
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`,
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
* Service for inferring job details from a pasted job description.
|
||||
*/
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { ManualJobDraft } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
|
||||
@ -111,7 +112,7 @@ export async function inferManualJobDetails(
|
||||
warning: "LLM API key not set. Fill details manually.",
|
||||
};
|
||||
}
|
||||
console.warn("Manual job inference failed:", result.error);
|
||||
logger.warn("Manual job inference failed", { error: result.error });
|
||||
return {
|
||||
job: {},
|
||||
warning: "AI inference failed. Fill details manually.",
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
import type { ResumeData } from "@shared/rxresume-schema";
|
||||
|
||||
type AnyObj = Record<string, unknown>;
|
||||
const MAX_ERROR_SNIPPET = 300;
|
||||
|
||||
const TOKEN_COOKIE_NAMES = [
|
||||
"accessToken",
|
||||
@ -241,8 +242,10 @@ export class RxResumeClient {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Login failed: HTTP ${res.status} ${text}`);
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Login failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as AnyObj;
|
||||
@ -266,7 +269,7 @@ export class RxResumeClient {
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
throw new Error(
|
||||
`Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(", ")}`,
|
||||
"Login succeeded but could not locate access token in response.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -295,8 +298,10 @@ export class RxResumeClient {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Create failed: HTTP ${res.status} ${text}`);
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Create failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as AnyObj;
|
||||
@ -310,7 +315,7 @@ export class RxResumeClient {
|
||||
|
||||
if (!id || typeof id !== "string") {
|
||||
throw new Error(
|
||||
`Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(", ")}`,
|
||||
"Create succeeded but could not locate resume id in response.",
|
||||
);
|
||||
}
|
||||
|
||||
@ -334,8 +339,10 @@ export class RxResumeClient {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Print failed: HTTP ${res.status} ${text}`);
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Print failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const d = (await res.json()) as AnyObj;
|
||||
@ -348,9 +355,7 @@ export class RxResumeClient {
|
||||
(d?.result as AnyObj)?.href;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new Error(
|
||||
`Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`,
|
||||
);
|
||||
throw new Error("Print succeeded but could not locate URL in response.");
|
||||
}
|
||||
|
||||
return url;
|
||||
@ -372,8 +377,10 @@ export class RxResumeClient {
|
||||
);
|
||||
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Delete failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -416,8 +423,10 @@ export class RxResumeClient {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`List resumes failed: HTTP ${res.status} ${text}`);
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`List resumes failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as AnyObj | AnyObj[];
|
||||
@ -445,3 +454,9 @@ export class RxResumeClient {
|
||||
return resume;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeResponseSnippet(text: string): string {
|
||||
if (!text) return "";
|
||||
const compact = text.replace(/\s+/g, " ").trim();
|
||||
return compact.slice(0, MAX_ERROR_SNIPPET);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
* Service for scoring job suitability using AI.
|
||||
*/
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { Job } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
|
||||
@ -50,7 +51,7 @@ export async function scoreJobSuitability(
|
||||
process.env.MODEL ||
|
||||
"google/gemini-3-flash-preview";
|
||||
|
||||
const prompt = buildScoringPrompt(job, profile);
|
||||
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile));
|
||||
|
||||
const llm = new LlmService();
|
||||
const result = await llm.callJson<{ score: number; reason: string }>({
|
||||
@ -63,11 +64,12 @@ export async function scoreJobSuitability(
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error.toLowerCase().includes("api key")) {
|
||||
console.warn("⚠️ LLM API key not set, using mock scoring");
|
||||
logger.warn("LLM API key not set, using mock scoring", { jobId: job.id });
|
||||
}
|
||||
console.error(
|
||||
`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`,
|
||||
);
|
||||
logger.error("Scoring failed, using mock scoring", {
|
||||
jobId: job.id,
|
||||
error: result.error,
|
||||
});
|
||||
return mockScore(job);
|
||||
}
|
||||
|
||||
@ -75,9 +77,9 @@ export async function scoreJobSuitability(
|
||||
|
||||
// Validate we got a reasonable response
|
||||
if (typeof score !== "number" || Number.isNaN(score)) {
|
||||
console.error(
|
||||
`❌ [Job ${job.id}] Invalid score in response, using mock scoring`,
|
||||
);
|
||||
logger.error("Invalid score in AI response, using mock scoring", {
|
||||
jobId: job.id,
|
||||
});
|
||||
return mockScore(job);
|
||||
}
|
||||
|
||||
@ -173,21 +175,19 @@ export function parseJsonFromContent(
|
||||
const reason = reasonMatch
|
||||
? reasonMatch[1].trim().replace(controlCharsRegex, "")
|
||||
: "Score extracted from malformed response";
|
||||
console.log(
|
||||
`⚠️ [Job ${jobId || "unknown"}] Parsed score via regex fallback: ${score}`,
|
||||
);
|
||||
logger.warn("Parsed score via regex fallback", {
|
||||
jobId: jobId || "unknown",
|
||||
score,
|
||||
});
|
||||
return { score, reason };
|
||||
}
|
||||
|
||||
// Log the failure with full content for debugging
|
||||
console.error(
|
||||
`❌ [Job ${jobId || "unknown"}] Failed to parse AI response. Raw content (first 500 chars):`,
|
||||
originalContent.substring(0, 500),
|
||||
);
|
||||
console.error(
|
||||
` Sanitized content (first 500 chars):`,
|
||||
sanitized.substring(0, 500),
|
||||
);
|
||||
logger.error("Failed to parse AI response", {
|
||||
jobId: jobId || "unknown",
|
||||
rawSample: originalContent.substring(0, 500),
|
||||
sanitizedSample: sanitized.substring(0, 500),
|
||||
});
|
||||
|
||||
throw new Error("Unable to parse JSON from model response");
|
||||
}
|
||||
@ -228,6 +228,38 @@ EXAMPLE VALID RESPONSE:
|
||||
{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`;
|
||||
}
|
||||
|
||||
function sanitizeProfileForPrompt(
|
||||
profile: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const p = profile as {
|
||||
basics?: Record<string, unknown>;
|
||||
sections?: {
|
||||
skills?: unknown;
|
||||
experience?: { items?: unknown[] };
|
||||
projects?: { items?: unknown[] };
|
||||
education?: { items?: unknown[] };
|
||||
};
|
||||
};
|
||||
|
||||
const experienceItems = Array.isArray(p.sections?.experience?.items)
|
||||
? p.sections?.experience?.items.slice(0, 5)
|
||||
: [];
|
||||
const projectItems = Array.isArray(p.sections?.projects?.items)
|
||||
? p.sections?.projects?.items.slice(0, 6)
|
||||
: [];
|
||||
|
||||
return {
|
||||
basics: {
|
||||
label: p.basics?.label,
|
||||
summary: p.basics?.summary,
|
||||
},
|
||||
skills: p.sections?.skills ?? null,
|
||||
experience: experienceItems,
|
||||
projects: projectItems,
|
||||
education: p.sections?.education?.items ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function mockScore(job: Job): SuitabilityResult {
|
||||
// Simple keyword-based scoring as fallback
|
||||
const jd = (job.jobDescription || "").toLowerCase();
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
* Service for generating tailored resume content (Summary, Headline, Skills).
|
||||
*/
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
|
||||
@ -88,7 +89,7 @@ export async function generateTailoring(
|
||||
const context = `provider=${llm.getProvider()} baseUrl=${llm.getBaseUrl()}`;
|
||||
if (result.error.toLowerCase().includes("api key")) {
|
||||
const message = `LLM API key not set, cannot generate tailoring. (${context})`;
|
||||
console.warn(`⚠️ ${message}`);
|
||||
logger.warn(message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
return {
|
||||
@ -101,7 +102,7 @@ export async function generateTailoring(
|
||||
|
||||
// Basic validation
|
||||
if (!summary || !headline || !Array.isArray(skills)) {
|
||||
console.warn("⚠️ AI response missing required fields:", result.data);
|
||||
logger.warn("AI response missing required tailoring fields", result.data);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@server/*": ["src/server/*"],
|
||||
"@infra/*": ["src/server/infra/*"],
|
||||
"@client/*": ["src/client/*"],
|
||||
"@shared/*": ["../shared/src/*"]
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@client": path.resolve(__dirname, "./src/client"),
|
||||
"@server": path.resolve(__dirname, "./src/server"),
|
||||
"@infra": path.resolve(__dirname, "./src/server/infra"),
|
||||
"@shared": path.resolve(__dirname, "../shared/src"),
|
||||
},
|
||||
},
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
"check:types:shared": "npm --workspace shared run check:types",
|
||||
"check:types": "npm --workspace shared run check:types && npm --workspace orchestrator run check:types",
|
||||
"check:types:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types",
|
||||
"check:all": "npx biome ci .",
|
||||
"format:all": "npx biome format . --write",
|
||||
"check:all": "./orchestrator/node_modules/.bin/biome ci .",
|
||||
"format:all": "./orchestrator/node_modules/.bin/biome format . --write",
|
||||
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -309,12 +309,28 @@ export interface PipelineRun {
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
export interface ApiMeta {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export type ApiResponse<T> =
|
||||
| {
|
||||
ok: true;
|
||||
data: T;
|
||||
meta?: ApiMeta;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: ApiErrorPayload;
|
||||
meta: ApiMeta;
|
||||
};
|
||||
|
||||
export interface JobsListResponse {
|
||||
jobs: Job[];
|
||||
total: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user