Spaces:
Sleeping
Sleeping
| """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% / 0% / no data NO 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's hire rate is below 25% (or shows no hires), 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." | |
| 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 | |
| if any(p in low for p in ("an hour ago", "a hour ago", "1 hour", "an hr", "1 hr")): | |
| return 1.0 | |
| 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 ("few hours", "couple hours", "couple of hours", "several hours")): | |
| return 4.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*day", low) | |
| if m: | |
| return int(m.group(1)) * 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 | |
| r = _first_float(value) | |
| if r is None or r > 5: | |
| return None | |
| return r | |
| 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 _is_new_client(jobs: Optional[int], hires: Optional[int]) -> bool: | |
| """True when the client is new enough that a thin track record is expected. | |
| Based on jobs posted (the clearest signal). Falls back to hires when jobs | |
| aren't visible. Returns False when we have no positive evidence of newness, | |
| so an established-but-non-hiring client is still flagged. | |
| """ | |
| if jobs is not None: | |
| return jobs < _NEW_CLIENT_JOB_LIMIT | |
| if hires is not None: | |
| return hires < _NEW_CLIENT_JOB_LIMIT | |
| return False | |
| 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" | |
| # 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 the client has explicitly made 0 hires | |
| # (a brand-new client), their effective hire rate IS 0% — record it as 0 | |
| # rather than "Not visible", so the table shows real data, not a blank. | |
| if hire is None and hires_n == 0: | |
| hire = 0.0 | |
| if _is_missing(hire_value): | |
| hire_value = "0% (no hires yet)" | |
| 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) | |
| # 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/absent hire rate is a NO GO for an ESTABLISHED client, | |
| # but for a NEW client (few jobs posted) it is expected and fine — beginners | |
| # often win work from new clients, so we don't penalise it. | |
| 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": | |
| add("hire_rate", "Hire Rate", hire_value, NO_GO, REASON_HIRE_RATE_LOW) | |
| else: | |
| add("hire_rate", "Hire Rate", NOT_VISIBLE, NO_GO, REASON_HIRE_RATE_LOW) | |
| # 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": | |
| add("client_activity", "Client Activity", data, NO_GO, REASON_ACTIVITY_DEAD) | |
| 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", | |
| # A new client's blank/low hire rate is not held against them. | |
| "hire_rate_below_25": (hire_b == "low" or 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} | |