你的薪酬篩選器讀取的是薪酬範圍上限
Your Salary Filter Reads the Top of the Pay Range
設定 minAnnualSalaryUsd 只會篩出年化薪酬上限達到門檻的美元招聘資訊,不能據此承諾薪酬下限符合用戶目標。文章建議明確區分上限與下限政策,並驗證薪酬範圍、幣種及薪酬週期;招聘資訊本身不能證明個別求職者的實際薪酬。
Your minimum-salary shortlist can include jobs whose advertised starting pay falls below your user's target.
You set minAnnualSalaryUsd to 120000, send the results into retrieval, and let the assistant describe them as “jobs paying at least your minimum.” That sentence quietly promises more than the filter establishes.
Lab note 016: can we distinguish a range that reaches the target from a range that starts there? Even a qualifying advertised lower bound doesn't establish what a particular applicant will be offered.
Which bound is your assistant promising?
The Actor's minAnnualSalaryUsd input selects USD listings whose annualized maximum meets the threshold.
That makes it useful for finding possible salary matches. Treating every returned listing as a confirmed salary-floor match doesn't work.
We'd make the product choose its promise explicitly. Example policy code keeps the comparisons separate:
import json
from decimal import Decimal
TARGET = 120000
POLICY = "published_lower_bound"
def clears_target(annual_bounds, policy):
low, high = annual_bounds
if policy == "upper_bound_reaches":
return high >= TARGET
if policy == "published_lower_bound":
return low >= TARGET
raise ValueError(f"Unknown salary policy: {policy}")
The function expects validated bounds. We'll put that boundary around it shortly.
| User intent | Required evidence | Wording the assistant can support |
|---|---|---|
| Find ranges that could reach the target | Qualifying annualized upper bound | “The advertised range reaches your target.” |
| Find ranges starting at the target | Qualifying annualized lower bound | “The advertised lower bound meets your target.” |
| Guarantee the applicant's compensation | An individual offer and its terms | Neither listing filter establishes this. |
Turning on salaryDisclosedOnly doesn't settle the distinction. Disclosure establishes that published salary data exists, not that its lower bound qualifies.
The lesson travels beyond salaries: the name of a retrieval tool mustn't become the definition of your acceptance rule. Otherwise, the assistant inherits a promise nobody actually checked.
A currency boundary can become a market claim
With the salary threshold enabled, the Actor excludes other currencies.
That's a coherent boundary for a USD shortlist. It becomes misleading when your assistant describes the result as the best-paying opportunities across currencies, or concludes that an excluded listing pays too little.
Keep the currency boundary explicit in your own gate:
def within_currency_policy(row):
return row.get("salaryCurrency") == "USD"
A record outside this policy belongs in a separate branch, not in a “salary too low” bucket.
If you later add currency conversion, that branch needs its own exchange-rate policy and provenance. Silently treating a currency amount as dollars would make a neat comparison out of incompatible units. Neat is doing a lot of work there.
For retrieval, preserve the scope in the answer instructions too: the assistant searched a USD-only candidate set. Absence from that set isn't evidence about compensation elsewhere.
Odd ranges should stop before embeddings
The Actor omits empty fields and passes unusual source salary values through unchanged.
Both behaviors matter. Defaulting missing pay to zero turns “unknown” into “underpaid.” Accepting a reversed range can make the upper-bound screen and lower-bound screen disagree for the wrong reason.
We'd rather quarantine that record than repair an employer's salary claim by arithmetic enthusiasm.
This validator requires positive, finite numeric bounds and checks their ordering:
PAY_FIELDS = (
"salaryMin",
"salaryMax",
"annualSalaryMin",
"annualSalaryMax",
)
def checked_bounds(row):
values = []
for field in PAY_FIELDS:
value = row.get(field)
if type(value) not in (int, float):
return None, "missing_or_nonnumeric_pay"
amount = Decimal(str(value))
if not amount.is_finite() or amount <= 0:
return None, "nonpositive_or_nonfinite_pay"
values.append(amount)
low, high, annual_low, annual_high = values
if low > high or annual_low > annual_high:
return None, "reversed_range"
return tuple(values), None
Requiring all these bounds is our conservative application policy. It gives both recommendation modes the same evidence standard.
The filter deliberately rejects numeric strings rather than guessing how to parse them. A failed check retains the original record for review; it doesn't rewrite source values.
A positive, internally consistent salary can still be wrong. Structural validation catches contradictions, not the truth of a compensation claim. A suspicious but coherent range still deserves inspection against the listing before your product makes a strong recommendation.
Annualized pay can impersonate guaranteed earnings
Hourly pay is annualized using 2,080 hours; monthly pay uses 12 months.
Those multipliers make ranges comparable on an annualized basis. They don't establish paid hours, continuity of employment, or guaranteed annual earnings.
This is where chunking can erase the distinction. If a salary chunk contains only the annualized figure, retrieval can hand the model an apparently annual salary with its original pay period missing.
Keep the original range and period attached, and verify that the annualized bounds agree with your accepted interpretation.
Example local allowlist: these spellings are application choices, and unfamiliar incoming labels require an explicit mapping before admission.
PERIOD_FACTORS = {
"year": Decimal(1),
"month": Decimal(12),
"hour": Decimal(2080),
}
def period_issue(row, bounds):
period = row.get("salaryPeriod")
if not isinstance(period, str) or period not in PERIOD_FACTORS:
return "unrecognized_salary_period"
low, high, annual_low, annual_high = bounds
factor = PERIOD_FACTORS[period]
if (low * factor, high * factor) != (annual_low, annual_high):
return "annualization_mismatch"
return None
Exact equality is intentionally conservative. Rounding differences also go to review, where you can decide whether a tolerance is justified.
Don't infer a period from the size of the salary. That replaces missing evidence with a guess, then gives the guess a calculator.
Put the gate before retrieval
Apply the salary policy before creating searchable salary evidence.
Filtering only after retrieval can let ineligible listings occupy the retrieved candidate slots. The model then gets fewer useful choices, and a tempting pile of near-matches.
The boundary should look like this:
Actor records
|
currency -> numeric bounds -> period consistency -> chosen salary bound
| | |
outside scope review accepted
|
salary evidence -> retrieval
The combined filter below keeps review records intact and creates a compact evidence chunk for accepted records:
EVIDENCE_FIELDS = (
"id", "jobUrl", "title", "company",
"salaryMin", "salaryMax", "salaryCurrency", "salaryPeriod",
"annualSalaryMin", "annualSalaryMax",
)
def screen(row, policy):
if not within_currency_policy(row):
return "outside_currency_policy", None
bounds, issue = checked_bounds(row)
if issue:
return f"review:{issue}", None
issue = period_issue(row, bounds)
if issue:
return f"review:{issue}", None
if not clears_target(bounds[2:], policy):
return "below_selected_bound", None
evidence = {
field: row[field]
for field in EVIDENCE_FIELDS
if field in row
}
factor = PERIOD_FACTORS[row["salaryPeriod"]]
context = (
f"Salary policy: {policy}; target: {TARGET} USD annualized. "
f"Annualization multiplier: {factor}. "
"Advertised pay; individual offer and paid hours unverified.\n"
)
return "accepted", context + json.dumps(
evidence, ensure_ascii=False, allow_nan=False
)
def partition(rows, policy):
accepted, review, excluded = [], [], []
for row in rows:
status, evidence = screen(row, policy)
if status == "accepted":
accepted.append(evidence)
elif status.startswith("review:"):
review.append((status, row))
else:
excluded.append((status, row))
return accepted, review, excluded
Keep this salary evidence intact when chunking. Its qualification, original range, currency, and period belong together.
The jobUrl points to the Jobicy listing, where the employer application step is handled.
For a salary-first query, retrieve from the accepted set, then fetch the matching job descriptions for richer answers. That saves description tokens until the listing has cleared the numeric requirement.
The model can explain the evidence. Your Python gate owns eligibility.
A synthetic range makes the split visible
Example synthetic records isolate the range-boundary mistake and the currency boundary without implying anything about actual listings:
example_base = {
"salaryMin": 120000,
"salaryMax": 140000,
"salaryCurrency": "USD",
"salaryPeriod": "year",
"annualSalaryMin": 120000,
"annualSalaryMax": 140000,
}
example_rows = [
dict(
example_base,
id="example-range",
salaryMin=80000,
annualSalaryMin=80000,
),
dict(example_base, id="example-floor"),
dict(
example_base,
id="example-other-currency",
salaryCurrency="EUR",
),
]
upper_chunks, upper_review, upper_excluded = partition(
example_rows, "upper_bound_reaches"
)
floor_chunks, floor_review, floor_excluded = partition(
example_rows, POLICY
)
assert len(upper_chunks) == 2
assert len(floor_chunks) == 1
assert not upper_review and not floor_review
The wider synthetic range clears the upper-bound policy and fails the lower-bound policy. The other-currency record stays outside both, regardless of its numeric amount.
These assertions describe the local acceptance rules. They say nothing about how frequently any case occurs in the feed.
The promise we'd ship
We'd use Jobicy Remote Job Listings Scraper to collect candidates, then enforce the selected bound before retrieval.
The honest promise is an advertised-range shortlist. An upper-bound match means the range reaches the target; a lower-bound match supports stronger wording about where the published range starts.
Even then, published ranges can cover different locations or experience bands. Check the description for which range applies to the applicant, and keep that uncertainty in the recommendation.
Passing the salary gate earns a place in the shortlist. An offer still has to come from the employer.
Reproduce the boundary
- Run the Actor with the input below to collect salary candidates.
- Pass its returned dictionaries to
partitionusing your chosen policy. - Inspect review records separately, including any period labels your allowlist doesn't recognize.
- Index accepted salary evidence with its qualifications attached.
- Check the source listing before presenting an applicant-specific compensation claim.
Example input for the candidate collection:
{
"mode": "search",
"industry": "engineering",
"salaryDisclosedOnly": true,
"minAnnualSalaryUsd": 120000,
"maxItems": 30
}
The model doesn't get to promote a salary ceiling into a starting offer.
來源:DEV Community · dev.to