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:
2026-04-05 19:35:14 -04:00
parent 0a7dbb4f16
commit fea00ae656
54 changed files with 2449 additions and 193 deletions
+13
View File
@@ -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);
+23 -1
View File
@@ -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[] {
+54
View File
@@ -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",
+34
View File
@@ -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 },
+4
View File
@@ -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;
}
+6 -2
View File
@@ -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[];
}
| {
+46
View File
@@ -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[];
}