lib/inbox-rank.mjs
Rama main · réplica de lectura del repositorio original.
/**
* inbox-rank.mjs — deterministic fit for a pending posting.
*
* One rule for the inbox list, the what's-new route, and rank-pipeline's
* heuristic segment. Turbopack cannot import this file from web/, so
* web/src/lib/core/inbox-rank.mjs is a byte copy. Edit this file, then copy it.
*
* The tokenizer is inlined. web/src/lib/title-fit.mjs cannot be imported here
* without forking the copy, and that helper is forbidden from ordering lists.
*
* A parsed rank stamp wins outright. Its score is already on the 0 to 5 scale
* this function uses, and its reason is the line a previous pass wrote.
* Mixing that number with a fresh title/place/pay total would let the two
* disagree on the same row. No stamp, and no hit, is null: unranked, not zero,
* so a real 0 stamp still sorts ahead of a row this function cannot judge.
*/
const TOKEN_RE = /[\p{L}\p{N}+#.]+/gu;
const SENIORITY = new Set([
"junior", "jr", "mid", "middle", "senior", "sr", "staff", "principal",
"lead", "head", "chief", "associate", "assistant", "intern", "internship",
"entry", "level", "ii", "iii", "iv",
]);
/**
* @param {unknown} profile Parsed profile.yml, or anything else.
* @returns {{ roles: string[], places: string[], wantsRemote: boolean, payFloor: number | null, currency: string | null }}
*/
export function fitTargetsFromProfile(profile) {
const doc = profile && typeof profile === "object" ? profile : {};
const rolesBlock = doc.target_roles;
let roles = [];
if (rolesBlock && typeof rolesBlock === "object" && !Array.isArray(rolesBlock)) {
const primary = Array.isArray(rolesBlock.primary) ? rolesBlock.primary : [];
const names = Array.isArray(rolesBlock.archetypes)
? rolesBlock.archetypes.map((item) => item && item.name)
: [];
roles = [...primary, ...names].filter((role) => typeof role === "string");
}
const location = doc.location && typeof doc.location === "object" ? doc.location : {};
const compensation = doc.compensation && typeof doc.compensation === "object" ? doc.compensation : {};
const candidate = doc.candidate && typeof doc.candidate === "object" ? doc.candidate : {};
const places = [location.city, location.country, candidate.location]
.filter((place) => typeof place === "string" && place.trim().length >= 3)
.map((place) => place.trim());
const remoteText = [compensation.location_flexibility, location.onsite_availability]
.filter((text) => typeof text === "string")
.join(" ");
const wantsRemote = location.remote === true || /remote/i.test(remoteText);
const rawMinimum = compensation.minimum;
const minimum = typeof rawMinimum === "number" && Number.isFinite(rawMinimum)
? String(rawMinimum)
: typeof rawMinimum === "string" ? rawMinimum : "";
const money = parseMoney(minimum);
const named = currencyCode(compensation.currency);
return {
roles,
places,
wantsRemote,
payFloor: money ? money.amount : null,
currency: named || (money && money.currency) || null,
};
}
/**
* @param {unknown} raw The text after `rank:`.
* @returns {{ score: number, reason: string } | null}
*/
export function parseRankStamp(raw) {
if (raw == null) return null;
const text = String(raw).trim();
const match = text.match(/(\d+(?:\.\d+)?)\s*\/\s*5\s*[—–-]\s*(.+)/);
if (!match) return null;
const reason = match[2].trim();
if (!reason) return null;
const score = Number(match[1]);
if (!Number.isFinite(score)) return null;
return { score: roundScore(score), reason };
}
/**
* @param {{ title?: unknown, location?: unknown, pay?: unknown, rankStamp?: unknown } | null | undefined} posting
* @param {{ roles?: string[], places?: string[], wantsRemote?: boolean, payFloor?: number | null, currency?: string | null } | null | undefined} targets
* @returns {{ score: number, reason: string } | null}
*/
export function scorePosting(posting, targets) {
const source = posting || {};
const stamp = parseRankStamp(source.rankStamp);
if (stamp) return stamp;
const profile = targets || {};
const clauses = [];
let score = 0;
const title = titleClause(source.title, profile.roles || []);
if (title) {
clauses.push(title.text);
score += title.points;
}
const where = locationClauses(source.location, profile);
clauses.push(...where.clauses);
score += where.points;
const pay = payClause(source.pay, profile);
if (pay) {
clauses.push(pay.text);
score += pay.points;
}
if (!clauses.length) return null;
return { score: Math.min(5, score), reason: clauses.join(" · ") };
}
/**
* Ranked rows first, higher score first. Unranked rows after them.
* Inside each group, a later YYYY-MM-DD comes first and a missing date goes last.
* `newest` ignores fit and uses that date rule alone. The input order breaks ties.
*
* @template T
* @param {T[]} rows
* @param {"fit" | "newest"} [mode]
* @returns {T[]}
*/
export function orderInbox(rows, mode = "fit") {
const list = Array.isArray(rows) ? rows : [];
const decorated = list.map((row, index) => ({ row, index }));
decorated.sort((a, b) => {
if (mode !== "newest") {
const left = rankedScore(a.row);
const right = rankedScore(b.row);
const leftRanked = left != null;
const rightRanked = right != null;
if (leftRanked !== rightRanked) return leftRanked ? -1 : 1;
if (leftRanked && rightRanked && left !== right) return right - left;
}
return compareRecency(a, b);
});
return decorated.map((item) => item.row);
}
/**
* Fit order for the what's-new payload. `fit` is not returned.
*
* @param {Array<Record<string, unknown>>} offers
* @param {Parameters<typeof scorePosting>[1]} targets
* @param {number} limit
*/
export function rankOffers(offers, targets, limit) {
const list = Array.isArray(offers) ? offers : [];
const ordered = orderInbox(list.map((offer) => ({
...offer,
postedAt: typeof offer.postedAt === "string" ? offer.postedAt : "",
fit: scorePosting({
title: offer.title || offer.role || "",
location: offer.location || "",
pay: offer.pay || offer.compensation || "",
rankStamp: offer.rankStamp || null,
}, targets),
})), "fit");
const cap = Number.isFinite(limit) ? Math.max(0, limit) : ordered.length;
return ordered.slice(0, cap).map((row) => {
const { fit, ...rest } = row;
return rest;
});
}
function rankedScore(row) {
const fit = row && row.fit;
if (!fit || typeof fit.score !== "number" || !Number.isFinite(fit.score)) return null;
return fit.score;
}
function compareRecency(a, b) {
const left = dateKey(a.row);
const right = dateKey(b.row);
if (left !== right) {
if (!left) return 1;
if (!right) return -1;
return left < right ? 1 : -1;
}
return a.index - b.index;
}
function dateKey(row) {
const value = row && row.postedAt;
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : "";
}
function roundScore(score) {
return Math.round(Math.min(5, Math.max(0, score)) * 10) / 10;
}
function currencyCode(raw) {
const text = String(raw ?? "").trim().toUpperCase();
if (text === "USD" || text === "$" || text === "US$") return "USD";
if (text === "EUR" || text === "€") return "EUR";
if (text === "GBP" || text === "£") return "GBP";
return null;
}
function parseMoney(raw) {
const text = String(raw ?? "");
const currency = /€|\bEUR\b/i.test(text) ? "EUR" : /£|\bGBP\b/i.test(text) ? "GBP" : /\$|\bUSD\b/i.test(text) ? "USD" : null;
const amounts = [];
const pattern = /(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)(\s*[kK])?/g;
for (const match of text.matchAll(pattern)) {
let amount = Number(match[1].replace(/,/g, ""));
if (!Number.isFinite(amount)) continue;
if (match[2]) amount *= 1000;
if (amount >= 1000) amounts.push(amount);
}
if (!amounts.length) return null;
return { amount: Math.min(...amounts), currency };
}
function tokenSet(text) {
const raw = String(text ?? "").toLowerCase().match(TOKEN_RE) ?? [];
return new Set(raw.map((token) => token.replace(/\.+$/, "")).filter((token) => token.length > 1 && !SENIORITY.has(token)));
}
function titleClause(title, roles) {
const titleTokens = tokenSet(title);
if (!titleTokens.size || !Array.isArray(roles)) return null;
const lowerTitle = String(title).toLowerCase();
let best = null;
for (const role of roles) {
if (typeof role !== "string" || !role.trim()) continue;
const phrase = role.trim();
const roleTokens = [...tokenSet(phrase)];
if (!roleTokens.length) continue;
let hits = 0;
for (const token of roleTokens) if (titleTokens.has(token)) hits += 1;
const ratio = hits / roleTokens.length;
const phraseHit = lowerTitle.includes(phrase.toLowerCase());
let points = 0;
if (phraseHit || hits === roleTokens.length) points = 3;
else if (ratio >= 0.6) points = 2;
else if (ratio >= 0.34) points = 1;
if (!points) continue;
if (
!best
|| points > best.points
|| (points === best.points && ratio > best.ratio)
|| (points === best.points && ratio === best.ratio && phrase.length > best.phrase.length)
) {
best = { points, ratio, phrase };
}
}
if (!best) return null;
return { points: best.points, text: `Title matches '${best.phrase}'` };
}
function locationClauses(location, targets) {
const text = String(location ?? "");
const clauses = [];
let points = 0;
if (targets.wantsRemote && /\bremote\b/i.test(text)) {
clauses.push("Remote");
points += 1;
}
const places = [...(targets.places || [])].filter((place) => typeof place === "string");
places.sort((a, b) => b.length - a.length);
for (const place of places) {
if (place.trim().length < 3) continue;
if (text.toLowerCase().includes(place.trim().toLowerCase())) {
clauses.push(`Location matches '${place.trim()}'`);
points += 1;
break;
}
}
return { clauses, points };
}
function payClause(pay, targets) {
if (targets.payFloor == null || !Number.isFinite(targets.payFloor)) return null;
const money = parseMoney(pay);
if (!money) return null;
const floorCurrency = currencyCode(targets.currency);
if (money.currency && floorCurrency && money.currency !== floorCurrency) return null;
if (money.amount < targets.payFloor) return null;
return { points: 1, text: "pay above your floor" };
}