feat: search profiles, cover letters, discovery fixes
- Add search profiles (DB, API, settings UI) and wire into scorer/pipeline search terms. - Add cover letter generation (service, job action, JobDetail UI). - Align JobSpy Indeed country with country-level search geography when settings conflict; warn in logs. - Infer country from search cities via inferCountryKeyFromSearchGeography (shared). - Ignore extractor venv/storage and local data in Biome; ignore orchestrator/storage and JobSpy .venv in git. - Vite: do not watch orchestrator/storage (prevents reloads during startup.jobs pipeline). - JobSpy: document Python 3.10+ and venv setup in README/requirements. - Onboarding and settings: local resume path handling, orchestrator .env.example for Vite. Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferCountryKeyFromSearchGeography,
|
||||
matchesRequestedCity,
|
||||
parseSearchCitiesSetting,
|
||||
resolveSearchCities,
|
||||
@@ -64,6 +65,18 @@ describe("search-cities", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("infers country key from geography when a token is a supported country", () => {
|
||||
expect(inferCountryKeyFromSearchGeography("UK", null)).toBe(
|
||||
"united kingdom",
|
||||
);
|
||||
expect(inferCountryKeyFromSearchGeography("London|UK", null)).toBe(
|
||||
"united kingdom",
|
||||
);
|
||||
expect(inferCountryKeyFromSearchGeography(null, "Canada")).toBe("canada");
|
||||
expect(inferCountryKeyFromSearchGeography("Leeds", null)).toBeNull();
|
||||
expect(inferCountryKeyFromSearchGeography(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("applies strict filter only when city differs from country", () => {
|
||||
expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
|
||||
expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { normalizeCountryKey } from "./location-support.js";
|
||||
import {
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "./location-support.js";
|
||||
|
||||
const supportedCountryKeySet = new Set(SUPPORTED_COUNTRY_KEYS);
|
||||
|
||||
const LOCATION_ALIASES: Record<string, string> = {
|
||||
uk: "united kingdom",
|
||||
@@ -14,6 +19,23 @@ export function normalizeLocationToken(
|
||||
return LOCATION_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* If search geography includes a supported country token (e.g. "UK", "Canada"),
|
||||
* returns its normalized country key; otherwise null (e.g. "London" only).
|
||||
*/
|
||||
export function inferCountryKeyFromSearchGeography(
|
||||
searchCities?: string | null,
|
||||
jobspyLocation?: string | null,
|
||||
): string | null {
|
||||
const raw = searchCities?.trim() || jobspyLocation?.trim();
|
||||
if (!raw) return null;
|
||||
for (const token of parseSearchCitiesSetting(raw)) {
|
||||
const key = normalizeCountryKey(token);
|
||||
if (supportedCountryKeySet.has(key)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSearchCitiesSetting(
|
||||
value: string | null | undefined,
|
||||
): string[] {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CHAT_STYLE_MANUAL_LANGUAGE_VALUES,
|
||||
type ChatStyleLanguageMode,
|
||||
type ChatStyleManualLanguage,
|
||||
type JobSearchProfile,
|
||||
type ResumeProjectsSettings,
|
||||
} from "./types/settings";
|
||||
|
||||
@@ -130,14 +131,58 @@ const parseChatStyleManualLanguageOrNull = createEnumParser(
|
||||
const WORKPLACE_TYPE_VALUES = ["remote", "hybrid", "onsite"] as const;
|
||||
const parseWorkplaceTypesOrNull = createEnumArrayParser(WORKPLACE_TYPE_VALUES);
|
||||
|
||||
export const jobSearchProfileSchema = z.object({
|
||||
targetRoles: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
experienceLevel: z.string().trim().max(50),
|
||||
mustHaveSkills: z.array(z.string().trim().min(1).max(200)).max(50),
|
||||
niceToHaveSkills: z.array(z.string().trim().min(1).max(200)).max(50),
|
||||
dealBreakers: z.array(z.string().trim().min(1).max(200)).max(50),
|
||||
preferredWorkArrangement: z.array(z.string().trim().min(1).max(50)).max(5),
|
||||
preferredLocations: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
minimumSalary: z.string().trim().max(100),
|
||||
industriesToTarget: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
industriesToAvoid: z.array(z.string().trim().min(1).max(200)).max(20),
|
||||
aboutMe: z.string().trim().max(4000),
|
||||
});
|
||||
|
||||
export const resumeProjectsSchema = z.object({
|
||||
maxProjects: z.number().int().min(0).max(100),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
});
|
||||
|
||||
const DEFAULT_JOB_SEARCH_PROFILE: JobSearchProfile = {
|
||||
targetRoles: [],
|
||||
experienceLevel: "",
|
||||
mustHaveSkills: [],
|
||||
niceToHaveSkills: [],
|
||||
dealBreakers: [],
|
||||
preferredWorkArrangement: [],
|
||||
preferredLocations: [],
|
||||
minimumSalary: "",
|
||||
industriesToTarget: [],
|
||||
industriesToAvoid: [],
|
||||
aboutMe: "",
|
||||
};
|
||||
|
||||
export const settingsRegistry = {
|
||||
// --- Typed Settings ---
|
||||
jobSearchProfile: {
|
||||
kind: "typed" as const,
|
||||
schema: jobSearchProfileSchema,
|
||||
default: (): JobSearchProfile => DEFAULT_JOB_SEARCH_PROFILE,
|
||||
parse: (raw: string | undefined): JobSearchProfile | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
serialize: (value: JobSearchProfile | null | undefined): string | null => {
|
||||
return value ? JSON.stringify(value) : null;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
kind: "typed" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
@@ -535,6 +580,10 @@ export const settingsRegistry = {
|
||||
},
|
||||
|
||||
// --- Simple Strings ---
|
||||
activeProfileId: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
},
|
||||
rxresumeBaseResumeId: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(200),
|
||||
@@ -560,6 +609,11 @@ export const settingsRegistry = {
|
||||
z.string().trim().url().max(2000).nullable(),
|
||||
),
|
||||
},
|
||||
/** Server path to Reactive Resume JSON export; used when RxResume API is not available. */
|
||||
localResumeProfilePath: {
|
||||
kind: "string" as const,
|
||||
schema: z.string().trim().max(4000),
|
||||
},
|
||||
ukvisajobsEmail: {
|
||||
kind: "string" as const,
|
||||
envKey: "UKVISAJOBS_EMAIL",
|
||||
|
||||
@@ -30,6 +30,8 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: "Strong fit",
|
||||
suitabilityAnalysis: null,
|
||||
coverLetter: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
@@ -125,6 +127,35 @@ export const createResumeProjectCatalogItem = (
|
||||
export const createAppSettings = (
|
||||
overrides: Partial<AppSettings> = {},
|
||||
): AppSettings => ({
|
||||
jobSearchProfile: {
|
||||
value: {
|
||||
targetRoles: [],
|
||||
experienceLevel: "",
|
||||
mustHaveSkills: [],
|
||||
niceToHaveSkills: [],
|
||||
dealBreakers: [],
|
||||
preferredWorkArrangement: [],
|
||||
preferredLocations: [],
|
||||
minimumSalary: "",
|
||||
industriesToTarget: [],
|
||||
industriesToAvoid: [],
|
||||
aboutMe: "",
|
||||
},
|
||||
default: {
|
||||
targetRoles: [],
|
||||
experienceLevel: "",
|
||||
mustHaveSkills: [],
|
||||
niceToHaveSkills: [],
|
||||
dealBreakers: [],
|
||||
preferredWorkArrangement: [],
|
||||
preferredLocations: [],
|
||||
minimumSalary: "",
|
||||
industriesToTarget: [],
|
||||
industriesToAvoid: [],
|
||||
aboutMe: "",
|
||||
},
|
||||
override: null,
|
||||
},
|
||||
model: { value: "gpt-4o", default: "gpt-4o", override: null },
|
||||
modelScorer: { value: "gpt-4o", override: null },
|
||||
modelTailoring: { value: "gpt-4o", override: null },
|
||||
@@ -147,6 +178,7 @@ export const createAppSettings = (
|
||||
},
|
||||
override: null,
|
||||
},
|
||||
activeProfileId: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
rxresumeBaseResumeIdV4: null,
|
||||
rxresumeBaseResumeIdV5: null,
|
||||
@@ -213,6 +245,7 @@ export const createAppSettings = (
|
||||
rxresumeApiKeyHint: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumeUrl: null,
|
||||
localResumeProfilePath: null,
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPasswordHint: null,
|
||||
@@ -222,6 +255,7 @@ export const createAppSettings = (
|
||||
adzunaAppKeyHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
localResumeFileConfigured: false,
|
||||
backupEnabled: { value: false, default: false, override: null },
|
||||
backupHour: { value: 3, default: 3, override: null },
|
||||
backupMaxCount: { value: 7, default: 7, override: null },
|
||||
|
||||
@@ -148,12 +148,14 @@ export interface Job {
|
||||
closedAt: number | null;
|
||||
suitabilityScore: number | null; // 0-100 AI-generated score
|
||||
suitabilityReason: string | null; // AI explanation
|
||||
suitabilityAnalysis: string | null; // JSON-encoded SuitabilityAnalysis
|
||||
tailoredSummary: string | null; // Generated resume summary
|
||||
tailoredHeadline: string | null; // Generated resume headline
|
||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
pdfPath: string | null; // Path to generated PDF
|
||||
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
|
||||
coverLetter: string | null; // AI-generated cover letter
|
||||
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||
|
||||
@@ -305,6 +307,7 @@ export interface UpdateJobInput {
|
||||
jobDescription?: string | null;
|
||||
suitabilityScore?: number;
|
||||
suitabilityReason?: string;
|
||||
suitabilityAnalysis?: string;
|
||||
tailoredSummary?: string;
|
||||
tailoredHeadline?: string;
|
||||
tailoredSkills?: string;
|
||||
@@ -312,6 +315,7 @@ export interface UpdateJobInput {
|
||||
pdfPath?: string;
|
||||
tracerLinksEnabled?: boolean;
|
||||
appliedAt?: string;
|
||||
coverLetter?: string | null;
|
||||
sponsorMatchScore?: number;
|
||||
sponsorMatchNames?: string;
|
||||
}
|
||||
|
||||
@@ -42,11 +42,15 @@ export interface JobsRevisionResponse {
|
||||
statusFilter: string | null;
|
||||
}
|
||||
|
||||
export type JobAction = "skip" | "move_to_ready" | "rescore";
|
||||
export type JobAction =
|
||||
| "skip"
|
||||
| "move_to_ready"
|
||||
| "rescore"
|
||||
| "generate_cover_letter";
|
||||
|
||||
export type JobActionRequest =
|
||||
| {
|
||||
action: "skip" | "rescore";
|
||||
action: "skip" | "rescore" | "generate_cover_letter";
|
||||
jobIds: string[];
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
export interface JobSearchProfile {
|
||||
targetRoles: string[];
|
||||
experienceLevel: string;
|
||||
mustHaveSkills: string[];
|
||||
niceToHaveSkills: string[];
|
||||
dealBreakers: string[];
|
||||
preferredWorkArrangement: string[];
|
||||
preferredLocations: string[];
|
||||
minimumSalary: string;
|
||||
industriesToTarget: string[];
|
||||
industriesToAvoid: string[];
|
||||
aboutMe: string;
|
||||
}
|
||||
|
||||
export interface SearchProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
data: JobSearchProfile;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSearchProfileInput {
|
||||
name: string;
|
||||
data: JobSearchProfile;
|
||||
}
|
||||
|
||||
export interface UpdateSearchProfileInput {
|
||||
name?: string;
|
||||
data?: JobSearchProfile;
|
||||
}
|
||||
|
||||
export interface SuitabilityAnalysis {
|
||||
roleTypeMatch: number;
|
||||
strengths: string[];
|
||||
gaps: string[];
|
||||
suggestions: string[];
|
||||
dealBreakerHits: string[];
|
||||
}
|
||||
|
||||
export interface ResumeProjectCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -144,6 +184,7 @@ export type ModelResolved = { value: string; override: string | null };
|
||||
|
||||
export interface AppSettings {
|
||||
// Typed settings (Resolved):
|
||||
jobSearchProfile: Resolved<JobSearchProfile>;
|
||||
model: Resolved<string>;
|
||||
llmProvider: Resolved<string>;
|
||||
llmBaseUrl: Resolved<string>;
|
||||
@@ -183,11 +224,14 @@ export interface AppSettings {
|
||||
modelProjectSelection: ModelResolved;
|
||||
|
||||
// Simple strings:
|
||||
activeProfileId: string | null;
|
||||
rxresumeBaseResumeId: string | null;
|
||||
rxresumeBaseResumeIdV4: string | null;
|
||||
rxresumeBaseResumeIdV5: string | null;
|
||||
rxresumeEmail: string | null;
|
||||
rxresumeUrl: string | null;
|
||||
/** Path to local Reactive Resume JSON (see JOBOPS_LOCAL_RESUME_PATH). */
|
||||
localResumeProfilePath: string | null;
|
||||
ukvisajobsEmail: string | null;
|
||||
adzunaAppId: string | null;
|
||||
basicAuthUser: string | null;
|
||||
@@ -203,5 +247,7 @@ export interface AppSettings {
|
||||
|
||||
// Computed:
|
||||
basicAuthActive: boolean;
|
||||
/** True when JOBOPS_LOCAL_RESUME_PATH is set on the server (not shown in UI). */
|
||||
localResumeFileConfigured: boolean;
|
||||
profileProjects: ResumeProjectCatalogItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user