"""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"(? 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}