Files
matchmaking-v2/app/db/models.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

59 lines
3.7 KiB
Python

"""ORM models. One table for now: the per-user cached feed (the last completed search)."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Float, Integer, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UserFeed(Base):
"""The user's last completed search — replayed on load so matches survive navigation.
Upserted on each `search_complete`; read by `get_feed`. One row per user (PK = user_id)."""
__tablename__ = "user_feed"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
prefs: Mapped[dict] = mapped_column(JSON, default=dict) # the ScoutPrefs that produced it
opportunities: Mapped[list] = mapped_column(JSON, default=list) # the curated deck (with match blocks)
sources: Mapped[dict] = mapped_column(JSON, default=dict) # per-board counts
engine: Mapped[str] = mapped_column(String, default="") # "opus" | "fallback:…"
scanned: Mapped[int] = mapped_column(Integer, default=0)
# PER-BOARD search cursors — {board: LAST page fetched}. Next page = last + 1, so a NEW query's
# first search reads page 1 and stores 1. Resets to {} when the query signature changes (or >24h).
# Dedup is purely per-user (the seen-net below) — never cross-user, never opaque actor-side state.
query_sig: Mapped[str] = mapped_column(String, default="")
cursors: Mapped[dict] = mapped_column(JSON, default=dict)
search_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # total searches run (engagement)
# Salary band = AVERAGE of each deck's HIGHEST disclosed salary (₹L). Each deck contributes one
# peak; the band is the running mean of those peaks. Survives decks that disclose nothing.
salary_peak_sum: Mapped[float] = mapped_column(Float, default=0.0, server_default="0")
salary_peak_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class OpportunityState(Base):
"""Per-(user, opportunity) state that SURVIVES deck refreshes. Persists: `applied`/`dismissed`
status, the resume-builder document ids already generated (never re-pay to re-tailor), and the
PROGRESSIVE activity flags that feed the dashboard funnel + engagement rank. A job can be viewed
AND saved AND applied at once, so viewed/saved are booleans (not the single-valued status)."""
__tablename__ = "opportunity_state"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
opportunity_id: Mapped[str] = mapped_column(String, primary_key=True)
status: Mapped[str | None] = mapped_column(String, nullable=True) # 'applied' | 'dismissed' | None
tailored_resume_id: Mapped[str | None] = mapped_column(String, nullable=True) # resume-builder resume id
tailored_version_id: Mapped[str | None] = mapped_column(String, nullable=True) # the tailored version
cover_letter_id: Mapped[str | None] = mapped_column(String, nullable=True) # the generated cover letter
seen: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # shown in a deck → exclude from new runs
viewed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # opened the job → funnel "Viewed"
saved: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # swiped right → funnel "Shortlisted"
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)