UpworkAutomation / app /services /beginner_evaluator.py
ananyajoshi's picture
Fix audited pipeline, session isolation and proposal grounding issues
357ee36
Raw History Blame Contribute Delete
36.9 kB
"""Job Evaluator (Instruction Set 1).
A hybrid rule layer that screens one uploaded Upwork opportunity through a
fixed 10-signal apply/skip checklist *before* the proposal is written. It
protects a beginner profile from wasting connects on jobs that are
statistically not worth a proposal.
Eight signals are fully **deterministic** (computed here from the confirmed
job fields). Two signals are **judgment calls** β€” job-description quality
and niche match β€” that the match engine computes with the LLM and passes in
via ``job_desc_quality`` / ``niche_match``. When those are not supplied the
evaluator falls back to a safe deterministic heuristic so it still works
offline (and in tests).
The 9 signals (each votes GO / CAUTION / NO GO):
1. Proposals β€” <20 GO Β· 20-50 CAUTION Β· 50+ NO GO
2. Hire rate β€” 50%+ GO Β· 25-50% CAUTION Β· <25% NO GO (only for a client
with a track record) Β· not visible CAUTION Β· new client GO
3. Payment β€” verified GO Β· not verified NO GO Β· no data CAUTION
4. Client rating β€” 4.8+ GO Β· 4.5-4.8 CAUTION Β· <4.5 NO GO Β· no rating CAUTION
5. Job desc. β€” clear scope GO Β· everything else CAUTION (never NO GO)
6. Time posted β€” <2h GO Β· 2-6h CAUTION Β· 6h+ NO GO Β· no data CAUTION
7. Experience β€” Entry GO Β· Intermediate CAUTION Β· Expert NO GO
8. Contract type β€” fixed + clear GO Β· hourly/vague CAUTION
9. Client activityβ€” consistent hiring GO Β· mixed CAUTION Β· zero hires NO GO
Niche match (job vs the freelancer's offer) is a separate, non-blocking
**note** β€” FULL / PARTIAL / NONE β€” never counted in the GO/CAUTION/NO-GO
totals.
The overall result is the *worst* signal: any NO GO β†’ "Not recommended"
(``Do Not Proceed``); else any CAUTION β†’ "Proceed with caution"
(``Proceed With Caution``); else "Strong opportunity" (``Apply Confidently``).
A field that is not legible is recorded and, where the spec allows, simply
lowers confidence rather than inventing a verdict.
"""
from __future__ import annotations
import re
from datetime import date
from typing import Any, Optional
NOT_VISIBLE = "Not visible"
# Result vocabulary (kept stable for the scoring + recommendation layers).
APPLY_CONFIDENTLY = "Apply Confidently"
PROCEED_WITH_CAUTION = "Proceed With Caution"
DO_NOT_PROCEED = "Do Not Proceed"
# Status tokens shown in the output table.
GO = "GO"
CAUTION = "CAUTION"
NO_GO = "NO GO"
# Map a worst-signal status to the internal result + the recommendation line.
_STATUS_TO_RESULT = {GO: APPLY_CONFIDENTLY, CAUTION: PROCEED_WITH_CAUTION, NO_GO: DO_NOT_PROCEED}
# --- User-facing reason strings -------------------------------------------
# NO GO reasons.
REASON_PROPOSALS_50_PLUS = (
"This job already has 50 or more proposals, so competition is too high "
"for a beginner profile."
)
REASON_HIRE_RATE_LOW = (
"The client has posted several jobs but hires for fewer than 1 in 4, so "
"they rarely hire β€” a high risk of wasted connects."
)
REASON_PAYMENT_NOT_VERIFIED = (
"Payment is not verified, so there is a higher risk of not getting paid."
)
REASON_RATING_LOW = (
"The client's rating is below 4.5, so they may be difficult to satisfy."
)
REASON_POSTED_STALE = (
"This job was posted more than 6 hours ago, so the client may already be "
"reviewing other freelancers."
)
REASON_EXPERT_LEVEL = (
"This job is marked Expert level, so a beginner profile will likely be "
"screened out."
)
REASON_ACTIVITY_DEAD = (
"The client has posted jobs but made no hires, so they may not actually "
"hire anyone."
)
# CAUTION reasons.
WARN_PROPOSALS_20_49 = (
"Competition is moderate (20-50 proposals), so the proposal must be very strong."
)
WARN_HIRE_RATE_MID = "The client's hire rate is 25-50%, so a hire is not guaranteed."
# A hire rate that could not be read is unknown, not low: it is checked by the
# user, never held against the client.
WARN_HIRE_RATE_UNKNOWN = (
"The client's hire rate was not visible in the screenshot. Check it in the "
"'About the client' box on Upwork before applying."
)
WARN_HIRE_RATE_LOW_UNPROVEN = (
"The hire rate looks low, but the client's job history was not visible. "
"Check how many jobs they have posted before applying."
)
WARN_ACTIVITY_UNPROVEN = (
"The client shows no hires yet, but their job history was not visible. "
"Check how many jobs they have posted before applying."
)
WARN_PAYMENT_UNKNOWN = "Payment verification was not visible, so confirm it before applying."
WARN_RATING_MID = (
"The client's rating is 4.5-4.8 β€” read the past reviews carefully before applying."
)
WARN_RATING_NONE = "The client has no rating yet, so there is no track record to judge."
WARN_DESC_VAGUE = (
"The job description is vague or bundles several tasks, so scope the proposal carefully."
)
WARN_POSTED_RECENT = "This job was posted 2-6 hours ago, so apply quickly while it is still fresh."
WARN_POSTED_UNKNOWN = "The posting time was not visible, so you may not be early."
WARN_INTERMEDIATE = (
"This job is Intermediate level, so lead with strong, directly relevant proof."
)
WARN_CONTRACT_HOURLY = "This is an hourly/ongoing role, so set clear expectations up front."
WARN_CONTRACT_VAGUE = "Fixed price with vague scope β€” pin down the deliverable before committing."
WARN_ACTIVITY_MIXED = "The client's hiring history is mixed, so vet them before spending connects."
WARN_ACTIVITY_UNKNOWN = "The client's hiring activity was not visible."
# Friendly labels for the fields that, when missing, lower confidence.
_FIELD_LABELS: dict[str, str] = {
"payment_verification": "payment verification",
"proposal_count": "proposal count",
"hire_rate": "hire rate",
"client_rating": "client rating",
"posted_date": "posted time",
"experience_level": "experience level",
"contract_type": "contract type",
"client_activity": "client activity",
}
# ---------------------------------------------------------------------------
# Field access helpers
# ---------------------------------------------------------------------------
def _value(confirmed_job: dict, *keys: str) -> str:
for key in keys:
entry = (confirmed_job or {}).get(key)
if isinstance(entry, dict):
value = str(entry.get("value", "") or "").strip()
else:
value = str(entry or "").strip()
if not _is_missing(value):
return value
return ""
def _is_missing(value: Optional[str]) -> bool:
if value is None:
return True
v = value.strip()
return not v or v.lower() == NOT_VISIBLE.lower()
def _ints(value: str) -> list[int]:
return [int(tok) for tok in re.findall(r"\d+", value.replace(",", ""))]
def _first_float(value: str) -> Optional[float]:
match = re.search(r"\d+(?:\.\d+)?", value.replace(",", ""))
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
# ---------------------------------------------------------------------------
# Field parsers β€” each returns a stable bucket; never guesses on missing data
# ---------------------------------------------------------------------------
def _payment_status(value: str) -> str:
"""``"verified"`` | ``"not_verified"`` | ``"not_visible"``."""
if _is_missing(value):
return "not_visible"
low = value.lower()
negative = (
"not verified", "not_verified", "unverified", "no payment",
"payment not", "not confirmed", "billing not",
)
if any(marker in low for marker in negative):
return "not_verified"
if low in {"no", "n", "false", "unverified"}:
return "not_verified"
if "verified" in low or "verify" in low or low in {"yes", "y", "true"}:
return "verified"
return "not_visible"
def _proposal_count(value: str) -> Optional[int]:
if _is_missing(value):
return None
ints = _ints(value)
if not ints:
return None
low = value.lower()
if any(p in low for p in ("less than", "fewer than", "under", "below")):
return max(min(ints) - 1, 0)
return ints[0]
def _proposal_bucket(count: Optional[int]) -> str:
"""``"low"`` (<20) | ``"high"`` (20-49) | ``"too_high"`` (50+) | ``"not_visible"``."""
if count is None:
return "not_visible"
if count >= 50:
return "too_high"
if count >= 20:
return "high"
return "low"
def _hire_rate(value: str) -> Optional[float]:
if _is_missing(value):
return None
low = value.lower()
if any(p in low for p in ("no hire", "not rated", "new client", "n/a")):
return None
m = re.search(r"(\d+(?:\.\d+)?)\s*%", value)
if m:
try:
rate = float(m.group(1))
except ValueError:
return None
else:
rate = _first_float(value)
if rate is None:
return None
if rate < 0 or rate > 100:
return None
return rate
def _hire_rate_bucket(rate: Optional[float]) -> str:
"""``"low"`` (<25) | ``"mid"`` (25-49) | ``"high"`` (50+) | ``"not_visible"``."""
if rate is None:
return "not_visible"
if rate < 25:
return "low"
if rate < 50:
return "mid"
return "high"
def _posted_age_hours(value: str, today: Optional[date] = None) -> Optional[float]:
if _is_missing(value):
return None
low = value.lower()
if any(m in low for m in (
"just now", "moments ago", "seconds ago", "less than a minute",
"a minute ago", "minute ago",
)):
return 0.0
m = re.search(r"(\d+)\s*min", low)
if m:
return int(m.group(1)) / 60.0
if any(p in low for p in ("less than an hour", "under an hour", "<1 hour", "<1 hr")):
return 0.5
# Counts before the one-unit phrases: "11 hours ago" contains "1 hour"
# and "21 days ago" contains "1 day".
m = re.search(r"(\d+)\s*hour", low) or re.search(r"(\d+)\s*hr", low)
if m:
return float(int(m.group(1)))
if any(p in low for p in ("an hour ago", "a hour ago", "1 hour", "an hr", "1 hr")):
return 1.0
if any(p in low for p in ("few hours", "couple hours", "couple of hours", "several hours")):
return 4.0
m = re.search(r"(\d+)\s*day", low)
if m:
return int(m.group(1)) * 24.0
if "yesterday" in low or "a day ago" in low or "1 day" in low:
return 24.0
m = re.search(r"(\d+)\s*week", low)
if m:
return int(m.group(1)) * 7 * 24.0
if "last week" in low or "a week ago" in low or "1 week" in low:
return 7 * 24.0
m = re.search(r"(\d+)\s*month", low)
if m:
return int(m.group(1)) * 30 * 24.0
if "last month" in low or "a month ago" in low or "1 month" in low:
return 30 * 24.0
days = _absolute_age_days(value, today)
return None if days is None else days * 24.0
def _absolute_age_days(value: str, today: Optional[date]) -> Optional[int]:
reference = today or _today()
if reference is None:
return None
for fmt in ("%b %d, %Y", "%B %d, %Y", "%Y-%m-%d", "%m/%d/%Y", "%d %b %Y", "%d %B %Y"):
try:
parsed = _strptime_date(value.strip(), fmt)
except ValueError:
continue
if parsed is None:
continue
return max((reference - parsed).days, 0)
return None
def _today() -> Optional[date]:
try:
return date.today()
except Exception: # pragma: no cover - defensive only
return None
def _strptime_date(value: str, fmt: str) -> Optional[date]:
from datetime import datetime
return datetime.strptime(value, fmt).date()
def _posted_bucket(age_hours: Optional[float]) -> str:
"""``"fresh"`` (<2h) | ``"recent"`` (2-6h) | ``"stale"`` (6h+) | ``"not_visible"``."""
if age_hours is None:
return "not_visible"
if age_hours < 2:
return "fresh"
if age_hours < 6:
return "recent"
return "stale"
def _rating(value: str) -> Optional[float]:
if _is_missing(value):
return None
low = value.lower()
if any(p in low for p in ("no review", "no rating", "not rated", "new client", "no feedback")):
return None
if re.search(r"(?<![\d.])0\s*reviews?\b", low):
return None
# The rating is the first number up to 5 that is not a review count, so
# "4.8 of 12 reviews" and "(12 reviews) 4.8" both read 4.8. Upwork stars
# run from 1 to 5: a 0 ("0.0 of 0 reviews") means no rating yet.
for m in re.finditer(r"(\d+(?:\.\d+)?)(\s*reviews?\b)?", low.replace(",", "")):
if m.group(2):
continue
r = float(m.group(1))
if r <= 5:
return r if r > 0 else None
return None
def _rating_bucket(rating: Optional[float]) -> str:
"""``"low"`` (<4.5) | ``"mid"`` (4.5-4.79) | ``"high"`` (4.8+) | ``"not_visible"``."""
if rating is None:
return "not_visible"
if rating < 4.5:
return "low"
if rating < 4.8:
return "mid"
return "high"
def _experience_level(value: str) -> str:
"""``"entry"`` | ``"intermediate"`` | ``"expert"`` | ``"other"`` | ``"not_visible"``."""
if _is_missing(value):
return "not_visible"
low = value.lower()
if "expert" in low:
return "expert"
if "intermediate" in low:
return "intermediate"
if "entry" in low or "beginner" in low:
return "entry"
return "other"
def _contract_type(value: str) -> str:
"""``"fixed"`` | ``"hourly"`` | ``"not_visible"``."""
if _is_missing(value):
return "not_visible"
low = value.lower()
if "hour" in low:
return "hourly"
if "fixed" in low:
return "fixed"
return "not_visible"
# A client with fewer than this many jobs posted is treated as "new". A new
# client naturally has little/no hire rate, rating, or history β€” that is normal
# and is NOT a red flag. New clients are often the best odds for a beginner, so
# we do not penalise the absence of a track record for them. (An *established*
# client who has posted many jobs but still made no hires IS a red flag.)
_NEW_CLIENT_JOB_LIMIT = 5
REASON_NEW_CLIENT = (
"New client with little history yet β€” normal, and often good odds for a beginner."
)
def _jobs_posted(jobs: Optional[int], hires: Optional[int], hire_rate: Optional[float]) -> Optional[float]:
"""Jobs the client has posted: the visible count, else an estimate.
When the count isn't visible but a hire rate above zero and at least one
hire are, it is about hires * 100 / rate (an 8% rate with 4 hires is about
50 jobs). A hires count on its own is never used: it may be the "Hires: 0"
Upwork shows for this one open post, and a client with a few hires may
still have posted many jobs.
"""
if jobs is not None:
return float(jobs)
if hires and hire_rate:
return hires * 100.0 / hire_rate
return None
def _is_new_client(jobs: Optional[int], hires: Optional[int], hire_rate: Optional[float] = None) -> bool:
"""True when the client is new enough that a thin track record is expected.
Based on jobs posted (the clearest signal), seen or worked out from a
visible hire rate and hires count. Returns False when we have no positive
evidence of newness, so an unknown client is checked rather than waved
through and an established-but-non-hiring client is still flagged.
"""
posted = _jobs_posted(jobs, hires, hire_rate)
return posted is not None and posted < _NEW_CLIENT_JOB_LIMIT
def _client_activity_bucket(jobs: Optional[int], hires: Optional[int], hire_rate: Optional[float]) -> str:
"""``"consistent"`` | ``"mixed"`` | ``"dead"`` | ``"not_visible"``.
* Zero hires across one or more posted jobs (or a 0% hire rate) β†’ dead.
* A healthy hire ratio (>=50%) β†’ consistent.
* Anything in between, or only partial data β†’ mixed.
"""
if hires is None and jobs is None and hire_rate is None:
return "not_visible"
# A visible hire rate above zero outranks a hires count of 0: that 0 is
# most likely the "Hires: 0" Upwork shows for this one open post.
if hire_rate is not None and hire_rate > 0:
return "consistent" if hire_rate >= 50 else "mixed"
# Explicit "posted jobs, hired no one" signal.
if hires == 0 and ((jobs or 0) >= 1 or (hire_rate is not None and hire_rate == 0)):
return "dead"
if hire_rate is not None and hire_rate == 0:
return "dead"
if jobs and hires is not None:
ratio = hires / jobs if jobs else 0.0
if ratio >= 0.5 and hires >= 1:
return "consistent"
if hires >= 1:
return "mixed"
return "dead"
if hire_rate is not None:
return "consistent" if hire_rate >= 50 else "mixed"
if hires and hires >= 1:
return "consistent"
return "mixed"
def _desc_quality_heuristic(confirmed_job: dict) -> str:
"""Deterministic fallback for Signal 5 β€” returns ``GO`` or ``CAUTION``.
Used only when the LLM job-description-quality signal is not supplied.
A description is treated as clear (GO) when it is reasonably detailed
and a deliverable + budget are present; otherwise CAUTION. Never NO GO.
"""
desc = _value(confirmed_job, "job_description", "client_need")
deliverable = _value(confirmed_job, "required_deliverables")
budget = _value(confirmed_job, "budget_or_rate")
if not desc:
return CAUTION
words = len(desc.split())
if words >= 25 and (deliverable or budget):
return GO
return CAUTION
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def evaluate(
confirmed_job: dict,
*,
today: Optional[date] = None,
job_desc_quality: Optional[dict] = None,
niche_match: Optional[dict] = None,
) -> dict[str, Any]:
"""Run the 10-signal checklist over one confirmed opportunity.
``job_desc_quality`` (optional) is the LLM judgment for Signal 5:
``{"status": "GO"|"CAUTION", "data": str}``. ``niche_match`` (optional)
is the LLM niche assessment: ``{"status": "FULL"|"PARTIAL"|"NONE",
"note": str}``. Both are computed by :mod:`app.services.match_engine`
when an API key is available; safe deterministic fallbacks run otherwise.
Returns a plain dict carrying the per-signal table (``signals``), the
GO/CAUTION/NO-GO counts, the recommendation line, the niche-match note,
plus the legacy fields (``result``, ``instant_no``, ``reasons``,
``warnings``, ``score_signals``, ``fields`` …) the rest of the app reads.
Never raises on missing or malformed fields.
"""
confirmed_job = confirmed_job or {}
# --- Parse every field once ---------------------------------------
payment_value = _value(confirmed_job, "payment_verification")
proposal_value = _value(confirmed_job, "proposal_count")
hire_value = _value(confirmed_job, "hire_rate")
rating_value = _value(confirmed_job, "client_rating")
posted_value = _value(confirmed_job, "posted_date", "posted_age")
experience_value = _value(confirmed_job, "experience_level")
contract_value = _value(confirmed_job, "contract_type", "project_type")
jobs_value = _value(confirmed_job, "client_jobs_posted")
hires_value = _value(confirmed_job, "client_hires")
last_active_value = _value(confirmed_job, "client_last_active")
payment = _payment_status(payment_value)
count = _proposal_count(proposal_value)
proposal_b = _proposal_bucket(count)
hire = _hire_rate(hire_value)
rating = _rating(rating_value)
rating_b = _rating_bucket(rating)
age_hours = _posted_age_hours(posted_value, today=today)
posted_b = _posted_bucket(age_hours)
experience = _experience_level(experience_value)
contract = _contract_type(contract_value)
jobs_n = (_ints(jobs_value)[0] if _ints(jobs_value) else None)
hires_n = (_ints(hires_value)[0] if _ints(hires_value) else None)
# If the hire-rate % isn't shown but both the client's jobs posted and
# total hires are, the rate can be worked out from them. Both counts are
# needed: a hires count on its own may be the "Hires: 0" that Upwork shows
# for this one open post, which says nothing about the client.
if hire is None and hires_n is not None and jobs_n:
hire = min(100.0, 100.0 * hires_n / jobs_n)
if _is_missing(hire_value):
hire_value = f"{round(hire)}% ({hires_n} hires from {jobs_n} jobs)"
hire_b = _hire_rate_bucket(hire)
activity_b = _client_activity_bucket(jobs_n, hires_n, hire)
new_client = _is_new_client(jobs_n, hires_n, hire)
# A low hire rate only means something once the client has a real record:
# enough jobs posted (seen, or worked out from the hire rate) or, failing
# that, enough hires to judge them by. Without that evidence, low or
# missing numbers are a reason to check, never a reason to skip the job.
has_history = not new_client and (
_jobs_posted(jobs_n, hires_n, hire) is not None
or (hires_n or 0) >= _NEW_CLIENT_JOB_LIMIT
)
# Signal 5 β€” job description quality (LLM if supplied, else heuristic).
if isinstance(job_desc_quality, dict) and job_desc_quality.get("status") in (GO, CAUTION):
desc_status = job_desc_quality["status"]
desc_data = str(job_desc_quality.get("data") or "").strip() or (
"Clear scope" if desc_status == GO else "Vague / bundled scope"
)
else:
desc_status = _desc_quality_heuristic(confirmed_job)
desc_data = "Clear scope, deliverable present" if desc_status == GO else "Vague or thin description"
# --- Build the 10 signal rows -------------------------------------
rows: list[dict[str, Any]] = []
def add(key, label, data, status, reason=""):
rows.append({"key": key, "label": label, "data": data, "status": status, "reason": reason})
# 1 β€” Proposals
if proposal_b == "too_high":
add("proposals", "Proposals", proposal_value or NOT_VISIBLE, NO_GO, REASON_PROPOSALS_50_PLUS)
elif proposal_b == "high":
add("proposals", "Proposals", proposal_value, CAUTION, WARN_PROPOSALS_20_49)
elif proposal_b == "low":
add("proposals", "Proposals", proposal_value, GO)
else:
add("proposals", "Proposals", NOT_VISIBLE, CAUTION, "Proposal count was not visible.")
# 2 β€” Hire rate. A low hire rate is a NO GO only for a client with a real
# track record. For a NEW client (few jobs posted) it is expected and fine β€”
# beginners often win work from new clients. A rate that was not visible,
# or a low rate with no visible history, is a CAUTION: check it on Upwork.
if hire_b == "high":
add("hire_rate", "Hire Rate", hire_value, GO)
elif hire_b == "mid":
add("hire_rate", "Hire Rate", hire_value, CAUTION, WARN_HIRE_RATE_MID)
elif new_client:
add("hire_rate", "Hire Rate", (hire_value or NOT_VISIBLE) + " β€” new client", GO,
"")
elif hire_b == "low" and has_history:
add("hire_rate", "Hire Rate", hire_value, NO_GO, REASON_HIRE_RATE_LOW)
elif hire_b == "low":
add("hire_rate", "Hire Rate", hire_value, CAUTION, WARN_HIRE_RATE_LOW_UNPROVEN)
else:
add("hire_rate", "Hire Rate", NOT_VISIBLE, CAUTION, WARN_HIRE_RATE_UNKNOWN)
# 3 β€” Payment verified
if payment == "verified":
add("payment", "Payment Verified", payment_value, GO)
elif payment == "not_verified":
add("payment", "Payment Verified", payment_value, NO_GO, REASON_PAYMENT_NOT_VERIFIED)
else:
add("payment", "Payment Verified", NOT_VISIBLE, CAUTION, WARN_PAYMENT_UNKNOWN)
# 4 β€” Client rating. No rating is normal for a NEW client (no reviews yet) β†’
# don't flag it. An ESTABLISHED client with no rating stays a soft caution.
# An explicit low rating (<4.5) is always a NO GO, new or not.
if rating_b == "high":
add("client_rating", "Client Rating", rating_value, GO)
elif rating_b == "mid":
add("client_rating", "Client Rating", rating_value, CAUTION, WARN_RATING_MID)
elif rating_b == "low":
add("client_rating", "Client Rating", rating_value, NO_GO, REASON_RATING_LOW)
elif new_client:
add("client_rating", "Client Rating", "No reviews yet β€” new client", GO, "")
else:
add("client_rating", "Client Rating", NOT_VISIBLE, CAUTION, WARN_RATING_NONE)
# 5 β€” Job description quality (GO or CAUTION only)
add(
"job_description", "Job Description", desc_data, desc_status,
"" if desc_status == GO else WARN_DESC_VAGUE,
)
# 6 β€” Time since posted
if posted_b == "fresh":
add("posted", "Time Since Posted", posted_value, GO)
elif posted_b == "recent":
add("posted", "Time Since Posted", posted_value, CAUTION, WARN_POSTED_RECENT)
elif posted_b == "stale":
add("posted", "Time Since Posted", posted_value, NO_GO, REASON_POSTED_STALE)
else:
add("posted", "Time Since Posted", NOT_VISIBLE, CAUTION, WARN_POSTED_UNKNOWN)
# 7 β€” Experience level
if experience == "entry":
add("experience", "Experience Level", experience_value, GO)
elif experience == "intermediate":
add("experience", "Experience Level", experience_value, CAUTION, WARN_INTERMEDIATE)
elif experience == "expert":
add("experience", "Experience Level", experience_value, NO_GO, REASON_EXPERT_LEVEL)
else:
add("experience", "Experience Level", experience_value or NOT_VISIBLE, CAUTION,
"Experience level was not clearly shown.")
# 8 β€” Contract type (depends on desc clarity for fixed-price)
if contract == "fixed":
if desc_status == GO:
add("contract_type", "Contract Type", contract_value, GO)
else:
add("contract_type", "Contract Type", contract_value, CAUTION, WARN_CONTRACT_VAGUE)
elif contract == "hourly":
add("contract_type", "Contract Type", contract_value, CAUTION, WARN_CONTRACT_HOURLY)
else:
add("contract_type", "Contract Type", contract_value or NOT_VISIBLE, CAUTION,
"Contract type was not visible.")
# 9 β€” Client activity. "No hires" is a NO GO for an ESTABLISHED client
# (posts many jobs, never hires), but for a NEW client it's expected and is
# actually a good opening for a beginner β€” so a new client is never flagged
# down here on the basis of a thin history.
data = _activity_data(jobs_value, hires_value, last_active_value)
if activity_b == "consistent":
add("client_activity", "Client Activity", data, GO)
elif new_client and activity_b in ("dead", "mixed"):
add("client_activity", "Client Activity",
(data if data != NOT_VISIBLE else "New client") + " β€” new client", GO, "")
elif activity_b == "mixed":
add("client_activity", "Client Activity", data, CAUTION, WARN_ACTIVITY_MIXED)
elif activity_b == "dead" and has_history:
add("client_activity", "Client Activity", data, NO_GO, REASON_ACTIVITY_DEAD)
elif activity_b == "dead":
add("client_activity", "Client Activity", data, CAUTION, WARN_ACTIVITY_UNPROVEN)
else:
add("client_activity", "Client Activity", NOT_VISIBLE, CAUTION, WARN_ACTIVITY_UNKNOWN)
# --- Niche match (separate note row, never counted) ---------------
niche = _normalize_niche(niche_match)
# --- Aggregate ----------------------------------------------------
go_count = sum(1 for r in rows if r["status"] == GO)
caution_count = sum(1 for r in rows if r["status"] == CAUTION)
nogo_count = sum(1 for r in rows if r["status"] == NO_GO)
if nogo_count:
worst = NO_GO
elif caution_count:
worst = CAUTION
else:
worst = GO
result = _STATUS_TO_RESULT[worst]
instant_no = worst == NO_GO
nogo_reasons = [r["reason"] for r in rows if r["status"] == NO_GO and r["reason"]]
caution_reasons = [r["reason"] for r in rows if r["status"] == CAUTION and r["reason"]]
warnings = [
{"key": _warn_key(r["key"]), "reason": r["reason"]}
for r in rows
if r["status"] == CAUTION and r["reason"]
]
if worst == NO_GO:
reasons = nogo_reasons[:2]
triggered_rule = "do_not_proceed:" + next(
(r["key"] for r in rows if r["status"] == NO_GO), "unknown"
)
recommendation_line = "Not recommended β€” " + _join_signal_names(rows, NO_GO) + ". Consider skipping this one."
elif worst == CAUTION:
reasons = caution_reasons[:2]
triggered_rule = "proceed_with_caution:" + _warn_key(
next((r["key"] for r in rows if r["status"] == CAUTION), "unknown")
)
recommendation_line = "Proceed with caution β€” watch " + _join_signal_names(rows, CAUTION) + "."
else:
reasons = ["Every signal is a GO β€” strong opportunity, proceed to proposal."]
triggered_rule = "apply_confidently:all_conditions_met"
recommendation_line = "Strong opportunity β€” proceed to proposal."
# --- Missing-info / confidence ------------------------------------
missing_fields: list[str] = []
if payment == "not_visible":
missing_fields.append("payment_verification")
if proposal_b == "not_visible":
missing_fields.append("proposal_count")
# For a new client, a blank hire rate / rating is expected, not "missing
# info" worth flagging β€” so don't add them when the client is new.
if hire_b == "not_visible" and not new_client:
missing_fields.append("hire_rate")
if rating_b == "not_visible" and not new_client:
missing_fields.append("client_rating")
if posted_b == "not_visible":
missing_fields.append("posted_date")
if experience in ("not_visible", "other"):
missing_fields.append("experience_level")
if contract == "not_visible":
missing_fields.append("contract_type")
if activity_b == "not_visible":
missing_fields.append("client_activity")
reduce_confidence = bool(missing_fields)
missing_info_note: Optional[str] = None
if missing_fields:
labels = ", ".join(_FIELD_LABELS.get(f, f) for f in missing_fields)
missing_info_note = (
f"Could not confirm: {labels}. Treating this recommendation with extra caution."
)
score_signals = {
"payment_not_verified": payment == "not_verified",
# Only a visible low rate from a client with a track record counts
# against them; a blank rate, or a new client's low one, does not.
"hire_rate_below_25": hire_b == "low" and has_history,
"hire_rate_unknown": hire_b == "not_visible" and not new_client,
"hire_rate_mid": hire_b == "mid",
"hire_rate_high": hire_b == "high",
"rating_below_4_5": rating_b == "low",
"rating_mid": rating_b == "mid",
"rating_high": rating_b == "high",
"proposals_50_plus": proposal_b == "too_high",
"proposals_20_49": proposal_b == "high",
"proposals_under_20": proposal_b == "low",
"posted_fresh": posted_b == "fresh",
"posted_recent": posted_b == "recent",
"posted_stale": posted_b == "stale",
"expert_level": experience == "expert",
"intermediate_level": experience == "intermediate",
}
fields = {
"payment_verification": {"value": payment_value or NOT_VISIBLE, "result": payment},
"proposal_count": {"value": proposal_value or NOT_VISIBLE, "count": count, "bucket": proposal_b},
"hire_rate": {"value": hire_value or NOT_VISIBLE, "rate": hire, "bucket": hire_b,
"warning": hire_b in ("low", "mid", "not_visible")},
"client_rating": {"value": rating_value or NOT_VISIBLE, "rating": rating, "bucket": rating_b,
"warning": rating_b in ("low", "mid")},
"posted_age": {"value": posted_value or NOT_VISIBLE, "age_hours": age_hours,
"age_days": None if age_hours is None else round(age_hours / 24.0, 2),
"bucket": posted_b},
"experience_level": {"value": experience_value or NOT_VISIBLE, "level": experience,
"warning": experience in ("expert", "intermediate")},
"contract_type": {"value": contract_value or NOT_VISIBLE, "type": contract},
"client_activity": {"value": _activity_data(jobs_value, hires_value, last_active_value),
"jobs": jobs_n, "hires": hires_n, "bucket": activity_b},
"job_description": {"value": desc_data, "status": desc_status},
}
return {
# New table-oriented output (Instruction Set 1, Step 5).
"signals": rows,
"go_count": go_count,
"caution_count": caution_count,
"nogo_count": nogo_count,
"recommendation_line": recommendation_line,
"niche_match": niche,
# Legacy fields consumed by scoring / recommendation / UI.
"result": result,
"instant_no": instant_no,
"reasons": reasons,
"warnings": warnings,
"instant_no_reasons": nogo_reasons,
"fields": fields,
"missing_fields": missing_fields,
"missing_info_note": missing_info_note,
"reduce_confidence": reduce_confidence,
"triggered_rule": triggered_rule,
"score_signals": score_signals,
}
# ---------------------------------------------------------------------------
# Small helpers used by evaluate()
# ---------------------------------------------------------------------------
_SIGNAL_NAMES = {
"proposals": "proposals", "hire_rate": "hire rate", "payment": "payment",
"client_rating": "client rating", "job_description": "job description",
"posted": "time since posted", "experience": "experience level",
"contract_type": "contract type", "client_activity": "client activity",
}
# Map a signal key to the warning key the scoring/recommendation layers expect.
_WARN_KEYS = {
"proposals": "proposals_20_49",
"hire_rate": "hire_rate_25_50",
"client_rating": "client_rating_4_5_to_4_8",
"posted": "posted_2_to_6_hours",
"experience": "intermediate_level",
"payment": "payment_unknown",
"job_description": "job_description_vague",
"contract_type": "contract_type",
"client_activity": "client_activity",
}
def _warn_key(signal_key: str) -> str:
return _WARN_KEYS.get(signal_key, signal_key)
def _join_signal_names(rows: list[dict], status: str) -> str:
names = [_SIGNAL_NAMES.get(r["key"], r["key"]) for r in rows if r["status"] == status]
if not names:
return "the flagged signals"
if len(names) == 1:
return names[0]
return ", ".join(names[:-1]) + " and " + names[-1]
def _activity_data(jobs_value: str, hires_value: str, last_active_value: str) -> str:
parts = []
if jobs_value:
parts.append(f"{jobs_value} jobs" if "job" not in jobs_value.lower() else jobs_value)
if hires_value:
parts.append(f"{hires_value} hires" if "hire" not in hires_value.lower() else hires_value)
if last_active_value:
parts.append(f"active {last_active_value}")
return ", ".join(parts) if parts else NOT_VISIBLE
def _normalize_niche(niche_match: Optional[dict]) -> dict:
"""Normalize the niche-match input to ``{"status", "note"}``.
``status`` is one of ``"FULL"`` | ``"PARTIAL"`` | ``"NONE"`` | ``None``.
A PARTIAL / NONE status carries the standard non-blocking warning note.
"""
if not isinstance(niche_match, dict):
return {"status": None, "note": ""}
status = str(niche_match.get("status") or "").upper().strip() or None
if status not in {"FULL", "PARTIAL", "NONE"}:
return {"status": None, "note": str(niche_match.get("note") or "")}
note = str(niche_match.get("note") or "").strip()
if not note:
if status == "PARTIAL":
note = (
"PARTIAL NICHE MATCH: This job touches your skill set but sits "
"outside your core offer. You can apply, but tailor the proposal "
"tightly to the overlapping skills only. Do not claim experience "
"in the parts that don't match."
)
elif status == "NONE":
note = (
"NICHE MISMATCH: This job is outside your current offer and skill "
"set. Applying means competing without your core strengths. Proceed "
"only if you are intentionally exploring a different direction β€” and "
"be honest about your relevant background."
)
return {"status": status, "note": note}