Files
matchmaking-v2/app/config.py
raulgupta 13faed0a06 Engine: signal-weighted India stack + cursorless pool + honest-card hardening
Boards (signal-weighted, ~150/sweep):
- Add TimesJobs (shahidirfan) — richest board: skills+salary+experience+offsite. Build/normalize +
  register; legacy city alias (New Delhi→Delhi, Bengaluru→Bangalore, Gurugram→Gurgaon) — verified live
  it returns 0 for "New Delhi", 35 for "Delhi".
- Swap LinkedIn harvestapi → curious_coder (structured industry/jobFunction/applicants); harvestapi kept
  registered as linkedin_v1 fallback.
- Drop Indeed from enabled (0% skills, redundant). Budgets: Foundit 40, TimesJobs 35, Naukri 30,
  LinkedIn 30, WorkIndia 15.

Pool — cursorless freshness + safety:
- REMOVE pagination/cursors: curious_coder only honors start=0 (start>0 → empty), proven; the rest are
  date feeds. Freshness now = boards' date-sort + the per-user seen-net + pool_save id-dedup.
- Exhaustion guard: a refill adding < POOL_MIN_NEW_PER_REFILL new jobs flags the (user,query) exhausted →
  the gate relaxes the floor instead of block-fetching dupes (makes the 100/90 floor safe on niche queries).
- Storage cap: pool_save trims beyond POOL_MAX_PER_QUERY freshest (kills DB swell). 72h TTL verified.
- Thresholds 100/90 (deep pool, pay-for-volume).

Scoring / cards:
- Evidence-based fallback prose: when Haiku's cards stage gives nothing, the card uses Opus's REAL
  dimension notes (not a generic "Strong on X") + coverage logging + generic salvage parser.
- "Skills & requirements" → "Skills fit" (consistent dimension labels).

Bug fixes:
- mark_seen dedups ids (was CardinalityViolationError on a duplicate id in one batch).

Tests: deep stack contracts + committed board-sample fixture (no cache-pollution flakiness) + offline
e2e + opt-in live e2e. 64 pass.
2026-06-26 15:56:27 +05:30

117 lines
6.7 KiB
Python

"""matchmaking-v2 settings — lean, on-demand. No corpus DB, no nightly scrape."""
from __future__ import annotations
from functools import lru_cache
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
ENV: str = "development"
LOG_LEVEL: str = "INFO"
SERVICE_NAME: str = "matchmaking-service" # MUST stay "matchmaking-service" — orchestrator routes by card name
API_PREFIX: str = "/api/v1"
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
CORS_ORIGINS: str = "*"
# Public URL the orchestrator discovers this agent at (one of AGENT_URLS).
AGENT_URL: str = "http://localhost:8006"
# A2A (agent-to-agent) bearer auth — the orchestrator presents one of these.
A2A_ALLOWED_KEYS: str = "dev-a2a-key"
# Orchestrator comms (Redis Streams). Optional in dev — the worker no-ops if absent.
ORCHESTRATOR_REDIS_URL: str | None = None
REDIS_URL: str = "redis://localhost:6379/0"
# Inputs / engine
APIFY_TOKEN: str | None = None
OPENAI_API_KEY: str | None = None
# LLM gateway (opencode.ai/zen — OpenAI-compatible; reuse interview-service's DSPY key).
# One `openai` client (base_url override) serves the Opus curator + embeddings.
DSPY_API_BASE: str | None = None
DSPY_API_KEY: str | None = None
CURATE_MODEL: str = "claude-opus-4-8" # the senior recruiter (Opus, latest) — Opus's call is final
SUGGEST_MODEL: str = "claude-haiku-4-5" # the bubbling engine — fast + cheap (latency-sensitive UI)
EMBED_MODEL: str = "text-embedding-3-small" # the vibe-engineer (direct OpenAI — gateway has no embeddings route)
ENGINE_LLM_ENABLED: bool = True # off / key absent → white-box sift + templated cards (safety net)
SIFT_TOP_K: int = 22 # candidate pool the curator reads — widened from 18 for
# recall, trimmed from 28 to keep the Opus payload lean
MATCH_FLOOR: int = 50 # HARD filter — never surface a match scored below this
# Presentation calibration of the HEADLINE score (breakdown stays raw evidence). Transparent +
# monotonic + floor-anchored: a genuinely-strong match presents in the 90s; weak NEVER becomes strong.
CALIBRATION_ENABLED: bool = True
CALIBRATION_GAMMA: float = 1.3 # 50→50, 70→74, 90→94, 95→97 (see rubric.calibrate)
# Result cache (dev cost-saver). Keyed by (actor, exact input) → saved job set on disk.
# off = always live, never save (PRODUCTION default)
# readwrite = replay if saved else live + save (DEV: one live sweep, then $0 replays)
# read = replay only, error if missing (offline / deterministic)
# refresh = always live + overwrite the saved set
APIFY_CACHE_MODE: str = "off"
APIFY_CACHE_DIR: str = ".apify_cache"
# Active boards — "Balanced" India stack: all three filter to the exact city at the board
# (hyper-relevant) and surface offsite apply links. Naukri=blackfalcondata feed,
# Foundit=Monster India, LinkedIn=geoId/city. Others (ats/indeed/naukri_v1) stay registered, off.
BOARDS_ENABLED: str = "naukri,foundit,linkedin,timesjobs,workindia" # signal-weighted; indeed dropped (0% skills, redundant)
BOARD_TIMEOUT_S: float = 55.0 # per-board cap — a slow/hung board is skipped, the search stays fast
# Warm pool ("the shelf") — bank surplus fetched jobs per (user, query) so we don't re-pay Apify for
# a full sweep on every search. Serve from the pool; refill only when it dips. Reorder EARLY (top up
# in the background) and hard-floor a blocking fetch so the pool never runs thin.
POOL_ENABLED: bool = True
POOL_REORDER_AT: int = 100 # fresh-unseen < this → background top-up (while still serving)
POOL_SAFETY_FLOOR: int = 90 # fresh-unseen < this → BLOCK + fetch before serving (hard floor)
# 100/90 keeps a DEEP pool (richer decks) at the cost of more
# frequent refills — we pay for volume, deliberately.
POOL_TTL_HOURS: int = 72 # banked jobs older than this are stale (likely filled) → dropped
POOL_MAX_PER_QUERY: int = 400 # cap the bank per (user, query) — STORAGE cap (kills DB swell) + load cap
# Exhaustion guard — a refill that adds fewer than this many NEW jobs means the query is tapped out;
# we then RELAX the floor (serve what's pooled, stop block-fetching duplicates) until it expires.
POOL_MIN_NEW_PER_REFILL: int = 8
POOL_EXHAUSTION_TTL_HOURS: int = 12 # re-try a tapped query after this (boards post new jobs ~daily)
# Per-board fetch budget (the #1 Apify cost lever). Demo-sized to conserve credits.
# Signal-weighted budgets → ~150 jobs/sweep, tilted to the high-signal boards.
NAUKRI_MAX_JOBS: int = 30 # blackfalcondata maxResults; fetchDetails=True for offsite apply links
FOUNDIT_MAX_JOBS: int = 40
LINKEDIN_MAX_JOBS: int = 30
TIMESJOBS_MAX_JOBS: int = 35 # shahidirfan — the richest board (skills+salary+experience)
# ATS lane (off by default) — company-seeded global/remote "dream companies".
ATS_MAX_PER_COMPANY: int = 4
ATS_COMPANIES: str = "stripe,databricks,gitlab,figma,ramp,notion,razorpay,zerodha"
INDEED_MAX_JOBS: int = 30
WELLFOUND_MAX_JOBS: int = 15 # blackfalcondata/wellfound-scraper — registered, OFF (US-heavy)
WORKINDIA_MAX_JOBS: int = 15 # shahidirfan/workindia-jobs-scraper — India blue/grey-collar (niche, low yield)
# QScore (competence proxy) — consumed, not computed. Dashboard Momentum/QX reads it via REST.
QSCORE_BASE_URL: str = "http://localhost:8004"
QSCORE_AUTH_TOKEN: str | None = None
QSCORE_ORG_ID: str = "growqr"
# user-service — dashboard Day-streak reads metadata.current_streak via GET /api/state/{clerk_id}
USER_SERVICE_BASE_URL: str = "http://localhost:8003"
# dashboard-service — Q-Score trend series via GET /api/qscore-history/{clerk_id}
DASHBOARD_SERVICE_BASE_URL: str = "http://localhost:8005"
A2A_OUTBOUND_KEY: str = "dev-a2a-key" # Bearer token presented to sibling services
# Storage for feedback/labels (NOT the old corpus). Optional until the learning loop lands.
DATABASE_URL: str | None = None
@property
def cors_origins_list(self) -> list[str]:
if self.CORS_ORIGINS.strip() == "*":
return ["*"]
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
if os.environ.get("PYTEST_CURRENT_TEST"):
return Settings(_env_file=None)
return Settings()