Initial commit: matchmaking-v2 on-demand multi-board job-search agent

On-demand Scout service (replaces the nightly aggregator):
- FastAPI agent-mesh service; card name "matchmaking-service" (HTTP /a2a/tasks
  + Redis-stream worker matching the sibling-service pattern)
- run_search: parallel multi-board sweep (Naukri/blackfalcondata + Foundit +
  LinkedIn), city-filtered at the board, cheapest-per-result first
- per-board adapters + normalizers -> ScoutJob with rich read-only details and
  offsite apply links; recall mode + MVQ guard
- result cache for dev replay ($0); per-board cost knobs; retry-on-5xx
- research/: engine design, board cost economics, India-actor shortlist, POC
This commit is contained in:
raulgupta
2026-06-18 19:22:03 +05:30
commit 89ca9ad647
78 changed files with 4424 additions and 0 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# matchmaking-v2 — copy to .env (git-ignored). On-demand: no corpus DB required.
ENV=development
LOG_LEVEL=INFO
API_PORT=8000
AGENT_URL=http://localhost:8006
# A2A bearer the orchestrator presents (match the mesh's shared key in prod)
A2A_ALLOWED_KEYS=dev-a2a-key
# Orchestrator comms (optional in dev — worker no-ops if unset/unreachable)
ORCHESTRATOR_REDIS_URL=
REDIS_URL=redis://localhost:6379/0
# Inputs / engine (wired in later slices)
APIFY_TOKEN=
OPENAI_API_KEY=
# QScore (consumed)
QSCORE_BASE_URL=http://localhost:8004
QSCORE_AUTH_TOKEN=
# Feedback/label store (NOT the old corpus) — optional until the learning loop
DATABASE_URL=

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# ── Secrets / environment (NEVER commit) ──
.env
.env.*
*.env
!.env.example
# ── Python ──
.venv/
venv/
env/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# ── Local dev result cache (saved Apify job sets) ──
.apify_cache/
# ── Tooling / local state ──
.claude/
research/poc/**/.env
research/poc/**/node_modules/
research/poc/**/__pycache__/
# ── OS / editor ──
.DS_Store
*.swp
.idea/
.vscode/

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# matchmaking-v2 (Scout)
Fresh, **on-demand** matchmaking service replacing the dead nightly aggregator. Same agent-mesh
mold as the other GrowQR services (FastAPI · a2a card · /a2a/tasks · orchestrator-routed). Not a
drop-in — it keeps the existing 4 skills working and **adds new actions from scratch**.
## Layout
```
app/
main.py FastAPI app (card + /a2a/tasks + /api/v1/health), lifespan worker
config.py lean settings (no corpus DB, no scrape schedule)
a2a/ card.py (discovery), auth.py (bearer), tasks.py (orchestrator entry)
agent/session.py Session: on_session_start / on_user_action dispatch ← the brain
adk/worker.py Redis-Streams worker (graceful no-op without Redis)
api/v1/health.py /api/v1/health
engine/
board_adapters/ ScoutPrefs → Apify actor inputs (Naukri-first, verified maps)
... the §3 cascade (normalize→filter→utility→fusion→rerank) lands here
contracts/ Pydantic contracts (user_context, transport, …) — added per slice
research/ docs/ (ENGINE_DESIGN, SIGNAL_AUDIT_V2, ENGINE_INPUTS, …) + poc/ (auto-apply)
```
## Contract (how it connects)
Frontend `useAgentSession` (page `"job-matching"`) → orchestrator (routes by card `name`
= `matchmaking-service`) → `POST /a2a/tasks {action, params, user_context}``Session` pushes
`agent_data{action,data}` → orchestrator → frontend `latestData[action]`.
**Skills:** existing `get_feed · sync_preferences · record_feedback · get_opportunity_detail`;
new `run_search · tailor_resume · submit_application · get_apply_proof` (stubbed). Each new action
needs: card skill (here) + orchestrator action-map entry + frontend `sendAction` wiring.
## Run (local)
```bash
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8006
# card: GET http://localhost:8006/.well-known/agent-card.json
# health: GET http://localhost:8006/api/v1/health
# action: POST http://localhost:8006/a2a/tasks (Bearer dev-a2a-key)
```
## Build order (full-stack slices)
0. ✅ scaffold (this) — bootable skeleton, contract wired, handlers stubbed.
1. on-demand `run_search` — board_adapters → Apify → engine cascade → ranked feed (+ frontend wire).
2. feedback labels + `record_feedback`. 3. `tailor_resume`. 4. `submit_application` + `get_apply_proof`.
5. cut over from old `:8006`, decommission corpus.
```

0
app/__init__.py Normal file
View File

0
app/a2a/__init__.py Normal file
View File

15
app/a2a/auth.py Normal file
View File

@@ -0,0 +1,15 @@
"""A2A (agent-to-agent) bearer auth — same scheme as the rest of the mesh."""
from fastapi import Request, HTTPException
from app.config import get_settings
async def verify_a2a_key(request: Request) -> str:
auth = request.headers.get("authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing A2A auth token")
token = auth[7:].strip()
allowed = [k.strip() for k in get_settings().A2A_ALLOWED_KEYS.split(",")]
if token not in allowed:
raise HTTPException(status_code=403, detail="Invalid A2A auth token")
return token

35
app/a2a/card.py Normal file
View File

@@ -0,0 +1,35 @@
"""Agent card — how the orchestrator discovers this service (it routes by `name`)."""
from fastapi import APIRouter
from app.config import get_settings
router = APIRouter()
def agent_card() -> dict:
return {
"name": "matchmaking-service", # keep — orchestrator page/action maps target this name
"description": "On-demand opportunity matching: live board search → white-box ranking → auto-apply with proof.",
"url": get_settings().AGENT_URL,
"version": "2.0.0",
"capabilities": {"streaming": False, "pushNotifications": False},
"skills": [
# ── existing contract (keeps the current orchestrator routing working) ──
{"id": "get_feed", "name": "Personalized Feed", "description": "Return the ranked opportunity feed for a seeker."},
{"id": "sync_preferences", "name": "Sync Preferences", "description": "Persist the seeker's search preferences."},
{"id": "record_feedback", "name": "Record Feedback", "description": "Record SAVE / DISMISS / APPLY feedback (training labels)."},
{"id": "get_opportunity_detail", "name": "Opportunity Detail", "description": "Opportunity detail with the match-score breakdown."},
# ── NEW in v2 (stubbed; each needs an orchestrator action-map entry + frontend wiring) ──
{"id": "run_search", "name": "On-demand Search", "description": "Fetch live listings (Apify) + rank against the seeker now."},
{"id": "tailor_resume", "name": "Tailor Resume", "description": "Tailor the resume to a chosen role (resume-builder)."},
{"id": "submit_application", "name": "Submit Application", "description": "Auto-apply via the master-key engine (tier-aware)."},
{"id": "get_apply_proof", "name": "Apply Proof", "description": "Return captured confirmation screenshots for submitted applications."},
],
"authentication": {"schemes": ["bearer"]},
}
@router.get("/.well-known/agent.json")
@router.get("/.well-known/agent-card.json")
async def get_agent_card():
return agent_card()

60
app/a2a/tasks.py Normal file
View File

@@ -0,0 +1,60 @@
"""A2A task endpoint — the single HTTP entry the orchestrator calls per action.
Collects the messages a Session pushes and returns them; the orchestrator forwards
each to the frontend WebSocket. (Same proven adapter as the rest of the mesh.)
"""
import logging
from uuid import uuid4
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.a2a.auth import verify_a2a_key
from app.agent.session import MatchmakingAgentSession
logger = logging.getLogger(__name__)
router = APIRouter()
class TaskRequest(BaseModel):
task_id: str = Field(default_factory=lambda: str(uuid4()))
action: str = ""
params: dict = Field(default_factory=dict)
user_id: str = ""
user_context: dict | None = None
session_start: bool = False
class TaskCollector:
"""WebSocket-shaped sink that captures pushed messages into a list."""
def __init__(self):
self.messages: list[dict] = []
async def accept(self):
pass
async def send_json(self, data: dict):
self.messages.append(data)
@router.post("/a2a/tasks")
async def handle_a2a_task(req: TaskRequest, _token: str = Depends(verify_a2a_key)):
collector = TaskCollector()
session = MatchmakingAgentSession(websocket=collector, user_id=req.user_id)
params = {**req.params}
if req.user_context:
params["user_context"] = req.user_context
try:
if req.session_start:
await session.on_session_start(params)
elif req.action:
await session.on_user_action(req.action, params)
else:
return {"task_id": req.task_id, "status": "error", "messages": [], "error": "No action or session_start"}
return {"task_id": req.task_id, "status": "completed", "messages": collector.messages}
except Exception as e: # noqa: BLE001
logger.error("A2A task %s failed: %s", req.task_id, e, exc_info=True)
return {"task_id": req.task_id, "status": "error", "messages": collector.messages, "error": str(e)}

0
app/adk/__init__.py Normal file
View File

34
app/adk/emitter.py Normal file
View File

@@ -0,0 +1,34 @@
"""EventEmitter — async queue adapter (mirrors the established agent-mesh pattern).
Agent session handlers call ``self.ws.send_json(data)`` expecting a WebSocket-like
interface. This adapter puts each message into an ``asyncio.Queue`` so the Redis Stream
worker can consume them one by one and publish to the per-task response channel.
"""
import asyncio
class EventEmitter:
"""WebSocket-compatible adapter backed by an asyncio.Queue."""
def __init__(self):
self.queue: asyncio.Queue = asyncio.Queue()
self._done = False
async def accept(self):
pass
async def send_json(self, data: dict):
"""Called by session handlers via ``self.push()``."""
await self.queue.put(data)
async def receive_json(self):
raise StopIteration("EventEmitter does not support receive")
def query_params(self):
return {}
def mark_done(self):
"""Signal that the session handler has finished."""
self._done = True
self.queue.put_nowait(None)

133
app/adk/worker.py Normal file
View File

@@ -0,0 +1,133 @@
"""Redis Stream worker for matchmaking-service — the established agent-mesh pattern.
Consumes tasks from the ``tasks:matchmaking-service`` Redis Stream (consumer group),
executes each via MatchmakingAgentSession, and streams response messages to the
per-task pub/sub channel ``responses:{task_id}``, ending with a ``__task_complete__``
sentinel. Mirrors the sibling services (../matchmaking, interview-service, pathways …).
"""
from __future__ import annotations
import asyncio
import json
import logging
from app.adk.emitter import EventEmitter
from app.agent.session import MatchmakingAgentSession
logger = logging.getLogger(__name__)
AGENT_NAME = "matchmaking-service"
class RedisStreamWorker:
"""Consumes tasks from a Redis Stream, executes via the session handler."""
def __init__(self, redis_url: str | None):
self.redis_url = redis_url
self.stream = f"tasks:{AGENT_NAME}"
self.group = f"{AGENT_NAME}_workers"
self.consumer = f"{AGENT_NAME}-1"
self._redis = None
self._task: asyncio.Task | None = None
async def start(self):
if not self.redis_url:
logger.info("RedisStreamWorker: no REDIS_URL — idle (HTTP /a2a/tasks still serves).")
return
try:
import redis.asyncio as aioredis # lazy import
self._redis = aioredis.from_url(self.redis_url, decode_responses=True)
await self._redis.ping()
except Exception as e: # noqa: BLE001 — boot standalone if Redis is down
logger.warning("RedisStreamWorker: Redis unavailable (%s) — idle, HTTP /a2a/tasks still serves.", e)
self._redis = None
return
try:
await self._redis.xgroup_create(self.stream, self.group, id="$", mkstream=True)
logger.info("Created consumer group %s on %s", self.group, self.stream)
except Exception: # noqa: BLE001 — group already exists
pass
self._task = asyncio.create_task(self._consume_loop())
logger.info("RedisStreamWorker started: stream=%s", self.stream)
async def _consume_loop(self):
while True:
try:
messages = await self._redis.xreadgroup(
self.group, self.consumer, {self.stream: ">"}, count=10, block=2000,
)
if not messages:
continue
for _stream_name, entries in messages:
for entry_id, data in entries:
asyncio.create_task(self._process_task(entry_id, data))
except asyncio.CancelledError:
logger.info("%s worker shutting down", AGENT_NAME)
break
except Exception as e: # noqa: BLE001
if "NOGROUP" in str(e):
logger.warning("Consumer group lost, recreating: %s", self.group)
try:
await self._redis.xgroup_create(self.stream, self.group, id="$", mkstream=True)
except Exception: # noqa: BLE001
pass
else:
logger.error("Worker consume error: %s", e, exc_info=True)
await asyncio.sleep(1)
async def _process_task(self, entry_id: str, data: dict):
task_id = data.get("task_id", "")
response_channel = f"responses:{task_id}"
try:
task_params = json.loads(data.get("payload", "{}"))
action = task_params.get("action", "")
params = {**task_params.get("params", {})}
user_id = task_params.get("user_id", "")
user_context = task_params.get("user_context")
session_start = task_params.get("session_start", False)
if user_context:
params["user_context"] = user_context
emitter = EventEmitter()
session = MatchmakingAgentSession(websocket=emitter, user_id=user_id)
async def run_session():
try:
if session_start:
await session.on_session_start(params)
elif action:
await session.on_user_action(action, params)
finally:
emitter.mark_done()
session_task = asyncio.create_task(run_session())
while True:
msg = await emitter.queue.get()
if msg is None:
break
await self._redis.publish(response_channel, json.dumps(msg))
await session_task
except Exception as e: # noqa: BLE001
logger.error("Task %s failed: %s", task_id, e, exc_info=True)
await self._redis.publish(
response_channel, json.dumps({"type": "agent_error", "message": str(e)}))
finally:
await self._redis.publish(
response_channel, json.dumps({"type": "__task_complete__", "task_id": task_id}))
await self._redis.xack(self.stream, self.group, entry_id)
async def stop(self):
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
if self._redis:
await self._redis.aclose()
self._redis = None
logger.info("%s worker stopped", AGENT_NAME)

0
app/agent/__init__.py Normal file
View File

91
app/agent/session.py Normal file
View File

@@ -0,0 +1,91 @@
"""Matchmaking-v2 agent session.
Mirrors the mesh contract: the orchestrator calls /a2a/tasks, which drives
on_session_start / on_user_action; handlers `push` messages back (agent_thinking,
agent_data{action,data}, agent_error) that the orchestrator forwards to the frontend.
Step-0 scaffold: the dispatch + contract are wired; handlers are honest stubs that
say "not implemented yet" so we can fill them in one full-stack slice at a time.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class MatchmakingAgentSession:
def __init__(self, websocket, user_id: str):
self.ws = websocket
self.user_id = user_id
self.context: dict = {}
async def push(self, msg_type: str, **kwargs):
await self.ws.send_json({"type": msg_type, **kwargs})
async def _stub(self, action: str, note: str):
await self.push("agent_data", action=action, data={"status": "not_implemented", "note": note})
async def on_session_start(self, params: dict):
self.context["user_context"] = params.get("user_context")
await self.push("agent_thinking", message="Scout is warming up…")
# Real flow lands in a later slice: resolve prefs → run_search → emit feed.
await self.push("agent_data", action="session_ready", data={"service": "matchmaking-v2", "version": "2.0.0"})
async def on_user_action(self, action: str, params: dict):
handlers = {
# existing contract
"sync_preferences": self.handle_sync_preferences,
"get_feed": self.handle_get_feed,
"record_feedback": self.handle_record_feedback,
"get_opportunity_detail": self.handle_get_opportunity_detail,
# new in v2
"run_search": self.handle_run_search,
"tailor_resume": self.handle_tailor_resume,
"submit_application": self.handle_submit_application,
"get_apply_proof": self.handle_get_apply_proof,
}
handler = handlers.get(action)
if not handler:
await self.push("agent_error", message=f"Unknown action: {action}")
return
try:
await handler(params)
except Exception as e: # noqa: BLE001 — surface to the client, log the trace
logger.error("action '%s' failed: %s", action, e, exc_info=True)
await self.push("agent_error", message=f"Failed to {action}: {e}")
# ── handlers (stubs — replaced one full-stack slice at a time) ──
async def handle_sync_preferences(self, params: dict):
await self._stub("preferences_synced", "persist ScoutPrefs → label store")
async def handle_get_feed(self, params: dict):
await self._stub("feed_loaded", "return cached ranked feed")
async def handle_record_feedback(self, params: dict):
await self._stub("feedback_recorded", "store SAVE/DISMISS/APPLY label")
async def handle_get_opportunity_detail(self, params: dict):
await self._stub("opportunity_detail", "return match-score breakdown")
async def handle_run_search(self, params: dict):
"""Slice-1a: ScoutPrefs → live multi-board fetch → cards. No engine, no user-arm."""
from app.engine import search
prefs = params # the frontend's ScoutPrefs arrive as params (user_context, if present, is ignored)
if not search.has_mvq(prefs):
await self.push("agent_data", action="search_complete",
data={"opportunities": [], "needs": ["title", "location"]})
return
await self.push("agent_thinking", message="Scanning live roles across job boards…")
result = await search.run_sweep(prefs)
await self.push("agent_data", action="search_complete", data=result)
async def handle_tailor_resume(self, params: dict):
await self._stub("resume_tailored", "resume-builder: tailor resume to the chosen role")
async def handle_submit_application(self, params: dict):
await self._stub("application_submitted", "master-key auto-apply (tier-aware)")
async def handle_get_apply_proof(self, params: dict):
await self._stub("apply_proof", "return captured confirmation screenshots")

0
app/api/__init__.py Normal file
View File

7
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""v1 REST router aggregator."""
from fastapi import APIRouter
from app.api.v1.health import router as health_router
router = APIRouter()
router.include_router(health_router)

12
app/api/v1/health.py Normal file
View File

@@ -0,0 +1,12 @@
"""Health endpoint — used by deploy.sh's health checks."""
from fastapi import APIRouter
from app.config import get_settings
router = APIRouter()
@router.get("/health")
async def health():
s = get_settings()
return {"status": "ok", "service": s.SERVICE_NAME, "version": "2.0.0", "env": s.ENV}

74
app/config.py Normal file
View File

@@ -0,0 +1,74 @@
"""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
# 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"
# Per-board fetch budget (the #1 Apify cost lever). Demo-sized to conserve credits.
NAUKRI_MAX_JOBS: int = 15 # blackfalcondata maxResults (no floor); fetchDetails=True for offsite apply links
FOUNDIT_MAX_JOBS: int = 50
LINKEDIN_MAX_JOBS: int = 25
# 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
# QScore (competence proxy) — consumed, not computed
QSCORE_BASE_URL: str = "http://localhost:8004"
QSCORE_AUTH_TOKEN: str | None = None
# 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()

View File

0
app/engine/__init__.py Normal file
View File

View File

@@ -0,0 +1,93 @@
"""Thin Apify client — run an actor synchronously and get the dataset items.
One call reused by every board adapter. On-demand: we fetch live and score only what
we fetched (no corpus). `run-sync-get-dataset-items` blocks up to ~300s.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from pathlib import Path
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
_BASE = "https://api.apify.com/v2/acts"
def _cache_path(actor_id: str, run_input: dict) -> Path:
"""Stable path per (actor, exact input) — same query replays the same saved set."""
canon = json.dumps(run_input, sort_keys=True, ensure_ascii=False)
digest = hashlib.sha1(f"{actor_id}|{canon}".encode()).hexdigest()[:16]
safe = actor_id.replace("/", "~").replace("~", "_")
return Path(get_settings().APIFY_CACHE_DIR) / f"{safe}__{digest}.json"
def _cache_read(path: Path) -> list[dict] | None:
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return None
def _cache_write(path: Path, items: list[dict]) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(items, ensure_ascii=False))
except OSError as e: # noqa: BLE001 — caching is best-effort
logger.warning("apify cache write failed: %s", e)
# Apify's run-sync endpoint occasionally throws a transient 5xx (esp. 502). Retry ONCE
# on that. We do NOT retry on empty results: a completed run is billed, so re-running it
# would double the cost for no gain (an empty result is usually a real "0 matches").
_MAX_ATTEMPTS = 2
_BACKOFF = 2.0
async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0) -> list[dict]:
"""POST input → return the dataset items (list). One retry on transient 5xx; raises otherwise.
Result cache (APIFY_CACHE_MODE): dev replays a saved job set for $0; production stays live.
"""
mode = get_settings().APIFY_CACHE_MODE
path = _cache_path(actor_id, run_input) if mode != "off" else None
if path and mode in ("readwrite", "read"):
cached = _cache_read(path)
if cached is not None:
logger.info("apify cache HIT %s (%d items) — $0", actor_id, len(cached))
return cached
if mode == "read":
raise RuntimeError(f"APIFY_CACHE_MODE=read but no cached set for {actor_id} ({path.name})")
token = get_settings().APIFY_TOKEN
if not token:
raise RuntimeError("APIFY_TOKEN not configured")
url = f"{_BASE}/{actor_id}/run-sync-get-dataset-items"
async with httpx.AsyncClient(timeout=timeout) as client:
for attempt in range(1, _MAX_ATTEMPTS + 1):
try:
resp = await client.post(url, params={"token": token}, json=run_input)
if resp.status_code >= 500:
raise httpx.HTTPStatusError(
f"{resp.status_code} from Apify", request=resp.request, response=resp)
resp.raise_for_status()
items = resp.json()
items = items if isinstance(items, list) else []
if path and mode in ("readwrite", "refresh"):
_cache_write(path, items)
logger.info("apify cache SAVE %s (%d items)", actor_id, len(items))
return items
except (httpx.HTTPStatusError, httpx.TransportError) as e:
if attempt < _MAX_ATTEMPTS:
logger.warning("apify %s: %s — retrying once", actor_id, e)
await asyncio.sleep(_BACKOFF)
continue
raise
return [] # pragma: no cover

View File

@@ -0,0 +1,15 @@
"""Board adapters — ScoutPrefs → Apify actor inputs (see docs/ENGINE_DESIGN.md §1.2)."""
from .coerce import has_mvq, title_of, parse_location, parsed_locations
from .adapters import (
build_naukri_input,
build_linkedin_input,
build_indeed_input,
build_indeed_searches,
build_ats_input,
)
__all__ = [
"has_mvq", "title_of", "parse_location", "parsed_locations",
"build_naukri_input", "build_linkedin_input", "build_indeed_input",
"build_indeed_searches", "build_ats_input",
]

View File

@@ -0,0 +1,223 @@
"""
Per-board query builders — `ScoutPrefs` (dict) → each Apify actor's input.
See `docs/ENGINE_DESIGN.md` §1.2. Returns the `input` dict you POST to the actor's
`run-sync-get-dataset-items` endpoint.
System knobs (budget / freshness) are arguments with sane defaults, not user inputs.
"""
from __future__ import annotations
from typing import Any
from . import coerce as C
Prefs = dict[str, Any]
# ── Naukri (India workhorse — richest structured filters) ────────────────────
def build_naukri_input(prefs: Prefs, *, max_jobs: int = 80, freshness: str = "7", recall: bool = False) -> dict:
# recall=True (retrieve stage): keep keyword + city + work-mode only; drop the
# narrowing filters (experience/department/industry/salary/stage) — those become
# ENGINE ranking later, not hard board cuts. Wider freshness for recall.
if recall:
freshness = "30"
locs = C.parsed_locations(prefs)
cities: list[int] = []
for lp in locs:
if lp["city"]:
cities += C.CITY_NAUKRI.get(lp["city"], []) # unmapped city → dropped (kept in keyword)
out: dict[str, Any] = {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"maxJobs": max_jobs, # REQUIRED — the budget
"freshness": freshness, # liveness gate
"sortBy": "date",
"fetchDetails": True, # → apply redirect / full details
}
if cities:
out["cities"] = [str(c) for c in sorted(set(cities))] # Naukri select codes are STRINGS
wm = [C.WORKMODE_NAUKRI[w] for w in (prefs.get("workMode") or []) if w in C.WORKMODE_NAUKRI]
if C.is_remote(prefs) and "remote" not in wm:
wm.append("remote")
if wm:
out["workMode"] = wm
if recall:
return out # retrieve stage stops here — keyword + cities + work-mode only
yrs = C.years_of(prefs)
if yrs is not None:
out["experience"] = str(yrs) # numeric-string enum "0".."30"
depts = [C.ROLE_NAUKRI_DEPT[r] for r in (prefs.get("role") or []) if r in C.ROLE_NAUKRI_DEPT]
if depts:
out["department"] = [str(d) for d in sorted(set(depts))] # codes → strings
inds = [C.INDUSTRY_NAUKRI[i] for i in (prefs.get("industry") or []) if i in C.INDUSTRY_NAUKRI]
if inds:
out["industry"] = [str(i) for i in sorted(set(inds))] # codes → strings
if prefs.get("targetComp") in C.COMP_NAUKRI_SALARY:
out["salaryRange"] = [C.COMP_NAUKRI_SALARY[prefs["targetComp"]]]
if prefs.get("companyStage") in C.COMPANY_TYPE_NAUKRI:
out["companyType"] = [C.COMPANY_TYPE_NAUKRI[prefs["companyStage"]]]
return out
# ── Naukri (blackfalcondata feed — city-precise, real offsite applyRedirectUrl) ──
def build_naukri_feed_input(prefs: Prefs, *, max_jobs: int = 50, fetch_details: bool = True) -> dict:
# City-filtered at the board (hyper-relevant + cheap). fetch_details=True surfaces the
# offsite `applyRedirectUrl`; skipReposts dedups for cost. Narrowing (dept/industry/exp)
# stays off here — that's engine ranking later.
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
return {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city, # precise city, e.g. "New Delhi"
"maxResults": max_jobs,
"fetchDetails": fetch_details,
"skipReposts": True,
"sortBy": "date",
}
# ── Foundit / Monster India (city-precise; apply_url is the offsite redirect) ──
# Foundit uses legacy city names — it returns 0 for "New Delhi"/"Bengaluru".
_FOUNDIT_CITY = {
"new delhi": "Delhi", "delhi ncr": "Delhi", "bengaluru": "Bangalore",
"gurugram": "Gurgaon", "mumbai": "Mumbai", "pune": "Pune", "hyderabad": "Hyderabad",
"chennai": "Chennai", "kolkata": "Kolkata", "noida": "Noida",
}
def build_foundit_input(prefs: Prefs, *, results_wanted: int = 50) -> dict:
city = next((lp["city"] for lp in C.parsed_locations(prefs) if lp["city"]), "")
city = _FOUNDIT_CITY.get(city.lower(), city) # alias to Foundit's canonical name
return {
"keyword": C.title_of(prefs) or C.build_keyword(prefs),
"location": city,
"results_wanted": results_wanted,
}
# ── LinkedIn (takes the band, not years; ₹ comp incompatible) ────────────────
def build_linkedin_input(prefs: Prefs, *, max_items: int = 80, posted: str = "week",
recall: bool = False) -> dict:
# recall=True (retrieve stage): title + COUNTRY-level location only — drop the narrowing
# filters (easyApply / workplaceType / experienceLevel / industryIds / freshness), which
# become ENGINE ranking later. City-level location + a wrong industryId zero the actor out.
out: dict[str, Any] = {
"jobTitles": [C.title_of(prefs)],
"maxItems": max_items,
"sortBy": "date",
}
cities: list[str] = [] # bare city names ("New Delhi") — harvestapi's location format
cities_q: list[str] = [] # city, country ("New Delhi, India") — used outside recall
countries: list[str] = []
for lp in C.parsed_locations(prefs):
if lp["country"] and lp["country"] not in countries:
countries.append(lp["country"])
if lp["city"]:
cities.append(lp["city"])
cities_q.append(f"{lp['city']}, {lp['country']}" if lp["country"] else lp["city"])
if recall:
# City-level for precision (engine ranks finer later). Fall back to country if no city.
out["locations"] = cities or countries
return out
out["postedLimit"] = posted # freshness
out["easyApply"] = False # favour offsite (auto-applyable) over login-gated easy-apply
if cities_q or countries:
out["locations"] = cities_q or countries
# TODO: geoIds = [lookup(loc)] is more reliable than free-text locations.
wm = [C.WORKMODE_LINKEDIN[w] for w in (prefs.get("workMode") or []) if w in C.WORKMODE_LINKEDIN]
if C.is_remote(prefs) and "remote" not in wm:
wm.append("remote")
if wm:
out["workplaceType"] = wm
band = C.seniority_band(prefs)
if band in C.SENIORITY_LINKEDIN:
out["experienceLevel"] = [C.SENIORITY_LINKEDIN[band]]
ind_ids = [C.INDUSTRY_LINKEDIN_ID[i] for i in (prefs.get("industry") or []) if i in C.INDUSTRY_LINKEDIN_ID]
if ind_ids:
out["industryIds"] = ind_ids # TODO verify IDs; unmapped industries already fall to keyword
return out
# ── Indeed (blunt: what + where + country only; everything else folds in) ────
def build_indeed_input(prefs: Prefs, *, max_items: int = 80) -> dict:
loc = next((l for l in C.parsed_locations(prefs) if l["city"] or l["country"]), {})
country_name = loc.get("country") or "India"
return {
# Indeed has no work-mode/seniority/industry params → fold them into the query.
"position": C.build_keyword(prefs, seniority=True, industry=True),
"location": loc.get("city") or "",
"country": C.COUNTRY_ISO2.get(country_name, "IN"),
"maxItemsPerSearch": max_items,
"followApplyRedirects": True, # → real offsite apply URL
"saveOnlyUniqueItems": True,
}
def build_indeed_searches(prefs: Prefs, *, max_items: int = 80) -> list[dict]:
"""Fan-out: Indeed `position`/`location` are single strings, so multi title×city
becomes N runs to merge+dedup downstream."""
titles = (prefs.get("targetTitles") or [])[:3] or [C.title_of(prefs)]
cities = [l["city"] for l in C.parsed_locations(prefs) if l["city"]] or [None]
base = build_indeed_input(prefs, max_items=max_items)
runs = []
for t in titles:
for city in cities:
r = dict(base)
r["position"] = " ".join(x for x in [t, *(["remote"] if C.is_remote(prefs) else [])] if x)
if city:
r["location"] = city
runs.append(r)
return runs
# ── Direct-ATS (curated-company lane — `companies` is passed in, NOT from prefs) ──
def build_ats_input(prefs: Prefs, *, companies: list[dict], recent_days: int = 30,
max_per_company: int = 50, output_profile: str = "compact",
recall: bool = False) -> dict:
loc = next((l for l in C.parsed_locations(prefs) if l["city"]), {})
inds = [i for i in (prefs.get("industry") or []) if i and i != "Any"]
out: dict[str, Any] = {
"companies": companies, # curated [{company}] — the lane's identity
"titleKeyword": C.title_of(prefs), # filters within those companies (role relevance)
"remoteOnly": C.is_remote(prefs),
"keywords": inds, # loose industry fold
"recentWindowDays": recent_days,
"maxJobsPerCompany": max_per_company,
"outputProfile": output_profile, # "compact" = cheaper (enough for display); "full" adds descriptions
}
# recall=True: skip the city filter — these curated companies are global/remote, so a
# city lock ("Bengaluru") empties the deck. Location becomes engine ranking later.
if not recall:
out["locationKeyword"] = loc.get("city", "")
return out
if __name__ == "__main__":
import json
demo: Prefs = {
"targetTitles": ["Payments PM"], "title": "Associate Product Manager",
"role": ["Product"], "industry": ["Fintech"],
"location": ["Bengaluru · India", "Delhi NCR · India"],
"workMode": ["Hybrid"], "experience": ["Junior"], "years": 2,
"targetComp": "₹1622L", "companyStage": "Growth-stage",
# engine-side only (ignored by adapters): priorities/stretch/intent/availability/dealBreakers/sort
}
print("MVQ ok:", C.has_mvq(demo), "\n")
for name, fn in [("NAUKRI", build_naukri_input), ("LINKEDIN", build_linkedin_input),
("INDEED", build_indeed_input)]:
print(f"── {name} ──"); print(json.dumps(fn(demo), indent=2, ensure_ascii=False), "\n")
print("── DIRECT-ATS ──")
print(json.dumps(build_ats_input(demo, companies=[{"ats": "ashby", "company": "razorpay"}]), indent=2))

View File

@@ -0,0 +1,121 @@
"""
Input coercion layer — turns the frontend `ScoutPrefs` (JSON dict) into each board
actor's exact input schema. See `docs/ENGINE_DESIGN.md` §1.2.
All maps verified against the live Apify input schemas (2026-06-17).
GOLDEN RULE: a value that doesn't resolve to a board enum/code is NEVER sent as an
invalid enum — it degrades to a keyword fold (or post-filter) and is flagged.
Coverage gaps lose precision, never the call.
"""
from __future__ import annotations
import re
from typing import Any, Optional
Prefs = dict[str, Any]
# ── lookup maps (verified codes/enums) ───────────────────────────────────────
COUNTRY_ISO2 = { # Indeed `country`
"India": "IN", "United States": "US", "United Kingdom": "GB", "Canada": "CA",
"Australia": "AU", "Germany": "DE", "Singapore": "SG", "UAE": "AE",
}
WORKMODE_NAUKRI = {"Remote": "remote", "Hybrid": "hybrid", "On-site": "office"}
WORKMODE_LINKEDIN = {"Remote": "remote", "Hybrid": "hybrid", "On-site": "office"}
SENIORITY_YEARS = {"Fresher": 0, "Junior": 2, "Mid-level": 5, "Senior": 9, "Lead": 13}
SENIORITY_LINKEDIN = { # LinkedIn `experienceLevel`
"Fresher": "entry", "Junior": "associate", "Mid-level": "mid-senior",
"Senior": "director", "Lead": "executive",
}
COMP_NAUKRI_SALARY = { # ₹ band → Naukri `salaryRange` (lakhs bucket)
"₹1014L": "10to15", "₹1418L": "15to25", "₹1622L": "15to25",
"₹2230L": "25to50", "₹30L+": "50to75",
}
COMPANY_TYPE_NAUKRI = { # loose — funding stage ≠ company type
"Seed / early": "Startup", "Growth-stage": "Startup",
"Late / pre-IPO": "Corporate", "Enterprise": "MNC",
}
# Naukri numeric codes (labels are display-only — the actor wants the code)
ROLE_NAUKRI_DEPT = { # role area → `department`
"Product": 10, "Engineering": 5, "Design": 15, "Data": 3,
"Marketing": 9, "Sales": 14, "Ops": 2, # "Open to any" → omit
}
INDUSTRY_NAUKRI = { # → `industry`
"Fintech": 114, "SaaS": 110, "E-commerce": 108, "EdTech": 133,
"HealthTech": 131, "Dev tools": 110, "Consulting": 126,
}
CITY_NAUKRI = { # → `cities` (1→N for metro regions)
"Bengaluru": [97], "Bangalore": [97], "Mumbai": [134], "New Delhi": [6], "Delhi": [6],
"Delhi NCR": [6, 73, 220, 350], "Gurgaon": [73], "Gurugram": [73], "Noida": [220],
"Hyderabad": [17], "Chennai": [183], "Pune": [139], "Kolkata": [232],
}
# LinkedIn industry IDs — best-effort, TODO verify vs LinkedIn taxonomy; unmapped → keyword fold
INDUSTRY_LINKEDIN_ID = {
"Fintech": "1742", "SaaS": "4", "E-commerce": "6", "EdTech": "68",
"HealthTech": "14", "Dev tools": "4", "Consulting": "11",
}
# ── parsing + accessors ──────────────────────────────────────────────────────
def parse_location(loc: str) -> dict:
"""'Bengaluru · India'{city, country, remote, anywhere}."""
s = (loc or "").strip()
low = s.lower()
if low == "remote":
return {"city": None, "country": None, "remote": True, "anywhere": False}
if low in ("anywhere", ""):
return {"city": None, "country": None, "remote": False, "anywhere": True}
parts = [p.strip() for p in re.split(r"[·,]", s) if p.strip()]
return {
"city": parts[0] if parts else None,
"country": parts[1] if len(parts) > 1 else None,
"remote": False, "anywhere": False,
}
def parsed_locations(prefs: Prefs) -> list[dict]:
locs = [parse_location(l) for l in (prefs.get("location") or [])]
return locs or [{"city": None, "country": None, "remote": False, "anywhere": True}]
def is_remote(prefs: Prefs) -> bool:
return any(l["remote"] for l in parsed_locations(prefs)) or "Remote" in (prefs.get("workMode") or [])
def title_of(prefs: Prefs) -> str:
"""Keyword precedence: specific fine-tune pick → typed title → first concrete role."""
tt = prefs.get("targetTitles") or []
if tt:
return tt[0]
if (prefs.get("title") or "").strip():
return prefs["title"].strip()
for r in (prefs.get("role") or []):
if r and r != "Open to any":
return r
return ""
def seniority_band(prefs: Prefs) -> Optional[str]:
exp = prefs.get("experience") or []
return exp[0] if exp else None
def years_of(prefs: Prefs) -> Optional[int]:
"""Numeric years: explicit `years` → else derived from the seniority band."""
y = prefs.get("years")
if isinstance(y, (int, float)):
return max(0, min(30, int(y)))
b = seniority_band(prefs)
return SENIORITY_YEARS.get(b) if b else None
def build_keyword(prefs: Prefs, *, seniority: bool = False, industry: bool = False) -> str:
"""Composite keyword for boards lacking department/industry filters."""
parts: list[str] = []
if seniority and seniority_band(prefs) in ("Senior", "Lead"):
parts.append(seniority_band(prefs)) # prefix only the high bands
parts.append(title_of(prefs))
if industry:
inds = prefs.get("industry") or []
if inds and inds[0] not in ("Any", None):
parts.append(inds[0])
return " ".join(p for p in parts if p).strip()
def has_mvq(prefs: Prefs) -> bool:
"""Minimum Viable Query = a title + a resolvable location (city/country/remote)."""
if not title_of(prefs):
return False
return any(l["city"] or l["country"] or l["remote"] for l in parsed_locations(prefs))

364
app/engine/normalize.py Normal file
View File

@@ -0,0 +1,364 @@
"""Per-board normalizers: raw Apify item → the frontend `ScoutJob` shape.
Slice-1a: no engine, so matchScore/note/qx are placeholders (matchScore is assigned
in search.py after merge). Each board's output differs — one mapper per board.
"""
from __future__ import annotations
import html as _html
import json
import re
_PALETTE = ["#4F46E5", "#EA580C", "#0F6E56", "#DB2777", "#0EA5E9", "#7C3AED", "#D97706", "#059669"]
def _plain(s, limit: int = 1600) -> str:
"""HTML/Markdown → readable plain text for the detail modal (keeps paragraph breaks).
We surface ONLY what the board/employer provided — and strip the scraper's own
aggregator disclaimer boilerplate (we add nothing of our own).
"""
if not s:
return ""
# HTML → text
s = re.sub(r"<\s*(br|/p|/li|/div|/h\d|/tr)\s*[^>]*>", "\n", str(s), flags=re.I) # breaks → newlines
s = re.sub(r"<li[^>]*>", "", s, flags=re.I)
s = re.sub(r"<[^>]+>", "", s) # drop remaining tags
s = _html.unescape(s)
# Markdown leftovers (when the source is markdown, not HTML)
s = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", s) # ATX headers → plain line
s = re.sub(r"(?m)^[ \t]*[-*+][ \t]+", "", s) # list bullets → •
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) # **bold** → text
s = re.sub(r"`([^`]+)`", r"\1", s) # `code` → text
# Broken-tag remnants (e.g. a leaked "> from malformed HTML)
s = re.sub(r'"\s*>', " ", s)
s = s.lstrip(' "\'>')
# Drop the scraper's appended disclaimer boilerplate (NOT employer content)
s = re.split(r"\n?\s*Disclaimer\s*[:\-]", s, maxsplit=1, flags=re.I)[0]
# whitespace
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\n{3,}", "\n\n", s).strip()
return (s[:limit].rstrip() + "") if len(s) > limit else s
def _compact(d: dict) -> dict:
"""Drop empty values so the details payload stays lean."""
return {k: v for k, v in d.items() if v not in (None, "", [], {})}
def _logo(org: str) -> dict:
org = (org or "").strip()
label = org[0].upper() if org else "?"
bg = _PALETTE[sum(map(ord, org)) % len(_PALETTE)] if org else "#9CA3AF"
return {"label": label, "bg": bg}
def _digits(s: str) -> int | None:
nums = re.sub(r"[^\d]", "", s or "")
return int(nums) if nums else None
def _salary_from_amount(amount: int | None, per_year: bool) -> tuple[float | None, str]:
"""amount in ₹ → (lpa, payLabel)."""
if not amount:
return None, "Not disclosed"
annual = amount if per_year else amount * 12
lpa = round(annual / 100_000, 1)
return lpa, f"{lpa:g}L/yr"
def _seniority_from_years(min_yrs: int | None):
if min_yrs is None:
return None
return ("junior" if min_yrs <= 1 else "junior" if min_yrs <= 4 else
"mid" if min_yrs <= 8 else "senior" if min_yrs <= 12 else "lead")
# free-text seniority (LinkedIn experienceLevel / ATS seniority) → our Seniority enum
_SENIORITY_TEXT = {
"internship": "junior", "entry level": "junior", "entry": "junior", "junior": "junior",
"associate": "mid", "mid": "mid", "mid-senior level": "senior", "senior": "senior",
"director": "lead", "executive": "lead", "lead": "lead",
}
def _seniority_from_text(s: str | None):
if not s:
return None
return _SENIORITY_TEXT.get(s.strip().lower())
_WORKMODE = {"remote": "remote", "hybrid": "hybrid", "on_site": "onsite", "onsite": "onsite"}
def naukri_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None:
# The naukri-job-scraper (fetchDetails=True) nests the real fields under `jobDetails`.
jd = it.get("jobDetails") or {}
title = (jd.get("title") or "").strip()
org = ((jd.get("companyDetail") or {}).get("name") or "").strip()
if not title or not org:
return None
locs = jd.get("locations") or []
city = (locs[0].get("label") if locs and isinstance(locs[0], dict) else None) or None
# keySkills is {other:[{label}], preferred:[{label}], ...} — flatten the labels.
skills: list[str] = []
ks = jd.get("keySkills") or {}
if isinstance(ks, dict):
for bucket in ks.values():
for s in bucket or []:
lbl = (s.get("label") if isinstance(s, dict) else str(s)).strip()
if lbl and lbl not in skills:
skills.append(lbl)
# salaryDetail is already a dict here (not a JSON string).
lpa, pay = None, "Not disclosed"
sd = jd.get("salaryDetail") or {}
if isinstance(sd, dict) and not sd.get("hideSalary") and sd.get("maximumSalary"):
lpa, pay = _salary_from_amount(int(sd["maximumSalary"]), per_year=True)
return {
"id": f"naukri-{jd.get('jobId')}",
"title": title,
"organization": org,
"logo": _logo(org),
"location_city": city,
"location_country": "India",
"matchScore": 0, # placeholder — set after merge
"salary_lpa": lpa,
"payLabel": pay,
"seniority_level": _seniority_from_years(_digits(str(jd.get("minimumExperience", "")))),
"required_skills": skills[:6],
"tags": skills[:3],
"applicants": jd.get("applyCount"),
"note": f"Live from {board}",
"apply_url": jd.get("staticUrl"),
"qx": 10,
}
def indeed_to_scoutjob(it: dict, board: str = "Indeed") -> dict | None:
if str(it.get("isExpired", "False")).lower() == "true":
return None
title = (it.get("positionName") or "").strip()
org = (it.get("company") or "").strip()
if not title or not org:
return None
# Indeed location is "Area, City, State" (or "City, State") — the city is the 2nd-to-last part.
parts = [p.strip() for p in (it.get("location") or "").split(",") if p.strip()]
city = (parts[-2] if len(parts) >= 2 else (parts[0] if parts else None))
types = []
try:
types = [t.strip() for t in eval(it.get("jobType") or "[]")] if it.get("jobType") else []
except Exception: # noqa: BLE001
pass
sal = it.get("salary") or ""
lpa, pay = _salary_from_amount(_digits(sal), per_year="year" in sal.lower()) if sal else (None, "Not disclosed")
apply_url = it.get("externalApplyLink")
if not apply_url or apply_url == "None":
apply_url = it.get("url")
return {
"id": f"indeed-{it.get('id')}",
"title": title,
"organization": org,
"logo": _logo(org),
"location_city": city,
"location_country": "India",
"matchScore": 0,
"salary_lpa": lpa,
"payLabel": pay,
"required_skills": types,
"tags": types[:3],
"applicants": None,
"note": f"Live from {board}",
"apply_url": apply_url,
"qx": 10,
}
def linkedin_to_scoutjob(it: dict, board: str = "LinkedIn") -> dict | None:
title = (it.get("title") or "").strip()
org = ((it.get("company") or {}).get("name") or "").strip()
if not title or not org:
return None
loc = it.get("location") or {}
parsed = loc.get("parsed") or {} if isinstance(loc, dict) else {}
city = parsed.get("city") or None
country = parsed.get("country") or parsed.get("countryFull") or None
mode = _WORKMODE.get((it.get("workplaceType") or "").lower())
industries = [s for s in (it.get("industries") or []) if isinstance(s, str)]
sal = it.get("salary") or {}
pay = sal.get("text") if isinstance(sal, dict) and sal.get("text") else "Not disclosed"
co = it.get("company") or {}
details = _compact({
"description": _plain(it.get("descriptionText") or it.get("descriptionHtml")),
"experience": it.get("experienceLevel"),
"employment_type": (it.get("employmentType") or "").replace("_", " ").title() or None,
"industry": ", ".join(industries[:3]) or None,
"skills": industries,
"company_about": _plain(co.get("description"), 700) if isinstance(co, dict) else "",
})
return {
"id": f"linkedin-{it.get('id')}",
"title": title,
"organization": org,
"logo": _logo(org),
"location_city": city,
"location_country": country,
"location_mode": mode,
"matchScore": 0,
"salary_lpa": None,
"payLabel": pay,
"seniority_level": _seniority_from_text(it.get("experienceLevel")),
"required_skills": industries[:6],
"tags": industries[:3],
"applicants": it.get("applicants"),
"note": f"Live from {board}",
"apply_url": it.get("linkedinUrl"),
"details": details,
"qx": 10,
}
def ats_to_scoutjob(it: dict, board: str = "Direct") -> dict | None:
title = (it.get("title") or "").strip()
org = (it.get("company") or "").strip()
if not title or not org:
return None
org = org.replace("-", " ").replace("_", " ").title() # ats slugs ("stripe") → "Stripe"
parts = [p.strip() for p in (it.get("location") or "").split(",") if p.strip()]
city = parts[0] if parts else None
mode = _WORKMODE.get((it.get("remote_type") or "").lower()) or ("remote" if it.get("remote") else None)
dept = [d for d in [it.get("department"), it.get("team")] if d]
return {
"id": f"ats-{it.get('global_id') or it.get('job_id')}",
"title": title,
"organization": org,
"logo": _logo(org),
"location_city": city,
"location_country": None,
"location_mode": mode,
"matchScore": 0,
"salary_lpa": None,
"payLabel": "Not disclosed",
"seniority_level": _seniority_from_text(it.get("seniority")),
"required_skills": dept[:6],
"tags": dept[:3],
"applicants": None,
"note": f"Live from {board}", # direct employer posting → real offsite apply link
"apply_url": it.get("apply_url") or it.get("url"),
"qx": 10,
}
def naukri_feed_to_scoutjob(it: dict, board: str = "Naukri") -> dict | None:
"""blackfalcondata/naukri-jobs-feed (fetchDetails=True) — flat shape with a real
offsite `applyRedirectUrl` when the employer uses an external ATS."""
title = (it.get("title") or "").strip()
org = (it.get("companyName") or it.get("staticCompanyName") or "").strip()
if not title or not org:
return None
loc = it.get("location") or ""
city = (loc.split("/")[0].split(",")[0].strip() or None) if isinstance(loc, str) else None
# keySkills = {preferred:[{label}], other:[{label}]} — flatten labels.
skills: list[str] = []
ks = it.get("keySkills") or {}
if isinstance(ks, dict):
for bucket in ks.values():
for s in bucket or []:
lbl = (s.get("label") if isinstance(s, dict) else str(s)).strip()
if lbl and lbl not in skills:
skills.append(lbl)
elif isinstance(ks, list):
skills = [str(s) for s in ks if s]
lpa, pay = None, "Not disclosed"
if not it.get("salaryHidden") and it.get("salaryMax"):
lpa, pay = _salary_from_amount(int(it["salaryMax"]), per_year=True)
redirect = it.get("applyRedirectUrl")
education = "; ".join(it.get("educationUG") or []) or it.get("degreeCombination") or None
details = _compact({
"description": _plain(it.get("descriptionMarkdown") or it.get("description") or it.get("shortDescription")),
"experience": it.get("experienceText"),
"employment_type": it.get("employmentType"),
"education": education,
"industry": it.get("industry"),
"role_category": it.get("roleCategory"),
"skills": skills,
"company_about": _plain(it.get("companyDescription"), 700),
"vacancies": it.get("vacancy"),
})
return {
"id": f"naukri-{it.get('jobId')}",
"title": title.title() if title.isupper() else title,
"organization": org,
"logo": _logo(org),
"location_city": city,
"location_country": "India",
"location_mode": _WORKMODE.get((it.get("wfhType") or "").lower()),
"matchScore": 0,
"salary_lpa": lpa,
"payLabel": pay,
"seniority_level": _seniority_from_years(it.get("minimumExperience")),
"required_skills": skills[:6],
"tags": skills[:3],
"applicants": it.get("applyCount"),
"note": f"Live from {board}",
"apply_url": redirect or it.get("staticUrl") or it.get("portalUrl"),
"offsite_apply": bool(redirect), # True = real 3rd-party employer/ATS link (auto-applyable)
"details": details,
"qx": 10,
}
def foundit_to_scoutjob(it: dict, board: str = "Foundit") -> dict | None:
title = (it.get("title") or "").strip()
org = (it.get("company") or "").strip()
if not title or not org:
return None
loc = it.get("location") or ""
city = (loc.split("/")[0].split(",")[0].strip() or None) if isinstance(loc, str) else None
skills = it.get("skills") or []
if isinstance(skills, str):
skills = [s.strip() for s in skills.split(",") if s.strip()]
skills = [str(s) for s in skills if s]
seniority = None
m = re.search(r"\d+", str(it.get("experience") or ""))
if m:
seniority = _seniority_from_years(int(m.group()))
redirect = it.get("apply_url")
details = _compact({
"description": _plain(it.get("description_text") or it.get("description_html")),
"experience": it.get("experience"),
"employment_type": it.get("employment_type"),
"industry": it.get("industry"),
"role_category": it.get("function"),
"skills": skills,
})
return {
"id": f"foundit-{it.get('job_id')}",
"title": title,
"organization": org,
"logo": _logo(org),
"location_city": city,
"location_country": "India",
"matchScore": 0,
"salary_lpa": None,
"payLabel": "Not disclosed",
"seniority_level": seniority,
"required_skills": skills[:6],
"tags": [it.get("function")][:1] if it.get("function") else skills[:3],
"applicants": None,
"note": f"Live from {board}",
"apply_url": redirect or it.get("url"),
"offsite_apply": bool(redirect),
"details": details,
"qx": 10,
}
# board key → normalizer
NORMALIZERS = {
"naukri": naukri_feed_to_scoutjob, # blackfalcondata (primary)
"foundit": foundit_to_scoutjob,
"linkedin": linkedin_to_scoutjob,
"naukri_v1": naukri_to_scoutjob, # muhammetakkurtt (fallback)
"indeed": indeed_to_scoutjob,
"ats": ats_to_scoutjob,
}

85
app/engine/search.py Normal file
View File

@@ -0,0 +1,85 @@
"""On-demand multi-board sweep: ScoutPrefs → all boards (parallel) → merge + dedup → ScoutJob[].
This is the retrieve layer (final shape). Slice-1a has NO engine — ranking is a placeholder
spread; the §3 cascade slots in here later without changing the output contract.
Degrade-don't-break: a board that errors/needs setup is skipped; the others still return.
"""
from __future__ import annotations
import asyncio
import logging
from app.config import get_settings
from app.engine.apify_client import run_actor
from app.engine.board_adapters import adapters as A
from app.engine.board_adapters import coerce as C
from app.engine import normalize as N
logger = logging.getLogger(__name__)
def _ats_companies() -> list[dict]:
return [{"company": c.strip()} for c in get_settings().ATS_COMPANIES.split(",") if c.strip()]
# board key → (actor_id, build_input, normalizer). "Balanced" India stack = naukri+foundit+linkedin,
# all city-filtered at the board. The rest stay registered (code ready) but off unless enabled.
BOARDS = {
"naukri": ("blackfalcondata~naukri-jobs-feed",
lambda p: A.build_naukri_feed_input(p, max_jobs=get_settings().NAUKRI_MAX_JOBS),
N.naukri_feed_to_scoutjob),
"foundit": ("shahidirfan~Foundit-Jobs-Scraper",
lambda p: A.build_foundit_input(p, results_wanted=get_settings().FOUNDIT_MAX_JOBS),
N.foundit_to_scoutjob),
"linkedin": ("harvestapi~linkedin-job-search",
lambda p: A.build_linkedin_input(p, recall=True, max_items=get_settings().LINKEDIN_MAX_JOBS),
N.linkedin_to_scoutjob),
# ── registered fallbacks / other lanes (off unless added to BOARDS_ENABLED) ──
"ats": ("bovi~greenhouse-lever-ashby-job-scraper",
lambda p: A.build_ats_input(p, recall=True, companies=_ats_companies(),
max_per_company=get_settings().ATS_MAX_PER_COMPANY),
N.ats_to_scoutjob),
"naukri_v1": ("muhammetakkurtt~naukri-job-scraper",
lambda p: A.build_naukri_input(p, recall=True, max_jobs=get_settings().NAUKRI_MAX_JOBS),
N.naukri_to_scoutjob),
"indeed": ("misceres~indeed-scraper",
lambda p: A.build_indeed_input(p, max_items=get_settings().INDEED_MAX_JOBS),
N.indeed_to_scoutjob),
}
async def _fetch_board(key: str, prefs: dict):
actor, build, norm = BOARDS[key]
items = await run_actor(actor, build(prefs))
jobs = [sj for it in items if (sj := norm(it))]
return key, jobs
async def run_sweep(prefs: dict) -> dict:
enabled = [b.strip() for b in get_settings().BOARDS_ENABLED.split(",") if b.strip() in BOARDS]
results = await asyncio.gather(*(_fetch_board(k, prefs) for k in enabled), return_exceptions=True)
merged: list[dict] = []
seen: set[str] = set()
sources: dict[str, int] = {}
for r in results:
if isinstance(r, Exception):
logger.warning("board fetch failed: %s", r)
continue
key, jobs = r
sources[key] = len(jobs)
for j in jobs:
dk = (j.get("apply_url") or "").strip() or f"{j['organization']}|{j['title']}|{j.get('location_city')}".lower()
if dk in seen:
continue
seen.add(dk)
merged.append(j)
# placeholder ranking (no engine yet): a descending spread so cards aren't all the same %
for i, j in enumerate(merged):
j["matchScore"] = max(55, 95 - i)
return {"opportunities": merged, "sources": sources}
def has_mvq(prefs: dict) -> bool:
return C.has_mvq(prefs)

51
app/main.py Normal file
View File

@@ -0,0 +1,51 @@
"""matchmaking-v2 — FastAPI app (course-service mold).
Serves: the agent card (discovery), /a2a/tasks (orchestrator entry), /api/v1/health.
On-demand by design — no corpus DB, no nightly ingestion.
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.adk.worker import RedisStreamWorker
from app.api.v1 import router as api_router
from app.a2a.card import router as a2a_card_router
from app.a2a.tasks import router as a2a_tasks_router
from app.config import get_settings
logging.basicConfig(level=get_settings().LOG_LEVEL)
_settings = get_settings()
_worker = RedisStreamWorker(redis_url=_settings.ORCHESTRATOR_REDIS_URL or _settings.REDIS_URL)
@asynccontextmanager
async def lifespan(_: FastAPI):
await _worker.start()
try:
yield
finally:
await _worker.stop()
def create_app() -> FastAPI:
s = get_settings()
app = FastAPI(title="Matchmaking v2", version="2.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=s.cors_origins_list,
allow_credentials=s.cors_origins_list != ["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=s.API_PREFIX)
app.include_router(a2a_card_router)
app.include_router(a2a_tasks_router)
return app
app = create_app()

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
# Local dev for matchmaking-v2 in isolation (prod wiring lives in the growqr monorepo compose).
services:
matchmaking-v2:
build: .
container_name: matchmaking-v2
ports:
- "8006:8000"
env_file:
- .env
restart: unless-stopped

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic>=2.7
pydantic-settings>=2.3
httpx>=0.27
redis>=5.0
# engine/auto-apply deps land in later slices:
# openai>=1.40 # GPT-5.4 rerank/explain + tailoring
# apify-client>=1.7 # on-demand board fetch (or call run-sync via httpx)

View File

@@ -0,0 +1,89 @@
# Auto-Apply Engine — State of the POC & Segment Thesis
> Companion to `ENGINE_DESIGN.md`. Captures where the browser-use auto-apply POC stands as of
> 2026-06-15: what's proven, the honest boundaries, and the segment thesis. Code: `poc/browser-apply/`.
> Numbers are from small, directional samples — real direction, not statistical precision.
---
## 0. What this is
The "heart" of Scout (`ENGINE_DESIGN.md` §6), built for real. We replaced the dead heuristic mock
(which proved nothing — fake ATS, fake liveness gate) with an **AI browser agent**: open-source
**browser-use** + **gpt-4.1 vision** driving real Chrome to fill and submit real job applications.
The design is a **board-agnostic "master key" engine** + a thin **adapter registry**, fed by Apify
apply-URLs. It applies on the *company's own ATS*, sidestepping the LinkedIn/Naukri login + ban zone.
## 1. Architecture (what's built)
- `candidate.py` — board-agnostic candidate model: vision-parsed résumé + confirmed facts + a
per-job tailored-motivation rule. (Résumé parsing reuses the resume-builder vision pattern.)
- `apply_engine.py` — the **master key**: navigate → autofill → fill-every-field → **accuracy gate**
→ verify → screenshot. Statuses incl. `no_form` / `login_required` so the long tail is legible.
- `adapters/` — registry. `GenericAdapter` (catch-all, pure vision) handles the long tail;
`AshbyAdapter` carries earned Ashby quirks. Route by URL domain. New boards = a few-line adapter.
- `apply_ashby.py` — the proven standalone (kept untouched).
- `apply_harness.py` — accuracy/cost/timing measurement rig. `apify_probe.py` — inbound yield rig.
**Design principle:** the engine is generic; adapters are thin hints; **verification is first-class**
— screenshot-as-ground-truth + a hard gate, because the agent's self-report is optimistic.
## 2. What's PROVEN (the receipts)
- **Real end-to-end submission.** Submitted a real application to OpenAI on AshbyHQ — verified by
the on-screen green success banner, not the agent's word.
- **The master key generalizes.** The *generic* adapter filled Greenhouse forms it had never seen
(GitLab, Figma, Anthropic's long adversarial form) + Ashby via its adapter — **4/5**, the 5th
honestly bounced on a login wall. **0 fake successes.**
- **India works, untuned.** Cracked **Zoho Recruit + Keka** (the big India-native ATS) with no India
tuning — even read their simple text-CAPTCHAs via vision — and honestly reported `no_form` on an
email-only careers page.
- **The loop closes, economics known.** Apify direct-ATS actor = 100% offsite Tier-1 links;
LinkedIn (harvestapi) = 60% offsite; combined ~43% offsite, ~14% immediately autonomous. Cost
**~$0.13/app clean ATS, ~$0.210.27 India ATS**; latency ~36 min/app; **vision-off ~43% cheaper**
with no accuracy loss on clean forms. Cheap models (gpt-4o-mini) are a **false economy** (0/2,
step-blowup).
## 3. Honest boundaries (what's NOT yet solved)
- **Login-gated demand stays Tier-3.** LinkedIn Easy Apply, Naukri's one-click dashboard,
Workday/Oracle account-gates — need persisted session + human/Cloud, not the master key.
- **Submit-time anti-bot is the persistent threat.** Got spam-flagged once on Ashby; reCAPTCHA/
hCaptcha would wall us; even the simple-CAPTCHA "solve" is **unverified** (we never submitted to
confirm acceptance). This is what breaks at scale.
- **Latency needs parallelization** (architecture, not a faster model). **ToS/account-safety** is a
real exposure on the login boards.
- **The matching engine (`ENGINE_DESIGN.md` core) is not built.** This arc was all *apply*.
## 4. The segment (defined precisely)
Not "auto-apply to anything." The defensible segment is:
> **Autonomous, honestly-verified applications to the public-ATS + India-native-ATS tier —
> applying on the company's own ATS, sidestepping the LinkedIn/Naukri login & account-ban problem.**
**Differentiation:** existing tools (LazyApply, Simplify) are brittle per-board scripts that operate
*inside* LinkedIn (the ban zone). A **board-agnostic vision engine that goes around to the ATS**,
with **no-fake-success verification**, is a different, more durable product. **India is the sharpest
edge** — a huge, Naukri-dominated market where the direct-ATS + Zoho/Keka/Darwinbox path is
genuinely underserved.
## 5. POC → segment (the path)
1. **Submit-time anti-bot lane** (make-or-break): Cloud stealth for spam/CAPTCHA; human-in-the-loop
final tap otherwise. Decides the true autonomous conversion rate.
2. **Parallelization** — concurrent sessions for acceptable latency at volume.
3. **Always-JSON output + answer-bank flywheel** (small): legible triage; engine learns answers over
time so human interventions shrink.
4. **Wrapper decision:** the *apply layer of Scout*, or a standalone *autonomous-applicant / apply-API*
product. The tech supports either.
## 6. Honesty ledger
| Proven (de-risked) | Open / must-prove |
|---|---|
| Generic vision engine fills arbitrary forms across boards & countries | Submit-time anti-bot (spam flags, reCAPTCHA) at scale |
| Real verified submission; no fake successes | Login-gated tier (LinkedIn/Naukri dashboard/Workday) |
| India-native ATS (Zoho/Keka) with no tuning | CAPTCHA acceptance (unverified — needs a sandbox submit) |
| Known unit cost (~$0.130.27/app) + the vision-off lever | Latency at volume (needs parallelization) |
| Apify inbound returns usable apply-URLs (loop closes) | The matching engine (still on paper) |
## Bottom line
A **proven, board-agnostic, honestly-verified apply engine** — real submission, generalizes across
US + India ATS, known economics. A strong POC and a legitimate **segment for the public-ATS /
India-ATS autonomous slice**. The realism check: "fully autonomous for *every* board" is not the
product — the login tier needs an assisted lane, and submit-time anti-bot is the risk that decides
scale. **Define the segment around the proven slice, lead with India.**

View File

@@ -0,0 +1,46 @@
# Old Matchmaking — Decommission & Slot-In Plan
> How `matchmaking-v2` (Scout) replaces the dead `matchmaking` service. Companion to
> `ENGINE_DESIGN.md`. Status (2026-06-16): **planned, local teardown not yet executed.**
## Current state (verified live 2026-06-16)
The old `matchmaking` runs identically in **both** local (`growqr-demo`) and prod (EC2):
| Component | Local | Prod (EC2 `3.109.159.139`) |
|---|---|---|
| `matchmaking-api` | `:8006` → 8000, up | `:8006` → 8000, up 4d |
| `matchmaking-worker` | up | up 4d |
| `matchmaking-ingestion-worker` | up | up 4d |
| `matchmaking-postgres` (own DB, NOT RDS) | `:5439`, vol `matchmaking-pgdata` | `:5439`, vol `matchmaking-pgdata` |
Containers are *running* but the **service is functionally dead** (ingestion chain broke 2026-05-27;
986,770 stale score rows for 21 users). `:8011` is free in both. AWS reachable: CLI as
`iam::189924572890:user/rahul`; SSH `ec2-user@3.109.159.139` (key `~/.ssh/growqr-key.pem`).
## Locked decisions
1. **Reuse port `:8006`** (the old matchmaking's slot) — NOT a new `:8011`. This overrides
`ENGINE_DESIGN.md` §1's "12th agent on :8011". Rationale: in-place swap — v2 inherits the
existing `api.growqr.ai/matchmaking/*` nginx route + the frontend's existing matchmaking wiring,
so the Scout UI works with minimal rewiring.
2. **Drop the old corpus DB.** v2 is **on-demand, no corpus** (`ENGINE_DESIGN.md` §1) — so
`matchmaking-postgres` + its `matchmaking-pgdata` volume are discarded. The data is stale/worthless
(design doc verdict). Irreversible — confirm before deleting the volume.
3. **WS-agent via orchestrator.** v2 reuses `:8006`, registers in orchestrator `AGENT_URLS`, and the
frontend reaches it through `useAgentSession` (WS) — matching the mesh pattern (like
course-service `:8010`). The old `/matchmaking/*` nginx HTTP route stays vestigial or is removed
later.
## Sequence
**A. Local — immediate (on go-ahead):**
- `docker compose stop` + `rm` the 4 matchmaking containers (api, worker, ingestion-worker, postgres).
- Drop the `matchmaking-pgdata` volume (decision #2). Frees `:8006` + `:5439` for v2 dev.
- Comment/remove the matchmaking services from the local compose; v2 takes `:8006`.
**B. Prod — later (after v2 validated):**
- Stop the 4 prod matchmaking containers; remove from `docker-compose.prod.yml`.
- Deploy v2 on `:8006`; add to orchestrator `AGENT_URLS`; keep/retire the nginx `/matchmaking/*` route.
- Periodic `docker builder/image prune` (per aws-deployment.md) after the swap.
## Rollback
Local teardown is reversible except the dropped volume. Keep the old `matchmaking` repo + compose
definition until v2 is validated in prod, so prod can be restored by re-adding the service block.

View File

@@ -0,0 +1,377 @@
# Matchmaking v2 (Scout) — Engine Design & Locked Plan
> Canonical design doc. Captures the engine research, the locked decisions, and the
> open items. Written 2026-06-12. **Revised 2026-06-16** with live-evidence findings.
> Supersedes the dead `matchmaking` service.
>
> **Companion docs (gathered evidence — read alongside):**
> - `SIGNAL_AUDIT_V2.md` — evidence-based signal inventory; **supersedes §2/§5 below**.
> - `AUTOAPPLY_STATE.md` — validated browser-use auto-apply engine; **supersedes/extends §6**.
> - `DECOMMISSION_PLAN.md` — how v2 slots in over the old matchmaking (port `:8006`).
>
> **2026-06-16 deltas in one line:** the engine is richer mostly by *wiring* (interview/roleplay/
> assessment are LIVE data, not "gaps"); a new high-trust signal (**course watch-behavior**) was
> found; auto-apply is now a **validated board-agnostic vision agent** (not just hosted-submit/
> handoff); v2 reuses port **`:8006`** (not `:8011`); **Pathways is cut**.
---
## 0. Why v2 exists
The old `matchmaking` service is being decommissioned. Diagnosis (verified June 1112):
- **Wrong shape:** it was a nightly *job aggregator* — scraped ~13.8k jobs into its own
Postgres and scored 21 users against the stale pile. 986,770 match-score rows for 21 users.
- **Dead scheduling:** not cron — a self-rescheduling RQ chain in Redis that went empty
(likely an ElastiCache eviction) and silently stopped. Ingestion dead since May 27.
- **Weak engine:** ranked on the *lowest-trust* signals (self-declared prefs + claimed skills
+ an often-absent QScore that, at 30% weight, collapsed everyone to a neutral 60).
v2 is a **fresh, purely on-demand** service. No aggregation. Fetch live listings when the
user searches, score only those.
---
## 1. Locked architecture (the shape)
- **On-demand only.** Per user search, fetch ~25200 live listings from **LinkedIn + Naukri +
Indeed** via Apify, score only those. No corpus, no nightly batch.
- Sync fetch is viable: Apify `run-sync-get-dataset-items` (300s cap), cold start ~1.2s.
- 3-board search ≈ **$0.150.25 uncached**; shared cache keyed `role+location`, TTL 16h.
- **Vendor picks — VALIDATED 2026-06-16 (live probe, `poc/browser-apply/apify_probe.py`):**
- **Direct-ATS `bovi/greenhouse-lever-ashby-job-scraper` = the autonomous backbone** — public
JSON endpoints (no auth), returns clean ATS `apply_url`, **100% Tier-1 offsite** in-sample,
~$1.50/1k. Company/preset-driven (not free-text). This is the new, highest-yield inbound.
- LinkedIn `harvestapi/linkedin-job-search`**60% offsite** (`applyMethod.companyApplyUrl`),
real ATS diversity. Needs a one-time `approvePermissions` (full-account-access actor).
- Indeed `misceres/indeed-scraper` — ~35% external, but ~0% clean-ATS for India/ML (breadth, low
autonomous yield).
- Naukri → use **`muhammetakkurtt/naukri-job-scraper`** (11k users, no permission gate), NOT
`memo23` or the fishy `infinity_and_beyond`. Its `applyRedirectUrl` is the true external link,
but ~93% point to bespoke small-company career pages, not clean ATS.
- **Measured combined yield:** ~43% have an offsite link, **~14% immediately Tier-1 autonomous**
on a free-text search (more if you lead with direct-ATS). Full data → `apify-inbound` memory.
- **Surface:** ONE responsive web app, **no extension, no native app download.**
- The cross-origin limitation is now **solved a third way (validated):** a server-side/cloud
**AI browser agent (browser-use) fills any board's form by vision** — no extension, no ATS
creds needed. See §6 + `AUTOAPPLY_STATE.md`. (Hosted-submit/prepared-handoff remain fallbacks.)
- **Slots in over the OLD matchmaking — port `:8006`, not a new `:8011`** (revised; see
`DECOMMISSION_PLAN.md`). v2 inherits the existing `api.growqr.ai/matchmaking/*` route + the
frontend's matchmaking wiring. Still FastAPI in the `course-service` mold: Redis-Streams worker,
registered in orchestrator `AGENT_URLS`, reached via the orchestrator WS, frontend talks to it
through the existing `useAgentSession(...)` hook. The old matchmaking's own Postgres (corpus) is
dropped — v2 is on-demand, no corpus.
---
## 1.1 Apify call contracts (verified live 2026-06-17)
Probed each actor's real input schema (`poc/browser-apply/apify_probe.py`, key loaded). Field-level
contract + the ScoutPrefs→params coverage matrix live in **`ENGINE_INPUTS.md`**. Headlines:
- **MVQ (Minimum Viable Query) = title + a resolvable location.** No MVQ → **do not call Apify**
(engine returns `{needs:[...]}`; frontend also gates "Find my matches"). Cache key = `title+geo`, TTL 16h.
- **Free-text lane:** `harvestapi/linkedin-job-search` (`jobTitles[]`,`locations[]`), `misceres/indeed-scraper`
(`position`,`location`,`country`), `muhammetakkurtt/naukri-job-scraper` (`keyword`,`cities[]`,`maxJobs*`).
- **Curated lane (separate):** `bovi/greenhouse-lever-ashby` is **company-list-driven**; user inputs only
*filter within* it (`titleKeyword`,`locationKeyword`,`remoteOnly`,`keywords[]`).
- **Naukri is the richest target for India** — uniquely exposes `department`, `industry`, ₹ `salaryRange`,
numeric `experience`. Lead with it. **Gotcha:** Naukri `industry`/`department`/`cities` take **numeric
codes**, not labels (labels are display-only).
## 1.2 Input coercion layer (the board adapters)
Nothing from the UI goes straight to an actor — a per-board **adapter** parses/maps/converts each
`ScoutPrefs` field to that actor's exact schema. **Code: `engine/board_adapters/`** (`coerce.py` +
`build_{naukri,linkedin,indeed,ats}_input(prefs)`).
**6 coercion dimensions:** ① parse/split (`"City · Country"``{city,country,remote}`) · ② enum-map
(label→board enum) · ③ unit-convert (₹→lakhs bucket; seniority⇄years) · ④ id-lookup (name→code:
LinkedIn `geoIds`/`industryIds`, **Naukri numeric codes**) · ⑤ cardinality (multi-select→array where
supported, else **fan-out N searches + dedup** — Indeed/bovi take single strings) · ⑥ presence
(`Any`→omit, `Remote`→flag, `Anywhere`→geo-only).
**Verified lookup maps (built into `coerce.py`):**
| Map | Examples |
|---|---|
| `COUNTRY_ISO2` (Indeed) | India→`IN`, US→`US`, UK→`GB` |
| `WORKMODE_*` | Remote→`remote`, On-site→`office`, Any→omit |
| `SENIORITY_YEARS` (Naukri `experience`) | Fresher 0 · Junior 2 · Mid 5 · Senior 9 · Lead 13 |
| `SENIORITY_LINKEDIN` (`experienceLevel`) | Junior→`associate`, Mid→`mid-senior`, Senior→`director` |
| `COMP_NAUKRI_SALARY` (`salaryRange`) | ₹1622L→`15to25`, ₹2230L→`25to50` |
| `ROLE_NAUKRI_DEPT` (codes) | Product→`10` · Engineering→`5` · Design→`15` · Data→`3` · Sales→`14` · Ops→`2` |
| `INDUSTRY_NAUKRI` (codes) | Fintech→`114` · SaaS→`110` · E-commerce→`108` · EdTech→`133` · HealthTech→`131` · Consulting→`126` |
| `CITY_NAUKRI` (codes, 1→N) | Bengaluru→`[97]` · Mumbai→`[134]` · **Delhi NCR→`[6,73,220,350]`** · Hyderabad→`[17]` · Pune→`[139]` |
| `INDUSTRY_LINKEDIN_ID` | best-effort — **TODO verify** vs LinkedIn taxonomy; falls back to keyword fold |
**System-set (not user):** `maxJobs`/`maxItems` (25200 budget) · freshness (`freshness=7`/`postedLimit=week`/
`recentWindowDays=30` — the liveness gate) · `sortBy=date` · `fetchDetails`/`followApplyRedirects=true`
(real offsite apply URL) · Naukri country implicit `IN`.
**Composite-keyword fallback** for boards lacking `department`/`industry` (LinkedIn/Indeed/bovi):
`keyword = [seniorityPrefix] + title + [industryTerm]` (e.g. Indeed `position="Senior Payments PM fintech"`),
while Naukri stays clean (`keyword="Payments PM"` + structured `department/industry/salaryRange`).
**Hard rule — degrade, don't break:** any value that doesn't resolve to a board enum/code is **never
sent as an invalid enum**. It falls back to the **keyword fold** or **post-fetch filter**, and is logged.
A missing lookup loses precision, never the call.
**Engine-side-only inputs** (no board param on any actor — used for ranking/floors/exclusions/ordering):
`priorities`, `stretch`, `intent`, `availability`, `dealBreakers`, `sort`. See `ENGINE_INPUTS.md` §6.
---
## 2. Signals — keep / discard (the input audit)
> **➜ `SIGNAL_AUDIT_V2.md` supersedes this section** (evidence-based, from live-DB sampling
> 2026-06-16). Deltas: (a) **course watch-behavior** is a NEW high-trust booster — revealed
> preference ("completes React content → step toward frontend") beats stated; (b) interview/
> roleplay/assessment are **LIVE data, not gaps** (§5) but are **already digested into QScore**, so
> v1 uses the QScore quotient as the competence proxy to avoid double-counting; (c) **Pathways is
> CUT** (feature *and* its questionnaire-as-signal — weak link: broken skill-gap, impractical roles).
> The trust⟂coverage discipline below is unchanged and is what keeps "more signals" from re-causing
> the v1 collapse.
**Governing tension: trust ⟂ coverage.** High-trust signals have low coverage; high-coverage
signals have low trust. **Design rule: a signal earns SCORE WEIGHT only if its coverage is high
enough that absence isn't the norm; otherwise it is an ADDITIVE BOOSTER (bonus when present,
never a penalty when absent).** The old engine broke this rule — that's the v1 collapse bug.
### KEEP
**Tier A — backbone (high coverage; filter + light score)**
- `years_experience` (date-derived, **not** the self-declared band)
- `current_role` / title (prefer social-branding LinkedIn-verified)
- `preferred_locations` + work mode (hard filter)
- `target_roles` / `target_industries` (filter, not score — it's *want* not *is*)
- skills (normalized — see §3.2)
**Tier B — verified enrichers (medium coverage; real weight when present)**
- verified title / company / **industry** (social-branding; industry only via BrightData path)
- degree / field_of_study / institution
- certifications
- assessment scores (`passed` / `percentage` — user-service, shipped Jun 11; earned, grows)
- QScore **quotient vector** (10-D; enricher/tiebreaker — not additive-only, coverage-penalized)
**Tier C — premium boosters (low coverage; ADDITIVE ONLY, never penalize absence)**
- interview 8-D rubric (~510% coverage)
- roleplay 6-D rubric (<3%)
- improvement velocity / coachability
- video on-camera score (<1%)
- (Doubles as an adoption flywheel: "do a mock interview sharper matches.")
### DISCARD (noise for matching)
- **ATS `overall_score`** measures resume *quality/formatting*, **not job fit** the trap.
- parser confidence, `resume_check` signals (QA, not the person)
- follower/connection counts, post engagement (vanity)
- `brand_score`, `pathway_archetype` (rare/external/unpopulated)
- self-declared `salary_range` (zeros/guesses use the listing's market salary)
- engagement streak/trends (engagement fit)
- names, avatar, email; cover-letter analysis & coaching narratives (for humans, not the scorer)
- **institution_tier** discarded deliberately; sidesteps the worst India pedigree-bias vector.
---
## 3. THE ENGINE (the v2 redesign)
### 3.0 Core verdict (research-backed)
- **Embedding/cosine similarity must NOT be the ranker** [proven, 3-0]. It rewards topical
word-overlap over qualification fit, can't enforce hard constraints (seniority, certs), and
*collapses on sparse profiles* exactly the v1 failure mode. Embedding = **one input signal**.
- **Winner: a deterministic white-box utility score, fused with other rankers by
WEIGHTED-AVERAGE RANK** [proven: WAvg rank +31.9% nDCG@10; beats RRF +5.8% and every single
ranker]. "Retrieve cheap, rerank expensive" still applies the boards did retrieval; we
spend compute reranking the 25200 fetched listings.
### 3.1 The cascade (per search)
```
① NORMALIZE skills → ESCO canonical; titles → canonical role
② HARD FILTER location/work-mode, must-have certs, seniority floor (deterministic gate —
embeddings CAN'T do this)
③ WHITE-BOX U = Σ_{f∈present} c_f·w_f·φ_f / Σ_{f∈present} c_f·w_f
UTILITY factors: skill_match, experience, location, salary, semantic_sim (ONE input),
industry/company, + QScore-quotient / assessment boosters
④ FUSION weighted-average RANK over: utility ‖ embedding-sim ‖ LLM soft-score
⑤ LLM RERANK GPT-5.4 on top ~20: calibrated 01 fit vs a 6-level rubric;
+ EXPLAIN ALSO logged as cold-start supervision; emits "why" + growth nudge
⑥ OUTPUT ranked list + breakdown + explanation
LOG soft-scores + user save/apply → accrue labels → swap ③④ priors for a learned
pairwise/listwise ranker (margin loss / CMMD — NOT pointwise MSE)
```
### 3.2 Skill matching (the core accuracy lever) [proven method]
Exact taxonomy lookup is a dead end (only 41/202 soft skills exact-match ESCO; real-world
span-match F1 caps ~27%). The stack:
1. **Synonym canonicalization** to ESCO (`k8s→Kubernetes`, `ML→Machine Learning`) seed from
ESCO + v1 role synonyms + India tech terms.
2. **Depth-2 knowledge-graph traversal** of `RELATED_TO` edges for implied/transferable skills
(Kubernetes "container orchestration"). Powers STRETCH.
3. **Contrastive embedding alignment** to the taxonomy to beat the ~27% ceiling.
*Validate locally — the F1@5 0.72 result is on Chinese ads.* v1 ships synonym+KG+cosine-to-ESCO;
upgrade to a trained contrastive aligner later.
4. **Required vs preferred** weighting: required near-gating, preferred additive.
- **Taxonomy = ESCO.** EU-origin sanity-check India role/skill coverage early; extend, don't swap.
### 3.3 Coverage-aware combination [our design — no validated formula exists]
Renormalize over **present** signals only, each weighted by confidence `c_f` (coverage × trust
from the audit): `U = Σ_{present} c_f·w_f·φ_f / Σ_{present} c_f·w_f`.
A missing signal redistributes its weight to what we know never penalizes, never flattens.
This makes the v1 "QScore absent everyone 60" bug impossible by construction. **Validate `c_f`
offline** (confirm it doesn't over-reward sparse profiles).
### 3.4 Cold-start weighting (no labels at launch) [proven]
- **Day 1:** expert-prior weights starting point `skill .35 / experience .25 / location .15 /
salary .10 / semantic .10 / company .05`. **Priors to calibrate, not optima.**
- **From day 1, in parallel:** GPT-5.4 as **teacher** — calibrated 01 soft scores on a 6-level
rubric, logged on every match. Synthesizes supervision (study: discriminative specificity
3.5× — 0.058 → 0.208) instead of waiting for outcomes (<0.05% dense).
- **As save/apply accrues:** train a **pairwise/listwise** ranker + contrastive/paraphrase
augmentation; shift fusion weight off the priors.
### 3.5 FIT vs STRETCH [our design — research left open]
- **FIT:** skill-gap ≈ 0; score current-state overlap; mild over-qualification penalty.
- **STRETCH:** target = one level up / adjacent; score by **reachability** = fraction of the
job's required skills the user *has OR is within depth-2 KG hops of*. "Reach but reachable" =
small, bridgeable gap → this is the prototype's growth nudge.
- Maps onto the Scout intent picker: current-state → "more like this"; aspiration → "step up / new".
---
## 4. Evaluation & model roles — **no human in the loop**
- **Runtime is fully autonomous.** No human ever reviews, approves, or scores a user's matches.
- **Eval grading is automated and cross-model** to avoid self-grading inflation:
| Role | Model |
|---|---|
| Match generation, cold-start soft-score teacher, explanations/growth nudges | **GPT-5.4** |
| Independent accuracy grading (LLM-as-judge) | **Opus 4.8** |
- Cross-family grading kills self-preference bias (no model marks its own homework).
- Residual generic LLM-judge bias (position/verbosity) → prompt hygiene: randomize order,
pairwise, strip length cues.
- **Un-gameable ground truth = real save/apply rate once live.** Opus is the pre-launch proxy.
- **Metrics:** nDCG@10, MRR, P@5, calibration MAE on a synthetic + dogfood gold set
(~50100 India-representative pairs; no hired annotators). Online: interleaving + A/B once live.
- Note: Opus-as-judge is a deliberate, fine exception to "always GPT-5.4" (it's Anthropic, not an
OpenAI call). Eval pipeline needs an Anthropic API key alongside OpenAI.
---
## 5. Transport readiness (do the pipes exist?)
Backbone exists: orchestrator `context_resolver` builds `user_context` from resume-builder +
social-branding `/api/state/{clerk_id}`; matchmaking calls qscore-service directly.
**REVISED 2026-06-16 — the "gaps" were assumptions; the data is LIVE.** Verified by sampling the
prod DBs over SSH (see `SIGNAL_AUDIT_V2.md` §3):
- **✅ READY:** all Tier A + QScore quotient vector.
- **✅ LIVE (was "partial"):** verified LinkedIn title/company/industry — social-branding does a
**live LinkedIn fetch**; LinkedIn education; certifications.
- **✅ LIVE (was "❌ GAP"):** assessment scores (user-service), interview rubrics (`growqr_interview`,
210 reviews — 8-D rubric + video presence + confidence-ratio), roleplay rubrics (`growqr_roleplay`,
Gemini video analysis), improvement velocity (`trend_data`/`historical_comparison`). **None are a
build — they're a query.** *Caveat: filter degenerate sessions (no-show/low-talk poison scores).*
- **Transport already exists:** interview & roleplay services **already receive résumé/LinkedIn
context** via the orchestrator `context_resolver`. **matchmaking-v2 reuses that exact pattern** —
no new transport to build. v1 wiring = consume the assembled `user_context` + one extra read of
course watch-behavior (`growqr_course`).
- **Still: QScore is the competence proxy for v1** (it already consumes interview/roleplay); read
raw rubrics later only if they add lift beyond the aggregate.
---
## 6. Auto-apply (the heart) — "one tap at the end"
> **➜ `AUTOAPPLY_STATE.md` supersedes/extends this section** (validated POC, 2026-06-16). Headline
> update: the original "full auto-submit = NO-GO" is **REVISED** — a server-side **AI browser agent
> (browser-use + gpt-4.1 vision)** is now a *validated, board-agnostic "master key"*:
> - **Proven:** a real application was submitted to OpenAI on AshbyHQ (on-screen success confirmed);
> the *generic* adapter then filled Greenhouse forms it had never seen (4/5) and India-native ATS
> (Zoho Recruit, Keka) with no tuning, honestly bouncing login-walled / no-form pages.
> - **Architecture:** one generic engine + a thin **adapter registry** (Ashby adapter carries earned
> quirks; generic adapter handles the long tail by vision). Verification-first: screenshot-as-
> ground-truth + a hard accuracy gate (never trust the agent's self-report).
> - **Tiered reality:** autonomous for **public ATS (Ashby/Greenhouse/Lever) + India-native ATS**;
> login-gated boards (LinkedIn Easy Apply, Naukri dashboard, Workday/Oracle) stay human/Cloud-assisted.
> - **Economics (measured):** gpt-4.1 **vision-off** ~$0.13/app (clean ATS), ~$0.210.27 (India ATS),
> ~36 min/app; **gpt-4o-mini is a false economy** (failed + not cheaper). Latency needs parallelism.
> - **Anti-bot is the make-or-break risk:** Ashby spam-flagged the *fast* LinkedIn-routed submit;
> the fix was direct-to-ATS + human-like pacing + verified upload. reCAPTCHA/hCaptcha remain a wall
> (simple text CAPTCHAs are vision-solvable). New POC code: `poc/browser-apply/` (the old
> `poc/auto-apply/` mock is retired).
By the time the user taps, all the work is done (found, tailored, filled). The tap ends an
application, it doesn't start one. In the no-install web app, "one tap" resolves two ways:
- **🛰️ Hosted submit** — backend POSTs the application to an **ATS API** (Greenhouse / Lever /
Ashby). True one-tap-done, any device, zero install. **Gated by employer creds** → coverage =
the integrations we provision; grows over time.
- **🔗 Prepared handoff** — one tap opens the board's own page (LinkedIn/Naukri) with résumé +
cover letter + answers staged; user taps submit there. Zero install, any device, zero ban risk.
- **Phase 4 full auto-submit:** NO-GO for now (unattended submit ~46% reliable + ToS-exposed;
the survivor pattern is user-device extension autofill + human submit). Opt-in only when revisited.
POC validates the hosted-submit path end-to-end → see `poc/auto-apply/`.
**Hard rule: never fire test applications at real employers' live postings.** POC-0 = mock ATS;
POC-1 = our own ATS sandbox/test job.
### 6.1 LISTING FRESHNESS / LIVENESS — first-class gate (learned from the POC, June 12)
The personalized demo tapped a real LinkedIn posting that was already "no longer accepting
applications." Then we empirically checked ~7 recent AI-Architect postings: **essentially all
were dead** (closed / HTTP 404 / redirect-to-search = LinkedIn's expired-job behavior). Findings:
- **A perfect match for a dead job is worse than no match** — it's actively undelightful.
- **Permalinks rot in days.** Never hand a user a *cached* posting URL — it's probably dead.
- **Tap-time server-side liveness re-check is unreliable:** LinkedIn serves bot-walled / guest
content (our httpx check false-returned "open" for a job we knew was closed), and expired URLs
302→search or 404. You cannot trust a scrape to tell you a posting is open.
Requirements for v2:
1. **Freshness at the DATA layer:** the on-demand Apify fetch returns posting date / `isExpired` /
applicant count — filter & rank by recency, drop/flag stale **before presenting**. The whole
point of on-demand: every shown listing was fetched seconds ago, so it's fresh by construction.
2. **Honest age signals** in the UI ("posted 3d ago", "closes soon") — derive from posting age
(no scraper returns a true deadline field).
3. **Apply handoff targets a live view**, never a stored permalink: the freshly-fetched listing
or the board's **live search** for the role (always current). Recover gracefully, never dead-end.
---
## 7. Proven vs. validate-locally (honesty ledger)
| Locked & proven (adopt method) | Our design — must validate locally |
|---|---|
| Hybrid fusion > embedding-only ranker | Exact fusion weights (60/25/15 was tuned on a tiny set) |
| Weighted-avg rank > RRF | The coverage-renormalization formula + `c_f` values |
| Synonym + KG-expand + contrastive skills > exact match | FIT/STRETCH reachability scoring |
| LLM-as-teacher for cold-start; pairwise > pointwise | India pedigree/fairness mitigations |
| Embedding-only collapses on sparse profiles | ESCO coverage for India roles |
**Headline accuracy numbers do NOT transfer** (single-paper, small-benchmark, domain-mismatched —
gig matching, Chinese job ads, offline corpora). Adopt the **patterns**, re-tune on GrowQR data.
## 8. Open items
**Resolved 2026-06-16:**
- ✅ Apify inbound validated (Naukri → `muhammetakkurtt`; direct-ATS = autonomous backbone; offsite-
yield measured). ✅ Auto-apply POC validated end-to-end (master key, real submission, India ATS).
- ✅ Signal audit refreshed against live evidence (`SIGNAL_AUDIT_V2.md`). ✅ Agent capability pass
(interview/roleplay strong, Pathways cut). ✅ Slot-in/decommission plan (`DECOMMISSION_PLAN.md`).
**Still open:**
- **Build the engine itself** — wire the v2 input set (Backbone + verified LinkedIn + QScore +
course watch-behavior) via the reused `context_resolver`; implement the §3 cascade.
- Latency benchmark of the actors from ap-south-1; **parallelize apply** (36 min/app is too slow serial).
- **Submit-time anti-bot lane** (the make-or-break): Cloud stealth / CAPTCHA / human-final-tap.
- Build the ~50100 pair India gold set; validate Opus-judge agreement. ESCO India coverage check.
- Label volume for the learned ranker (old corpus was 21 users — accrue via the live save/apply loop).
**UI — in progress (2026-06-17, branch `job-ui-final`):**
- ✅ Scout UI built **in-place in `dashboard-ui/scout`** (not a separate `index.html` POC) around the
locked feature set; screen-by-screen decisions + as-shipped divergences tracked in `SCOUT_UI_SPEC.md`
("BUILD PROGRESS" section). Re-verified honest against this doc — all cuts/improvements, no
unbacked claims. Notable: cascading **Country→City** location (feeds Tier-A `preferred_locations` +
ported location-fit); a **no-resume "Any/open" state** that is the UI face of §3.3 (sparse profile
coverage renormalizes, never fabricates); loader + scope made honest (≤120, on-demand framing).
- 🔧 UI still waiting on the engine for: apply-result statuses (proof screenshots + auto/failedmanual
split), per-job resume-tailoring spinner, and the real signal/match-breakdown wiring behind the cards.
## Key sources (engine research, 2026)
- Synapse (arXiv 2604.02539) hybrid fusion > embedding-only; WAvg rank fusion +31.9% nDCG@10.
- JobMatchAI (arXiv 2603.14558) — white-box utility weights; ESCO synonym + depth-2 KG expansion.
- ConFit / ConFit v2 (arXiv 2401.16349) — contrastive + paraphrase augmentation; label sparsity <0.05%.
- Person-Job Fit distillation (arXiv 2601.10321) LLM-as-teacher soft scores; specificity 0.0580.208; CMMD.
- JobSkape (arXiv 2402.03242) ESCO span-match F1 ~2627% ceiling.
- CareerBERT (arXiv 2503.02056) embedding degradation on short/sparse profiles (coverage collapse).
- Springer 12651-025-00409-x only 41/202 soft skills exact-match ESCO.

View File

@@ -0,0 +1,154 @@
# Scout → Engine — Exhaustive Input Inventory
> Every field the **new Scout UI** (`dashboard-ui`, branch `job-ui-final`) produces that is (or should
> be) an input to matchmaking-v2. Derived field-by-field from the code, cross-checked against what the
> handlers actually pass (`ScoutConfirm.go()`, `ScoutBubbleJourney.toPrefs()`). Written 2026-06-17.
> Pair with `ENGINE_DESIGN.md` §2 (signal tiers) and `SIGNAL_AUDIT_V2.md`.
There are **three kinds of input**:
1. **Search prefs** — the `ScoutPrefs` object the UI hands the engine per search (`onDone(prefs)`).
2. **Profile/context** — assembled by the orchestrator `context_resolver` (resume + LinkedIn + QScore +
course-behavior); NOT picked in Scout, but the Confirm card *displays/edits* some of it.
3. **Behavioral events** — view/save/pass emitted while swiping (learning loop + activity rank).
---
## 1. SEARCH PREFS — the `ScoutPrefs` contract (what `onDone` sends)
| # | Field | Type | UI surface(s) | Options / shape | Engine role (per ENGINE_DESIGN §2/§3) |
|---|---|---|---|---|---|
| 1 | `role` | `string[]` | Bubble journey role stage (like/love); Confirm via intent | Product/Design/Engineering/Data/Marketing/Sales/Ops/Open-to-any **+ add-your-own** | Tier-A `target_roles`**filter, not score** (want, not is) |
| 2 | `industry` | `string[]` | Confirm "Industry" select; Bubble journey industry stage | Fintech/SaaS/E-commerce/EdTech/HealthTech/Dev tools/Consulting **+ Any + add-your-own** | Tier-A `target_industries` — filter |
| 3 | `location` | `string[]` | Confirm "Where" (LocationPicker); Bubble constraints (LocationPicker) | `"City · Country"` strings (+ Remote/Anywhere) — cascading Country→City **+ custom** | Tier-A `preferred_locations`**hard filter + location-fit** (city AND country substring; Remote→100; Anywhere→open) |
| 4 | `workMode` | `string[]` | Confirm "Work mode"; Bubble constraints | Remote/Hybrid/On-site (Any = none) | Tier-A work mode — hard filter |
| 5 | `experience` | `string[]` | Confirm "Seniority" select; Bubble constraints | Fresher/Junior/Mid-level/Senior/Lead **+ Any** | Tier-A seniority floor — hard filter |
| 6 | `targetComp` | `string?` | Confirm "Target comp" select | ₹1014L … ₹30L+ **+ Any** | preference signal (NOT self-declared salary, which §2 discards; this is target band) |
| 7 | `companyStage` | `string?` | Confirm "Company stage" select | Seed/Growth/Late-pre-IPO/Enterprise **+ Any** | company-fit enricher |
| 8 | `availability` | `string?` | Confirm "Availability" select | Open now / 30d / 6090d / Just exploring **+ Any** | timing context |
| 9 | `dealBreakers` | `string[]?` | Confirm "Deal-breakers" chips | Remote-first/No on-call/Equity/Visa/4-day/No relocation | hard exclusions |
| 10 | `priorities` | `string[]?` | Confirm "What matters most? · Pick 2" | up to 2 of PRIORITY_OPTIONS | **ranking weights** (soft — orders results) |
| 11 | `stretch` | `Stretch?` | Confirm "How far should I reach?" | safe / balanced / reach | FIT↔STRETCH reach band (§3.5) → match-score floor |
| 12 | `sort` | `"match"\|"salary"\|"closing"` | default "match"; Shortlist has a local sort | — | output ordering (not a match signal) |
| 13 | **`intent`** *(not stored on prefs; shapes 1/2/5)* | `"same"\|"levelup"\|"new"` | Confirm "What are you after?" | More like this / A step up / Something new | **FIT vs STRETCH selector** (§3.5) — current-state vs aspiration |
| 14 | **`title`** ✅ wired 2026-06-17 | `string?` | Confirm card "Title" input | free text | Tier-A `current_role` / title (filter + weight). Sole title source when no resume |
| 15 | **`years`** ✅ wired 2026-06-17 | `number?` | Confirm card "Experience (yrs)" input | number | Tier-A `years_experience` (weight). Sole years source when no resume |
| 16 | **`targetTitles`** ✅ added 2026-06-17 | `string[]` (single-select → ≤1) | Bubble journey **stage 3 "Roles that fit you"** — AI/resume+LinkedIn-generated specific titles, **pick one** | e.g. "Payments PM", "Risk & Fraud Product" | **High-signal Tier-A target** — the precise role the engine should prefer to **seed the on-demand board query** (specific > broad `role`). The "true fine-tuning" pick. *Generation is a STUB for the GPT-5.4 /suggest call, mirroring the courses AI flow.* |
> Both `title` and `years` flow through **both** paths — `ScoutConfirm.buildPrefs()` and (via the
> carry-forward `base`) `ScoutBubbleJourney.toPrefs()`, so Fine-tune no longer drops them.
---
## 2. ⚠️ Remaining gap — captured but not fully used
| Field | Where it's collected | Status | Why it matters |
|---|---|---|---|
| **bubble `like` vs `love`** | Bubble journey (3-state) | **Flattened**`toPrefs()` maps both → "selected" | "love" = top priority — a free **weight** signal still collapsed to binary. Optional: feed a per-pick weight |
**Resolved 2026-06-17:** the bubble-journey path (`toPrefs`) used to drop everything except
role/industry/workMode/experience/location. It now starts from the **carry-forward `base`** the Confirm
card passes in, so title/years/comp/stage/availability/deal-breakers/priorities/stretch survive Fine-tune.
---
## 3. ✅ RESOLVED — orphaned prefs DROPPED from the contract (2026-06-17)
These had no UI surface left, so they were removed from `ScoutPrefs` entirely (interface, `emptyPrefs`,
`resumePrefs`, `filterSortScoutJobs`, `go()`), and the orphaned `ScoutFilterSheet.tsx` + its option
constants (`JOB_TYPE_OPTIONS`, `SALARY_BANDS`, `JOB_TYPE_TO_VALUE`, `DREAM_COMPANY_OPTIONS`) were deleted.
| Field | Was | Now |
|---|---|---|
| `jobType` | Action filter sheet (removed) | **dropped** |
| `salaryBand` | Action filter sheet (removed) | **dropped** (`targetComp` is the surviving comp input) |
| `dreamCompanies` | Confirm card edit (cut) | **dropped** |
| `hideEmployer` | Confirm card toggle (cut) | **dropped** |
---
## 4. BEHAVIORAL EVENTS — emitted while swiping (feed the learning loop, not the per-search query)
| Event | UI trigger | Engine role |
|---|---|---|
| **VIEW** | open a card / tap info (expand QX breakdown) | funnel "Viewed"; activity rank; weak relevance label |
| **SAVE** | swipe right / Save (heart) | funnel "Shortlisted"; **strong positive label** (§3.1 ⑥ accrue save/apply → learned ranker) |
| **PASS** | swipe left / X | negative label |
| **UNDO** | undo button | corrects the last save/pass |
| **APPLY (hand-off)** | DeckEnd "approve once" → auto/manual apply | **un-gameable ground truth** (§4) — the real save/apply rate |
---
## 5. PROFILE / CONTEXT — assembled by `context_resolver`, surfaced (not picked) in Scout
The Confirm card *displays and lets you edit* the resume-derived defaults; the rest is read server-side.
A **no-resume user** sends all of §1 as "Any"/open → engine §3.3 coverage-renormalizes (never fabricates).
| Source | Fields | Tier |
|---|---|---|
| **Resume (resume-builder parse, "Mira")** | title, seniority, industry, city, years, + pre-filled guesses for comp/stage/availability/deal-breakers/priorities/stretch (editable on the card) | A / B |
| **LinkedIn (social-branding, live fetch)** | verified title / company / industry; education; certifications | B |
| **QScore** | 10-D quotient vector (already digests interview + roleplay) | B |
| **Assessment** | passed / percentage (user-service) | B |
| **Course watch-behavior** (`growqr_course`) | revealed-preference booster (e.g. completes React → frontend signal) | C (additive) |
| **Interview / roleplay rubrics** | 8-D / 6-D (low coverage) | C (additive) — read later only if lift beyond QScore |
---
## 6. DEEP AUDIT — every engine signal × frontend coverage (2026-06-17)
Walks the **entire** `SIGNAL_AUDIT_V2.md` inventory (Tier A/B/C + labels) and assigns each signal an
owner. **Rule:** the frontend owns only what the *user states* (request prefs + editable overrides +
feedback events). Everything resume/identity/competence-derived is **server-assembled by
`context_resolver` / qscore / Perplexity / `growqr_course`** — by design NOT a Scout UI field.
| Engine signal (SIGNAL_AUDIT_V2) | Owner | Frontend field / event | Status |
|---|---|---|---|
| **Tier A** | | | |
| `years_experience` | Frontend override + resume | `years` (Confirm) | ✅ WIRED (was the gap) |
| `current_role` / title | Frontend override + resume/LinkedIn | `title` (Confirm) | ✅ WIRED (was the gap) |
| `skills` → ESCO | **Server** (resume parse) | — (not a Scout pref) | ✅ correctly server-side |
| `preferred_locations` + work mode | Frontend (request prefs) | `location` (City·Country), `workMode` | ✅ WIRED |
| `target_roles` / `target_industries` | Frontend (request prefs) | `role`, `industry` | ✅ WIRED |
| **Tier B** | | | |
| verified title / company / industry | **Server** (social-branding LinkedIn) | industry/title *overridable* via Confirm | ✅ server; overrides wired |
| degree / field / institution | **Server** (resume/LinkedIn) | — | ✅ server-side |
| certifications | **Server** (resume/LinkedIn) | — | ✅ server-side |
| assessment scores | **Server** (user-service) | — | ✅ server-side |
| QScore quotient vector (10-D) | **Server** (qscore-service) | — | ✅ server-side |
| market context (demand/freshness/comp) | **Server** (Perplexity, cached) | — | ✅ server-side |
| **Tier C (additive boosters)** | | | |
| course watch-behavior | **Server** (`growqr_course`) | — | ✅ server-side |
| improvement velocity / coachability | **Server** (interview/roleplay) | — | ✅ server-side |
| video on-camera presence | **Server** (interview/roleplay) | — | ✅ server-side |
| **Soft ranking / FIT-STRETCH (engine §3.4/§3.5)** | | | |
| ranking weights | Frontend | `priorities` (Pick 2) | ✅ WIRED |
| reach band | Frontend | `stretch` | ✅ WIRED |
| FIT vs STRETCH selector | Frontend | `intent` | ✅ WIRED |
| comp / stage / availability / deal-breakers | Frontend | `targetComp`/`companyStage`/`availability`/`dealBreakers` | ✅ WIRED |
| **Labels (training targets)** | | | |
| SAVE / DISMISS / APPLY feedback | Frontend events | swipe deck + apply hand-off | ✅ emitted (§4) |
### Verdict
**Every engine input the frontend is responsible for is now wired.** After this pass there are **no
dropped frontend fields** — `title` + `years` were the last gaps and are now sent on both paths.
Everything still "unwired" in the UI is **server-assembled context** (skills, LinkedIn identity,
QScore, assessment, course-behavior, market context) and *must not* be a Scout form field — capturing
it client-side would duplicate or fake what `context_resolver` already assembles.
**Only non-blocking residual:** the bubble `like` vs `love` 3-state is flattened to binary "selected"
(`toPrefs`); wiring it as a per-pick weight is an optional enhancement, not a missing engine input.
---
## Summary — counts
- **15 search-pref fields** actively sent (incl. intent). The `ScoutPrefs` contract is now exactly:
`role · location · workMode · experience · industry · sort · title · years · targetComp ·
companyStage · availability · dealBreakers · priorities · stretch` (+ `intent` shapes role/industry/experience).
- **title + years WIRED** (2026-06-17) on both the Confirm and Fine-tune paths — were the last gaps.
- **1 non-blocking residual** (bubble like/love → binary) — optional weight enhancement, not a missing input.
- **4 orphaned prefs DROPPED** (jobType, salaryBand, dreamCompanies, hideEmployer) — contract trimmed
to only what the UI renders.
- **5 behavioral events** for the learning loop.
- **6 server-assembled context sources** (skills/LinkedIn/QScore/assessment/course-behavior/market) —
correctly NOT Scout fields.
- **Deep-audit verdict (§6):** every frontend-owned engine input is wired; no dropped fields remain.

View File

@@ -0,0 +1,134 @@
# Scout UI — Lean Finalization Spec
> Screen-by-screen "what we surface" decisions for the Scout UI, made against what the **engine +
> deployed agents can actually produce** (see `ENGINE_DESIGN.md`, `SIGNAL_AUDIT_V2.md`,
> `agent-capability` evidence). Work happens **in-place in `dashboard-ui/scout`** (no rebuilds).
> Written 2026-06-16.
## Guiding principles
- **Don't fake it; lock it.** Features that are real *later* are shown as a tasteful **"coming soon"
locked tile**, never with fabricated data. Nothing here is a lie.
- **Personas stay only where backed.** **Mira** = real (resume-builder parsed/tailored an existing
résumé) → keep. The bubble-journey **"Nova"** is being replaced wholesale (see Configure) → gone.
- Cut the three never-backed claims everywhere: **"live status", "in-network", invented deadlines.**
---
## Summary tab (two states)
### First-time view → **UNCHANGED**
Onboarding doorway (hero + 4-step "how it works" + CTA). Locked as-is — "812 live roles" reads as
*market scale*, not a corpus claim; "Mira" is backed. No edits.
### Returning view (metrics dashboard) → leaned
- **KEEP (producible today):** Streak · Monthly report · Readiness·QX (qscore + assessment) ·
Matches found · Top match · Salary band · Competition (avg applicants from listings) · Match range/
distribution · Momentum / Readiness trend (qscore over time) · What your agents did (logged) ·
**Application funnel** = `Matches → Viewed → Shortlisted → Applied`, **all user-activity** (Viewed =
jobs the user opened, Shortlisted = saved) · **Your Apply window** (freshness-based urgency from
listing posting-age, replaces generic "Golden window") · **Your rank** = activity percentile from
**Scout activity only** (searches/views/saves/applies in the matchmaking DB; "top X% by how much
you're working your search" — honest engagement, not a quality claim) · Search stage.
- **🔒 LOCK (coming soon):** Expert / human review.
- **CUT:** Search plan ("as you sleep") · Proof-ready · Market value · Response·tracked (no recruiter
callback) · Warm intros (no referral agent).
---
## Configure tab → ~90% intact (the strongest screen)
| Section | Decision |
|---|---|
| Hero "Here's the search I'd run for you" | ✅ keep |
| Profile summary ("parsed by **Mira**", title/seniority/industry/city/years) | ✅ keep — real (resume-builder + LinkedIn) |
| **"Matching you on N signals"** transparency bar | ✅ keep, **but fix the list** to match `SIGNAL_AUDIT_V2`: keep Role&level + Skills + What-you-value; **Market context is now BACKED via Perplexity** (keep); add verified-LinkedIn / QScore / course-behavior; drop any item with no source |
| "Also matching on · derived" (comp/stage/availability/deal-breakers/dream cos/hide-employer) | ✅ keep — real preference/filter inputs |
| **Video intro** | ✅ **KEEP** — reuse the existing `VideoResumeBuilder.tsx`; persist via interview-service's MinIO blob storage (record → store). Drop the "rides with in-network" copy |
| "What are you after?" (More like this / step up / new) | ✅ keep — **FIT vs STRETCH** core |
| "What matters most?" (pick 2) | ✅ keep — ranking weights |
| "How far should I reach?" (Safe/Balanced/Reach) | ✅ keep — reach band |
| "Work mode" | ✅ keep — hard filter |
| **The must-haves bubble box** ("Fine-tune everything") | ✅ **KEEP the box, swap the engine** → reuse course **`BubblePicker` + `useBubbleField`** (physics: grow-on-like +6 / love +12, live repack). **"Add your own" on EVERY row (crucial)** — esp. **Where/location** (tier-2/3 cities typed → real preferred-location bubble). **AI suggestions via `onGenerateMore` but STRIP the "ai" badge** — untagged, indistinguishable from presets. Replaces `ScoutBubbleJourney`/Nova |
| **Location** (the "Where" row, lives in the bubble box) | ✅ feeds the **ported location-fit logic** (`SIGNAL_AUDIT_V2`): substring city match, Remote→100, Anywhere→open. Add-your-own + that scoring = location finally handled right |
| Footer "Scout never applies without you" | ✅ keep — honest consent |
**Net Configure:** keep almost everything; the only real changes are (1) fix the signal-transparency
list to the true set, (2) reuse `VideoResumeBuilder` for video-intro, (3) replace the bubble journey
with the course bubbler (add-your-own + untagged AI), (4) wire the ported location logic.
---
## Action tab → leaned, with an honest apply flow
**A) Loader** (`JobMatchingLoader`) — **rework copy to the on-demand truth:** *"Looking in 3+ job
boards · Scanning 100+ roles"* → normalize skills → score against your signals → rank. Drop the fake
"812 corpus scan." ("fresh by construction — pulled seconds ago.")
**B) Filter bar** — ✅ keep (the hard filters + active chips + Clear all).
**C) Match card** (`MatchCard`):
| Element | Decision |
|---|---|
| match % + **QX breakdown** | ✅ keep — but it IS **the engine's match breakdown**: the white-box signal dimensions (skill match, experience, **location-fit**, course-behavior, QScore competence, market context). "Why we matched you," powered by the real engine, not gamified QX |
| title · ₹pay · applicants | ✅ keep (from listings) |
| **"Closes in N days"** | ❌ **removed** → "posted Nd ago" (no real deadline data) |
| "+N QX on apply" · "Proof-ready" · "In-network · live status" | ❌ cut |
| "Video intro ✓" | ✅ keep (reuse Video Resume; drop in-network gating) |
| "Warm intro · can refer you" | ❌ cut (no mentor/referral agent) |
| "light on {gap} → 15-min mock / Expert mock / {course}" | ✅ keep (interview/roleplay/course are real) |
| "Why Scout picked this" + tags | ✅ keep — wire to the TRUE signals |
**D) Swipe controls** (Pass / undo / info / Save) — ✅ keep. These emit the **VIEW / SAVE / PASS
events** that feed the funnel + activity rank.
**E) Apply flow (the hand-off) — make it honest and concrete:**
1. **Approve once** — the single consent tap (keep).
2. **Per-job résumé tailoring** — for each saved role, resume-builder tailors the résumé + cover
letter. **NEW UX: a per-job spinner** ("Tailoring your résumé for {company}…") — this step had no UI.
3. **Auto-apply** — the master-key engine submits (tier-aware: public-ATS/India-ATS = autonomous;
login-gated = staged handoff).
4. **Result — split, with proof:**
-**Applied (auto):** show the **captured confirmation screenshot** per role — *verified, not
assumed* (the engine already screenshots the success page).
- ⚠️ **Couldn't auto-apply** (login_required / no_form / failed / spam-flagged): list these with
**"Apply manually →"** + the live link, so the user finishes them by hand.
**Net Action:** real bones kept (match + engine breakdown + why + mock/course boosts + apply), the
recurring cuts applied (in-network/live-status, +QX framing, warm-intro/referral, add-to-pathway,
deadlines), loader made honest, and the apply flow gains the **resume-tailoring spinner** + **proof
screenshots** + **manual-apply fallback** for failures.
---
## BUILD PROGRESS — as shipped on branch `job-ui-final` (updated 2026-06-17)
Built in-place in `dashboard-ui/scout` (+ `components/job-matching`). Committed/pushed:
`job-ui-final` (head `780e09e`+). All states compile; `/scout` serves clean. The build went
**further than this spec in places** — recorded here so the spec stays the source of truth.
Honesty check: **every divergence is a cut or an improvement; nothing surfaces a claim the engine
can't back** (re-verified against `ENGINE_DESIGN.md`).
**Configure (most changed):**
- ✅ Bubble box swapped to the ported course physics — `components/scout/bubble/{useBubbleField,ScoutBubbleField}`; like/love grow+repack, **add-your-own + untagged "Suggest more"** on taste rows. (`ScoutBubbleJourney` rewired off Nova/`NovaBubbles`.)
- ✅ Location upgraded **beyond spec**: cascading **Country→City** picker (`LocationPicker.tsx`, ported from the old matchmaking `FilterBar` — flags, searchable city, "select country first"). Stored as `"City · Country"`; the deck matcher now scores **city AND country** (`filterSortScoutJobs`). Feeds Tier-A `preferred_locations`.
- ✅ Profile card is now **editable in place** ("Edit details" ↔ "Done") + a **"Fine-tune" orange pill** (header) and **"Fine-tune everything"** (bottom) → the bubble journey. All dropdowns are one shared **searchable `CustomSelect`** (type-your-own → "Use 'X'") with an **"Any"** option that maps to *no constraint* for the engine.
-**No-resume / nothing-selected state** (NEW, not in original spec): card opens in edit mode, every field defaults to **"Any"**, headline "What are you looking for?", an **"Add a resume to pre-fill"** action (simulated Mira parse). This is the UI face of engine §3.3 (sparse profile → coverage renormalizes, never fabricates). Preview toggle to view both states.
- ⚠️ **DIVERGENCE — "Matching you on N signals" bar: REMOVED** (text-light pass), not "kept + fixed" as spec said. Signals still feed the engine; just not surfaced. *Re-add a 1-line honest version if we want the credibility back.*
- ⚠️ **DIVERGENCE — Video intro: REMOVED** from card + chip (Tier-C, not wired). Spec wanted keep.
- ⚠️ **DIVERGENCE — dream-companies + hide-employer editing dropped** (kept comp/stage/availability/deal-breakers as read-only chips → editable inline).
- ✅ Removed the lame **"Use my résumé instead"** shortcut everywhere.
**Summary:**
- ✅ 9 headline cards (activity-led) + 3 behind "More metrics"; Expert/human review **locked "coming soon"** (Mira/Vera hand-offs stay live).
- ✅ Market signals trimmed to producible set (dropped layoff/funnel/hiring-velocity).
- ⚠️ **DIVERGENCE — Readiness·QX assessment card REMOVED** (+8 QX reward theatre gone). QScore/assessment remain real Tier-B engine signals; "Readiness trend" still in More-metrics. Spec wanted keep.
- ⚠️ **PENDING — "Golden apply window" not yet renamed** to the freshness/posting-age "Apply window" (§6.1).
**Action / match:**
- ✅ Loader honest: `Looking across 3+ job boards · Scanning 100+ live roles · Scoring · Ranking`; **MATCH_SCOPE 812→120**.
-**Video-intro chip removed** from `MatchCard`.
- ✅ Swipe card **action-bar overlap fixed** — card now renders in normal flow (`AnimatePresence popLayout`), so expanding the QX breakdown grows the page and Pass/Undo/Info/Save stay below it (removed the brittle absolute+ResizeObserver container).
- ⚠️ **DIVERGENCE — Action-tab filter bar + chips + `ScoutFilterSheet`: REMOVED.** Configure is the single source of search criteria; empty deck → "Adjust in Configure". Spec wanted keep.
- 🔧 **PENDING (needs engine):** apply-flow proof screenshots + auto/failed→manual split; per-job resume-tailoring spinner. These wait on engine apply-result statuses.
**Text/polish:** all accented "résumé" → plain **"resume"**; "Pick 2" casing.

View File

@@ -0,0 +1,107 @@
# Signal Audit v2 — Evidence-Based Input Inventory
> Companion to `ENGINE_DESIGN.md`. Supersedes its §2/§5 signal audit. Written 2026-06-16, grounded
> in **live prod-DB evidence** (sampled over SSH) rather than the original conservative assumptions.
> Key correction: the v1 doc flagged interview/roleplay/assessment as "❌ GAP — build transport."
> The data is **live and queryable today** — richer engine is mostly *wiring + discipline*, not new builds.
---
## 0. Governing discipline (unchanged — the v1-collapse guardrails)
More signals make the engine **stronger only if** we keep these rules. Breaking them is how v1 died.
1. **Trust ⟂ coverage.** A signal earns SCORE WEIGHT only if coverage is high enough that absence
isn't the norm. Otherwise it is an **ADDITIVE BOOSTER** (bonus when present, never a penalty when
absent). Most new signals are low-coverage → boosters, not weights.
2. **Filter degenerate data.** Verified live: the sampled interview review was a *no-show* (score 30),
the roleplay an *8-word low-talk*. These would poison the profile. Drop `no_show` / `low_talk`
sessions (the data already flags `exclude_from_trend: true` / `scoring_kind: no_show`).
3. **Coverage-aware renormalization** (`ENGINE_DESIGN.md` §3.3): score over *present* signals only;
a missing signal redistributes its weight, never flattens.
## 1. The signal inventory (by tier)
### Tier A — Backbone (high coverage → filter + weight)
| Signal | Source (verified) | Role | Flows in via |
|---|---|---|---|
| years_experience (date-derived) | resume-builder parse | weight | context-resolver |
| current_role / title | resume + verified LinkedIn | filter + weight | context-resolver |
| skills (→ ESCO normalized) | resume-builder parse | **core weight** (skill match) | context-resolver |
| **location + work mode** | user prefs (Configure must-haves) + user-service | **hard filter + 30% weight** | request prefs |
| target_roles / industries | Configure (user-stated *want*) | filter, not score | request prefs |
> **Location logic — PORT from the old engine** (`matchmaking/app/engine/core.py`, it did this better
> than v1's one-liner). Bring forward `_score_location_fit` + `_location_filter_blocks_match`:
> remote job → 100 (0 if seeker is onsite-only); else **city+country substring match** vs preferred
> locations (so an add-your-own typed city "just works"); travel-mode adds travel locations;
> unknown → neutral 60. As a **hard gate**: block remote for onsite-only seekers, block no-fit jobs
> (clamp below threshold) unless the seeker set no location prefs ("Anywhere" = open). Weighted 30%.
### Tier B — Verified enrichers (medium coverage → real weight when present)
| Signal | Source (verified) | Role | Notes |
|---|---|---|---|
| verified title / company / **industry** | **social-branding live LinkedIn fetch** | weight (present) | doc said "partial" — it's *live* |
| degree / field / institution | resume parse / LinkedIn education | weight (present) | |
| certifications | resume / LinkedIn | booster | |
| assessment scores | user-service (assessment tables) | booster | doc called it a "gap"; it exists |
| **QScore quotient vector (10-D)** | qscore-service | **competence proxy (weight, coverage-penalized)** | see §2 — this is the key aggregate |
| **Market context** (demand, hiring velocity, posting freshness, comp band) | **Perplexity API (online)** — TESTED 2026-06-16, returns sourced numbers + honest "can't-verify" on layoffs | enricher / context for ranking + the UI "why" | **cache per role+location** (latency/cost). Honest by design — Perplexity flags what it can't confirm (e.g. layoff signals). Replaces the fabricated market-intel that was in the UI signal list. |
### Tier C — Behavioral / competence boosters (LOW coverage → ADDITIVE ONLY)
| Signal | Source (verified) | Role | Notes |
|---|---|---|---|
| **Course watch behavior** (watch-time, completion, interest profile) | `growqr_course` | **booster — target-role inference + FIT/STRETCH direction** | **NEW class. Highest-trust: revealed preference > stated.** "Completing React content → step toward frontend." |
| improvement velocity / coachability | interview/roleplay `trend_data`, `historical_comparison` | booster | growth trajectory |
| video on-camera presence | interview/roleplay `video_analysis` (Gemini 2.5 Pro) | booster | <1% relevant; rarely surfaced |
> Interview 8-D rubric + roleplay EQ are **not listed as independent inputs** — see §2 (already folded into QScore).
### Labels (training targets, NOT scoring inputs)
| Signal | Source | Use |
|---|---|---|
| SAVE / DISMISS / APPLY / RSVP feedback | old matchmaking (`Record Feedback`) + **our apply engine outcomes** | accrue labels train pairwise/listwise ranker (`ENGINE_DESIGN.md` §3.1) |
## 2. Entanglement: QScore already consumes interview/roleplay
**Do not double-count.** QScore is *consumed by* interview & roleplay, and the interview/roleplay
competence is *digested into* the QScore quotient vector. So for the engine:
- **v1 (recommended):** use the **QScore quotient vector as the competence proxy** it already
incorporates the interview/roleplay rubric signals. This matches `ENGINE_DESIGN.md` §5: "launch on
READY rails (Tier A + QScore quotients), add direct rubric access later as a precision upgrade."
- **Later (precision upgrade):** read the **raw** interview/roleplay rubrics directly as boosters
only once we've confirmed they add lift *beyond* the QScore aggregate (else it's redundant weight).
This means course watch-behavior is the **one genuinely new, non-redundant signal** to wire in v1.
## 3. Transport: mostly already solved
**The résumé + LinkedIn service transport already exists** interview & roleplay services already
receive résumé/LinkedIn context (they're résumé/LinkedIn-aware), assembled by the orchestrator's
`context_resolver` into `user_context`. **matchmaking-v2 reuses that exact pattern**: consume the
assembled `user_context` (resume + verified LinkedIn + QScore + assessment) the same way interview/
roleplay do. So the rich user-side profile is a *wiring* job against a **proven, existing transport**,
not a new build. Course watch-behavior is the one extra read (from `growqr_course`).
The user-side profile is then scored against the **on-demand fetched listings** (LinkedIn/Naukri/
Indeed via Apify) the job side stays on-demand; the *user* side is what these signals enrich.
## 4. Cut / discard
- **Pathways (entire) CUT.** Feature dropped (weak link: buggy skill-gap, impractical roles) AND
its questionnaire is **not used as a signal**. The Configure screen already captures stated prefs.
- Carry-over discards (`ENGINE_DESIGN.md` §2): ATS overall_score (resume quality fit), parser
confidence, vanity metrics, self-declared salary, **institution_tier** (pedigree bias).
- UI overclaims with **no backing signal**: "live status" (tap-time liveness unreliable, §6.1),
"in-network" (no referral agent), "Closes in N days" (no deadline data).
## 5. Net: stronger engine, same discipline
v1 input set = **Backbone (resume skills/experience + Configure prefs/filters) + verified LinkedIn +
QScore quotient vector (competence proxy) + course watch-behavior (new, high-trust booster)**, fused
coverage-aware, scored against on-demand listings, with save/apply feedback accruing as labels. That
is materially richer than the doc's "Tier-A + QScore proxy" and it's reachable now because the
transport exists and the discipline (boosters for sparse, filter degenerate) keeps it from collapsing.
## Honesty ledger
| Now proven available (was assumed a gap) | Still must validate |
|---|---|
| Interview/roleplay competence (live in DB; via QScore) | Does raw rubric add lift beyond QScore? |
| Course watch-behavior as a signal | Coverage of active learners; interestrole mapping quality |
| Verified LinkedIn identity (live fetch) | India LinkedIn fetch reliability |
| Feedback labels exist (save/apply) | Volume sufficient to train a ranker (was 21 users) |
| Transport reuse (context_resolver) | matchmaking-v2 wiring into the mesh |

View File

@@ -0,0 +1,35 @@
# Auto-apply POC — "one tap at the end"
Proves the heart of Scout's apply: **one tap → backend submits → confirmation → tracked**,
with the apply-router branching (hosted ATS submit vs prepared board handoff).
## What it proves / doesn't
- ✅ End-to-end one-tap flow + the router that picks the mechanism + latency.
- ✅ Hosted-submit path posts a complete application to an ATS endpoint and gets a confirmation.
- ❌ Does **not** submit to a real employer. The ATS is a **mock** (`/mock-ats/...`).
> **Hard rule:** never fire test applications at real employers' live postings. It pollutes real
> hiring pipelines and is abuse. POC-0 = mock ATS. POC-1 = our own ATS *sandbox* job.
## Run
```bash
python3 -m venv venv && source venv/bin/activate
pip install fastapi uvicorn httpx
uvicorn app:app --port 8765
# open http://localhost:8765
```
## Prove it from the CLI
```bash
curl -s localhost:8765/api/kit/job_ats # kit staged & ready
curl -s -X POST localhost:8765/api/apply/job_ats # the one tap -> submitted + confirmation_id
curl -s -X POST localhost:8765/api/apply/job_board # prepared handoff -> deeplink + staged
curl -s localhost:8765/api/tracker # pipeline view
```
## POC-1 (real ATS sandbox) — what's needed from Rahul
1. Create a **free/trial ATS account** (Ashby has a clean `applicationForm.submit`; or
Greenhouse/Lever trial) and post a **test job** in it.
2. Get the submission endpoint + token/board-token for *that test job*.
3. Set `ATS_SUBMIT_URL` (and add auth) — the mock route is then deleted. Same flow, real ATS.
- Confirms: real field requirements, résumé upload, and that programmatic submit is accepted.

View File

@@ -0,0 +1,233 @@
"""
Auto-apply POC — proves the "one tap at the end" works end-to-end.
What this proves (and what it does NOT):
✅ One tap -> backend assembles the staged application kit -> apply-router picks the
right mechanism (hosted ATS submit vs prepared board handoff) -> submits -> confirmation
-> tracked, with measured latency.
✅ The apply-router BRANCHING that is the architectural heart of the no-install web app.
❌ It does NOT submit to a real employer. The "ATS" here is a MOCK we control
(route: /mock-ats/...). POC-1 swaps ATS_SUBMIT_URL for a real ATS *sandbox* job.
HARD RULE: never fire test applications at real employers' live postings.
Run:
pip install fastapi uvicorn httpx
uvicorn app:app --port 8765
open http://localhost:8765
"""
from __future__ import annotations
import os
import time
import uuid
from typing import Literal
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
app = FastAPI(title="Scout auto-apply POC")
# Swap this for a real ATS sandbox endpoint in POC-1. Default = our local mock.
ATS_SUBMIT_URL = os.environ.get(
"ATS_SUBMIT_URL", "http://localhost:8765/mock-ats/greenhouse/acme-test/eng-42"
)
# ---------------------------------------------------------------------------
# Demo data: two jobs, one routed to an ATS (hosted submit), one to a board
# (prepared handoff). In production these come from the on-demand listings fetch.
# ---------------------------------------------------------------------------
JOBS = {
"job_ats": {
"title": "Product Analyst",
"company": "Acme (Greenhouse)",
"source": "ats", # -> hosted submit
"ats_vendor": "greenhouse",
},
"job_board": {
"title": "Associate PM",
"company": "Globex (Naukri)",
"source": "board", # -> prepared handoff
"board": "naukri",
"apply_url": "https://www.naukri.com/job-listings-associate-pm-globex",
},
}
# The "application kit" — staged BEFORE the tap. In production: tailored résumé from
# resume-builder + field answers from the user's profile.
KIT = {
"candidate": {
"first_name": "Asha",
"last_name": "Rao",
"email": "asha.rao@example.com",
"phone": "+91-90000-00000",
},
"resume_url": "https://files.growqr.ai/resumes/asha-rao-tailored.pdf",
"answers": {
"years_experience": "3",
"work_authorization": "India - citizen",
"notice_period_days": "30",
},
}
# In-memory application tracker (the "pipeline" view).
TRACKER: list[dict] = []
class ApplyResult(BaseModel):
status: Literal["submitted", "prepared"]
mechanism: Literal["hosted_ats", "board_handoff"]
job_id: str
confirmation_id: str | None = None
deeplink: str | None = None
staged: dict | None = None
latency_ms: int
@app.get("/api/kit/{job_id}")
def get_kit(job_id: str):
"""The frontend shows 'kit ready' before the tap."""
if job_id not in JOBS:
raise HTTPException(404, "unknown job")
job = JOBS[job_id]
return {
"job": {"id": job_id, "title": job["title"], "company": job["company"]},
"ready": {
"resume": True,
"cover_letter": True,
"fields_filled": len(KIT["answers"]),
},
"mechanism": "hosted_ats" if job["source"] == "ats" else "board_handoff",
}
@app.post("/api/apply/{job_id}", response_model=ApplyResult)
async def apply(job_id: str):
"""THE ONE TAP. Routes to hosted submit or prepared handoff."""
if job_id not in JOBS:
raise HTTPException(404, "unknown job")
job = JOBS[job_id]
t0 = time.perf_counter()
if job["source"] == "ats":
# Hosted submit: backend POSTs the full application to the ATS API.
payload = {
"first_name": KIT["candidate"]["first_name"],
"last_name": KIT["candidate"]["last_name"],
"email": KIT["candidate"]["email"],
"phone": KIT["candidate"]["phone"],
"resume_url": KIT["resume_url"],
"answers": KIT["answers"],
}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(ATS_SUBMIT_URL, json=payload)
if resp.status_code >= 400:
raise HTTPException(502, f"ATS rejected: {resp.status_code} {resp.text}")
confirmation_id = resp.json().get("confirmation_id")
latency_ms = int((time.perf_counter() - t0) * 1000)
result = ApplyResult(
status="submitted",
mechanism="hosted_ats",
job_id=job_id,
confirmation_id=confirmation_id,
latency_ms=latency_ms,
)
else:
# Prepared handoff: stage everything, hand the user a deep link to the board.
latency_ms = int((time.perf_counter() - t0) * 1000)
result = ApplyResult(
status="prepared",
mechanism="board_handoff",
job_id=job_id,
deeplink=job["apply_url"],
staged={"resume_url": KIT["resume_url"], "answers": KIT["answers"]},
latency_ms=latency_ms,
)
TRACKER.append(
{
"job_id": job_id,
"company": job["company"],
"status": result.status,
"confirmation_id": result.confirmation_id,
}
)
return result
@app.get("/api/tracker")
def tracker():
return {"applications": TRACKER}
# ---------------------------------------------------------------------------
# MOCK ATS — stands in for Greenhouse/Lever/Ashby. POC-1 deletes this and points
# ATS_SUBMIT_URL at a real *sandbox* job. The contract mirrors Greenhouse's job-board
# application submission shape (first/last/email/phone/resume + question answers).
# ---------------------------------------------------------------------------
@app.post("/mock-ats/greenhouse/{board}/{job}")
def mock_ats_submit(board: str, job: str, payload: dict):
required = ["first_name", "last_name", "email", "resume_url"]
missing = [f for f in required if not payload.get(f)]
if missing:
raise HTTPException(422, f"missing required fields: {missing}")
return {
"accepted": True,
"board": board,
"job": job,
"confirmation_id": f"GH-{uuid.uuid4().hex[:10]}",
}
@app.get("/", response_class=HTMLResponse)
def index():
return INDEX_HTML
INDEX_HTML = """<!doctype html>
<html><head><meta charset="utf-8"><title>Scout — one-tap apply POC</title>
<style>
body{font-family:system-ui,sans-serif;max-width:560px;margin:40px auto;color:#1a1a1a}
.card{border:1px solid #eee;border-radius:14px;padding:18px;margin:14px 0;box-shadow:0 1px 4px #0001}
.ready{color:#0a7d34;font-size:14px}
button{background:#ea580c;color:#fff;border:0;border-radius:10px;padding:12px 18px;font-size:16px;cursor:pointer}
button:disabled{opacity:.6}
.out{font-family:ui-monospace,monospace;font-size:13px;background:#faf7f5;border-radius:8px;padding:10px;margin-top:10px;white-space:pre-wrap}
h2{margin:.2em 0}
</style></head><body>
<h1>🕵️ Scout — one-tap apply</h1>
<p>Everything is staged before the tap. The tap <b>ends</b> the application.</p>
<div id="cards"></div>
<script>
const JOBS = ["job_ats","job_board"];
async function load(){
const root = document.getElementById('cards'); root.innerHTML='';
for(const id of JOBS){
const kit = await (await fetch('/api/kit/'+id)).json();
const div = document.createElement('div'); div.className='card';
div.innerHTML = `<h2>${kit.job.title}</h2><div>${kit.job.company}</div>
<div class="ready">✓ Résumé ready · ✓ Cover letter ready · ✓ ${kit.ready.fields_filled} fields filled
· ${kit.mechanism==='hosted_ats'?'🛰️ hosted submit':'🔗 prepared handoff'}</div>
<p><button onclick="apply('${id}',this)">🚀 Apply (one tap)</button></p>
<div class="out" id="out-${id}" style="display:none"></div>`;
root.appendChild(div);
}
}
async function apply(id,btn){
btn.disabled=true; btn.textContent='';
const r = await (await fetch('/api/apply/'+id,{method:'POST'})).json();
const out = document.getElementById('out-'+id); out.style.display='block';
if(r.status==='submitted'){
out.textContent = `✅ APPLIED — confirmation ${r.confirmation_id}\\nmechanism: ${r.mechanism} · ${r.latency_ms}ms`;
btn.textContent='✓ Applied';
} else {
out.textContent = `🔗 PREPARED — tap to finish on the board:\\n${r.deeplink}\\nstaged résumé + ${Object.keys(r.staged.answers).length} answers · ${r.latency_ms}ms`;
btn.textContent='Open board →'; btn.disabled=false;
btn.onclick=()=>window.open(r.deeplink,'_blank');
}
}
load();
</script>
</body></html>"""

View File

@@ -0,0 +1,192 @@
"""
Scout — personalized one-tap apply DEMO, now with a LIVENESS GATE.
The lesson from the first run: job postings expire fast (we checked 3 recent AI-Architect
postings live — 2 were already closed). A perfect match for a DEAD job is worse than no match.
So this version re-checks the posting is still accepting applications AT TAP-TIME:
• still open -> prepared handoff: open the real posting with materials staged (you submit).
• just closed -> Scout catches it and recovers to FRESH, currently-open matches.
It never sends you to a closed door.
LinkedIn = a board, so Scout never auto-submits — it preps everything and you give the final tap.
Run:
source venv/bin/activate
uvicorn scout_demo:app --port 8765
open http://localhost:8765
"""
from __future__ import annotations
import re
import httpx
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
app = FastAPI(title="Scout demo — Rahul")
# The matched posting (may be expired — that's the point; we re-check live).
JOB_URL = "https://www.linkedin.com/jobs/view/ai-testing-architect-remote-at-teksystems-4418606196"
# Always-current fallback: live LinkedIn search for fresh AI Architect roles (remote).
LIVE_SEARCH = "https://www.linkedin.com/jobs/search/?keywords=AI%20Architect&f_WT=2&sortBy=DD"
COVER_LETTER = """Dear Hiring Team,
I build production AI systems and the verification that keeps them honest — exactly the
intersection an AI Architect role sits at.
Most recently I architected a 12-agent, event-driven platform (FastAPI, Redis Streams, an
agent-to-agent protocol) with LLMs and a pgvector retrieval layer in production on AWS. To
trust it, I built an adversarial verification harness: multi-model panels that fan out, fetch
evidence, and vote to refute claims before they ship — AI-enabled QA in practice, with
explainability and responsible-use guardrails built in.
My stack — Python, FastAPI, LLM/GenAI, vector databases, CI/CD, and AWS/Azure/GCP — lines up
directly with the requirements, and I've shipped and mentored on all of it.
Warm regards,
Rahul Gupta
rahulgupta2608@icloud.com"""
@app.post("/api/apply")
async def apply():
"""
Lesson learned the hard way: individual posting permalinks rot within days (we checked ~7
real AI-Architect postings — closed, 404, or redirect-to-search), and LinkedIn bot-walls
make tap-time liveness unverifiable server-side. So the honest handoff targets the LIVE,
newest-first results for the role (always current) with materials tailored + staged.
"""
return JSONResponse({
"mechanism": "board_handoff",
"fresh_search": LIVE_SEARCH,
"cover_letter": COVER_LETTER,
"qx": 8,
})
@app.get("/", response_class=HTMLResponse)
def index():
return HTML
HTML = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Scout · one-tap apply</title>
<style>
:root{--o:#ea580c;--o2:#fb923c;--ink:#1c1917;--mut:#78716c;--line:#f0ebe7;--ok:#059669;--amb:#b45309}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);
background:radial-gradient(1200px 500px at 80% -10%,#fff4ed,#fffdfb 55%);min-height:100vh}
.wrap{max-width:460px;margin:0 auto;padding:28px 20px 60px}
.hi{display:flex;align-items:center;gap:10px}
.av{width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,var(--o),var(--o2));
display:grid;place-items:center;font-size:19px;box-shadow:0 6px 18px #ea580c44}
.hi b{font-size:15px}.hi span{font-size:12.5px;color:var(--mut)}
h1{font-size:23px;letter-spacing:-.5px;margin:16px 0 4px}
.sub{color:var(--mut);font-size:14px;margin-bottom:18px}
.card{background:#fff;border:1px solid var(--line);border-radius:20px;padding:20px;box-shadow:0 10px 40px #ea580c14;
animation:rise .6s cubic-bezier(.2,.7,.2,1) both}
@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
.co{font-size:12.5px;color:var(--mut);font-weight:600}.ti{font-size:19px;font-weight:700;margin:3px 0 7px}
.meta{display:flex;gap:8px;flex-wrap:wrap}
.chip{font-size:11.5px;color:var(--mut);background:#faf6f3;border:1px solid var(--line);padding:4px 9px;border-radius:999px}
.ring{position:relative;width:62px;height:62px;flex:none}.ring svg{transform:rotate(-90deg)}
.ring b{position:absolute;inset:0;display:grid;place-items:center;font-size:15px;font-weight:800;color:var(--o)}
.why{margin:16px 0 4px;border-top:1px dashed var(--line);padding-top:14px}
.why h3{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);margin-bottom:9px}
.why div{display:flex;gap:8px;font-size:13.5px;margin:7px 0}
.tick{color:var(--ok);font-weight:800}
.ready{margin-top:16px;background:#f7fdf9;border:1px solid #d6f0e2;border-radius:14px;padding:13px 14px}
.ready .r{display:flex;gap:8px;font-size:13.5px;margin:6px 0;color:#0f5132}
.peek{font-size:12.5px;color:var(--o);cursor:pointer;font-weight:600;margin-top:8px;display:inline-block}
.letter{display:none;white-space:pre-wrap;font-size:12.5px;line-height:1.55;color:#44403c;background:#fffaf6;
border:1px solid var(--line);border-radius:12px;padding:14px;margin-top:10px}
button.go{margin-top:20px;width:100%;border:0;border-radius:14px;padding:16px;font-size:16px;font-weight:700;color:#fff;
background:linear-gradient(135deg,var(--o),var(--o2));cursor:pointer;box-shadow:0 10px 26px #ea580c4d;transition:transform .12s}
button.go:active{transform:scale(.98)}button.go:disabled{opacity:.9}
.steps{margin-top:14px;font-size:13.5px;display:none}
.steps div{display:flex;gap:9px;align-items:center;color:var(--mut);margin:9px 0;opacity:.4;transition:.3s}
.steps div.on{opacity:1;color:var(--ink)}
.spin{width:15px;height:15px;border:2px solid #f0ddd0;border-top-color:var(--o);border-radius:50%;animation:sp .7s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
.panel{display:none;margin-top:18px;animation:rise .5s both}
.panel .big{font-size:32px;text-align:center}.panel h2{font-size:18px;margin:8px 0 4px;text-align:center}
.panel p{color:var(--mut);font-size:13.5px;text-align:center}
.qx{display:block;width:fit-content;margin:10px auto 0;background:#fff4ed;color:var(--o);font-weight:700;font-size:13px;
padding:6px 12px;border-radius:999px;border:1px solid #fcd9c2}
.open{display:block;margin-top:16px;text-align:center;text-decoration:none;background:#0a66c2;color:#fff;font-weight:700;
padding:14px;border-radius:13px}
.guard{background:#fffbeb;border:1px solid #fde9b3;border-radius:14px;padding:14px;margin-top:14px}
.guard .h{display:flex;gap:8px;font-weight:700;color:var(--amb);font-size:14px;margin-bottom:6px}
.alt{display:flex;justify-content:space-between;gap:10px;border:1px solid var(--line);border-radius:11px;padding:10px 12px;margin:8px 0}
.alt b{font-size:13.5px}.alt span{font-size:11.5px;color:var(--mut)}
.note{font-size:11.5px;color:var(--mut);margin-top:20px;text-align:center;line-height:1.5}
</style></head><body><div class="wrap">
<div class="hi"><div class="av">🕵️</div><div><b>Scout</b><br><span>your opportunity agent</span></div></div>
<h1>Evening, Rahul. I found a role built for you.</h1>
<div class="sub">Scanned LinkedIn · 1 strong match · already tailored</div>
<div class="card">
<div class="top"><div>
<div class="co">TEKSYSTEMS</div><div class="ti">AI Testing Architect</div>
<div class="meta"><span class="chip">🌐 Remote</span><span class="chip">MidSenior</span><span class="chip">$50100/hr</span></div>
</div><div class="ring"><svg width="62" height="62">
<circle cx="31" cy="31" r="26" fill="none" stroke="#f3ede9" stroke-width="6"/>
<circle cx="31" cy="31" r="26" fill="none" stroke="#ea580c" stroke-width="6" stroke-linecap="round"
stroke-dasharray="163" stroke-dashoffset="163"><animate attributeName="stroke-dashoffset" from="163" to="15" dur="1.1s" fill="freeze"/></circle>
</svg><b>91%</b></div></div>
<div class="why"><h3>Why Scout picked this for you</h3>
<div><span class="tick">✓</span><span>Your <b>FastAPI + LLM + vector-DB</b> stack matches their core requirements.</span></div>
<div><span class="tick">✓</span><span>You've built <b>adversarial AI verification</b> — that <i>is</i> AI-enabled testing.</span></div>
<div><span class="tick">✓</span><span>Governance &amp; explainability you already practice map to their standards mandate.</span></div>
</div>
<div class="ready">
<div class="r"><span class="tick">✓</span> Résumé tailored to this role</div>
<div class="r"><span class="tick">✓</span> Cover letter written for the role</div>
<div class="r"><span class="tick">✓</span> Contact &amp; screening answers filled</div>
<span class="peek" onclick="pk()">Preview your cover letter ↓</span>
<div class="letter" id="letter"></div>
</div>
<button class="go" id="go" onclick="go()">🚀 Apply with one tap</button>
<div class="steps" id="steps">
<div id="s1"><span class="spin"></span> Tailoring your final résumé…</div>
<div id="s2">📎 Staging résumé + cover letter…</div>
<div id="s3">🔄 Pulling today's open roles…</div>
</div>
<div class="panel" id="ok">
<div class="big">✅</div><h2>Tailored &amp; staged</h2>
<p>Your résumé + cover letter are ready. Here are <b>today's open</b> AI Architect roles —
freshly pulled, newest first. Your tailored application drops straight in.</p>
<span class="qx">+8 QX earned</span>
<a class="open" id="okopen" href="#" target="_blank" rel="noopener" style="background:var(--o)">See today's open AI Architect roles →</a>
</div>
</div>
<div class="note">🔒 Scout never submits on a board for you — it preps everything and re-checks the
role is still open, then you give the final tap. Safe for your account, and no dead ends.</div>
<script>
const COVER = `__COVER__`;
document.getElementById('letter').textContent = COVER;
function pk(){const l=document.getElementById('letter');l.style.display=l.style.display==='block'?'none':'block';}
async function go(){
document.getElementById('go').style.display='none';
const steps=document.getElementById('steps');steps.style.display='block';
const r=await (await fetch('/api/apply',{method:'POST'})).json();
const seq=['s1','s2','s3'];
for(let i=0;i<seq.length;i++){
document.getElementById(seq[i]).classList.add('on');
await new Promise(z=>setTimeout(z,640));
if(i<seq.length-1) document.getElementById(seq[i]).querySelector('.spin')?.replaceWith(Object.assign(document.createElement('span'),{textContent:'',className:'tick'}));
}
steps.style.display='none';
document.getElementById('okopen').href=r.fresh_search;
document.getElementById('ok').style.display='block';
}
</script>
</div></body></html>""".replace("__COVER__", COVER_LETTER)

4
research/poc/browser-apply/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
venv/
__pycache__/
*.pyc

View File

@@ -0,0 +1,77 @@
# browser-apply — browser-use (open source) POC
The pivot away from heuristics. Instead of mocking an apply flow, an **AI agent drives a
real Chrome browser** — it looks at the page and clicks/types like a human. No cross-origin
wall, because it *is* the browser.
## The moving parts (what's actually running)
```
your task (plain English)
┌─────────┐ "what do I click next?" ┌──────────────┐
│ Agent │ ───────────────────────────────► │ OpenAI LLM │
│(browser-│ ◄─────────────────────────────── │ (the brain) │
│ use) │ "click element #3, type X" └──────────────┘
│ Chrome DevTools Protocol (cdp-use)
┌──────────────┐
│ real Chrome │ ← navigates, reads the page, clicks, types, uploads
└──────────────┘
```
- **browser-use** (v0.13.1) — the library. Runs the loop: screenshot/read page → ask the
LLM what to do → do it → repeat until the task is done.
- **The LLM** (OpenAI here) — the decision-maker. Sees the page, decides the next action.
It's swappable: OpenAI, Anthropic, Google, or local (Ollama).
- **Chrome** — your installed Google Chrome, driven over the DevTools Protocol. No separate
browser download needed. browser-use auto-adds ad/cookie-blocker extensions.
## Setup (already done once)
```bash
python3 -m venv venv # Python >= 3.11 (you have 3.13.2)
source venv/bin/activate
pip install browser-use # pulls openai/anthropic/google SDKs + cdp-use
# Chrome is reused from /Applications — nothing else to install
```
Secrets live in `.env` (git-ignored):
```
OPENAI_API_KEY=sk-...
```
## Run the smoke test
```bash
source venv/bin/activate
python verify.py # headless (no window)
HEADLESS=0 python verify.py # SEE Chrome open and the agent drive it
```
Expected: it visits example.com and prints `The main heading on the page is 'Example Domain'.`
## What's proven so far
- ✅ The full stack works: browser launches, LLM reasons, agent reads a live page (`verify.py`).
- ✅ Vision résumé parsing works on the real `Resume.pdf` (`parse_resume.py`).
## Real job-apply flow (built)
Applies to one real posting: LinkedIn job → hand-off to OpenAI's official job board → fill & submit.
| File | Role |
|---|---|
| `parse_resume.py` | Vision model (gpt-4o-mini + PyMuPDF) → structured `profile` from `Resume.pdf`. Mirrors resume-builder's `parse_service.py`. |
| `browser_config.py` | Shared `BrowserProfile` (dedicated `chrome-profile/`, real Chrome, visible) so login persists into apply. |
| `login_once.py` | One-time: open LinkedIn, you log in yourself, session saved. |
| `apply.py` | Parses résumé → drives the LinkedIn→OpenAI-board apply → auto-submits → structured result. |
```bash
source venv/bin/activate
python login_once.py # one time: log into LinkedIn in the window
python apply.py # parses résumé, applies, auto-submits
AUTO_SUBMIT=0 python apply.py # fill everything but STOP before submit (human review)
```
Honesty rules kept: a **liveness gate** (dead posting → report, never fake a submit), résumé is
the source of truth (no invented data), and auto-submit is one flag away from review-first.

Binary file not shown.

View File

@@ -0,0 +1,20 @@
import asyncio
from browser_use import BrowserSession
from browser_config import make_profile
async def ev(s,e):
c=await s.get_or_create_cdp_session()
r=await c.cdp_client.send.Runtime.evaluate(params={"expression":e,"returnByValue":True},session_id=c.session_id)
return r.get("result",{}).get("value")
async def click(s,t):
return await ev(s,f"""(()=>{{const el=[...document.querySelectorAll('button,[role=tab]')].find(e=>(e.textContent||'').trim().toLowerCase().includes('{t.lower()}'));if(el){{el.click();return true}}return false}})()""")
async def main():
s=BrowserSession(browser_profile=make_profile(headless=True,persist=False)); await s.start()
try: await s._cdp_set_viewport(width=1340,height=1080)
except: pass
await ev(s,"try{localStorage.removeItem('scout-onboarded')}catch(e){}")
await s.navigate_to("http://localhost:3001/scout"); await asyncio.sleep(4)
await click(s,"Configure"); await asyncio.sleep(1)
await click(s,"Find my matches"); await asyncio.sleep(8)
await s.take_screenshot(path="/tmp/action_deck.png",full_page=True); print("deck")
await s.kill()
asyncio.run(main())

View File

@@ -0,0 +1,23 @@
"""
Adapter registry. pick_adapter(url) returns the first specialized adapter that claims the URL,
else the GenericAdapter (the master key's catch-all). Add new specialized adapters to _SPECIALIZED.
"""
from __future__ import annotations
from .base import Adapter
from .generic import GenericAdapter
from .ashby import AshbyAdapter
# Specialized adapters, checked in order. Generic is the fallback.
_SPECIALIZED: list[Adapter] = [
AshbyAdapter(),
# GreenhouseAdapter(), LeverAdapter(), WorkdayAdapter(), ... ← grow here
]
_GENERIC = GenericAdapter()
def pick_adapter(url: str) -> Adapter:
for a in _SPECIALIZED:
if a.matches(url):
return a
return _GENERIC

View File

@@ -0,0 +1,28 @@
"""
AshbyAdapter — the hard-won Ashby knowledge from apply_ashby.py, as a thin hint layer.
This is how the Ashby progress is preserved without duplicating the engine.
"""
from __future__ import annotations
from .base import Adapter
class AshbyAdapter(Adapter):
name = "ashby"
tier = "tier1-autonomous"
def matches(self, url: str) -> bool:
return "ashbyhq.com" in (url or "").lower()
def hints(self) -> str:
return (
"BOARD = AshbyHQ (public form, no login needed). Known quirks:\n"
"- There is an 'Autofill from resume' control near the top — upload the résumé THERE\n"
" first to auto-populate fields, then verify the required 'Resume' field shows the\n"
" file attached (filename visible, no spinner).\n"
"- The required consent checkbox reads 'I confirm I have read the above.' (no asterisk\n"
" but REQUIRED) — tick it and verify it is checked.\n"
"- 'Where are you currently located?' is a typeahead: type the city, wait, click the\n"
" matching suggestion (typing alone does not commit).\n"
"- The page is a JS app; it can render blank for a few seconds on load."
)

View File

@@ -0,0 +1,20 @@
"""
Adapter contract. An adapter is a THIN layer of board-specific knowledge — it does NOT
re-implement the apply loop. The generic engine does all the work; an adapter only:
- matches(url): claims a URL by its domain/ATS, and
- hints(): returns extra prompt text about that board's known quirks (controls, field names).
The GenericAdapter returns no hints — pure vision. Specialized adapters (Ashby, later Greenhouse/
Lever/Workday) add hints that raise accuracy on that board.
"""
from __future__ import annotations
class Adapter:
name = "base"
tier = "unknown" # tier1-autonomous | account-gated | generic
def matches(self, url: str) -> bool:
return False
def hints(self) -> str:
return ""

View File

@@ -0,0 +1,23 @@
"""
GenericAdapter — the master key's default. Matches ANY URL, adds no board-specific assumptions.
This is what handles the long tail (bespoke company career pages, unknown ATS) purely by vision.
"""
from __future__ import annotations
from .base import Adapter
class GenericAdapter(Adapter):
name = "generic"
tier = "generic"
def matches(self, url: str) -> bool:
return True # catch-all fallback
def hints(self) -> str:
# No board assumptions. The engine's universal logic does everything.
return ("BOARD = unknown / bespoke. No known shortcuts — read the page carefully. Look for "
"any 'autofill from resume' control and use it if present; otherwise fill fields "
"manually. Watch for a login/account wall (report login_required) or a page with no "
"real application form, e.g. a generic /careers page or 'email your CV' (report "
"no_form).")

View File

@@ -0,0 +1,149 @@
"""
apify_probe.py — measure OFFSITE-APPLY-LINK YIELD across job-board actors.
Question: for an on-demand user search, how many jobs come back with a usable OFFSITE apply URL
(the employer's own ATS — Ashby/Greenhouse/Lever/Workday/...), i.e. the Tier-1 links our
autonomous apply engine can actually finish? We probe each actor with a small sample and report:
total jobs · #with offsite link · #Tier-1 ATS · breakdown by ATS.
Small samples on purpose (this costs Apify credits). Tune SAMPLE below.
Run: python apify_probe.py
"""
from __future__ import annotations
import os
import json
from collections import Counter
from urllib.parse import urlparse
import httpx
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.environ["APIFY_TOKEN"]
SAMPLE = int(os.environ.get("SAMPLE", "25"))
ONLY = os.environ.get("ONLY", "").lower() # run only actors whose label matches
FOLLOW = os.environ.get("FOLLOW_REDIRECTS", "0") == "1" # Indeed: resolve externalApplyLink
# role/geo to probe — tweak freely
TITLE = "Machine Learning Engineer"
COUNTRY = "IN"
LOCATION = "India"
# ATS domain -> (label, is_tier1_autonomous)
ATS = {
"ashbyhq.com": ("Ashby", True), "jobs.ashbyhq.com": ("Ashby", True),
"greenhouse.io": ("Greenhouse", True), "boards.greenhouse.io": ("Greenhouse", True),
"lever.co": ("Lever", True), "jobs.lever.co": ("Lever", True),
"smartrecruiters.com": ("SmartRecruiters", True), "recruitee.com": ("Recruitee", True),
"personio.de": ("Personio", True), "personio.com": ("Personio", True),
"workable.com": ("Workable", True), "breezy.hr": ("Breezy", True),
"myworkdayjobs.com": ("Workday", False), "icims.com": ("iCIMS", False),
"taleo.net": ("Taleo", False), "successfactors.com": ("SuccessFactors", False),
"bamboohr.com": ("BambooHR", False), "jobvite.com": ("Jobvite", False),
}
def classify(url: str | None):
"""Return (ats_label, is_tier1) for an offsite URL, or (None, False) if no/empty url."""
if not url or not isinstance(url, str) or not url.startswith("http"):
return None, False
host = (urlparse(url).hostname or "").lower()
for dom, (label, t1) in ATS.items():
if host == dom or host.endswith("." + dom) or dom in host:
return label, t1
return "other-ATS/site", False # an offsite link, just not a known ATS
def offsite_url(kind: str, item: dict) -> str | None:
if kind == "linkedin":
am = item.get("applyMethod") or {}
return am.get("companyApplyUrl") or item.get("companyApplyUrl")
if kind == "indeed":
return item.get("externalApplyLink") or item.get("applyUrl")
if kind == "ats":
return item.get("apply_url") or item.get("applyUrl") or item.get("url")
if kind == "naukri":
# applyRedirectUrl = the true external handoff. companyApplyUrl is Naukri's OWN apply API
# (naukri.com/cloudgateway-apply/...), i.e. on-platform — not an external link.
url = item.get("applyRedirectUrl") or item.get("companyApplyUrl")
if url and "naukri.com" in url:
return None
return url
return None
ACTORS = [
{"label": "LinkedIn (harvestapi)", "kind": "linkedin",
"id": "harvestapi~linkedin-job-search",
"input": {"jobTitles": [TITLE], "locations": [LOCATION], "maxItems": SAMPLE, "sortBy": "date"}},
{"label": "Indeed (misceres)", "kind": "indeed",
"id": "misceres~indeed-scraper",
"input": {"position": TITLE, "location": LOCATION, "country": COUNTRY,
"maxItemsPerSearch": SAMPLE, "followApplyRedirects": FOLLOW,
"saveOnlyUniqueItems": True}},
{"label": "ATS-direct (bovi G/L/Ashby)", "kind": "ats",
"id": "bovi~greenhouse-lever-ashby-job-scraper",
"input": {"presetLists": ["ai-ml"], "maxJobsPerCompany": 2, "outputProfile": "compact"}},
# muhammetakkurtt: 11k users, no full-permission gate, returns applyRedirectUrl (external)
# + companyApplyUrl (Naukri-internal) + companyApplyJob flag. Uses maxJobs (NOT maxItems).
# maxJobs has a min of 50; cities wants numeric city IDs (omit -> nationwide).
{"label": "Naukri (muhammetakkurtt)", "kind": "naukri",
"id": "muhammetakkurtt~naukri-job-scraper",
"input": {"keyword": TITLE, "maxJobs": max(50, SAMPLE)}},
]
def run_actor(actor: dict) -> list[dict]:
url = f"https://api.apify.com/v2/acts/{actor['id']}/run-sync-get-dataset-items?token={TOKEN}"
r = httpx.post(url, json=actor["input"], timeout=300)
r.raise_for_status()
data = r.json()
return data if isinstance(data, list) else data.get("items", [])
def main():
print(f"Probe: '{TITLE}' / {LOCATION} · sample≈{SAMPLE} per actor\n" + "=" * 68)
grand = {"jobs": 0, "offsite": 0, "tier1": 0}
grand_ats = Counter()
for a in ACTORS:
if ONLY and ONLY not in a["label"].lower():
continue
print(f"\n{a['label']} [{a['id']}]")
try:
items = run_actor(a)
except Exception as e:
print(f" ✗ failed: {type(e).__name__}: {str(e)[:160]}")
continue
n = len(items)
ats_counter = Counter()
offsite = tier1 = 0
for it in items:
label, t1 = classify(offsite_url(a["kind"], it))
if label:
offsite += 1
ats_counter[label] += 1
if t1:
tier1 += 1
grand["jobs"] += n
grand["offsite"] += offsite
grand["tier1"] += tier1
grand_ats.update(ats_counter)
pct = (100 * offsite / n) if n else 0
t1pct = (100 * tier1 / n) if n else 0
print(f" jobs={n} offsite-link={offsite} ({pct:.0f}%) Tier-1 autonomous={tier1} ({t1pct:.0f}%)")
if ats_counter:
print(" breakdown:", ", ".join(f"{k}={v}" for k, v in ats_counter.most_common()))
print("\n" + "=" * 68)
j, o, t = grand["jobs"], grand["offsite"], grand["tier1"]
print(f"COMBINED: jobs={j} offsite={o} ({(100*o/j if j else 0):.0f}%) "
f"Tier-1 autonomous={t} ({(100*t/j if j else 0):.0f}%)")
if grand_ats:
print("ATS mix:", ", ".join(f"{k}={v}" for k, v in grand_ats.most_common()))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,192 @@
"""
apply.py — apply to one real job with a browser-use agent.
Flow:
1. Parse Resume.pdf with a vision model -> structured `profile` (source of truth).
2. Open the LinkedIn job in the dedicated (logged-in) Chrome profile.
3. Liveness gate: if the posting is closed/expired, STOP and report — never fake a submit.
4. Click Apply -> follow the hand-off to OpenAI's official job board (new tab).
5. Upload Resume.pdf, fill every field from `profile`, answer screening questions truthfully.
6. Submit (auto, per Rahul's choice) and report a structured result.
Run (after `python login_once.py`):
python apply.py
Toggle AUTO_SUBMIT=0 to make the agent fill everything but STOP before the final submit.
"""
from __future__ import annotations
import asyncio
import json
import os
from dotenv import load_dotenv
from browser_use import Agent, BrowserSession, ChatOpenAI
from browser_config import make_profile, RESUME_PATH
from parse_resume import parse_resume_to_profile
load_dotenv()
JOB_URL = "https://www.linkedin.com/jobs/view/4418852767/"
APPLY_MODEL = os.environ.get("APPLY_MODEL", "gpt-4.1") # strong model; a real form needs reasoning
AUTO_SUBMIT = os.environ.get("AUTO_SUBMIT", "1") != "0"
MAX_STEPS = int(os.environ.get("MAX_STEPS", "55"))
# Credentials are referenced by PLACEHOLDER NAMES only. browser-use injects the real values
# from .env at the keystroke, masks them in logs/screenshots, and scopes them to linkedin.com —
# the LLM never sees the real password.
LINKEDIN_USER = os.environ.get("LINKEDIN_USER", "")
LINKEDIN_PASS = os.environ.get("LINKEDIN_PASS", "")
SENSITIVE_DATA = {
"https://*.linkedin.com": {
"linkedin_user": LINKEDIN_USER,
"linkedin_pass": LINKEDIN_PASS,
}
} if (LINKEDIN_USER and LINKEDIN_PASS) else None
def build_task(profile: dict) -> str:
submit_clause = (
"Submit the application (click the final Submit / Apply button)."
if AUTO_SUBMIT else
"Fill everything but DO NOT click the final submit button — stop at the review step "
"and report that it is ready for human review."
)
login_clause = (
"0. LOGIN: Go to https://www.linkedin.com/feed/. If you are already logged in, continue. "
"If you see a login form, log in using the placeholders linkedin_user and linkedin_pass "
"(type them into the email and password fields and submit). If LinkedIn shows a security "
"checkpoint, CAPTCHA, or 'is this you' verification code, STOP and report status=\"blocked\" "
"with notes saying a human needs to clear the checkpoint — do NOT attempt to bypass it.\n"
if SENSITIVE_DATA else
"0. LOGIN: Assume you are already logged into LinkedIn (a session was set up beforehand).\n"
)
return f"""You are applying to ONE job on behalf of the candidate below. Use ONLY the
candidate's real data — never invent qualifications, employers, dates, or work authorization.
CANDIDATE PROFILE (the source of truth, parsed from their résumé):
{json.dumps(profile, indent=2, ensure_ascii=False)}
Résumé file to upload when asked: {RESUME_PATH}
CONFIRMED FACTS (use these EXACT answers — they OVERRIDE any guess from the résumé):
- Preferred contact email: rahul.gupta2608@yahoo.com (the résumé email).
- The candidate lives in India and is an Indian citizen — authorized to work in India.
- The candidate is NOT authorized to work in the United States, and WOULD require visa
sponsorship for any US-based role. Do not overstate authorization — honesty over optimism.
- So answer work-authorization / sponsorship questions truthfully based on the job's actual
location as shown on the form:
* "Authorized to work in the country where the job is located?" -> US role: NO. India role: YES.
* "Will you require visa sponsorship?" -> US role: YES. India role: NO.
- Availability / start date: can start as soon as possible. If a date is required, pick the
earliest selectable date; if it is a text field, enter "Immediately available". Never leave a
required start-date field blank.
STEPS:
{login_clause}1. Go to {JOB_URL}.
2. LIVENESS CHECK FIRST. If the page shows "No longer accepting applications", is a 404, or
redirects to a generic job search, then the job is DEAD: STOP immediately, do not apply, and
in your final answer report status="closed" with what you saw. A real apply to a dead job is
worse than no apply.
3. If the job is open, click the Apply button. This job hands off to OPENAI'S OFFICIAL JOB BOARD
(an external AshbyHQ careers site), usually opening a NEW TAB. Switch to that new tab.
IMPORTANT: the Ashby page is a JavaScript app that takes several seconds to render — it may
look BLANK at first. Do NOT conclude it failed on the first look: `wait` ~6 seconds and re-read
the page; if still blank, `wait` again and refresh the tab ONCE. Only report a load failure if
it is still blank after two waits AND a refresh. (If instead it opens LinkedIn "Easy Apply"
inline, handle that form. If it dead-ends or demands an account you cannot create, STOP and
report.)
4. RÉSUMÉ UPLOAD — do this FIRST and treat it as the make-or-break step (a half-finished upload
is what got the submission flagged as spam before). Follow this exactly:
a. Find the résumé / CV upload control and upload the file at the path above.
b. IMMEDIATELY use the `wait` action for ~8 seconds. Ashby uploads the file in the BACKGROUND
after you select it; the form is not valid until that finishes.
c. VERIFY the upload actually completed using CONCRETE signals (not a guess):
- the résumé FILENAME is displayed in the upload area, AND
- NO spinner / progress bar / "uploading" / "we're updating your application" text remains, AND
- ideally, Ashby has auto-parsed the résumé and pre-filled some fields (name / email /
experience) — that is strong proof the file reached their server.
d. If those signals are NOT all present, the upload FAILED or is pending: remove the file if
possible, re-upload, `wait` ~8s again, and re-verify. Retry up to 2 more times.
e. Only once the upload is CONFIRMED, continue. Remember whether it was confirmed for the
final report (resume_upload_confirmed).
5. Now fill the rest of the form top to bottom, never skipping a REQUIRED field. Two fields are
tricky widgets — fill them so the value actually COMMITS, then visually confirm it stuck:
- "Where are you currently located?" is a TYPEAHEAD: type the city (e.g. "New Delhi"), WAIT for
the suggestion dropdown, and CLICK the matching option. Typing alone does NOT commit — verify
the field now shows the selected location, not the "Start typing…" placeholder.
- "When can you start a new role?" is a DATE PICKER: open it, click an actual date (earliest
selectable), and verify the field now shows a date, not "Pick date…".
- Also fill name, the yahoo email, phone, LinkedIn/portfolio links from the profile, and any
experience/skills questions.
6. Screening questions: answer truthfully from the profile and CONFIRMED FACTS. For anything not
covered, choose the most truthful/neutral valid option, leave optional ones blank, and note it.
NEVER fabricate. Tick any required certification/consent checkbox.
7. Before submitting, re-scan the whole form: fix any empty REQUIRED field or validation error,
and CONFIRM the résumé upload is still showing as complete (not reverted to "uploading").
8. {submit_clause}
9. AFTER clicking submit, VERIFY — never assume it worked. IMPORTANT: click Submit only ONCE, then
`wait` ~5s and read the page. Do NOT rapid-click Submit repeatedly — repeated clicks themselves
look like spam.
- If it says files are still uploading / "try again when finished": the upload was not done.
Go back, re-verify the upload per step 4, then click Submit ONE more time.
- If validation errors appear, fix the flagged fields and submit once more.
- If it says the submission was "flagged as possible spam": STOP, do NOT resubmit, and report
status="failed" with that exact message (resubmitting hardens the flag).
- Look for an EXPLICIT confirmation: a success page or text such as "Application submitted",
"Thank you for applying", or a confirmation/reference ID. Read its exact wording.
- ONLY report status="submitted" if you actually SEE such an explicit confirmation, and quote
it verbatim in "confirmation". If you clicked submit but NO confirmation/success page
appears, report status="failed" with exactly what was on screen. NEVER claim success
without on-screen proof.
10. Final answer = a JSON object: {{"status": "submitted"|"ready_for_review"|"closed"|"failed"|"blocked",
"surface": "openai_board"|"linkedin_easy_apply"|"none", "resume_upload_confirmed": true|false,
"confirmation": <exact text/id or null>,
"answered": [<question: answer>...], "uncertain": [<questions you guessed or skipped>],
"notes": <short explanation>}}.
"""
async def main():
print(f"[1/3] Parsing résumé via vision model ({RESUME_PATH})...")
profile = await parse_resume_to_profile(RESUME_PATH)
print(f" -> {profile.get('full_name')} · {profile.get('years_experience')} yrs · "
f"{len(profile.get('skills', []))} skills")
print(f"[2/3] Launching Chrome (dedicated profile) and the apply agent "
f"(model={APPLY_MODEL}, auto_submit={AUTO_SUBMIT})...")
session = BrowserSession(browser_profile=make_profile())
agent = Agent(
task=build_task(profile),
llm=ChatOpenAI(model=APPLY_MODEL),
browser_session=session,
available_file_paths=[RESUME_PATH],
sensitive_data=SENSITIVE_DATA,
)
print(f" login mode: {'credentials via sensitive_data' if SENSITIVE_DATA else 'persisted session'}")
try:
history = await agent.run(max_steps=MAX_STEPS)
print("\n[3/3] ========== AGENT RESULT ==========")
print(history.final_result())
print("=====================================")
# Capture the final page as proof of the confirmation (or of whatever is on screen).
try:
shot = os.path.join(os.path.dirname(os.path.abspath(__file__)), "confirmation.png")
await session.take_screenshot(path=shot, full_page=True)
print(f"📸 Saved final-page screenshot -> {shot}")
except Exception as e:
print(f"(could not save screenshot: {e})")
finally:
try:
await asyncio.get_event_loop().run_in_executor(
None, input, "\nPress Enter to close the browser... "
)
except Exception:
pass
await session.kill()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,204 @@
"""
apply_ashby.py — master the Ashby application in ISOLATION (no LinkedIn).
Goes DIRECTLY to the OpenAI / AshbyHQ application URL. That page is public — no login, so no
LinkedIn new-device alerts and no ban vector. "5 applications ≠ 5 logins": here it's 0 logins.
This is the focused "master Ashby" script. The LinkedIn discovery+login flow is preserved as a
COMMENTED block below (we'll re-enable it and merge the two into one unit once Ashby is solid).
ACCURACY IS GO/NO-GO: the agent must satisfy a hard checklist — NO required field left empty —
before it will submit or hand off. It reports every required field and whether all were filled.
Run:
ASHBY_URL="https://jobs.ashbyhq.com/openai/<id>/application" python apply_ashby.py
AUTO_SUBMIT=0 ASHBY_URL="..." python apply_ashby.py # fill + gate, STOP before submit (you submit)
"""
from __future__ import annotations
import asyncio
import json
import os
from datetime import date, timedelta
from dotenv import load_dotenv
from browser_use import Agent, BrowserSession, ChatOpenAI
from browser_config import make_profile, RESUME_PATH
from parse_resume import parse_resume_to_profile
load_dotenv()
ASHBY_URL = os.environ.get("ASHBY_URL", "") # REQUIRED: the exact application page URL
APPLY_MODEL = os.environ.get("APPLY_MODEL", "gpt-4.1")
AUTO_SUBMIT = os.environ.get("AUTO_SUBMIT", "1") != "0"
MAX_STEPS = int(os.environ.get("MAX_STEPS", "55"))
# ---------------------------------------------------------------------------------------------
# FUTURE — LinkedIn discovery + login (kept for when we merge into one unit; intentionally OFF).
# Do NOT delete. When combining: prepend a login step using these, then navigate from the
# LinkedIn job to Apply, then fall through to the Ashby steps below.
#
# LINKEDIN_USER = os.environ.get("LINKEDIN_USER", "")
# LINKEDIN_PASS = os.environ.get("LINKEDIN_PASS", "")
# SENSITIVE_DATA = {
# "https://*.linkedin.com": {"linkedin_user": LINKEDIN_USER, "linkedin_pass": LINKEDIN_PASS}
# } if (LINKEDIN_USER and LINKEDIN_PASS) else None
# LOGIN_STEP = (
# "0. LOGIN: Go to https://www.linkedin.com/feed/. If a login form appears, log in with the "
# "placeholders linkedin_user / linkedin_pass. If a security checkpoint/CAPTCHA appears, STOP "
# "and report status='blocked' (a human must clear it). NOTE: prefer a pre-logged-in session "
# "to avoid repeated automated logins (each triggers a LinkedIn new-device alert)."
# )
# ---------------------------------------------------------------------------------------------
def build_task(profile: dict) -> str:
# Compute dates at runtime so this never goes stale. Suggest a realistic ~2-week-out start.
today = date.today()
start_target = today + timedelta(days=14)
today_str = today.strftime("%Y-%m-%d")
start_str = start_target.strftime("%Y-%m-%d (%B %-d, %Y)")
submit_clause = (
"Submit the application (click the final Submit / Apply button) — but only AFTER the "
"accuracy gate in step 6 passes."
if AUTO_SUBMIT else
"DO NOT click the final submit button. Stop at the review step and report it is ready for "
"a human to submit — but only AFTER the accuracy gate in step 6 passes."
)
return f"""You are completing ONE job application on a public OpenAI / AshbyHQ careers page on
behalf of the candidate below. Use ONLY the candidate's real data — never invent qualifications,
employers, dates, or work authorization.
CANDIDATE PROFILE (source of truth, parsed from their résumé):
{json.dumps(profile, indent=2, ensure_ascii=False)}
Résumé file to upload: {RESUME_PATH}
CONFIRMED FACTS (use these EXACT answers — they OVERRIDE any résumé guess):
- Preferred contact email: rahul.gupta2608@yahoo.com (the résumé email).
- The candidate lives in India, is an Indian citizen, and is authorized to work in India.
- This role is India-based, so: "authorized to work in the country where the job is located" = YES;
"require visa sponsorship" = NO. (If the page clearly shows a non-India location, answer
truthfully instead: not authorized / needs sponsorship for a US role. Honesty over optimism.)
- Availability: available to start soon. Today's date is {today_str}. For a "when can you start"
date field, pick a date ON OR AFTER today — NEVER a past date. A good choice is about two weeks
out: {start_str}. Use that unless the picker only allows a different valid future date.
- MOTIVATION (use this VERBATIM for any "what interests you about this job" / "why do you want to
work here" / "Additional Information" free-text field — a strong application never leaves this
blank):
"I want to help organizations actually put frontier AI to work, and the AI Deployment Engineer,
Codex role is the most direct place I've seen to do that. My background is hands-on ML and data
engineering — I shipped machine-learning systems at WalmartLabs and have built with LLM/GenAI
APIs, vector retrieval, and cloud infrastructure across AWS, Azure, and GCP. Codex is changing
how software gets written, and I'm excited to work directly with teams to turn that capability
into real, reliable deployments, then carry what I learn on the ground back into the product.
Being based in India, I'm especially motivated to help bring this technology to engineering
teams here. Deploying AI that people can trust and genuinely depend on is the work I most want
to be doing."
STEPS:
1. Go DIRECTLY to the application page: {ASHBY_URL}. No login is needed. The Ashby page is a
JavaScript app and may look BLANK for a few seconds — `wait` ~6s and re-read; refresh ONCE if
still blank. Only report a load failure after two waits AND a refresh.
2. LIVENESS CHECK: if the page shows "no longer accepting applications", a 404, or the role is
closed, STOP and report status="closed" with what you saw. Never fake a submit on a dead job.
3. AUTOFILL FIRST (most reliable way to fill fields): find the "Autofill from resume" control near
the top of the form and upload the résumé file THERE. `wait` ~8s for Ashby to parse it and
pre-fill fields. This populates name/email/experience automatically.
4. RÉSUMÉ ATTACHMENT (make-or-break — a half-finished upload got us flagged before):
- Ensure the required "Resume" field shows the file ATTACHED: the filename is visible and NO
spinner / "uploading" / "we're updating your application" text remains.
- If autofill did not attach it to the Resume field, upload the file there, `wait` ~8s, verify.
Retry up to 2 times. Do not continue until the attachment is CONFIRMED.
5. FILL EVERY FIELD THAT STRENGTHENS THE APPLICATION — not only the "*" required ones. "Required"
and "needed for a good application" are different: a blank motivation box is technically allowed
but makes a WEAK application. So fill every field you reasonably can from the profile + CONFIRMED
FACTS, and leave a field blank ONLY if it is truly irrelevant or asks for something the candidate
genuinely does not have. Specifics:
- "Where are you currently located?" = TYPEAHEAD: type "New Delhi", WAIT for the dropdown, and
CLICK the matching suggestion. Verify it no longer shows "Start typing…".
- "When can you start a new role?" = DATE PICKER: open it, CLICK an actual date, verify it no
longer shows "Pick date…".
- Work-authorization = YES, sponsorship = NO (India).
- "What interests you about this job" / "Additional Information" free-text box: fill it with the
MOTIVATION text from CONFIRMED FACTS verbatim. NEVER leave this blank — it is what makes or
breaks the application even though it has no "*".
- REQUIRED CONSENT CHECKBOX: tick "I confirm I have read the above." (the certification near the
bottom). This is REQUIRED even though it has no "*". After clicking it, VERIFY the box is
actually checked (a check mark / filled box) — re-click if it did not register.
6. ACCURACY GATE — GO/NO-GO (the whole point). Before doing step 7, go through the form field by
field and build a checklist of every field that must be filled. This includes: every "*" field;
every required consent CHECKBOX (e.g. "I confirm I have read the above") even with no "*"; AND
the motivation / "what interests you" / "Additional Information" free-text box. For each, record
its current value/state. A field counts as FILLED only if:
- text/select fields: a real value, NOT a placeholder ("Start typing…", "Pick date…",
"Type here…", "hello@example.com…");
- checkboxes/toggles: actually CHECKED/selected;
- the motivation box: contains the MOTIVATION text (not empty).
If ANY of these is empty, a placeholder, or an unchecked required box, it is NOT done: fill/tick
it now and re-check. You may NOT proceed to step 7 until EVERY one passes. Then CLOSE any open
date-picker/dropdown so the form is settled. Set all_required_filled=true only if every item
(including the consent checkbox and the motivation box) passes.
7. {submit_clause}
8. AFTER submit (if you submitted), VERIFY — never assume. Click Submit only ONCE, `wait` ~5s, read
the page. Do NOT rapid-click Submit (looks like spam).
- "still uploading / try again": upload wasn't done — re-verify step 4, then Submit once more.
- validation errors: fix the flagged fields, Submit once more.
- "flagged as possible spam": STOP, do NOT resubmit, report status="failed" with the exact text.
- Only report status="submitted" if you SEE an explicit confirmation ("Application submitted",
"Thank you for applying", or a reference id) — quote it verbatim. No proof => status="failed".
9. Final answer = JSON: {{"status": "submitted"|"ready_for_review"|"closed"|"failed"|"blocked",
"all_required_filled": true|false, "resume_upload_confirmed": true|false,
"required_fields": {{<field label>: <value you entered>, ...}},
"confirmation": <exact text/id or null>,
"uncertain": [<anything you guessed or skipped>], "notes": <short explanation>}}.
"""
async def main():
if not ASHBY_URL or "ashby" not in ASHBY_URL.lower():
print("✋ ASHBY_URL is not set (or doesn't look like an Ashby URL).")
print(" Open the OpenAI application tab and copy its address bar URL, then run:")
print(' ASHBY_URL="https://jobs.ashbyhq.com/openai/<id>/application" python apply_ashby.py')
return
print(f"[1/3] Parsing résumé via vision model ({RESUME_PATH})...")
profile = await parse_resume_to_profile(RESUME_PATH)
print(f" -> {profile.get('full_name')} · {profile.get('years_experience')} yrs · "
f"{len(profile.get('skills', []))} skills")
print(f"[2/3] Direct to Ashby (NO LinkedIn). model={APPLY_MODEL}, auto_submit={AUTO_SUBMIT}")
print(f" url: {ASHBY_URL}")
session = BrowserSession(browser_profile=make_profile())
agent = Agent(
task=build_task(profile),
llm=ChatOpenAI(model=APPLY_MODEL),
browser_session=session,
available_file_paths=[RESUME_PATH],
# sensitive_data not needed — no login on the Ashby page.
)
try:
history = await agent.run(max_steps=MAX_STEPS)
print("\n[3/3] ========== AGENT RESULT ==========")
print(history.final_result())
print("=====================================")
try:
shot = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ashby_result.png")
await session.take_screenshot(path=shot, full_page=True)
print(f"📸 Saved final-page screenshot -> {shot}")
except Exception as e:
print(f"(could not save screenshot: {e})")
finally:
try:
await asyncio.get_event_loop().run_in_executor(
None, input, "\nPress Enter to close the browser... "
)
except Exception:
pass
await session.kill()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,121 @@
"""
apply_engine.py — the MASTER KEY. One board-agnostic engine for any job board.
any apply URL -> pick_adapter(url) -> generic fill loop + adapter hints -> accuracy gate
-> verify + screenshot. Ashby is just one adapter; the GenericAdapter handles the
long tail (bespoke career pages) by vision alone.
Statuses it can report (the last three are what make the long-tail measurable):
submitted | ready_for_review | closed | login_required | no_form | failed | blocked
Run:
APPLY_URL="https://...apply..." AUTO_SUBMIT=0 python apply_engine.py
"""
from __future__ import annotations
import asyncio
import json
import os
from dotenv import load_dotenv
from browser_use import Agent, BrowserSession, ChatOpenAI
from browser_config import make_profile, RESUME_PATH
from candidate import build_candidate, candidate_prompt_block
from adapters import pick_adapter
load_dotenv()
APPLY_URL = os.environ.get("APPLY_URL", "")
APPLY_MODEL = os.environ.get("APPLY_MODEL", "gpt-4.1")
AUTO_SUBMIT = os.environ.get("AUTO_SUBMIT", "0") != "0" # default SAFE (fill, don't submit)
MAX_STEPS = int(os.environ.get("MAX_STEPS", "55"))
def build_task(cand: dict, url: str, hints: str) -> str:
submit_clause = (
"Submit the application (click the final Submit/Apply button) — ONLY after the gate passes."
if AUTO_SUBMIT else
"DO NOT click final submit. Stop at review and report ready_for_review — ONLY after the gate passes."
)
return f"""You are completing ONE job application on the careers page below, on behalf of the
candidate. Use ONLY the candidate's real data — never fabricate qualifications, employers, or dates.
{candidate_prompt_block(cand)}
{hints}
STEPS:
1. Go to {url}. It may be a JS app that renders blank for a few seconds — wait ~6s and re-read;
refresh ONCE if still blank.
2. TRIAGE the page:
- If it shows "no longer accepting applications" / 404 / closed -> STOP, status="closed".
- If applying REQUIRES creating an account or logging in -> STOP, status="login_required"
(we do not create accounts; that is the human/Cloud lane).
- If there is NO real per-job application form (just a generic /careers page, or "email your
CV to...") -> STOP, status="no_form".
- Otherwise there is a fillable application form -> continue.
3. RÉSUMÉ: if an "autofill from resume" control exists, upload there first. Then ensure the résumé
file is ATTACHED to the resume field — filename shown, NO spinner/"uploading" text. Uploads are
async; wait ~8s and verify; retry up to 2x. A half-finished upload causes failures.
4. FILL EVERY FIELD THAT STRENGTHENS THE APPLICATION (not just "*" required ones):
- contact details, links, work-authorization/sponsorship per CONFIRMED FACTS;
- typeaheads: type then CLICK the suggestion (verify it committed, not a placeholder);
- date pickers: open and click a valid FUTURE date;
- motivation / "why" / "additional information": write the tailored answer (never blank).
5. Tick every required consent checkbox (verify it is actually checked).
6. ACCURACY GATE — GO/NO-GO. List EVERY required field: every "*" field, every required consent
checkbox, and the motivation box. A field passes only if it has a real value (not a placeholder
"Start typing…"/"Pick date…"/"Type here…") or, for checkboxes, is CHECKED. If any fails, fix it
and re-check. Do NOT proceed until all pass. Then close any open picker so the form is settled.
7. {submit_clause}
8. If you submitted: click Submit ONCE, wait ~5s, read the page (don't rapid-click — looks like
spam). Handle "still uploading"->re-verify upload & retry once; validation errors->fix & retry;
"flagged as spam"->STOP, status="failed". Report status="submitted" ONLY with an explicit on-
screen confirmation (quote it); otherwise status="failed".
9. Final answer = JSON: {{"status": "submitted"|"ready_for_review"|"closed"|"login_required"|
"no_form"|"failed"|"blocked", "all_required_filled": true|false,
"resume_upload_confirmed": true|false, "fields": {{<label>: <value>, ...}},
"confirmation": <exact text/id or null>, "blockers": [<what stopped you, if any>],
"notes": <short explanation>}}.
"""
async def main():
if not APPLY_URL.startswith("http"):
print('✋ Set APPLY_URL, e.g. APPLY_URL="https://jobs.ashbyhq.com/.../application" python apply_engine.py')
return
adapter = pick_adapter(APPLY_URL)
print(f"[1/3] Parsing résumé ({RESUME_PATH}) ...")
cand = await build_candidate()
print(f" -> {cand['profile'].get('full_name')} · {cand['profile'].get('years_experience')} yrs")
print(f"[2/3] URL={APPLY_URL}\n adapter={adapter.name} ({adapter.tier}) · model={APPLY_MODEL} · auto_submit={AUTO_SUBMIT}")
session = BrowserSession(browser_profile=make_profile())
agent = Agent(
task=build_task(cand, APPLY_URL, adapter.hints()),
llm=ChatOpenAI(model=APPLY_MODEL),
browser_session=session,
available_file_paths=[RESUME_PATH],
)
try:
history = await agent.run(max_steps=MAX_STEPS)
print("\n[3/3] ========== RESULT ==========")
print(history.final_result())
print("=================================")
try:
shot = os.path.join(os.path.dirname(os.path.abspath(__file__)), "engine_result.png")
await session.take_screenshot(path=shot, full_page=True)
print(f"📸 {shot}")
except Exception as e:
print(f"(no screenshot: {e})")
finally:
try:
await asyncio.get_event_loop().run_in_executor(None, input, "\nPress Enter to close... ")
except Exception:
pass
await session.kill()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,131 @@
"""
apply_harness.py — accuracy harness. Runs the master-key engine (apply_engine) in NO-SUBMIT mode
over a list of real apply URLs and tallies the outcome per job. This is how we measure the precise
accuracy on the offsite set (does the generic engine actually fill arbitrary boards?).
SAFE: always no-submit (these are real employers — we never fire applications). Headless, fresh
browser per job. Reads URLs from /tmp/picks.json (list of {company, ats, apply_url, title}).
Run: python apply_harness.py
"""
from __future__ import annotations
import asyncio
import inspect
import json
import os
import re
import time
from dotenv import load_dotenv
from browser_use import Agent, BrowserSession, ChatOpenAI
from browser_config import make_profile, RESUME_PATH
from candidate import build_candidate
from adapters import pick_adapter
from apply_engine import build_task, APPLY_MODEL # build_task uses AUTO_SUBMIT=False by default
load_dotenv()
HERE = os.path.dirname(os.path.abspath(__file__))
TAG = os.environ.get("TAG", "")
RESULTS = os.path.join(HERE, f"harness_results{('_' + TAG) if TAG else ''}.jsonl")
MAX_STEPS = int(os.environ.get("MAX_STEPS", "38"))
USE_VISION = os.environ.get("USE_VISION", "1") != "0"
def parse_status(text: str) -> dict:
"""Pull the engine's final JSON (or at least its status) out of the agent's final message."""
if not text:
return {"status": "unknown"}
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
pass
m = re.search(r'status"?\s*[:=]\s*"?(\w+)', text)
return {"status": m.group(1) if m else "unknown"}
async def run_one(cand: dict, job: dict, idx: int) -> dict:
url = job["apply_url"]
adapter = pick_adapter(url)
session = BrowserSession(browser_profile=make_profile(headless=True, persist=False))
agent = Agent(
task=build_task(cand, url, adapter.hints()),
llm=ChatOpenAI(model=APPLY_MODEL),
browser_session=session,
available_file_paths=[RESUME_PATH],
calculate_cost=True,
use_vision=USE_VISION,
)
out = {"company": job.get("company"), "ats": job.get("ats"), "adapter": adapter.name, "url": url}
t0 = time.perf_counter()
try:
hist = await agent.run(max_steps=MAX_STEPS)
out["elapsed_s"] = round(time.perf_counter() - t0, 1)
out["steps"] = len(getattr(hist, "history", []) or [])
res = parse_status(hist.final_result())
out["status"] = res.get("status", "unknown")
out["all_required_filled"] = res.get("all_required_filled")
out["notes"] = (res.get("notes") or "")[:160]
try:
summ = agent.token_cost_service.get_usage_summary()
if inspect.isawaitable(summ):
summ = await summ
get = (lambda o, k: (getattr(o, k, None) if not isinstance(o, dict) else o.get(k)))
out["cost_usd"] = round(get(summ, "total_cost") or 0, 4)
out["tokens"] = get(summ, "total_tokens") or get(summ, "total_prompt_tokens")
except Exception as e:
out["cost_err"] = str(e)[:80]
try:
shot = os.path.join(HERE, f"harness_{idx}_{job.get('company')}.png")
await session.take_screenshot(path=shot, full_page=True)
out["screenshot"] = os.path.basename(shot)
except Exception:
pass
except Exception as e:
out["elapsed_s"] = round(time.perf_counter() - t0, 1)
out["status"] = "engine_error"
out["notes"] = f"{type(e).__name__}: {str(e)[:140]}"
finally:
await session.kill()
with open(RESULTS, "a") as f:
f.write(json.dumps(out) + "\n")
return out
async def main():
jobs = json.load(open(os.environ.get("PICKS", "/tmp/picks.json")))[:int(os.environ.get("LIMIT", "5"))]
open(RESULTS, "w").close() # reset
print(f"Harness: {len(jobs)} jobs, NO-SUBMIT, model={APPLY_MODEL}")
cand = await build_candidate()
print(f"Candidate: {cand['profile'].get('full_name')} · {cand['profile'].get('years_experience')} yrs\n")
results = []
for i, job in enumerate(jobs):
print(f"[{i+1}/{len(jobs)}] {job.get('company')} ({job.get('ats')}) -> running...")
r = await run_one(cand, job, i)
print(f" status={r['status']} filled={r.get('all_required_filled')} "
f"cost=${r.get('cost_usd') or 0:.4f} time={r.get('elapsed_s') or 0:.0f}s steps={r.get('steps','?')}")
results.append(r)
print("\n================ SUMMARY ================")
from collections import Counter
c = Counter(r["status"] for r in results)
print(f"model: {APPLY_MODEL} | vision: {'ON' if USE_VISION else 'OFF'}")
print("status tally:", dict(c))
ok = sum(1 for r in results if r["status"] in ("ready_for_review", "submitted") and r.get("all_required_filled"))
total_cost = sum((r.get("cost_usd") or 0) for r in results)
total_time = sum((r.get("elapsed_s") or 0) for r in results)
print(f"fully-filled (gate passed): {ok}/{len(results)}")
print(f"TOTAL COST: ${total_cost:.4f} (avg ${total_cost/max(1,len(results)):.4f}/job)")
print(f"TOTAL TIME: {total_time:.0f}s (avg {total_time/max(1,len(results)):.0f}s/job)")
for r in results:
print(f" - {r['company']:11} [{r['adapter']:7}] {r['status']:16} "
f"${r.get('cost_usd') or 0:.4f} {r.get('elapsed_s') or 0:.0f}s "
f"{r.get('steps','?')}st {r.get('notes','')[:40]}")
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,40 @@
"""
Shared browser configuration for login_once.py and apply.py.
Both MUST use the identical BrowserProfile so the LinkedIn session you create during login
is reused at apply time. The session lives in ./chrome-profile (git-ignored).
Why a dedicated profile instead of your real Chrome: Chrome 149 blocks remote-debugging on the
default profile (a 2025 anti-infostealer change), and there's no default Chrome profile on this
machine anyway. A dedicated profile gives the same thing — a real, logged-in, persistent
session you control — without sharing any password with our code.
"""
import os
from browser_use import BrowserProfile
HERE = os.path.dirname(os.path.abspath(__file__))
CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
PROFILE_DIR = os.path.join(HERE, "chrome-profile")
RESUME_PATH = os.path.join(HERE, "Resume.pdf")
def make_profile(headless: bool = False, persist: bool = True) -> BrowserProfile:
# persist=False -> fresh browser (no user_data_dir): right for public ATS forms that need no
# login, and for unattended sweeps where many runs shouldn't share one profile lock.
kwargs = dict(
executable_path=CHROME_PATH,
headless=headless, # False = you watch it; True = unattended sweep
keep_alive=True, # don't tear the browser down between steps
# --- Hardened pacing (defaults are 0.1 / 0.25 / 0.5) ---
# Slower, human-like timing. Critically, wait_for_network_idle lets Ashby's ASYNC résumé
# upload finish before the next action fires — the flaky step that got us spam-flagged.
# Slower pacing also reduces the bot fingerprint that feeds the spam heuristic.
wait_between_actions=1.2,
minimum_wait_page_load_time=1.0,
wait_for_network_idle_page_load_time=2.5,
)
if persist:
kwargs["user_data_dir"] = PROFILE_DIR # persists cookies/login across runs
return BrowserProfile(**kwargs)

View File

@@ -0,0 +1,51 @@
"""
candidate.py — the board-agnostic candidate model (used by every adapter).
= parsed résumé (source of truth) + confirmed facts not on the résumé (the "answer bank") +
the rule for writing a tailored motivation. This never changes per board — it's the user's data.
Grow CONFIRMED over time as new screening questions get answered (the answer-bank flywheel).
"""
from __future__ import annotations
import json
from datetime import date, timedelta
from parse_resume import parse_resume_to_profile
from browser_config import RESUME_PATH
# Durable answers that aren't on a résumé. Extend as new questions recur.
CONFIRMED = {
"preferred_email": "rahul.gupta2608@yahoo.com",
"citizenship": "Indian citizen, based in India",
"work_auth": ("Authorized to work in India. NOT authorized to work in the US; would require "
"visa sponsorship for any US-based role."),
"availability": "Available to start soon (within ~2 weeks).",
}
async def build_candidate(pdf_path: str = RESUME_PATH) -> dict:
profile = await parse_resume_to_profile(pdf_path)
return {"profile": profile, "facts": CONFIRMED, "resume_path": pdf_path}
def candidate_prompt_block(cand: dict) -> str:
today = date.today()
soon = today + timedelta(days=14)
f = cand["facts"]
return f"""CANDIDATE PROFILE (parsed from résumé — source of truth):
{json.dumps(cand["profile"], indent=2, ensure_ascii=False)}
CONFIRMED FACTS (override any résumé guess):
- Preferred contact email: {f["preferred_email"]}
- {f["citizenship"]}. {f["work_auth"]}
* "authorized to work where the job is located?" -> YES if the role is in India, NO if US.
* "require visa sponsorship?" -> NO if India role, YES if US role.
Honesty over optimism — never overstate authorization.
- {f["availability"]} Today is {today:%Y-%m-%d}; for a start-date picker choose a FUTURE date near
{soon:%Y-%m-%d}, never a past date.
- MOTIVATION: for any "why this role" / "about you" / "additional information" free-text field,
write a genuine 2-4 sentence answer TAILORED to THIS specific job (use its title / company /
description visible on screen) grounded in the candidate's real background above. NEVER leave it
blank — a blank motivation makes a weak application.
Résumé file to upload when asked: {cand["resume_path"]}"""

View File

@@ -0,0 +1,33 @@
import asyncio
from browser_use import BrowserSession
from browser_config import make_profile
BASE = "http://localhost:3001/scout"
async def ev(s, e):
cdp = await s.get_or_create_cdp_session()
r = await cdp.cdp_client.send.Runtime.evaluate(params={"expression": e, "returnByValue": True}, session_id=cdp.session_id)
return r.get("result", {}).get("value")
async def click(s, t):
return await ev(s, f"""(()=>{{const el=[...document.querySelectorAll('button,[role=tab]')].find(e=>(e.textContent||'').trim().toLowerCase().includes('{t.lower()}'));if(el){{el.click();return true}}return false}})()""")
async def main():
s = BrowserSession(browser_profile=make_profile(headless=True, persist=False))
await s.start()
try:
await s._cdp_set_viewport(width=1340, height=1100)
except Exception:
pass
await ev(s, "try{localStorage.removeItem('scout-onboarded')}catch(e){}")
await s.navigate_to(BASE); await asyncio.sleep(4)
await click(s, "Configure"); await asyncio.sleep(1.5)
await s.take_screenshot(path="/tmp/cfg_collapsed.png", full_page=True); print("collapsed")
await click(s, "See all"); await asyncio.sleep(.8)
await click(s, "Adjust"); await asyncio.sleep(1.0)
await s.take_screenshot(path="/tmp/cfg_expanded.png", full_page=True); print("expanded")
await s.kill()
asyncio.run(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,2 @@
{"company": "gitlab", "ats": "greenhouse", "adapter": "generic", "url": "https://job-boards.greenhouse.io/gitlab/jobs/8503792002", "status": "unknown", "all_required_filled": null, "notes": "", "cost_usd": 0.4036, "tokens": 2922333, "screenshot": "harness_0_gitlab.png"}
{"company": "figma", "ats": "greenhouse", "adapter": "generic", "url": "https://boards.greenhouse.io/figma/jobs/5822886004?gh_jid=5822886004", "status": "failed", "all_required_filled": false, "notes": "The application is incomplete due to unresolved dropdown selections.", "cost_usd": 0.4012, "tokens": 2921361, "screenshot": "harness_1_figma.png"}

View File

@@ -0,0 +1,4 @@
{"company": "Index Infotech Ind", "ats": "zohorecruit", "adapter": "generic", "url": "https://indexinfotech.zohorecruit.com/jobs/Index-Jobs/588695000002902001/Senior-Software-Developer-C-net-SQL-ERP-Software-?source=CareerSite", "elapsed_s": 288.1, "steps": 12, "status": "ready_for_review", "all_required_filled": true, "notes": "All required fields including CAPTCHA, Street address, and r\u00e9sum\u00e9 upload are confirmed filled. No visible consent checkboxes remain unchecked. Stopping at revie", "cost_usd": 0.2138, "tokens": 174455, "screenshot": "harness_0_Index Infotech Ind.png"}
{"company": "Suavis India Softw", "ats": "keka", "adapter": "generic", "url": "https://suavis.keka.com/careers/jobdetails/44285", "elapsed_s": 342.7, "steps": 16, "status": "ready_for_review", "all_required_filled": true, "notes": "All required fields are filled with real candidate data. R\u00e9sum\u00e9 upload is confirmed and attached. No motivation/why-this-role/additional information field exist", "cost_usd": 0.267, "tokens": 233059, "screenshot": "harness_1_Suavis India Softw.png"}
{"company": "Oracle", "ats": "oraclecloud", "adapter": "generic", "url": "https://eeho.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX/job/336572", "elapsed_s": 179.5, "steps": 7, "status": "unknown", "all_required_filled": null, "notes": "", "cost_usd": 0.1025, "tokens": 91293, "screenshot": "harness_2_Oracle.png"}
{"company": "ShreeCom InfoTech ", "ats": "bespoke", "adapter": "generic", "url": "https://www.shreecominfotech.com/Careers", "elapsed_s": 94.9, "steps": 3, "status": "unknown", "all_required_filled": null, "notes": "", "cost_usd": 0.0419, "tokens": 37034, "screenshot": "harness_3_ShreeCom InfoTech .png"}

View File

@@ -0,0 +1,2 @@
{"company": "gitlab", "ats": "greenhouse", "adapter": "generic", "url": "https://job-boards.greenhouse.io/gitlab/jobs/8503792002", "status": "ready_for_review", "all_required_filled": true, "notes": "All required fields are filled with real candidate data from the provided profile. Resume upload is confirmed by filename display. All dropdowns were committed ", "cost_usd": 0.1608, "tokens": 152189, "screenshot": "harness_0_gitlab.png"}
{"company": "figma", "ats": "greenhouse", "adapter": "generic", "url": "https://boards.greenhouse.io/figma/jobs/5822886004?gh_jid=5822886004", "status": "ready_for_review", "all_required_filled": true, "notes": "All required application fields have been filled with candidate data; resume upload is confirmed; voluntary self-ID questions are visible but not mandatory; for", "cost_usd": 0.1069, "tokens": 99222, "screenshot": "harness_1_figma.png"}

View File

@@ -0,0 +1,2 @@
{"company": "gitlab", "ats": "greenhouse", "adapter": "generic", "url": "https://job-boards.greenhouse.io/gitlab/jobs/8503792002", "status": "unknown", "all_required_filled": null, "notes": "", "cost_usd": 0.2284, "tokens": 174665, "screenshot": "harness_0_gitlab.png"}
{"company": "figma", "ats": "greenhouse", "adapter": "generic", "url": "https://boards.greenhouse.io/figma/jobs/5822886004?gh_jid=5822886004", "status": "unknown", "all_required_filled": null, "notes": "", "cost_usd": 0.2329, "tokens": 188078, "screenshot": "harness_1_figma.png"}

View File

@@ -0,0 +1,40 @@
"""
login_once.py — one-time LinkedIn login into the dedicated Chrome profile.
Run this ONCE (or whenever the session expires). A Chrome window opens on LinkedIn's login page.
YOU type your email/password (and any 2FA) directly in that window — nothing is sent to our code.
When the feed loads, come back to this terminal and press Enter; the session is saved in
./chrome-profile and reused by apply.py.
python login_once.py
"""
import asyncio
from browser_use import BrowserSession
from browser_config import make_profile
async def main():
session = BrowserSession(browser_profile=make_profile())
await session.start()
await session.navigate_to("https://www.linkedin.com/login")
print("\n" + "=" * 70)
print(" A Chrome window opened on LinkedIn.")
print(" 1. Log in there (email, password, 2FA if asked).")
print(" 2. Wait until your LinkedIn FEED is visible.")
print(" 3. Then press Enter here to save the session.")
print("=" * 70)
await asyncio.get_event_loop().run_in_executor(None, input, "\nPress Enter once logged in... ")
try:
url = await session.get_current_page_url()
print(f"Current page: {url}")
except Exception:
pass
print("Session saved to ./chrome-profile — you can close the window. Run apply.py next.")
await session.kill()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,164 @@
"""
parse_resume.py — turn a résumé PDF into a structured profile using a VISION model.
This reuses the proven pattern from the resume-builder service
(growqr-demo/resume-builder/app/services/parse_service.py): render each PDF page to a PNG,
send the images to gpt-4o-mini with JSON mode, merge the pages. Vision (not text extraction)
because it preserves layout — columns, headers, contact blocks — that plain text destroys.
The output `profile` dict is the SINGLE SOURCE OF TRUTH the apply agent uses to fill the form,
so identity and experience come from the real résumé, never from guesses.
Standalone usage:
python parse_resume.py # parses ./Resume.pdf and prints the profile
Importable:
from parse_resume import parse_resume_to_profile
profile = await parse_resume_to_profile("Resume.pdf")
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
from datetime import date
from typing import Any
import fitz # PyMuPDF
from dotenv import load_dotenv
from openai import AsyncOpenAI
load_dotenv()
VISION_MODEL = "gpt-4o-mini"
SYSTEM_PROMPT = """You are a precise résumé parser. You are given an image of ONE page of a
candidate's résumé. Extract ONLY what is actually present — never invent or infer values that
are not visibly written. Return a JSON object with exactly these keys:
{
"full_name": string | null,
"email": string | null,
"phone": string | null,
"location": string | null, // city/region as written
"linkedin_url": string | null,
"github_url": string | null,
"portfolio_url": string | null,
"current_title": string | null, // most recent / current role title
"skills": [string], // technical skills, as written
"experience": [ // every role visible on THIS page
{"company": string, "title": string, "start_date": "YYYY-MM" | null,
"end_date": "YYYY-MM" | null, "is_current": boolean}
],
"education": [
{"institution": string, "degree": string, "field_of_study": string | null,
"end_date": "YYYY-MM" | null}
]
}
If a field is not on this page, use null (or [] for lists). Output ONLY the JSON object."""
def _pdf_to_png_b64(pdf_path: str, dpi: int = 200) -> list[str]:
"""Render each PDF page to a base64-encoded PNG (same approach as resume-builder)."""
doc = fitz.open(pdf_path)
images: list[str] = []
for page in doc:
pix = page.get_pixmap(dpi=dpi)
images.append(base64.b64encode(pix.tobytes("png")).decode("utf-8"))
doc.close()
return images
async def _parse_page(client: AsyncOpenAI, img_b64: str, page_num: int, total: int) -> dict[str, Any]:
resp = await client.chat.completions.create(
model=VISION_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": [
{"type": "text", "text": f"Résumé page {page_num} of {total}."},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}", "detail": "high"}},
],
},
],
temperature=0.1,
max_tokens=4000,
response_format={"type": "json_object"},
)
try:
return json.loads(resp.choices[0].message.content or "{}")
except json.JSONDecodeError:
return {}
def _first(*vals):
for v in vals:
if v:
return v
return None
def _years_experience(experience: list[dict]) -> int | None:
"""Rough total experience: earliest start date -> today (or latest end)."""
starts = [e.get("start_date") for e in experience if e.get("start_date")]
if not starts:
return None
earliest = min(starts) # "YYYY-MM" strings sort chronologically
try:
y, m = (int(x) for x in earliest.split("-")[:2])
except (ValueError, IndexError):
return None
today = date.today()
months = (today.year - y) * 12 + (today.month - m)
return max(0, round(months / 12))
def _merge(pages: list[dict[str, Any]]) -> dict[str, Any]:
"""Merge per-page results into one profile; first non-empty wins for scalars."""
skills: list[str] = []
experience: list[dict] = []
education: list[dict] = []
for p in pages:
for s in p.get("skills") or []:
if s and s not in skills:
skills.append(s)
experience.extend(p.get("experience") or [])
education.extend(p.get("education") or [])
profile = {
"full_name": _first(*[p.get("full_name") for p in pages]),
"email": _first(*[p.get("email") for p in pages]),
"phone": _first(*[p.get("phone") for p in pages]),
"location": _first(*[p.get("location") for p in pages]),
"linkedin_url": _first(*[p.get("linkedin_url") for p in pages]),
"github_url": _first(*[p.get("github_url") for p in pages]),
"portfolio_url": _first(*[p.get("portfolio_url") for p in pages]),
"current_title": _first(*[p.get("current_title") for p in pages]),
"skills": skills,
"experience": experience,
"education": education,
}
profile["years_experience"] = _years_experience(experience)
return profile
async def parse_resume_to_profile(pdf_path: str = "Resume.pdf") -> dict[str, Any]:
if not os.path.exists(pdf_path):
raise FileNotFoundError(f"résumé not found: {pdf_path}")
if not os.environ.get("OPENAI_API_KEY"):
raise RuntimeError("OPENAI_API_KEY not set (put it in .env)")
images = _pdf_to_png_b64(pdf_path)
client = AsyncOpenAI()
pages = await asyncio.gather(
*[_parse_page(client, img, i + 1, len(images)) for i, img in enumerate(images)]
)
return _merge(list(pages))
if __name__ == "__main__":
profile = asyncio.run(parse_resume_to_profile())
print(json.dumps(profile, indent=2, ensure_ascii=False))

View File

@@ -0,0 +1,22 @@
"""shoot.py <url> <out.png> — headless full-page screenshot of a running UI route."""
import asyncio
import sys
from browser_use import BrowserSession
from browser_config import make_profile
async def main():
url, out = sys.argv[1], sys.argv[2]
wait = float(sys.argv[3]) if len(sys.argv) > 3 else 4.0
session = BrowserSession(browser_profile=make_profile(headless=True, persist=False))
await session.start()
await session.navigate_to(url)
await asyncio.sleep(wait) # let the Next.js client render
await session.take_screenshot(path=out, full_page=True)
await session.kill()
print("saved", out)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,40 @@
"""Capture both states of the Scout Summary tab."""
import asyncio
from browser_use import BrowserSession
from browser_config import make_profile
BASE = "http://localhost:3001/scout"
async def ev(s, expr):
cdp = await s.get_or_create_cdp_session()
r = await cdp.cdp_client.send.Runtime.evaluate(
params={"expression": expr, "returnByValue": True}, session_id=cdp.session_id)
return r.get("result", {}).get("value")
async def click(s, t):
return await ev(s, f"""(()=>{{const el=[...document.querySelectorAll('button,[role=tab],a')]
.find(e=>(e.textContent||'').trim().toLowerCase()==='{t.lower()}');if(el){{el.click();return true}}return false}})()""")
async def main():
s = BrowserSession(browser_profile=make_profile(headless=True, persist=False))
await s.start()
try:
await s._cdp_set_viewport(width=1340, height=1040)
except Exception:
pass
# First-time view (not onboarded)
await ev(s, "try{localStorage.removeItem('scout-onboarded')}catch(e){}")
await s.navigate_to(BASE); await asyncio.sleep(4)
print("summary tab ->", await click(s, "Summary")); await asyncio.sleep(2.5)
await s.take_screenshot(path="/tmp/summ_first.png", full_page=True); print("shot first")
# Returning view (onboarded → summary defaults + metrics)
await ev(s, "try{localStorage.setItem('scout-onboarded','1')}catch(e){}")
await s.navigate_to(BASE); await asyncio.sleep(4.5)
await click(s, "Summary"); await asyncio.sleep(2.5)
await s.take_screenshot(path="/tmp/summ_returning.png", full_page=True); print("shot returning")
await s.kill()
asyncio.run(main())

View File

@@ -0,0 +1,43 @@
"""tour.py — capture each Scout flow state by clicking through via CDP."""
import asyncio
from browser_use import BrowserSession
from browser_config import make_profile
CLICK = """(()=>{const t=%r.toLowerCase();
const el=[...document.querySelectorAll('button,a,[role=tab],[role=button]')]
.find(e=>(e.textContent||'').trim().toLowerCase().includes(t));
if(el){el.click();return (el.textContent||'').trim().slice(0,40)} return null})()"""
async def click(s, text):
cdp = await s.get_or_create_cdp_session()
r = await cdp.cdp_client.send.Runtime.evaluate(
params={"expression": CLICK % text, "returnByValue": True}, session_id=cdp.session_id)
return r.get("result", {}).get("value")
async def shot(s, path):
await s.take_screenshot(path=path, full_page=True)
print("shot", path)
async def main():
s = BrowserSession(browser_profile=make_profile(headless=True, persist=False))
await s.start()
try:
await s._cdp_set_viewport(width=1320, height=900)
except Exception as e:
print("viewport set skipped:", e)
await s.navigate_to("http://localhost:3001/scout")
await asyncio.sleep(5)
await shot(s, "/tmp/sc_2_configure.png") # default tab
print("Summary ->", await click(s, "Summary")); await asyncio.sleep(2.5); await shot(s, "/tmp/sc_1_summary.png")
print("Action ->", await click(s, "Action")); await asyncio.sleep(2.5); await shot(s, "/tmp/sc_3_idle.png")
print("Find ->", await click(s, "Find my matches")); await asyncio.sleep(1.6); await shot(s, "/tmp/sc_4_loading.png")
await asyncio.sleep(7.5); await shot(s, "/tmp/sc_5_deck.png") # loader → deck
await s.kill()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,37 @@
"""Capture the lean Scout POC screens."""
import asyncio
from browser_use import BrowserSession
from browser_config import make_profile
URL = "http://localhost:8099/"
CLICK = """(()=>{const t=%r.toLowerCase();const el=[...document.querySelectorAll('button')]
.find(e=>(e.textContent||'').trim().toLowerCase().includes(t));if(el){el.click();return el.textContent.trim()}return null})()"""
async def click(s, t):
cdp = await s.get_or_create_cdp_session()
r = await cdp.cdp_client.send.Runtime.evaluate(
params={"expression": CLICK % t, "returnByValue": True}, session_id=cdp.session_id)
return r.get("result", {}).get("value")
async def shot(s, p):
await s.take_screenshot(path=p, full_page=True); print("shot", p)
async def main():
s = BrowserSession(browser_profile=make_profile(headless=True, persist=False))
await s.start()
try:
await s._cdp_set_viewport(width=720, height=1024)
except Exception:
pass
await s.navigate_to(URL); await asyncio.sleep(3.5)
await shot(s, "/tmp/scout_1_config.png")
print("find ->", await click(s, "Find my matches")); await asyncio.sleep(6.5)
await shot(s, "/tmp/scout_2_deck.png")
print("apply ->", await click(s, "Apply")); await asyncio.sleep(1.5)
await shot(s, "/tmp/scout_3_applied.png")
await s.kill()
asyncio.run(main())

View File

@@ -0,0 +1,40 @@
"""
verify.py — the smallest possible proof that browser-use works end to end.
It confirms three things are wired up correctly:
1. The browser launches and is driven by browser-use.
2. Your OpenAI key (from .env) reaches the LLM.
3. The agent can read a real page and report back.
This does NOT log into anything or apply to any job — it's a hello-world.
Run:
source venv/bin/activate
python verify.py
"""
import asyncio
import os
from dotenv import load_dotenv
from browser_use import Agent, ChatOpenAI
load_dotenv() # pulls OPENAI_API_KEY out of the .env file next to this script
# HEADLESS=0 in the env -> you SEE a Chrome window open and the agent drive it.
# Default here is headless (no window) so it runs cleanly in any terminal.
HEADLESS = os.environ.get("HEADLESS", "1") != "0"
async def main():
agent = Agent(
task="Go to https://example.com and tell me the main heading (the big H1 text) on the page.",
llm=ChatOpenAI(model="gpt-4o-mini"), # cheap model — this is just a smoke test
)
history = await agent.run(max_steps=6)
print("\n========== RESULT ==========")
print(history.final_result())
print("============================")
if __name__ == "__main__":
asyncio.run(main())