Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/extension/src/dev/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import type { TJMHistory, TJMRecord, TJMRegion } from '$lib/core/types/tjm';

export const mockProfile: UserProfile = {
firstName: 'Alice',
stack: ['TypeScript', 'React', 'Node.js', 'Svelte'],
keywords: ['TypeScript', 'React', 'Node.js', 'Svelte'],
tjmMin: 500,
tjmMax: 750,
location: 'Paris',
remote: 'hybrid',
seniority: 'senior',
jobTitle: 'Développeur Fullstack',
searchKeywords: [],
};

const stacks = [
Expand Down
6 changes: 2 additions & 4 deletions apps/extension/src/dev/qa-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,28 +410,26 @@ function buildAlertPreferences(baseDate: Date): ConnectedAlertPreferences {
function buildCompleteProfile(): UserProfile {
return {
firstName: 'Alice',
stack: ['TypeScript', 'React', 'Node.js', 'Svelte'],
keywords: ['TypeScript', 'React', 'Node.js', 'Svelte'],
tjmMin: 500,
tjmMax: 750,
location: 'Paris',
remote: 'hybrid',
seniority: 'senior',
jobTitle: 'Développeur Fullstack',
searchKeywords: [],
};
}

function buildIncompleteProfile(): UserProfile {
return {
firstName: '',
stack: [],
keywords: [],
tjmMin: 0,
tjmMax: 0,
location: '',
remote: 'hybrid',
seniority: 'senior',
jobTitle: '',
searchKeywords: [],
};
}

Expand Down
20 changes: 2 additions & 18 deletions apps/extension/src/lib/core/backup/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { z } from 'zod';
import type { UserProfile } from '../types/profile';
import { UserProfileSchema } from '../types/schemas';
import type { AppSettings } from '../types/app-settings';

// ============================================
Expand All @@ -14,24 +15,7 @@ import type { AppSettings } from '../types/app-settings';
export const BackupDataSchema = z.object({
version: z.number().int().min(1),
timestamp: z.number().int().positive(),
profile: z.object({
firstName: z.string(),
stack: z.array(z.string()),
tjmMin: z.number(),
tjmMax: z.number(),
location: z.string(),
remote: z.union([z.enum(['full', 'hybrid', 'onsite']), z.literal('any')]),
seniority: z.enum(['junior', 'confirmed', 'senior']),
jobTitle: z.string(),
scoringWeights: z
.object({
stack: z.number(),
location: z.number(),
tjm: z.number(),
remote: z.number(),
})
.optional(),
}),
profile: UserProfileSchema,
settings: z.object({
scanIntervalMinutes: z.number(),
enabledConnectors: z.array(z.string()),
Expand Down
20 changes: 11 additions & 9 deletions apps/extension/src/lib/core/connectors/search-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ export interface ConnectorSearchContext {
* PURE FUNCTION — no I/O, no async, no side effects.
*
* Strategy:
* - query: derived from searchKeywords ONLY (if explicitly set by user).
* jobTitle is NOT used as fallback because it's too restrictive for API keyword
* search (e.g., "Développeur Fullstack" returns 0 results on most platforms).
* Local scoring (scoreMission) handles relevance matching much better.
* - query: derived from the unified `keywords` list (same terms used for local
* scoring vs `mission.stack`). jobTitle is NOT used as fallback because it's
* too restrictive for API keyword search (e.g., "Développeur Fullstack"
* returns 0 results on most platforms). Local scoring (scoreMission) handles
* relevance matching much better.
* - skills: EMPTY — skills are NOT sent as server-side filters because:
* 1. APIs use AND logic (each additional skill further narrows results)
* 2. Skill names don't match across platforms (e.g. "React.js" vs "React" vs "ReactJS")
Expand All @@ -44,11 +45,12 @@ export const buildSearchContext = (
profile: UserProfile,
lastSync: Date | null
): ConnectorSearchContext => {
// Build query: ONLY from explicit searchKeywords.
// Do NOT fallback to jobTitle — it's too restrictive for server-side keyword search
// (e.g., "Développeur Fullstack Senior" matches almost nothing on Free-Work/Hiway/Collective).
// Relevance is handled locally by scoreMission() with fuzzy matching.
const query = profile.searchKeywords
// Build the free-text query from the unified keywords list. These are the
// same terms scored locally against mission.stack; here they drive the
// server-side search. Do NOT fallback to jobTitle — it's too restrictive
// (e.g., "Développeur Fullstack Senior" matches almost nothing on
// Free-Work/Hiway/Collective). Relevance is refined locally by scoreMission().
const query = profile.keywords
.map((keyword) => keyword.trim().replace(/\s+/g, ' '))
.filter((keyword) => keyword.length > 0)
.join(' ');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Mission:
Profil:
- Prénom: ${profile.firstName}
- Poste: ${profile.jobTitle}
- Stack: ${profile.stack.join(', ')}
- Stack: ${profile.keywords.join(', ')}
- Seniorité: ${profile.seniority}
- TJM: ${profile.tjmMin}-${profile.tjmMax}€/jour

Expand Down
4 changes: 2 additions & 2 deletions apps/extension/src/lib/core/generation/build-cv-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { UserProfile } from '../types/profile';
*/
export const buildCvSummaryPrompt = (mission: Mission, profile: UserProfile): string => {
// Find overlapping stack items
const profileStackLower = profile.stack.map((s) => s.toLowerCase());
const profileStackLower = profile.keywords.map((s) => s.toLowerCase());
const matchingStack = mission.stack.filter((s) => profileStackLower.includes(s.toLowerCase()));
const missingStack = mission.stack.filter((s) => !profileStackLower.includes(s.toLowerCase()));

Expand All @@ -32,7 +32,7 @@ Mission cible:

Ton profil:
- Poste: ${profile.jobTitle}
- Stack: ${profile.stack.join(', ')}
- Stack: ${profile.keywords.join(', ')}
- Seniorité: ${profile.seniority}
${matchingStack.length > 0 ? `- Compétences matchantes: ${matchingStack.join(', ')}` : ''}
${missingStack.length > 0 ? `- Compétences manquantes (ne pas mentionner): ${missingStack.join(', ')}` : ''}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Mission:

Profil:
- Poste: ${profile.jobTitle}
- Stack: ${profile.stack.join(', ')}
- Stack: ${profile.keywords.join(', ')}
- Seniorité: ${profile.seniority}
- TJM attendu: ${profile.tjmMin}-${profile.tjmMax}€/jour
${matchContext}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import type { CanonicalCandidateProfileDraft } from './types';
* Merge semantics:
* - jobTitle ← draft.title (overwrite — importing LinkedIn is an explicit
* "use as reference" action).
* - stack ← union of the current stack and the draft's skills, deduplicated
* - keywords ← union of the current keywords and the draft's skills, deduplicated
* case-insensitively while keeping the first-seen display casing.
* - location ← the current location when it is non-empty; otherwise the first
* experience that carries a non-empty location; otherwise ''.
* - All other fields (firstName, tjmMin/Max, remote, seniority, searchKeywords,
* scoringWeights) are carried over from the current profile via defaults.
* - All other fields (firstName, tjmMin/Max, remote, seniority, scoringWeights)
* are carried over from the current profile via defaults.
*
* STRICTLY PURE: no Date, no async, no I/O, no side effects, no chrome.*.
*/
Expand All @@ -24,9 +24,9 @@ export function mergeCandidateProfileIntoUserProfile(
): UserProfile {
const base = withProfileDefaults({ ...current });

const stack = draft.skills.reduce<string[]>(
const keywords = draft.skills.reduce<string[]>(
(acc, skill) => appendUniqueNormalized(acc, skill.skill),
[...(current?.stack ?? [])]
[...(current?.keywords ?? [])]
);

const currentLocation = current?.location ?? '';
Expand All @@ -40,7 +40,7 @@ export function mergeCandidateProfileIntoUserProfile(
return {
...base,
jobTitle: draft.title,
stack,
keywords,
location,
};
}
9 changes: 3 additions & 6 deletions apps/extension/src/lib/core/profile/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@ import type { UserProfile } from '../types/profile';

const DEFAULT_PROFILE = {
firstName: '',
stack: [],
keywords: [],
tjmMin: 0,
tjmMax: 9999,
location: '',
remote: 'any',
seniority: 'senior',
jobTitle: '',
searchKeywords: [],
scoringWeights: {
stack: 0,
location: 10,
Expand All @@ -35,23 +34,21 @@ const DEFAULT_PROFILE = {
export function createDefaultProfile(): UserProfile {
return {
...DEFAULT_PROFILE,
stack: [...DEFAULT_PROFILE.stack],
searchKeywords: [...DEFAULT_PROFILE.searchKeywords],
keywords: [...DEFAULT_PROFILE.keywords],
scoringWeights: { ...DEFAULT_PROFILE.scoringWeights },
};
}

export function isDefaultProfile(profile: UserProfile): boolean {
return (
profile.firstName === DEFAULT_PROFILE.firstName &&
profile.stack.length === 0 &&
profile.keywords.length === 0 &&
profile.tjmMin === DEFAULT_PROFILE.tjmMin &&
profile.tjmMax === DEFAULT_PROFILE.tjmMax &&
profile.location === DEFAULT_PROFILE.location &&
profile.remote === DEFAULT_PROFILE.remote &&
profile.seniority === DEFAULT_PROFILE.seniority &&
profile.jobTitle === DEFAULT_PROFILE.jobTitle &&
profile.searchKeywords.length === 0 &&
(profile.scoringWeights?.stack ?? 0) === DEFAULT_PROFILE.scoringWeights.stack &&
(profile.scoringWeights?.location ?? 0) === DEFAULT_PROFILE.scoringWeights.location &&
(profile.scoringWeights?.tjm ?? 0) === DEFAULT_PROFILE.scoringWeights.tjm &&
Expand Down
10 changes: 3 additions & 7 deletions apps/extension/src/lib/core/profile/normalize-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ export interface ProfileDraftInput {
seniority?: UserProfile['seniority'] | null;
tjmMin?: number | string | null;
tjmMax?: number | string | null;
stack?: readonly string[] | null;
stackInput?: string | null;
searchKeywords?: readonly string[] | null;
keywords?: readonly string[] | null;
keywordInput?: string | null;
scoringWeights?: UserProfile['scoringWeights'];
}
Expand Down Expand Up @@ -58,15 +56,14 @@ export function appendUniqueNormalized(

export const withProfileDefaults = (profile: Partial<UserProfile>): UserProfile => ({
firstName: profile.firstName ?? '',
stack: [...(profile.stack ?? [])],
keywords: [...(profile.keywords ?? [])],
tjmMin: profile.tjmMin ?? 0,
tjmMax: profile.tjmMax ?? 0,
location: profile.location ?? '',
remote: profile.remote ?? 'any',
seniority: profile.seniority ?? 'senior',
jobTitle: profile.jobTitle ?? '',
scoringWeights: profile.scoringWeights,
searchKeywords: [...(profile.searchKeywords ?? [])],
});

export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfileResult {
Expand All @@ -87,8 +84,7 @@ export function normalizeProfileDraft(input: ProfileDraftInput): NormalizeProfil
seniority: input.seniority ?? 'senior',
tjmMin,
tjmMax,
stack: appendUniqueNormalized(input.stack, input.stackInput),
searchKeywords: appendUniqueNormalized(input.searchKeywords, input.keywordInput),
keywords: appendUniqueNormalized(input.keywords, input.keywordInput),
scoringWeights: input.scoringWeights,
}),
};
Expand Down
34 changes: 9 additions & 25 deletions apps/extension/src/lib/core/profile/profile-impact.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import type { UserProfile } from '../types/profile';

export type ProfileImpactFieldId =
| 'stack'
| 'keywords'
| 'tjm-min'
| 'remote'
| 'location'
| 'search-keywords'
| 'job-title'
| 'tjm-max'
| 'first-name';

export type ProfileImpactInput = Pick<
UserProfile,
| 'firstName'
| 'jobTitle'
| 'location'
| 'remote'
| 'tjmMin'
| 'tjmMax'
| 'stack'
| 'searchKeywords'
'firstName' | 'jobTitle' | 'location' | 'remote' | 'tjmMin' | 'tjmMax' | 'keywords'
>;

export interface ProfileImpactItem {
Expand Down Expand Up @@ -51,12 +43,12 @@ interface ProfileImpactDefinition {

const PROFILE_IMPACT_DEFINITIONS: ProfileImpactDefinition[] = [
{
id: 'stack',
label: 'Stack technique',
weight: 25,
impact: 'Scoring de pertinence et alertes prioritaires',
action: 'Ajouter 3 à 5 technologies qui déclenchent une vraie décision.',
isComplete: (profile) => profile.stack.length > 0,
id: 'keywords',
label: 'Mots-clés',
weight: 35,
impact: 'Scoring de pertinence, recherche connecteur et alertes ciblées',
action: 'Ajouter technologies, secteurs ou contextes (ex. React, SaaS, fintech).',
isComplete: (profile) => profile.keywords.length > 0,
},
{
id: 'tjm-min',
Expand All @@ -82,14 +74,6 @@ const PROFILE_IMPACT_DEFINITIONS: ProfileImpactDefinition[] = [
action: 'Renseigner la zone qui doit servir de référence au radar.',
isComplete: (profile) => profile.location.trim().length > 0,
},
{
id: 'search-keywords',
label: 'Mots-clés',
weight: 10,
impact: 'Recherche connecteur et alertes plus ciblées',
action: 'Ajouter les secteurs, contextes ou domaines à remonter en priorité.',
isComplete: (profile) => profile.searchKeywords.length > 0,
},
{
id: 'job-title',
label: 'Poste cible',
Expand Down Expand Up @@ -163,7 +147,7 @@ export function buildProfileImpactSimulation(items: ProfileImpactItem[]): Profil
prioritizedItems,
title: 'Le radar profil utilise déjà tous les signaux clés',
description:
'La stack, le TJM, le remote, la localisation et les mots-clés peuvent alimenter les recherches, le scoring et les alertes.',
'Les mots-clés, le TJM, le remote et la localisation alimentent les recherches, le scoring et les alertes.',
};
}

Expand Down
32 changes: 21 additions & 11 deletions apps/extension/src/lib/core/scoring/relevance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ export const scoreMission = (
const weights = profile.scoringWeights ?? DEFAULT_SCORING_WEIGHTS;
const normalizedWeights = normalizeWeights(weights);

// Raw match percentages (0-100) — directly gradable
const stackMatch = rawStackScore(mission.stack, profile.stack);
// Raw match percentages (0-100) — directly gradable.
// NOTE: mission.stack is the platform-parsed tech stack; profile.keywords is
// the unified keyword list (post-unification). The dimension name "stack" in
// ScoringWeights/breakdown is unchanged — it names the scoring axis, not the
// profile field. See models/keywords-unification.model.md.
const stackMatch = rawStackScore(mission.stack, profile.keywords);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep search-only profiles from zeroing stack scores

For migrated users who had an empty stack but non-empty searchKeywords (for example only SaaS or marketplace), the previous scorer treated stack as unconstrained and returned 100 for that axis. Passing the merged keywords list here makes the stack axis non-empty and usually unmatched against mission.stack, dropping those missions by the stack weight even though the user never chose a technical stack constraint; preserve the old unconstrained behavior for search-only/domain-only keywords.

Useful? React with 👍 / 👎.

const locationMatch = rawLocationScore(mission.location, profile.location);
const tjmMatch = rawTjmScore(mission.tjm, profile.tjmMin, profile.tjmMax);
const remoteMatch = rawRemoteScore(mission.remote, profile.remote);
Expand Down Expand Up @@ -97,22 +101,28 @@ const normalizeWeights = (weights: ScoringWeights): ScoringWeights => {

/**
* Raw stack match percentage (0-100).
* Returns % of mission stack that matches the profile.
* Returns % of mission stack that matches the profile keywords.
*
* Scoring-neutral merge invariant: the denominator is `missionStack.length`,
* NOT `profileKeywords.length`. Adding domain keywords (e.g. "SaaS", "fintech")
* that never appear in a mission's parsed stack does not change any mission's
* score — they are simply never matched. This is what makes it safe to merge
* the former `stack` and `searchKeywords` into a single `keywords` list.
*/
const rawStackScore = (missionStack: string[], profileStack: string[]): number => {
if (profileStack.length === 0) {
const rawStackScore = (missionStack: string[], profileKeywords: string[]): number => {
if (profileKeywords.length === 0) {
return 100;
}
if (missionStack.length === 0) {
return 0;
}
// Build the profile once as a Set for O(1) membership checks. This runs once
// per mission during scoring; the previous Array.includes made it O(m) per
// mission-stack entry. Output is identical: each truthy mission-stack entry
// that matches a (lowercased) profile entry counts once toward the numerator,
// and the denominator stays the full missionStack.length.
// Build the profile keywords once as a Set for O(1) membership checks. This
// runs once per mission during scoring; the previous Array.includes made it
// O(m) per mission-stack entry. Output is identical: each truthy mission-stack
// entry that matches a (lowercased) profile keyword counts once toward the
// numerator, and the denominator stays the full missionStack.length.
const profileSet = new Set<string>();
for (const entry of profileStack) {
for (const entry of profileKeywords) {
if (entry) {
profileSet.add(entry.toLowerCase());
}
Expand Down
Loading
Loading