Author SHA1 Message Date
Sai-karthik 97958c8302 surface total job board outages 2026-07-17 07:50:25 +00:00
Sai-karthik e963825c8a fix matchmaking search cache identity 2026-07-17 07:40:07 +00:00
puter 46078bff62 Merge pull request 'Consume strict RQ Score contract' (#1) from fix/rq-score-contract into staging 2026-07-14 14:14:13 +00:00
-Puter f3c0775e59 fix: harden RQ Score response parsing 2026-07-14 18:20:06 +05:30
sai karthik 81a845d244 fix: consume strict RQ Score contract 2026-07-14 15:41:48 +05:30
-Puter 8b36dd4e99 fix(staging): unify matchmaking-v2 network topology + redact Apify token
Root cause: the VPS ran two Compose projects from the same file — api under
-p matchmaking-v2-staging, postgres/redis under -p matchmaking-v2 — creating
two disjoint <project>_default bridges. Every dependency hostname (postgres,
redis) was NXDOMAIN from the api container, so feed store init, pool_load,
save_feed, and RedisStreamWorker all failed with [Errno -2].

Fix: standardize on a single project (matchmaking-v2) for all three services
with an external shared bridge (matchmaking-v2_default) and inline DB/Redis
host pinning. The deploy script (sync-staging.sh) now brings up all three
services, pre-creates the external network, and has a public_health_url case.

Apify: moved the token from query params (4 sites) to an Authorization header
so no request URL can leak it. All raise_for_status() paths now raise
token-free _ApifyHTTPError. The live 403 (platform-feature-disabled: monthly
usage cap exceeded) is an external blocker — no code fix.

Tests: 15 focused regression tests covering network topology, volume config,
deploy-script alignment, and Apify token redaction (with call-counters).
2026-07-11 05:56:14 +05:30
-Puter 98a4ec305e fix(events): normalize ingest URL, add source header, raise_for_status, dedupe keys
Grow event emitter fixes (wrong route / silent failure cannot recur):
- URL: /events/ingest -> /events/ingest/service (canonical service ingest path)
- Add x-growqr-source: matchmaking-service header
- Call resp.raise_for_status() so a 401 is caught and logged, not silently
  swallowed (401 is now observable; a wrong route surfaces immediately)
- Action-specific dedupe keys so retries collapse but later distinct user
  actions are NOT dropped:
    matches.generated  -> matchmaking:generated:{user}:{sig}
    feed.viewed        -> matchmaking:feed:{user}:{updated_at|none}
    match.viewed       -> matchmaking:viewed:{user}:{opp_id}
    match.applied      -> matchmaking:applied:{user}:{opp_id}  (already present)

All four emit handlers fire the event AFTER the persistent mutation
(set_opportunity_state / save_feed), so no event leaks when a mutation fails.

Tests (tests/test_grow_events.py, 12 cases, red->green TDD):
- URL normalizes to /events/ingest/service (with/without trailing slash)
- x-growqr-source header present
- 401 response raises_for_status -> logged warning; does not escape _post
- emit-after-mutation ordering; no emit when mutation raises
- dedupe keys stable for same evidence, distinct for different evidence
  across generated/feed/viewed/applied event types
2026-07-10 20:02:27 +05:30
-Puter 28cacfcc7c fix(curate): guard _salvage_objects against None LLM content
The LLM (Opus score stage / Haiku cards stage) can return None or empty
content, causing _salvage_objects to crash with
'NoneType' object has no attribute 'find' — which killed the curation
pipeline and returned zero matches to the user.

- Guard _salvage_objects and _salvage_kept against None/empty input
- Add explicit empty-content checks in both stages with logging
- Log content[:200] on unparseable score stage for QA diagnostics
2026-07-09 13:17:50 +05:30
-Puter db5c606783 Merge main into staging 2026-07-08 20:55:51 +05:30
Sai-karthik b27e389e17 Use direct OpenAI defaults for matchmaking 2026-07-01 11:44:50 +00:00
Sai-karthik 47be9dc715 Support OpenRouter embeddings gateway 2026-06-29 19:27:17 +00:00
18 changed files with 1149 additions and 56 deletions
+6
View File
@@ -14,6 +14,12 @@ REDIS_URL=redis://localhost:6379/0
# Inputs / engine (wired in later slices) # Inputs / engine (wired in later slices)
APIFY_TOKEN= APIFY_TOKEN=
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_API_BASE=
DSPY_API_BASE=
DSPY_API_KEY=
CURATE_MODEL=gpt-4o-mini
SUGGEST_MODEL=gpt-4o-mini
EMBED_MODEL=text-embedding-3-small
# QScore (consumed) # QScore (consumed)
QSCORE_BASE_URL=http://localhost:8004 QSCORE_BASE_URL=http://localhost:8004
+109
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from app.grow_events import emit_grow_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,6 +22,34 @@ class MatchmakingAgentSession:
self.ws = websocket self.ws = websocket
self.user_id = user_id self.user_id = user_id
self.context: dict = {} self.context: dict = {}
async def _grow_activity_fields(self) -> dict:
"""Cheap cumulative activity context shared across Grow payloads — the funnel counts the
backend extractor maps to qscore signals (applications_submitted, opportunities_viewed).
Degrades to empty dict if the DB is off."""
from app.db.repo import get_activity_stats
try:
act = await get_activity_stats(self.user_id)
return {
"applications_submitted": act.get("applied", 0), # ← projector reads applications_submitted
"opportunities_viewed": act.get("viewed", 0),
"jobs_viewed": act.get("viewed", 0), # ← projector reads jobs_viewed
"opportunities_saved": act.get("saved", 0),
"searches": act.get("searches", 0),
"matches_generated": act.get("matches", 0),
"matchCount": act.get("matches", 0), # ← projector reads matchCount
}
except Exception: # noqa: BLE001 — never fatal
return {}
@staticmethod
def _match_scores(opps: list[dict]) -> list[int]:
"""Extract integer match scores from opportunities for source score fields."""
out = []
for o in opps:
s = (o.get("match") or {}).get("score") or o.get("matchScore")
if isinstance(s, (int, float)) and s > 0:
out.append(int(s))
return out
async def push(self, msg_type: str, **kwargs): async def push(self, msg_type: str, **kwargs):
await self.ws.send_json({"type": msg_type, **kwargs}) await self.ws.send_json({"type": msg_type, **kwargs})
@@ -94,6 +124,25 @@ class MatchmakingAgentSession:
from app.db.repo import get_feed from app.db.repo import get_feed
feed = await get_feed(self.user_id) feed = await get_feed(self.user_id)
await self.push("agent_data", action="feed_loaded", data=feed or {"opportunities": [], "cached": False}) await self.push("agent_data", action="feed_loaded", data=feed or {"opportunities": [], "cached": False})
feed_opps = (feed or {}).get("opportunities") or []
feed_scores = self._match_scores(feed_opps)
await emit_grow_event(
"matchmaking.feed.viewed",
self.user_id,
payload={
"action": "get_feed",
"match_count": len(feed_opps),
"cached": bool((feed or {}).get("cached", True)),
"source_scores": feed_scores,
"matchMin": min(feed_scores) if feed_scores else None,
"matchMax": max(feed_scores) if feed_scores else None,
"match_min": min(feed_scores) if feed_scores else None,
"match_max": max(feed_scores) if feed_scores else None,
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": "feed"},
dedupe_key=f"matchmaking:feed:{self.user_id}:{(feed or {}).get('updated_at') or 'none'}",
)
async def handle_record_feedback(self, params: dict): async def handle_record_feedback(self, params: dict):
await self._stub("feedback_recorded", "store SAVE/DISMISS/APPLY label") await self._stub("feedback_recorded", "store SAVE/DISMISS/APPLY label")
@@ -135,6 +184,17 @@ class MatchmakingAgentSession:
search.run_sweep(prefs, fresh=fresh), search.run_sweep(prefs, fresh=fresh),
["Scanning live roles across the boards…", "Pulling the latest Naukri + LinkedIn + Foundit roles…", ["Scanning live roles across the boards…", "Pulling the latest Naukri + LinkedIn + Foundit roles…",
"Gathering fresh postings for you…"]) "Gathering fresh postings for you…"])
if sweep.get("all_boards_failed"):
await self.push(
"agent_error",
action="search_failed",
data={
"code": "job_boards_unavailable",
"message": "All enabled job boards are temporarily unavailable.",
"failed_boards": sweep.get("failed_boards", 0),
},
)
return
new_count = await _repo.pool_save(self.user_id, sig, sweep["opportunities"]) new_count = await _repo.pool_save(self.user_id, sig, sweep["opportunities"])
# tapped out? a sweep that added almost nothing new → flag it so we stop block-fetching dupes. # tapped out? a sweep that added almost nothing new → flag it so we stop block-fetching dupes.
if new_count < s.POOL_MIN_NEW_PER_REFILL: if new_count < s.POOL_MIN_NEW_PER_REFILL:
@@ -184,6 +244,33 @@ class MatchmakingAgentSession:
result["opportunities"] = _repo.annotate_and_filter(result["opportunities"], states) result["opportunities"] = _repo.annotate_and_filter(result["opportunities"], states)
result["shortlisted"] = len(result["opportunities"]) result["shortlisted"] = len(result["opportunities"])
await self.push("agent_data", action="search_complete", data=result) await self.push("agent_data", action="search_complete", data=result)
# Grow event: matches were generated for this user → backend extracts qscore signals
gen_opps = result["opportunities"] or []
gen_scores = self._match_scores(gen_opps)
await emit_grow_event(
"matchmaking.matches.generated",
self.user_id,
payload={
"action": "run_search",
"matchCount": len(gen_opps), # ← projector reads matchCount/matches/shortlisted
"match_count": len(gen_opps), # snake_case alias
"scanned": result.get("scanned"),
"total_candidates": result.get("scanned"), # ← projector reads total_candidates for match_rate
"shortlisted": result.get("shortlisted"),
"sources": result.get("sources"),
"engine": result.get("engine"),
"source_scores": gen_scores,
"matchMin": min(gen_scores) if gen_scores else None, # ← scout_stats camelCase
"matchMax": max(gen_scores) if gen_scores else None,
"match_min": min(gen_scores) if gen_scores else None, # snake_case alias
"match_max": max(gen_scores) if gen_scores else None,
"top_match_score": max(gen_scores) if gen_scores else None,
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": sig},
correlation={"taskId": sig},
dedupe_key=f"matchmaking:generated:{self.user_id}:{sig}",
)
# The user already has their deck — if the pool dipped below the reorder mark, top it up OUT OF # The user already has their deck — if the pool dipped below the reorder mark, top it up OUT OF
# BAND so the next search stays free and the shelf never runs thin. (No await: fire-and-forget.) # BAND so the next search stays free and the shelf never runs thin. (No await: fire-and-forget.)
@@ -246,6 +333,17 @@ class MatchmakingAgentSession:
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status="applied") await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), status="applied")
await self.push("agent_data", action="opportunity_state_saved", await self.push("agent_data", action="opportunity_state_saved",
data={"opportunity_id": params.get("opportunity_id"), "status": "applied"}) data={"opportunity_id": params.get("opportunity_id"), "status": "applied"})
await emit_grow_event(
"matchmaking.match.applied",
self.user_id,
payload={
"opportunity_id": params.get("opportunity_id"),
"status": "applied",
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": params.get("opportunity_id", "")},
dedupe_key=f"matchmaking:applied:{self.user_id}:{params.get('opportunity_id', '')}",
)
async def handle_save_apply_docs(self, params: dict): async def handle_save_apply_docs(self, params: dict):
"""Remember the resume-builder document ids generated for a role so we never re-pay to """Remember the resume-builder document ids generated for a role so we never re-pay to
@@ -262,6 +360,17 @@ class MatchmakingAgentSession:
"""The user opened a job → funnel 'Viewed'. Fire-and-forget (no response needed).""" """The user opened a job → funnel 'Viewed'. Fire-and-forget (no response needed)."""
from app.db.repo import set_opportunity_state from app.db.repo import set_opportunity_state
await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), viewed=True) await set_opportunity_state(self.user_id, params.get("opportunity_id", ""), viewed=True)
await emit_grow_event(
"matchmaking.match.viewed",
self.user_id,
payload={
"opportunity_id": params.get("opportunity_id"),
"action": "viewed",
**await self._grow_activity_fields(),
},
subject={"serviceId": "matchmaking-service", "externalId": params.get("opportunity_id", "")},
dedupe_key=f"matchmaking:viewed:{self.user_id}:{params.get('opportunity_id', '')}",
)
async def handle_mark_saved(self, params: dict): async def handle_mark_saved(self, params: dict):
"""The user saved/swiped-right a job → funnel 'Shortlisted'.""" """The user saved/swiped-right a job → funnel 'Shortlisted'."""
+7 -7
View File
@@ -31,14 +31,14 @@ class Settings(BaseSettings):
# Inputs / engine # Inputs / engine
APIFY_TOKEN: str | None = None APIFY_TOKEN: str | None = None
OPENAI_API_KEY: str | None = None OPENAI_API_KEY: str | None = None
OPENAI_API_BASE: str | None = None
# LLM gateway (opencode.ai/zen — OpenAI-compatible; reuse interview-service's DSPY key). # LLM gateway override for chat curation. Leave blank to use OPENAI_API_KEY directly.
# One `openai` client (base_url override) serves the Opus curator + embeddings.
DSPY_API_BASE: str | None = None DSPY_API_BASE: str | None = None
DSPY_API_KEY: str | None = None DSPY_API_KEY: str | None = None
CURATE_MODEL: str = "claude-opus-4-8" # the senior recruiter (Opus, latest) — Opus's call is final CURATE_MODEL: str = "gpt-4o-mini" # the senior recruiter
SUGGEST_MODEL: str = "claude-haiku-4-5" # the bubbling engine — fast + cheap (latency-sensitive UI) SUGGEST_MODEL: str = "gpt-4o-mini" # the bubbling engine
EMBED_MODEL: str = "text-embedding-3-small" # the vibe-engineer (direct OpenAI — gateway has no embeddings route) EMBED_MODEL: str = "text-embedding-3-small" # direct OpenAI embeddings
ENGINE_LLM_ENABLED: bool = True # off / key absent → white-box sift + templated cards (safety net) ENGINE_LLM_ENABLED: bool = True # off / key absent → white-box sift + templated cards (safety net)
SIFT_TOP_K: int = 22 # candidate pool the curator reads — widened from 18 for SIFT_TOP_K: int = 22 # candidate pool the curator reads — widened from 18 for
# recall, trimmed from 28 to keep the Opus payload lean # recall, trimmed from 28 to keep the Opus payload lean
@@ -89,13 +89,13 @@ class Settings(BaseSettings):
WELLFOUND_MAX_JOBS: int = 15 # blackfalcondata/wellfound-scraper — registered, OFF (US-heavy) WELLFOUND_MAX_JOBS: int = 15 # blackfalcondata/wellfound-scraper — registered, OFF (US-heavy)
WORKINDIA_MAX_JOBS: int = 15 # shahidirfan/workindia-jobs-scraper — India blue/grey-collar (niche, low yield) WORKINDIA_MAX_JOBS: int = 15 # shahidirfan/workindia-jobs-scraper — India blue/grey-collar (niche, low yield)
# QScore (competence proxy) — consumed, not computed. Dashboard Momentum/QX reads it via REST. # RQ Score (competence proxy) — consumed, not computed. Dashboard Momentum reads it via REST.
QSCORE_BASE_URL: str = "http://localhost:8004" QSCORE_BASE_URL: str = "http://localhost:8004"
QSCORE_AUTH_TOKEN: str | None = None QSCORE_AUTH_TOKEN: str | None = None
QSCORE_ORG_ID: str = "growqr" QSCORE_ORG_ID: str = "growqr"
# user-service — dashboard Day-streak reads metadata.current_streak via GET /api/state/{clerk_id} # user-service — dashboard Day-streak reads metadata.current_streak via GET /api/state/{clerk_id}
USER_SERVICE_BASE_URL: str = "http://localhost:8003" USER_SERVICE_BASE_URL: str = "http://localhost:8003"
# dashboard-service — Q-Score trend series via GET /api/qscore-history/{clerk_id} # dashboard-service — RQ Score trend series via GET /api/qscore-history/{clerk_id}
DASHBOARD_SERVICE_BASE_URL: str = "http://localhost:8005" DASHBOARD_SERVICE_BASE_URL: str = "http://localhost:8005"
A2A_OUTBOUND_KEY: str = "dev-a2a-key" # Bearer token presented to sibling services A2A_OUTBOUND_KEY: str = "dev-a2a-key" # Bearer token presented to sibling services
+25 -4
View File
@@ -17,14 +17,35 @@ logger = logging.getLogger(__name__)
# fields a caller may set on an opportunity's state (status + the generated document ids + activity flags) # fields a caller may set on an opportunity's state (status + the generated document ids + activity flags)
_STATE_FIELDS = ("status", "tailored_resume_id", "tailored_version_id", "cover_letter_id", "seen", "viewed", "saved") _STATE_FIELDS = ("status", "tailored_resume_id", "tailored_version_id", "cover_letter_id", "seen", "viewed", "saved")
# the search-defining pref fields — same values → same signature → cursor advances (new jobs); def _effective_title(prefs: dict) -> str:
# change any → new signature → cursor resets to page 1. """Match the board adapter's title precedence for cache identity."""
_SIG_FIELDS = ("title", "role", "location", "industry", "experience", "years", "work_mode", "remote") target_titles = prefs.get("targetTitles") or []
if target_titles and str(target_titles[0]).strip():
return str(target_titles[0]).strip()
title = str(prefs.get("title") or "").strip()
if title:
return title
for role in prefs.get("role") or []:
if role and role != "Open to any":
return str(role)
return ""
def search_signature(prefs: dict) -> str: def search_signature(prefs: dict) -> str:
"""Stable short hash of the search-defining prefs (ignores transient flags / user_context).""" """Stable short hash of the search-defining prefs (ignores transient flags / user_context)."""
payload = {k: prefs.get(k) for k in _SIG_FIELDS} # Canonicalize the fields exactly as the board adapters consume them. The
# dashboard contract is camelCase; work_mode remains a legacy fallback.
work_mode = prefs.get("workMode") if "workMode" in prefs else prefs.get("work_mode")
payload = {
"effective_title": _effective_title(prefs),
"role": prefs.get("role"),
"location": prefs.get("location"),
"industry": prefs.get("industry"),
"experience": prefs.get("experience"),
"years": prefs.get("years"),
"workMode": work_mode,
"remote": prefs.get("remote"),
}
blob = json.dumps(payload, sort_keys=True, default=str) blob = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha1(blob.encode()).hexdigest()[:16] return hashlib.sha1(blob.encode()).hexdigest()[:16]
+41 -11
View File
@@ -51,13 +51,31 @@ _RUNS_BASE = "https://api.apify.com/v2"
_POLL = 3.0 # async-run poll interval (s) _POLL = 3.0 # async-run poll interval (s)
def _auth_headers(token: str) -> dict:
"""Bearer auth — keeps the token OUT of request URLs (and thus out of error/log strings).
Passing the token as a query param puts it in resp.request.url, which httpx embeds in
HTTPStatusError.__str__ — so any logged error would leak the credential. The Apify v2 API
accepts a Bearer header for every endpoint, so this is the single-point fix.
"""
return {"Authorization": f"Bearer {token}"}
class _ApifyHTTPError(RuntimeError):
"""Token-free HTTP error raised in place of httpx.HTTPStatusError (whose str includes the URL)."""
async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, token: str) -> list[dict]: async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, token: str) -> list[dict]:
"""Start an ASYNC run, poll to completion, return its dataset items. Needed for incremental / """Start an ASYNC run, poll to completion, return its dataset items. Needed for incremental /
stateful actors (state persisted across runs via `stateKey`) whose crawl exceeds the run-sync stateful actors (state persisted across runs via `stateKey`) whose crawl exceeds the run-sync
window — that window aborts the run mid-crawl. No cache: the actor itself is the dedup.""" window — that window aborts the run mid-crawl. No cache: the actor itself is the dedup."""
async with httpx.AsyncClient(timeout=60.0) as client: headers = _auth_headers(token)
r = await client.post(f"{_BASE}/{actor_id}/runs", params={"token": token}, json=run_input) async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
r.raise_for_status() r = await client.post(f"{_BASE}/{actor_id}/runs", json=run_input)
try:
r.raise_for_status()
except httpx.HTTPStatusError:
raise _ApifyHTTPError(f"apify async run-start {actor_id}: HTTP {r.status_code}") from None
run = r.json().get("data", {}) run = r.json().get("data", {})
run_id = run.get("id") run_id = run.get("id")
ds_id = run.get("defaultDatasetId") ds_id = run.get("defaultDatasetId")
@@ -67,7 +85,7 @@ async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, to
while elapsed < timeout: while elapsed < timeout:
await asyncio.sleep(_POLL) await asyncio.sleep(_POLL)
elapsed += _POLL elapsed += _POLL
s = await client.get(f"{_RUNS_BASE}/actor-runs/{run_id}", params={"token": token}) s = await client.get(f"{_RUNS_BASE}/actor-runs/{run_id}")
data = s.json().get("data", {}) data = s.json().get("data", {})
status = data.get("status") status = data.get("status")
ds_id = data.get("defaultDatasetId") or ds_id ds_id = data.get("defaultDatasetId") or ds_id
@@ -79,8 +97,11 @@ async def _run_actor_async(actor_id: str, run_input: dict, *, timeout: float, to
else: else:
logger.warning("apify async run %s did not finish within %ss", actor_id, timeout) logger.warning("apify async run %s did not finish within %ss", actor_id, timeout)
return [] return []
d = await client.get(f"{_RUNS_BASE}/datasets/{ds_id}/items", params={"token": token, "format": "json"}) d = await client.get(f"{_RUNS_BASE}/datasets/{ds_id}/items", params={"format": "json"})
d.raise_for_status() try:
d.raise_for_status()
except httpx.HTTPStatusError:
raise _ApifyHTTPError(f"apify async dataset {actor_id}: HTTP {d.status_code}") from None
items = d.json() items = d.json()
n = len(items) if isinstance(items, list) else 0 n = len(items) if isinstance(items, list) else 0
logger.info("apify async run %s SUCCEEDED — %d items (incremental)", actor_id, n) logger.info("apify async run %s SUCCEEDED — %d items (incremental)", actor_id, n)
@@ -114,15 +135,18 @@ async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0,
return await _run_actor_async(actor_id, run_input, timeout=timeout, token=token) return await _run_actor_async(actor_id, run_input, timeout=timeout, token=token)
url = f"{_BASE}/{actor_id}/run-sync-get-dataset-items" url = f"{_BASE}/{actor_id}/run-sync-get-dataset-items"
headers = _auth_headers(token)
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout, headers=headers) as client:
for attempt in range(1, _MAX_ATTEMPTS + 1): for attempt in range(1, _MAX_ATTEMPTS + 1):
try: try:
resp = await client.post(url, params={"token": token}, json=run_input) resp = await client.post(url, json=run_input)
if resp.status_code >= 500: if resp.status_code >= 500:
raise httpx.HTTPStatusError( raise _ApifyHTTPError(f"{resp.status_code} from Apify")
f"{resp.status_code} from Apify", request=resp.request, response=resp) try:
resp.raise_for_status() resp.raise_for_status()
except httpx.HTTPStatusError:
raise _ApifyHTTPError(f"apify {actor_id}: HTTP {resp.status_code}") from None
items = resp.json() items = resp.json()
items = items if isinstance(items, list) else [] items = items if isinstance(items, list) else []
if path and mode in ("readwrite", "refresh"): if path and mode in ("readwrite", "refresh"):
@@ -135,4 +159,10 @@ async def run_actor(actor_id: str, run_input: dict, *, timeout: float = 240.0,
await asyncio.sleep(_BACKOFF) await asyncio.sleep(_BACKOFF)
continue continue
raise raise
except _ApifyHTTPError as e:
if attempt < _MAX_ATTEMPTS and "from Apify" in str(e):
logger.warning("apify %s: %s — retrying once", actor_id, e)
await asyncio.sleep(_BACKOFF)
continue
raise
return [] # pragma: no cover return [] # pragma: no cover
+11 -5
View File
@@ -121,10 +121,12 @@ def _parse_json(text: str) -> dict:
return json.loads(t) return json.loads(t)
def _salvage_objects(text: str, *, after: str | None = None) -> list[dict]: def _salvage_objects(text: str | None, *, after: str | None = None) -> list[dict]:
"""Recover valid objects from malformed/truncated model JSON: brace-scan each top-level {...} and """Recover valid objects from malformed/truncated model JSON: brace-scan each top-level {...} and
json.loads it on its own, skipping the one broken/cut-off object. `after` anchors the scan past a key json.loads it on its own, skipping the one broken/cut-off object. `after` anchors the scan past a key
(e.g. '"kept"'); omit it to scan the whole text (the cards stage uses '"cards"', not '"kept"').""" (e.g. '"kept"'); omit it to scan the whole text (the cards stage uses '"cards"', not '"kept"')."""
if not text:
return []
s = text s = text
if after: if after:
i = text.find(after) i = text.find(after)
@@ -145,9 +147,7 @@ def _salvage_objects(text: str, *, after: str | None = None) -> list[dict]:
pass pass
start = None start = None
return out return out
def _salvage_kept(text: str | None) -> list[dict]:
def _salvage_kept(text: str) -> list[dict]:
"""Score-stage salvage — recover kept-job objects (anchored after the '"kept"' key).""" """Score-stage salvage — recover kept-job objects (anchored after the '"kept"' key)."""
return _salvage_objects(text, after='"kept"') return _salvage_objects(text, after='"kept"')
@@ -216,12 +216,15 @@ def _score_stage(prefs: dict, ctx: dict | None, sifted_jobs: list[dict], s) -> l
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("score stage (Opus) failed, falling back to sift: %s", e) logger.warning("score stage (Opus) failed, falling back to sift: %s", e)
return None return None
if not content or not content.strip():
logger.warning("score stage LLM returned empty content — falling back to sift")
return None
try: try:
kept = _parse_json(content).get("kept") or [] kept = _parse_json(content).get("kept") or []
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
kept = _salvage_kept(content) kept = _salvage_kept(content)
if not kept: if not kept:
logger.warning("score stage unparseable — falling back to sift") logger.warning("score stage unparseable — falling back to sift (content[:200]=%r)", content[:200])
return None return None
logger.warning("score stage JSON malformed; salvaged %d items", len(kept)) logger.warning("score stage JSON malformed; salvaged %d items", len(kept))
# Floor check on the RAW computed score (weak never shown), THEN the transparent presentation # Floor check on the RAW computed score (weak never shown), THEN the transparent presentation
@@ -256,6 +259,9 @@ def _cards_stage(prefs: dict, ctx: dict | None, scored: list[dict], by_id: dict,
except Exception as e: # noqa: BLE001 — prose is non-critical; degrade to templated cards except Exception as e: # noqa: BLE001 — prose is non-critical; degrade to templated cards
logger.warning("cards stage (Haiku) call failed, using evidence-based fallback prose: %s", e) logger.warning("cards stage (Haiku) call failed, using evidence-based fallback prose: %s", e)
return {} return {}
if not content or not content.strip():
logger.warning("cards stage LLM returned empty content — using evidence-based fallback prose")
return {}
try: try:
cards = _parse_json(content).get("cards") or [] cards = _parse_json(content).get("cards") or []
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
+34 -6
View File
@@ -10,6 +10,37 @@ from app.config import get_settings
from app.engine.llm import embed_client from app.engine.llm import embed_client
def _embedding_vectors(s, texts: list[str]) -> list[list[float]] | None:
if s.OPENAI_API_BASE:
import httpx
try:
resp = httpx.post(
s.OPENAI_API_BASE.rstrip("/") + "/embeddings",
headers={
"Authorization": f"Bearer {s.OPENAI_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://sai-onchain.me",
"X-Title": "Sai Onchain Matchmaking",
},
json={"model": s.EMBED_MODEL, "input": texts},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return [item["embedding"] for item in data.get("data", [])]
except Exception:
return None
client = embed_client()
if not client:
return None
try:
resp = client.embeddings.create(model=s.EMBED_MODEL, input=texts)
return [d.embedding for d in resp.data]
except Exception:
return None
def _job_text(job: dict) -> str: def _job_text(job: dict) -> str:
# The embedding is the best semantic matcher — feed it the FULL signal, not a 600-char stub. # The embedding is the best semantic matcher — feed it the FULL signal, not a 600-char stub.
# role_category + industry anchor non-tech JDs (narrative prose, few repeated title tokens). # role_category + industry anchor non-tech JDs (narrative prose, few repeated title tokens).
@@ -51,18 +82,15 @@ def _cos(a, b) -> float:
def vibe_scores(prefs: dict, ctx: dict | None, jobs: list[dict]) -> dict[str, float] | None: def vibe_scores(prefs: dict, ctx: dict | None, jobs: list[dict]) -> dict[str, float] | None:
"""id → cosine sim (0..1). None if embeddings can't run (caller falls back to white-box only).""" """id → cosine sim (0..1). None if embeddings can't run (caller falls back to white-box only)."""
client = embed_client() if not jobs:
if not client or not jobs:
return None return None
ptext = _profile_text(prefs, ctx) ptext = _profile_text(prefs, ctx)
if not ptext.strip(): if not ptext.strip():
return None return None
s = get_settings() s = get_settings()
texts = [ptext] + [_job_text(j) for j in jobs] texts = [ptext] + [_job_text(j) for j in jobs]
try: vecs = _embedding_vectors(s, texts)
resp = client.embeddings.create(model=s.EMBED_MODEL, input=texts) if not vecs or len(vecs) != len(texts):
except Exception:
return None return None
vecs = [d.embedding for d in resp.data]
pvec, jvecs = vecs[0], vecs[1:] pvec, jvecs = vecs[0], vecs[1:]
return {job["id"]: max(0.0, min(1.0, _cos(pvec, jv))) for job, jv in zip(jobs, jvecs)} return {job["id"]: max(0.0, min(1.0, _cos(pvec, jv))) for job, jv in zip(jobs, jvecs)}
+16 -9
View File
@@ -1,8 +1,8 @@
"""LLM clients — ONE place to build them. """LLM clients — ONE place to build them.
Two backends, by capability: Two backends, by capability:
- **Gateway** (opencode.ai/zen, OpenAI-compatible) → chat for the Opus curator. Has no embeddings route. - **Gateway** → chat for the curator, using DSPY_* when configured or direct OpenAI otherwise.
- **Direct OpenAI** (poc key) → embeddings (`text-embedding-3-small`). - **Embeddings** → direct OpenAI or an OpenAI-compatible embeddings API via OPENAI_API_BASE.
Both keys come from config/.env (git-ignored). Each builder returns `None` when unconfigured so the Both keys come from config/.env (git-ignored). Each builder returns `None` when unconfigured so the
engine degrades gracefully (white-box sift + templated cards — the safety net). engine degrades gracefully (white-box sift + templated cards — the safety net).
@@ -16,25 +16,32 @@ from app.config import get_settings
@lru_cache @lru_cache
def gateway_client(): def gateway_client():
"""OpenAI-compatible client for the opencode.ai/zen gateway — chat (Opus). None if unconfigured.""" """Chat client for curation/suggestions. Prefer DSPY_* override, otherwise use OPENAI_API_KEY."""
s = get_settings() s = get_settings()
if not (s.DSPY_API_BASE and s.DSPY_API_KEY): api_key = s.DSPY_API_KEY or s.OPENAI_API_KEY
if not api_key:
return None return None
from openai import OpenAI from openai import OpenAI
return OpenAI(base_url=s.DSPY_API_BASE, api_key=s.DSPY_API_KEY, timeout=60, max_retries=2) kwargs = {"api_key": api_key, "timeout": 60, "max_retries": 2}
if s.DSPY_API_BASE:
kwargs["base_url"] = s.DSPY_API_BASE
return OpenAI(**kwargs)
@lru_cache @lru_cache
def embed_client(): def embed_client():
"""Direct OpenAI client for embeddings (the gateway has no embeddings route). None if no key.""" """OpenAI-compatible client for embeddings. None if no key."""
s = get_settings() s = get_settings()
if not s.OPENAI_API_KEY: if not s.OPENAI_API_KEY:
return None return None
from openai import OpenAI from openai import OpenAI
return OpenAI(api_key=s.OPENAI_API_KEY, timeout=30, max_retries=2) kwargs = {"api_key": s.OPENAI_API_KEY, "timeout": 30, "max_retries": 2}
if s.OPENAI_API_BASE:
kwargs["base_url"] = s.OPENAI_API_BASE
return OpenAI(**kwargs)
def curate_enabled() -> bool: def curate_enabled() -> bool:
"""Is the Opus curator usable? (flag on + gateway key present).""" """Is the curator usable? (flag on + any chat key present)."""
s = get_settings() s = get_settings()
return bool(s.ENGINE_LLM_ENABLED and s.DSPY_API_BASE and s.DSPY_API_KEY) return bool(s.ENGINE_LLM_ENABLED and (s.DSPY_API_KEY or s.OPENAI_API_KEY))
+9 -1
View File
@@ -132,8 +132,10 @@ async def run_sweep(prefs: dict, *, fresh: bool = False) -> dict:
merged: list[dict] = [] merged: list[dict] = []
seen: set[str] = set() seen: set[str] = set()
sources: dict[str, int] = {} sources: dict[str, int] = {}
failed_boards = 0
for r in results: for r in results:
if isinstance(r, Exception): if isinstance(r, Exception):
failed_boards += 1
logger.warning("board fetch failed: %s", r) logger.warning("board fetch failed: %s", r)
continue continue
key, jobs = r key, jobs = r
@@ -146,7 +148,13 @@ async def run_sweep(prefs: dict, *, fresh: bool = False) -> dict:
merged.append(j) merged.append(j)
# No ranking here — the engine (app/engine/rank.py) scores + selects after the sweep. # No ranking here — the engine (app/engine/rank.py) scores + selects after the sweep.
return {"opportunities": merged, "sources": sources} return {
"opportunities": merged,
"sources": sources,
"all_boards_failed": bool(enabled) and failed_boards == len(enabled),
"failed_boards": failed_boards,
"enabled_boards": len(enabled),
}
def has_mvq(prefs: dict) -> bool: def has_mvq(prefs: dict) -> bool:
+2 -2
View File
@@ -4,7 +4,7 @@ Honest sources (SCOUT_UI_SPEC):
- feed stats (matches / top / range / salary / competition) ← the persisted feed - feed stats (matches / top / range / salary / competition) ← the persisted feed
- funnel + engagement rank ← opportunity_state activity (this DB) - funnel + engagement rank ← opportunity_state activity (this DB)
- Apply window (freshness urgency) ← listing posting-age - Apply window (freshness urgency) ← listing posting-age
- Momentum/QX + Readiness trend ← qscore-service (Phase 1) - Momentum/RQ Score + Readiness trend ← qscore-service (Phase 1)
- Day streak ← dashboard-service (Phase 1) - Day streak ← dashboard-service (Phase 1)
Anything without a real source is omitted — the UI cuts or locks it, never fakes it. Anything without a real source is omitted — the UI cuts or locks it, never fakes it.
@@ -109,6 +109,6 @@ async def build_scout_stats(user_id: str, *, user_uuid: str | None = None) -> di
# ── cross-service (real; None → the UI omits the card, never fakes it) ── # ── cross-service (real; None → the UI omits the card, never fakes it) ──
"qxNow": (qx or {}).get("qx"), "qxNow": (qx or {}).get("qx"),
"quotients": (qx or {}).get("quotients"), "quotients": (qx or {}).get("quotients"),
"qxTrend": qx_trend, # Q-Score series (dashboard-service) → Readiness-trend sparkline "qxTrend": qx_trend, # RQ Score series (qscore-service) → Readiness-trend sparkline
"dayStreak": streak, "dayStreak": streak,
} }
+39 -10
View File
@@ -1,4 +1,4 @@
"""Cross-service reads for the dashboard — QScore (Momentum/QX) + user-service (Day streak). """Cross-service reads for the dashboard — RQ Score (Momentum/QX) + user-service (Day streak).
Direct REST, Clerk-mesh-authed. Both degrade to None on any failure (timeout / 404 / service down) — Direct REST, Clerk-mesh-authed. Both degrade to None on any failure (timeout / 404 / service down) —
a missing KPI is omitted by the UI, never faked, and never crashes the dashboard. a missing KPI is omitted by the UI, never faked, and never crashes the dashboard.
@@ -6,6 +6,8 @@ a missing KPI is omitted by the UI, never faked, and never crashes the dashboard
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from collections.abc import Mapping
import httpx import httpx
@@ -14,8 +16,19 @@ from app.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _score_value(value: object) -> float | None:
"""Parse one canonical RQ Score value; malformed values are absent."""
if isinstance(value, bool) or value is None:
return None
try:
score = float(value)
except (TypeError, ValueError):
return None
return score if math.isfinite(score) else None
async def fetch_qx(user_uuid: str | None) -> dict | None: async def fetch_qx(user_uuid: str | None) -> dict | None:
"""QScore for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service).""" """RQ Score for the user → {qx, quotients}. GET /v1/qscore/{uuid}?org_id (qscore-service)."""
if not user_uuid: if not user_uuid:
return None return None
s = get_settings() s = get_settings()
@@ -25,16 +38,21 @@ async def fetch_qx(user_uuid: str | None) -> dict | None:
if r.status_code == 404: # no score computed yet — honest absence if r.status_code == 404: # no score computed yet — honest absence
return None return None
r.raise_for_status() r.raise_for_status()
d = r.json() payload = r.json()
qx = d.get("q_score") if not isinstance(payload, Mapping):
return {"qx": round(float(qx), 1), "quotients": d.get("quotients") or {}} if qx is not None else None return None
except (httpx.HTTPError, ValueError) as e: qx = _score_value(payload.get("rq_score"))
if qx is None:
return None
quotients = payload.get("quotients")
return {"qx": round(qx, 1), "quotients": quotients if isinstance(quotients, Mapping) else {}}
except (httpx.HTTPError, ValueError, TypeError) as e:
logger.warning("fetch_qx failed (non-fatal): %s", e) logger.warning("fetch_qx failed (non-fatal): %s", e)
return None return None
async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None: async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None:
"""Q-Score trend (oldest→newest) ← qscore-service GET /v1/qscore/{uuid}/history. Same source as """RQ Score trend (oldest→newest) ← qscore-service GET /v1/qscore/{uuid}/history. Same source as
the live QX, so the trend's latest point matches the Momentum value (consistent, not stale). the live QX, so the trend's latest point matches the Momentum value (consistent, not stale).
None on failure → the UI keeps the Readiness card locked.""" None on failure → the UI keeps the Readiness card locked."""
if not user_uuid: if not user_uuid:
@@ -48,10 +66,21 @@ async def fetch_qx_trend(user_uuid: str | None) -> list[float] | None:
) )
if r.status_code == 404: if r.status_code == 404:
return None return None
r.raise_for_status() payload = r.json()
pts = [round(float(p["q_score"]), 1) for p in (r.json().get("points") or []) if p.get("q_score") is not None] if not isinstance(payload, Mapping):
return None
points = payload.get("points")
if not isinstance(points, list):
return None
pts = []
for point in points:
if not isinstance(point, Mapping):
continue
score = _score_value(point.get("rq_score"))
if score is not None:
pts.append(round(score, 1))
return pts if len(pts) >= 2 else None # need ≥2 points for a trend return pts if len(pts) >= 2 else None # need ≥2 points for a trend
except (httpx.HTTPError, ValueError, TypeError, KeyError) as e: except (httpx.HTTPError, ValueError, TypeError) as e:
logger.warning("fetch_qx_trend failed (non-fatal): %s", e) logger.warning("fetch_qx_trend failed (non-fatal): %s", e)
return None return None
+73
View File
@@ -0,0 +1,73 @@
"""Grow event emitter — best-effort POST to the backend /events/ingest/service endpoint.
Every call degrades to a no-op when GROW_EVENTS_URL is unset or the POST fails, so
a Grow outage can never break the search/apply flows. The emitter runs in a
background thread (via asyncio) so it never blocks the agent session loop.
"""
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timezone
import httpx
logger = logging.getLogger(__name__)
SERVICE_NAME = "matchmaking-service"
def _post(event: dict) -> None:
"""Synchronous POST — run off the event loop via ``asyncio.to_thread``."""
grow_url = os.getenv("GROW_EVENTS_URL", "").rstrip("/")
if not grow_url:
return
url = grow_url + "/events/ingest/service"
token = os.getenv("GROW_EVENTS_TOKEN", "") or os.getenv("A2A_SERVICE_KEY", "")
headers = {"Content-Type": "application/json", "x-growqr-source": SERVICE_NAME}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
resp = httpx.post(url, json=event, headers=headers, timeout=5.0)
resp.raise_for_status()
except Exception as e: # noqa: BLE001 — never fatal
logger.warning("emit_grow_event failed (%s): %s", event.get("type"), e)
async def emit_grow_event(
event_type: str,
user_id: str,
*,
org_id: str | None = None,
payload: dict | None = None,
correlation: dict | None = None,
subject: dict | None = None,
dedupe_key: str | None = None,
) -> None:
"""Emit a Grow event to the backend ingest endpoint.
Non-blocking and non-fatal: if GROW_EVENTS_URL is unset the call is a no-op,
and any HTTP error is logged and swallowed. The POST runs in a worker thread
so the agent session loop is never blocked.
"""
if not user_id:
return
event: dict = {
"source": SERVICE_NAME,
"type": event_type,
"category": "service",
"userId": user_id,
"orgId": org_id or "growqr",
"occurredAt": datetime.now(timezone.utc).isoformat(),
"payload": payload or {},
}
if correlation:
event["correlation"] = correlation
if subject:
event["subject"] = subject
if dedupe_key:
event["dedupeKey"] = dedupe_key
await asyncio.to_thread(_post, event)
+74
View File
@@ -0,0 +1,74 @@
# ── Matchmaking v2 staging stack ────────────────────────────────────────────
# Project name is `matchmaking-v2` (matches scripts/sync-staging.sh compose_project
# and the live volume/network names). All three services deploy together so they
# share one Compose default bridge and the depends_on graph holds:
#
# docker compose -p matchmaking-v2 -f docker-compose.staging.yml up -d
#
# The prior VPS split (api under `-p matchmaking-v2-staging`, postgres/redis under
# `-p matchmaking-v2`) created two disjoint `<project>_default` bridges → every
# dependency hostname was NXDOMAIN from the api. Standardizing on `matchmaking-v2`
# for all services fixes that at the root and matches the deploy automation.
#
# The Postgres data volume stays project-scoped (matchmaking-v2_matchmaking_pgdata) —
# the existing live volume with its PG cluster is reused as-is, no migration or rename.
#
# External network — pre-created by the deploy guard (sync-staging.sh compose_networks)
# or manually (guard is a no-op if it already exists):
# docker network create matchmaking-v2_default 2>/dev/null || true
services:
api:
build: .
container_name: growqr-matchmaking-api
ports:
- "127.0.0.1:18006:8000"
env_file:
- .env.staging
# Pin DB/Redis hosts to the compose service names so connectivity doesn't
# hinge on .env.staging (git-ignored). Credentials mirror the postgres
# service below — no new secret introduced.
environment:
DATABASE_URL: postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking
REDIS_URL: redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: growqr-matchmaking-postgres
environment:
POSTGRES_USER: matchmaking
POSTGRES_PASSWORD: matchmaking
POSTGRES_DB: matchmaking
ports:
- "127.0.0.1:15446:5432"
volumes:
- matchmaking_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U matchmaking -d matchmaking"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: growqr-matchmaking-redis
restart: unless-stopped
# External bridge — pre-created and shared so a cold redeploy works even if the
# network was last created under a different project name.
networks:
default:
name: matchmaking-v2_default
external: true
# Project-scoped volume — the live `matchmaking-v2_matchmaking_pgdata` (47.7M PG
# cluster) is reused as-is. No rename, no migration, no data risk.
volumes:
matchmaking_pgdata:
+1
View File
@@ -10,6 +10,7 @@ services:
environment: environment:
# inside the compose network the DB host is `postgres` (overrides the host-dev .env value) # inside the compose network the DB host is `postgres` (overrides the host-dev .env value)
DATABASE_URL: postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking DATABASE_URL: postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+355
View File
@@ -0,0 +1,355 @@
"""Grow event emitter + handler contract tests (red→green).
Covers:
1. The POST URL normalizes to ``/events/ingest/service`` (not ``/events/ingest``,
which was the wrong ingest route — a wrong Clerk route cannot recur).
2. The ``x-growqr-source`` header identifies the emitting service.
3. ``raise_for_status`` is invoked: a 401 response surfaces as a logged warning
(401 is observable) rather than being silently swallowed.
4. Emit-after-mutation ordering: handlers fire the Grow event *after* the
persistent mutation, never before / never without it.
5. Action-specific dedupe keys: stable for the same evidence (retries collapse),
distinct for different evidence (a different viewed job / a different search
sig / a later feed snapshot is NOT dropped).
"""
from __future__ import annotations
import asyncio
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from app.grow_events import _post, emit_grow_event, SERVICE_NAME
from app.agent.session import MatchmakingAgentSession
# ── helpers ──────────────────────────────────────────────────────────────────
def _capture_post(monkeypatch):
"""Patch httpx.post to capture the request kwargs and return a fake Response."""
captured: dict = {}
fake_resp = MagicMock(spec=httpx.Response)
fake_resp.status_code = 202
fake_resp.raise_for_status = MagicMock()
def _fake_post(url, **kw):
captured["url"] = url
captured["headers"] = kw.get("headers", {})
captured["json"] = kw.get("json", {})
return fake_resp
monkeypatch.setattr(httpx, "post", _fake_post)
return captured
def _capture_post_401(monkeypatch):
"""Patch httpx.post to return a 401 Response whose raise_for_status raises."""
captured: dict = {}
fake_resp = MagicMock(spec=httpx.Response)
fake_resp.status_code = 401
fake_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"401 Unauthorized", request=httpx.Request("POST", "http://x"), response=fake_resp,
)
def _fake_post(url, **kw):
captured["url"] = url
captured["headers"] = kw.get("headers", {})
captured["json"] = kw.get("json", {})
return fake_resp
monkeypatch.setattr(httpx, "post", _fake_post)
return captured
async def _run(coro):
return await coro
# ── 1. URL normalize → /events/ingest/service ────────────────────────────────
def test_post_url_is_events_ingest_service(monkeypatch):
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
_post({"type": "X", "userId": "u1"})
assert captured["url"].rstrip("/").endswith("/events/ingest/service"), (
f"URL must be /events/ingest/service, got {captured['url']!r}"
)
def test_post_url_trailing_slash_normalized(monkeypatch):
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com/")
_post({"type": "X", "userId": "u1"})
assert captured["url"].rstrip("/").endswith("/events/ingest/service")
# ── 2. x-growqr-source header ────────────────────────────────────────────────
def test_post_has_growqr_source_header(monkeypatch):
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
_post({"type": "X", "userId": "u1"})
assert captured["headers"].get("x-growqr-source") == SERVICE_NAME, (
f"x-growqr-source header must be {SERVICE_NAME!r}, got {captured['headers'].get('x-growqr-source')!r}"
)
# ── 3. raise_for_status observability ────────────────────────────────────────
def test_post_raises_for_status_on_401_and_logs(monkeypatch, caplog):
captured = _capture_post_401(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
with caplog.at_level("WARNING", logger="app.grow_events"):
_post({"type": "X", "userId": "u1"})
assert any("401" in r.message or "Unauthorized" in r.message for r in caplog.records if r.levelname == "WARNING"), (
f"a 401 must surface as a logged warning: {[r.message for r in caplog.records]!r}"
)
def test_post_401_does_not_raise_out_of_post(monkeypatch):
"""The 401 is caught + logged; it never escapes _post (non-fatal contract)."""
_capture_post_401(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
# Must not raise.
_post({"type": "X", "userId": "u1"})
# ── 4. emit-after-mutation ordering ───────────────────────────────────────────
class _StubSession:
"""Minimal stand-in matching MatchmakingAgentSession interface for ordering tests."""
pass
def _make_session(user_id="u1"):
ws = MagicMock()
ws.send_json = AsyncMock()
# Build a real session so we exercise the actual handler code path.
from app.agent.session import MatchmakingAgentSession
s = MatchmakingAgentSession(ws, user_id)
return s, ws
def test_emit_mark_applied_after_set_opportunity_state(monkeypatch):
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
called = []
async def fake_set_state(uid, oid, **kw):
called.append("mutate")
return None
emitted = []
async def fake_event(etype, uid, **kw):
emitted.append(("emit", etype))
return None
s, ws = _make_session("u1")
monkeypatch.setattr("app.agent.session.set_opportunity_state", fake_set_state, raising=False)
# Need the actual repo patch path the handler uses (from app.db.repo import set_opportunity_state)
monkeypatch.setattr("app.db.repo.set_opportunity_state", fake_set_state)
monkeypatch.setattr("app.agent.session.emit_grow_event", fake_event)
asyncio.run(s.handle_mark_applied({"opportunity_id": "job-A"}))
assert called == ["mutate"], "set_opportunity_state must run before emit"
assert len(emitted) == 1 and emitted[0][1] == "matchmaking.match.applied"
assert called[0] == "mutate" # mutation precedes emit
def test_emit_no_event_without_mutation(monkeypatch):
"""If the mutation does not run, the handler must NOT emit a Grow event."""
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
async def fake_set_state_raise(uid, oid, **kw):
raise RuntimeError("db down")
emitted = []
async def fake_event(*a, **kw):
emitted.append("emit")
return None
s, ws = _make_session("u1")
monkeypatch.setattr("app.db.repo.set_opportunity_state", fake_set_state_raise)
monkeypatch.setattr("app.agent.session.emit_grow_event", fake_event)
# handler logs+swallows via the on_user_action catch; call directly → raises
try:
asyncio.run(s.handle_mark_applied({"opportunity_id": "job-A"}))
except RuntimeError:
pass
assert emitted == [], "no event may be emitted when the mutation fails"
# ── 5. dedupe keys: stable for same evidence, distinct for different ──────────
def test_dedupe_key_mark_applied_stable_and_distinct(monkeypatch):
"""retry same (user,opp) → same key; different opp → different key (not dropped)."""
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
async def fake_set_state(uid, oid, **kw):
return None
monkeypatch.setattr("app.db.repo.set_opportunity_state", fake_set_state)
s, ws = _make_session("u1")
asyncio.run(s.handle_mark_applied({"opportunity_id": "job-A"}))
asyncio.run(s.handle_mark_applied({"opportunity_id": "job-A"})) # retry
asyncio.run(s.handle_mark_applied({"opportunity_id": "job-B"})) # distinct later action
events = captured["json"].__class__ if False else None # placeholder
# We patch emit_grow_event to capture the dedupe_key instead
keys = []
async def capture_event(etype, uid, *, dedupe_key=None, **kw):
keys.append((etype, dedupe_key))
return None
monkeypatch.setattr("app.agent.session.emit_grow_event", capture_event)
s2, _ = _make_session("u1")
asyncio.run(s2.handle_mark_applied({"opportunity_id": "job-A"}))
asyncio.run(s2.handle_mark_applied({"opportunity_id": "job-A"}))
asyncio.run(s2.handle_mark_applied({"opportunity_id": "job-B"}))
assert keys[0] == keys[1], " retries of the same (user,opp) must collapse (same dedupe_key)"
assert keys[2][1] != keys[0][1], "a different opp must get a distinct dedupe_key so it is NOT dropped"
assert keys[0][1] is not None and "job-A" in keys[0][1]
assert keys[2][1] is not None and "job-B" in keys[2][1]
def test_dedupe_key_mark_viewed_stable_and_distinct(monkeypatch):
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
async def fake_set_state(uid, oid, **kw):
return None
monkeypatch.setattr("app.db.repo.set_opportunity_state", fake_set_state)
keys = []
async def capture_event(etype, uid, *, dedupe_key=None, **kw):
keys.append((etype, dedupe_key))
return None
monkeypatch.setattr("app.agent.session.emit_grow_event", capture_event)
s, _ = _make_session("u1")
asyncio.run(s.handle_mark_viewed({"opportunity_id": "job-A"}))
asyncio.run(s.handle_mark_viewed({"opportunity_id": "job-A"})) # retry same
asyncio.run(s.handle_mark_viewed({"opportunity_id": "job-B"})) # distinct
assert keys[0][1] is not None, "mark_viewed must have a dedupe_key"
assert keys[0][1] == keys[1][1], "retry of same viewed opp collapses"
assert keys[2][1] != keys[0][1], "different viewed opp must be distinct (not dropped)"
assert "job-A" in keys[0][1] and "job-B" in keys[2][1]
def test_dedupe_key_matches_generated_stable_and_distinct(monkeypatch):
"""generated with sig1 vs sig2 -> different keys; retry same sig -> same key."""
# We test the dedupe_key construction by calling the handler path with mocked
# engine/repo so only the emit-grow-event key shape is exercised.
import app.engine.sift, app.engine.curate, app.engine.rank, app.engine.search # noqa: F401 -- pre-import for monkeypatch
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
# Mock the repo bits the handler imports inline.
async def fake_save_feed(uid, prefs, result, **kw):
return None
async def fake_update_salary(uid, sals):
return None
async def fake_mark_seen(uid, ids):
return None
async def fake_get_states(uid):
return {}
async def fake_activity_fields_hook(self):
return {}
def fake_annotate(opps, states):
return opps
monkeypatch.setattr("app.db.repo.save_feed", fake_save_feed)
monkeypatch.setattr("app.db.repo.update_salary_band", fake_update_salary)
monkeypatch.setattr("app.db.repo.mark_seen", fake_mark_seen)
monkeypatch.setattr("app.db.repo.get_opportunity_states", fake_get_states)
monkeypatch.setattr("app.db.repo.annotate_and_filter", fake_annotate)
# pool helpers
async def fake_pool_load(*a, **kw):
return []
async def fake_is_pool_exhausted(*a, **kw):
return False
def fake_search_sig(prefs):
import hashlib
return hashlib.md5(str(prefs).encode()).hexdigest()
monkeypatch.setattr("app.db.repo.pool_load_fresh_unseen", fake_pool_load)
monkeypatch.setattr("app.db.repo.is_pool_exhausted", fake_is_pool_exhausted)
monkeypatch.setattr("app.db.repo.search_signature", fake_search_sig)
monkeypatch.setattr("app.db.repo.get_seen_ids", AsyncMock(return_value=[]))
keys = []
async def capture_event(etype, uid, *, dedupe_key=None, **kw):
keys.append((etype, dedupe_key))
return None
monkeypatch.setattr("app.agent.session.emit_grow_event", capture_event)
# We directly call the event-emit portion logic via the full handler with
# mocked engine so the expensive LLM path is skipped. To keep the test
# deterministic we mock the engine modules the handler imports inline.
class FakeSift:
@staticmethod
def sift(prefs, ctx, cands):
return [], {"mode": "test", "scored": 0}
class FakeCurate:
@staticmethod
def curate(prefs, ctx, top):
return [{"id": "j1", "match": {"score": 90}}]
class FakeRank:
@staticmethod
def select(top, stretch):
return top
monkeypatch.setattr("app.engine.sift", FakeSift)
monkeypatch.setattr("app.engine.curate", FakeCurate)
monkeypatch.setattr("app.engine.rank", FakeRank)
monkeypatch.setattr("app.engine.search", MagicMock(has_mvq=lambda p: True, BOARDS=["naukri"]))
# patch _grow_activity_fields to avoid DB
monkeypatch.setattr(MatchmakingAgentSession, "_grow_activity_fields", fake_activity_fields_hook)
s, _ = _make_session("u1")
# search A
asyncio.run(s.handle_run_search({"title": "PM", "location": "BLR"}))
# retry (same prefs -> same sig) should collapse
asyncio.run(s.handle_run_search({"title": "PM", "location": "BLR"}))
# search B: different prefs -> different sig
asyncio.run(s.handle_run_search({"title": "SWE", "location": "BLR"}))
assert all(k[1] is not None for k in keys), "matches.generated must carry a dedupe_key"
assert keys[0][1] == keys[1][1], "retry same sig must collapse"
assert keys[2][1] != keys[0][1], "different sig must be distinct so a later search is not dropped"
def test_dedupe_key_feed_viewed_stable_and_distinct(monkeypatch):
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
async def fake_get_feed(uid):
return {"opportunities": [{"id": "j1"}], "sources": {}, "updated_at": "2026-07-10T00:00:00"}
async def fake_activity(self):
return {}
monkeypatch.setattr("app.db.repo.get_feed", fake_get_feed)
monkeypatch.setattr(MatchmakingAgentSession, "_grow_activity_fields", fake_activity)
keys = []
async def capture_event(etype, uid, *, dedupe_key=None, **kw):
keys.append((etype, dedupe_key))
return None
monkeypatch.setattr("app.agent.session.emit_grow_event", capture_event)
s, _ = _make_session("u1")
asyncio.run(s.handle_get_feed({}))
asyncio.run(s.handle_get_feed({})) # same updated_at → same key
# Now simulate a re-search (feed updated_at changes)
async def fake_get_feed2(uid):
return {"opportunities": [{"id": "j2"}], "sources": {}, "updated_at": "2026-07-11T00:00:00"}
monkeypatch.setattr("app.db.repo.get_feed", fake_get_feed2)
asyncio.run(s.handle_get_feed({})) # new updated_at → distinct key
assert keys[0][1] is not None, "feed.viewed must carry a dedupe_key"
assert keys[0][1] == keys[1][1], "same feed snapshot retries must collapse"
assert keys[2][1] != keys[0][1], "a later feed snapshot must be distinct so it is NOT dropped"
def test_dedupe_key_idempotent_retries_match_applied(monkeypatch):
"""Direct emitter-level check: the same dedupe_key produces the same posted dedupeKey."""
captured = _capture_post(monkeypatch)
monkeypatch.setenv("GROW_EVENTS_URL", "https://backend.example.com")
asyncio.run(emit_grow_event("matchmaking.match.applied", "u1", dedupe_key="matchmaking:applied:u1:job-A"))
k1 = captured["json"].get("dedupeKey")
captured2 = _capture_post(monkeypatch)
asyncio.run(emit_grow_event("matchmaking.match.applied", "u1", dedupe_key="matchmaking:applied:u1:job-A"))
k2 = captured2["json"].get("dedupeKey")
assert k1 == k2 == "matchmaking:applied:u1:job-A"
+50
View File
@@ -1,5 +1,11 @@
"""Warm-pool gate — the reorder/floor decision that keeps the shelf stocked without over-fetching.""" """Warm-pool gate — the reorder/floor decision that keeps the shelf stocked without over-fetching."""
from types import SimpleNamespace
import pytest
from app.engine import search
from app.engine.search import pool_decision from app.engine.search import pool_decision
from app.db.repo import search_signature
FLOOR, REORDER = 90, 100 # the configured thresholds (POOL_SAFETY_FLOOR / POOL_REORDER_AT) FLOOR, REORDER = 90, 100 # the configured thresholds (POOL_SAFETY_FLOOR / POOL_REORDER_AT)
@@ -39,3 +45,47 @@ def test_floor_never_above_reorder_invariant():
from app.config import get_settings from app.config import get_settings
s = get_settings() s = get_settings()
assert s.POOL_SAFETY_FLOOR <= s.POOL_REORDER_AT assert s.POOL_SAFETY_FLOOR <= s.POOL_REORDER_AT
def test_target_title_changes_search_signature():
base = {
"title": "Product Manager",
"role": ["Product"],
"targetTitles": ["Backend Engineer"],
"location": ["Bangalore · India"],
"workMode": ["Hybrid"],
}
changed = {**base, "targetTitles": ["Data Analyst"]}
assert search_signature(base) != search_signature(changed)
def test_work_mode_changes_search_signature_using_dashboard_contract():
base = {"title": "Data Analyst", "location": ["Bangalore · India"], "workMode": ["Hybrid"]}
changed = {**base, "workMode": ["Remote"]}
assert search_signature(base) != search_signature(changed)
def test_legacy_and_camel_case_work_mode_have_same_signature():
common = {"title": "Data Analyst", "location": ["Bangalore · India"]}
assert search_signature({**common, "workMode": ["Hybrid"]}) == search_signature({**common, "work_mode": ["Hybrid"]})
@pytest.mark.asyncio
async def test_sweep_marks_total_board_failure(monkeypatch):
async def fail_board(*_args, **_kwargs):
raise RuntimeError("provider blocked")
monkeypatch.setattr(search, "_fetch_board", fail_board)
monkeypatch.setattr(search, "get_settings", lambda: SimpleNamespace(
BOARDS_ENABLED="naukri,foundit",
BOARD_TIMEOUT_S=1,
))
result = await search.run_sweep({"title": "Data Analyst", "location": ["Bangalore · India"]})
assert result["all_boards_failed"] is True
assert result["failed_boards"] == 2
assert result["sources"] == {}
+224
View File
@@ -0,0 +1,224 @@
"""Staging infra regression — the durable fixes this slice landed:
1. Single-project topology: docker-compose.staging.yml deploys all three services
under ONE project name (`matchmaking-v2` — matches scripts/sync-staging.sh
compose_project and the live volume/network names) with fixed container names and
an external shared bridge (matchmaking-v2_default). The prior split (api under
`-p matchmaking-v2-staging`, postgres/redis under `-p matchmaking-v2`) created two
disjoint `<project>_default` bridges → every dependency hostname was NXDOMAIN from
the api → feed store init failed with [Errno -2].
2. Project-scoped volume: the Postgres data volume stays project-scoped
(matchmaking-v2_matchmaking_pgdata) — the existing live volume with its PG cluster
is reused as-is, no migration or rename.
3. Apify token redaction: the client authenticates via an Authorization header (not a
query param), so no request URL — and therefore no httpx.HTTPStatusError string —
can ever contain the token. A 403 surfaces as a token-free RuntimeError.
These tests don't need a live stack — they lock the CONFIG shape and the ERROR SHAPE so
the regressions they guard (network split, token-in-logs) can't return.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import httpx
import pytest
import yaml
from app.engine import apify_client
_COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.staging.yml"
def _load_compose() -> dict:
return yaml.safe_load(_COMPOSE.read_text())
# ── 1. Single-project topology + external shared network ─────────────────────
def test_staging_compose_uses_external_shared_network():
"""The default network must be external + named matchmaking-v2_default so a cold
redeploy works even if the bridge was last created under a different project."""
d = _load_compose()
net = d["networks"]["default"]
assert net.get("external") is True, "network must be external (no project owns/removes it)"
assert net.get("name") == "matchmaking-v2_default", (
"name must be the literal matchmaking-v2_default — the bridge postgres/redis already use"
)
def test_staging_api_depends_on_postgres_and_redis():
"""The api service must depend on BOTH postgres and redis — this is the depends_on
graph that guarantees they land on the same Compose network when deployed as one
project. Breaking this (e.g. --no-deps deploy) reintroduces the network split."""
d = _load_compose()
deps = d["services"]["api"].get("depends_on", {})
assert "postgres" in deps, "api must depend_on postgres (same-network guarantee)"
assert "redis" in deps, "api must depend_on redis (same-network guarantee)"
assert deps["postgres"].get("condition") == "service_healthy", "postgres dep must wait for healthcheck"
assert deps["redis"].get("condition") == "service_started", "redis dep must wait for start"
def test_staging_api_pins_db_and_redis_hosts():
"""Inline environment overrides guarantee the api reaches postgres/redis by service
name even if the git-ignored .env.staging drifts to localhost."""
d = _load_compose()
env = d["services"]["api"]["environment"]
assert env["DATABASE_URL"].endswith("@postgres:5432/matchmaking"), "DB host must be the compose service name"
assert env["REDIS_URL"].startswith("redis://redis:"), "Redis host must be the compose service name"
def test_staging_service_names_are_unique():
"""Unique container names prevent cross-project DNS ambiguity on the shared bridge."""
d = _load_compose()
names = {s["container_name"] for s in d["services"].values()}
assert names == {"growqr-matchmaking-api", "growqr-matchmaking-postgres", "growqr-matchmaking-redis"}
# ── 2. Project-scoped volume ─────────────────────────────────────────────────
def test_staging_postgres_mounts_volume():
"""The postgres service must mount the data volume at the PG data dir."""
d = _load_compose()
mounts = d["services"]["postgres"]["volumes"]
assert any("matchmaking_pgdata:/var/lib/postgresql/data" in m for m in mounts), (
"postgres must mount matchmaking_pgdata at the PG data directory"
)
def test_staging_volume_is_project_scoped():
"""The volume must be project-scoped (NOT external) so the live
matchmaking-v2_matchmaking_pgdata is reused as-is — no rename, no data risk."""
d = _load_compose()
vol = d["volumes"]["matchmaking_pgdata"]
assert vol is None or (isinstance(vol, dict) and not vol.get("external")), (
"volume must NOT be external — the live project-scoped volume is reused as-is"
)
# ── 2b. Deploy script alignment ─────────────────────────────────────────────
_SYNC_STAGING = Path(__file__).resolve().parents[2] / "scripts" / "sync-staging.sh"
def test_sync_staging_compose_services_includes_all_three():
"""The deploy script must bring up all three services (api, postgres, redis) under
the single project — not just api. Without this, postgres/redis stay on whatever
project they were last started under, and the network split can recur."""
if not _SYNC_STAGING.exists():
pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)")
text = _SYNC_STAGING.read_text()
# compose_services must have an explicit matchmaking-v2 case listing all three
assert "matchmaking-v2) printf 'api postgres redis'" in text, (
"sync-staging.sh compose_services must list 'api postgres redis' for matchmaking-v2"
)
def test_sync_staging_compose_networks_includes_shared_bridge():
"""The deploy guard must pre-create the external matchmaking-v2_default network
so `external: true` in the compose never fails on ordering."""
if not _SYNC_STAGING.exists():
pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)")
text = _SYNC_STAGING.read_text()
assert "matchmaking-v2_default" in text, (
"sync-staging.sh compose_networks must pre-create matchmaking-v2_default"
)
def test_sync_staging_public_health_url_has_matchmaking_case():
"""public_health_url must have a matchmaking-v2 case — without it, the function
returns 1 (the `*)` fallthrough), and under `set -e` the command substitution at
the call site exits the script before the deploy runs."""
if not _SYNC_STAGING.exists():
pytest.skip("sync-staging.sh not in this checkout (run from growqr monorepo)")
text = _SYNC_STAGING.read_text()
assert "matchmaking-v2) printf 'https://matchmaking-staging.gqr.puter.wtf/api/v1/health'" in text, (
"sync-staging.sh public_health_url must have a matchmaking-v2 case to avoid set -e abort"
)
# ── 3. Apify token redaction ─────────────────────────────────────────────────
_TOKEN = "apify_api_secret_TOKEN_value_DO_NOT_LEAK"
def test_auth_header_not_query_param():
"""Token must travel in a header, never in the URL."""
h = apify_client._auth_headers(_TOKEN)
assert h == {"Authorization": f"Bearer {_TOKEN}"}
def test_run_actor_4xx_raises_token_free_error(monkeypatch):
"""A provider 403 (the live failure mode) must surface WITHOUT the token in the message.
The live Apify account returns 403 platform-feature-disabled (monthly cap exceeded).
That error must be explicit and safe — no credential in the exception or any log line.
"""
calls = {"post": 0}
class _FakeResponse:
status_code = 403
request = httpx.Request("POST", "https://api.apify.com/v2/acts/x/run-sync-get-dataset-items")
def raise_for_status(self):
raise httpx.HTTPStatusError("403 Forbidden", request=self.request, response=self)
def json(self):
return {}
async def _fake_post(self, url, **kwargs):
calls["post"] += 1
# CRITICAL: assert no token leaked into the URL or query params
assert "token" not in str(url), "token must not be in the request URL"
assert "token" not in str(kwargs.get("params", {})), "token must not be in query params"
return _FakeResponse()
_FakeSettings = type("S", (), {
"APIFY_TOKEN": _TOKEN, "APIFY_CACHE_MODE": "off", "APIFY_CACHE_DIR": ".apify_cache",
})
monkeypatch.setattr(apify_client, "get_settings", lambda: _FakeSettings())
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post)
with pytest.raises(RuntimeError) as exc_info:
asyncio.run(apify_client.run_actor("test~actor", {"q": "dev"}))
assert calls["post"] >= 1, "the fake POST must be reached (else the test passed for the wrong reason)"
msg = str(exc_info.value)
assert _TOKEN not in msg, f"token leaked into error message: {msg!r}"
assert "403" in msg, f"the HTTP status must be in the message: {msg!r}"
def test_run_actor_async_4xx_raises_token_free_error(monkeypatch):
"""The async-run path (incremental actors) must likewise not leak the token."""
calls = {"post": 0}
class _FakeResponse:
status_code = 403
request = httpx.Request("POST", "https://api.apify.com/v2/acts/x/runs")
def raise_for_status(self):
raise httpx.HTTPStatusError("403 Forbidden", request=self.request, response=self)
def json(self):
return {}
async def _fake_post(self, url, **kwargs):
calls["post"] += 1
assert "token" not in str(url), "token must not be in the request URL"
return _FakeResponse()
_FakeSettings = type("S", (), {
"APIFY_TOKEN": _TOKEN, "APIFY_CACHE_MODE": "off", "APIFY_CACHE_DIR": ".apify_cache",
})
monkeypatch.setattr(apify_client, "get_settings", lambda: _FakeSettings())
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_post)
with pytest.raises(RuntimeError) as exc_info:
asyncio.run(apify_client.run_actor("test~actor", {"q": "dev", "incremental": True}))
assert calls["post"] >= 1, "the fake POST must be reached (else the test passed for the wrong reason)"
assert _TOKEN not in str(exc_info.value), "token leaked into async error message"
+73 -1
View File
@@ -1,5 +1,10 @@
"""Dashboard stats — pure-helper contract tests (no DB / no network).""" """Dashboard stats — pure-helper and RQ Score client contract tests."""
import asyncio
import httpx
from app.engine import stats as S from app.engine import stats as S
from app.engine.stats import clients
from app.engine.normalize import _posted_date from app.engine.normalize import _posted_date
from app.db.repo import _activity_score from app.db.repo import _activity_score
@@ -31,3 +36,70 @@ def test_activity_score_weighting():
# applies weighted highest, then saves, views, searches # applies weighted highest, then saves, views, searches
assert _activity_score(0, 0, 1, 0) > _activity_score(0, 1, 0, 0) > _activity_score(1, 0, 0, 0) assert _activity_score(0, 0, 1, 0) > _activity_score(0, 1, 0, 0) > _activity_score(1, 0, 0, 0)
assert _activity_score(0, 0, 0, 0) == 0 assert _activity_score(0, 0, 0, 0) == 0
class _Response:
status_code = 200
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
def test_fetch_qx_reads_strict_rq_score_contract(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({"rq_score": 68.25, "quotients": {"technical": 71}})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx("user-uuid")) == {
"qx": 68.2,
"quotients": {"technical": 71},
}
def test_fetch_qx_does_not_alias_legacy_q_score(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({"q_score": 99, "quotients": {"technical": 99}})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx("user-uuid")) is None
def test_fetch_qx_treats_malformed_payload_as_absent(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response([{"rq_score": 88}])
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx("user-uuid")) is None
def test_fetch_qx_trend_ignores_malformed_and_absent_points(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({"points": [None, {"rq_score": "bad"}, {"rq_score": 35}, {"rq_score": 40}]})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx_trend("user-uuid")) == [35.0, 40.0]
def test_fetch_qx_trend_reads_strict_rq_score_points(monkeypatch):
async def _get(_self, _url, **_kwargs):
return _Response({
"points": [
{"rq_score": 35, "q_score": 91},
{"rq_score": 44.16, "q_score": 92},
{"q_score": 93},
]
})
monkeypatch.setattr(httpx.AsyncClient, "get", _get)
assert asyncio.run(clients.fetch_qx_trend("user-uuid")) == [35.0, 44.2]