Files
matchmaking-v2/app/db/repo.py
raulgupta be921559e7 Real Scout dashboard backend + per-board cursor rework
- get_scout_stats aggregator (app/engine/stats): assembles the REAL Summary metrics —
  funnel + cohort engagement rank + active-window (activity timestamps by hour) from our DB,
  accumulating salary band (avg of each deck's peak, ₹L), match/competition stats from the
  feed, Momentum/QX + Q-Score trend (qscore-service), day streak (user-service). Honest:
  unsourced cards return None so the UI omits/locks them, never faked. posted_date extractor.
- Activity tracking: viewed/saved flags + search_count → funnel (Matches→Viewed→Shortlisted→
  Applied) + engagement percentile. matchesFound = all-time count.
- Per-board search cursors {board: page} (replaces the single cursor): only boards that truly
  paginate (LinkedIn) get one; cursor = LAST page fetched (1st search of a new query → 1).
  Resets on query change OR >24h (boards refresh ~daily). Dropped Naukri incremental/stateKey
  (opaque, exhausting, cross-account dedup state) — dedup is the PER-USER seen-net only.
- tests: stats helpers (posting-age, histogram, active-window, engagement score).
2026-06-21 12:12:28 +05:30

363 lines
16 KiB
Python

"""Feed repository — upsert + read the per-user cached feed. All calls degrade to no-op/None
when the DB is off or unreachable (persistence is optional; the engine must never crash on it)."""
from __future__ import annotations
import hashlib
import json
import logging
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.db.models import Base, OpportunityState, UserFeed
from app.db.session import engine, session_factory
logger = logging.getLogger(__name__)
# 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")
# the search-defining pref fields — same values → same signature → cursor advances (new jobs);
# change any → new signature → cursor resets to page 1.
_SIG_FIELDS = ("title", "role", "location", "industry", "experience", "years", "work_mode", "remote")
def search_signature(prefs: dict) -> str:
"""Stable short hash of the search-defining prefs (ignores transient flags / user_context)."""
payload = {k: prefs.get(k) for k in _SIG_FIELDS}
blob = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha1(blob.encode()).hexdigest()[:16]
async def init_db() -> None:
"""Create tables if the DB is configured (best-effort; logged, never fatal)."""
eng = engine()
if eng is None:
logger.info("DATABASE_URL unset — feed persistence disabled")
return
try:
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("feed store ready")
except Exception as e: # noqa: BLE001
logger.warning("feed store init failed (persistence off): %s", e)
async def save_feed(user_id: str, prefs: dict, result: dict, *, query_sig: str = "", cursors: dict | None = None) -> None:
"""Upsert the user's last completed search + the advanced per-board cursors. No-op if the DB is off."""
factory = session_factory()
if factory is None or not user_id:
return
row = {
"user_id": user_id,
"prefs": prefs or {},
"opportunities": result.get("opportunities") or [],
"sources": result.get("sources") or {},
"engine": result.get("engine") or "",
"scanned": int(result.get("scanned") or 0),
"query_sig": query_sig,
"cursors": cursors or {},
"search_count": 1,
}
try:
async with factory() as s:
stmt = pg_insert(UserFeed).values(**row)
stmt = stmt.on_conflict_do_update(
index_elements=[UserFeed.user_id],
set_={**{k: row[k] for k in ("prefs", "opportunities", "sources", "engine", "scanned", "query_sig", "cursors")},
# engagement: count every search run (insert starts at 1, each re-run +1)
"search_count": UserFeed.search_count + 1},
)
await s.execute(stmt)
await s.commit()
except Exception as e: # noqa: BLE001
logger.warning("save_feed failed (non-fatal): %s", e)
async def update_salary_band(user_id: str, salaries: list[float]) -> None:
"""Accumulate the salary band: each deck contributes its HIGHEST disclosed salary (a 'peak'); the
band is the running AVERAGE of those peaks (₹L). No-op when the deck disclosed nothing (persists)."""
sal = [float(x) for x in salaries if x]
factory = session_factory()
if factory is None or not user_id or not sal:
return
hi = max(sal)
try:
async with factory() as s:
row = (await s.execute(select(UserFeed).where(UserFeed.user_id == user_id))).scalar_one_or_none()
if row is None:
return
row.salary_peak_sum = (row.salary_peak_sum or 0.0) + hi # this deck's peak
row.salary_peak_count = (row.salary_peak_count or 0) + 1
await s.commit()
except Exception as e: # noqa: BLE001
logger.warning("update_salary_band failed (non-fatal): %s", e)
async def get_salary_band(user_id: str) -> float | None:
"""The salary band = average of each deck's peak salary (₹L), or None if no deck disclosed one."""
factory = session_factory()
if factory is None or not user_id:
return None
try:
async with factory() as s:
row = (await s.execute(select(UserFeed).where(UserFeed.user_id == user_id))).scalar_one_or_none()
except Exception as e: # noqa: BLE001
logger.warning("get_salary_band failed (non-fatal): %s", e)
return None
if not row or not row.salary_peak_count:
return None
return round((row.salary_peak_sum or 0.0) / row.salary_peak_count, 1)
async def get_feed(user_id: str) -> dict | None:
"""The user's last cached feed as a search_complete-shaped dict, or None."""
factory = session_factory()
if factory is None or not user_id:
return None
try:
async with factory() as s:
row = (await s.execute(select(UserFeed).where(UserFeed.user_id == user_id))).scalar_one_or_none()
except Exception as e: # noqa: BLE001
logger.warning("get_feed failed (non-fatal): %s", e)
return None
if not row:
return None
states = await get_opportunity_states(user_id)
opps = annotate_and_filter(row.opportunities or [], states)
return {
"opportunities": opps,
"sources": row.sources or {},
"prefs": row.prefs or {},
"engine": row.engine,
"scanned": row.scanned,
"shortlisted": len(opps),
"cached": True,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
async def set_opportunity_state(user_id: str, opportunity_id: str, **fields) -> None:
"""Upsert per-opportunity state (status and/or document ids). No-op if the DB is off.
Only the provided fields are written, so partial updates don't clobber existing values."""
factory = session_factory()
if factory is None or not user_id or not opportunity_id:
return
vals = {k: fields[k] for k in _STATE_FIELDS if k in fields}
if not vals:
return
row = {"user_id": user_id, "opportunity_id": opportunity_id, **vals}
try:
async with factory() as s:
stmt = pg_insert(OpportunityState).values(**row)
stmt = stmt.on_conflict_do_update(
index_elements=[OpportunityState.user_id, OpportunityState.opportunity_id],
set_=vals,
)
await s.execute(stmt)
await s.commit()
except Exception as e: # noqa: BLE001
logger.warning("set_opportunity_state failed (non-fatal): %s", e)
async def get_opportunity_states(user_id: str) -> dict[str, dict]:
"""{opportunity_id: {status, tailored_resume_id, tailored_version_id, cover_letter_id}}."""
factory = session_factory()
if factory is None or not user_id:
return {}
try:
async with factory() as s:
rows = (await s.execute(
select(OpportunityState).where(OpportunityState.user_id == user_id)
)).scalars().all()
except Exception as e: # noqa: BLE001
logger.warning("get_opportunity_states failed (non-fatal): %s", e)
return {}
return {
r.opportunity_id: {
"status": r.status,
"tailored_resume_id": r.tailored_resume_id,
"tailored_version_id": r.tailored_version_id,
"cover_letter_id": r.cover_letter_id,
}
for r in rows
}
# engagement score weights — applies count most, then saves, views, searches (honest "working it")
def _activity_score(viewed: int, saved: int, applied: int, searches: int) -> int:
return applied * 3 + saved * 2 + viewed * 1 + searches * 1
async def get_activity_stats(user_id: str) -> dict:
"""Real dashboard activity for `user_id`: the funnel counts (viewed/saved/applied), how many
documents Mira tailored, search count, and the COHORT engagement percentile (this user's activity
vs every Scout user in the matchmaking DB). All honest, DB-derived. Degrades to zeros if DB is off."""
factory = session_factory()
empty = {"matches": 0, "viewed": 0, "saved": 0, "applied": 0, "tailored": 0, "searches": 0, "percentile": 0, "cohort_size": 0}
if factory is None or not user_id:
return empty
try:
async with factory() as s:
# per-user activity across ALL users (one grouped pass → funnel for this user + cohort rank)
os_rows = (await s.execute(
select(
OpportunityState.user_id,
func.count().filter(OpportunityState.seen.is_(True)).label("matches"),
func.count().filter(OpportunityState.viewed.is_(True)).label("viewed"),
func.count().filter(OpportunityState.saved.is_(True)).label("saved"),
func.count().filter(OpportunityState.status == "applied").label("applied"),
func.count().filter(
OpportunityState.tailored_version_id.isnot(None)
| OpportunityState.cover_letter_id.isnot(None)
).label("tailored"),
).group_by(OpportunityState.user_id)
)).all()
feed_rows = (await s.execute(select(UserFeed.user_id, UserFeed.search_count))).all()
except Exception as e: # noqa: BLE001
logger.warning("get_activity_stats failed (non-fatal): %s", e)
return empty
searches_by = {r.user_id: int(r.search_count or 0) for r in feed_rows}
me, scores = dict(empty), []
users = {r.user_id for r in os_rows} | set(searches_by)
by_os = {r.user_id: r for r in os_rows}
for uid in users:
r = by_os.get(uid)
mt, v, sv, ap, tl = (int(r.matches), int(r.viewed), int(r.saved), int(r.applied), int(r.tailored)) if r else (0, 0, 0, 0, 0)
sc = searches_by.get(uid, 0)
scores.append(_activity_score(v, sv, ap, sc))
if uid == user_id:
me = {"matches": mt, "viewed": v, "saved": sv, "applied": ap, "tailored": tl, "searches": sc}
my_score = _activity_score(me["viewed"], me["saved"], me["applied"], me["searches"])
cohort = len(scores) or 1
# percentile = share of the cohort you're STRICTLY ahead of (so rank-1-of-3 → 67th → "top 33%",
# not "top 0%"). UI shows top (100 - percentile)%.
below = sum(1 for x in scores if x < my_score)
me["percentile"] = round(100 * below / cohort)
me["cohort_size"] = cohort
return me
async def get_active_window(user_id: str, *, tz_offset_min: int = 330) -> dict:
"""WHEN the user works their search — activity timestamps (views/saves/applies + searches)
bucketed by local hour-of-day. Returns the peak 3-hour band + a 24-bucket histogram. Real signal,
from this DB. tz_offset_min defaults to IST (+5:30); the product is India-centric.
SQLite/Postgres-agnostic: we read raw UTC timestamps and bucket in Python (so it works in tests too)."""
factory = session_factory()
empty = {"by_hour": [0] * 24, "peak_start": None, "peak_end": None, "samples": 0}
if factory is None or not user_id:
return empty
try:
async with factory() as s:
os_ts = (await s.execute(
select(OpportunityState.updated_at).where(OpportunityState.user_id == user_id)
)).scalars().all()
feed_ts = (await s.execute(
select(UserFeed.updated_at).where(UserFeed.user_id == user_id)
)).scalars().all()
except Exception as e: # noqa: BLE001
logger.warning("get_active_window failed (non-fatal): %s", e)
return empty
by_hour = [0] * 24
for ts in [*os_ts, *feed_ts]:
if ts is None:
continue
local_min = ts.hour * 60 + ts.minute + tz_offset_min
by_hour[(local_min // 60) % 24] += 1
samples = sum(by_hour)
if not samples:
return empty
# peak 3-hour window (circular) by summed activity
best_h, best_sum = 0, -1
for h in range(24):
win = by_hour[h] + by_hour[(h + 1) % 24] + by_hour[(h + 2) % 24]
if win > best_sum:
best_h, best_sum = h, win
return {"by_hour": by_hour, "peak_start": best_h, "peak_end": (best_h + 3) % 24, "samples": samples}
def annotate_and_filter(opportunities: list[dict], states: dict[str, dict]) -> list[dict]:
"""Drop dismissed opportunities and annotate the rest with their persisted state (applied flag
+ already-generated document ids) so the UI can restore without re-tailoring."""
out: list[dict] = []
for o in opportunities:
st = states.get(o.get("id"))
if st and st.get("status") == "dismissed":
continue # never resurface
if st:
o = {**o,
"applied": st.get("status") == "applied",
"tailored_resume_id": st.get("tailored_resume_id"),
"tailored_version_id": st.get("tailored_version_id"),
"cover_letter_id": st.get("cover_letter_id")}
out.append(o)
return out
_CURSOR_TTL_HOURS = 24 # job boards refresh ~daily (live scrape, 7-day newest-first window) → reset every 24h
async def get_search_cursors(user_id: str, query_sig: str) -> dict:
"""Per-board start pages for this query, e.g. {'linkedin': 3}. RESETS to empty (every board → page 1)
when EITHER the query changed OR the last search was >24h ago (so we re-surface the freshest daily
postings instead of marching into stale depth). Each board advances its OWN page; dedup is the
per-user seen-net, never cross-user."""
from datetime import datetime, timezone
factory = session_factory()
if factory is None or not user_id:
return {}
try:
async with factory() as s:
row = (await s.execute(select(UserFeed).where(UserFeed.user_id == user_id))).scalar_one_or_none()
except Exception as e: # noqa: BLE001
logger.warning("get_search_cursors failed (non-fatal): %s", e)
return {}
if not row or row.query_sig != query_sig:
return {} # new/changed query → all boards start at page 1
if row.updated_at:
age_h = (datetime.now(timezone.utc) - row.updated_at).total_seconds() / 3600
if age_h >= _CURSOR_TTL_HOURS:
return {} # >24h stale → reset to catch the newest jobs
return dict(row.cursors or {})
async def get_seen_ids(user_id: str) -> set[str]:
"""Every opportunity the user has already been shown / acted on — excluded from new runs."""
factory = session_factory()
if factory is None or not user_id:
return set()
try:
async with factory() as s:
rows = (await s.execute(
select(OpportunityState.opportunity_id).where(OpportunityState.user_id == user_id)
)).scalars().all()
return set(rows)
except Exception as e: # noqa: BLE001
logger.warning("get_seen_ids failed (non-fatal): %s", e)
return set()
async def mark_seen(user_id: str, opportunity_ids: list[str]) -> None:
"""Record that these opportunities were shown in a deck (so future runs skip them). No-op on DB off.
Sets seen=true without touching any existing status/document ids."""
factory = session_factory()
if factory is None or not user_id or not opportunity_ids:
return
rows = [{"user_id": user_id, "opportunity_id": oid, "seen": True} for oid in opportunity_ids if oid]
if not rows:
return
try:
async with factory() as s:
stmt = pg_insert(OpportunityState).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=[OpportunityState.user_id, OpportunityState.opportunity_id],
set_={"seen": True},
)
await s.execute(stmt)
await s.commit()
except Exception as e: # noqa: BLE001
logger.warning("mark_seen failed (non-fatal): %s", e)