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

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

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

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

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

79 lines
4.9 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)
# Set when a refill for `query_sig` returned almost no NEW jobs (the query is tapped out). While set
# (within POOL_EXHAUSTION_TTL), the pool gate relaxes the floor instead of block-fetching duplicates.
exhausted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
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 UserJobPool(Base):
"""The WARM POOL (the 'shelf'). Surplus fetched-but-not-yet-shown jobs banked per (user, query) so
subsequent searches serve from here instead of re-paying Apify for a fresh sweep every time. One row
per (user, query_sig, job). TTL'd by `pooled_at`; already-shown jobs are filtered on read via the
seen-net. The gate reorders (background top-up) at REORDER fresh-unseen and hard-floors a blocking
fetch below SAFETY_FLOOR, so the pool never runs thin."""
__tablename__ = "user_job_pool"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
query_sig: Mapped[str] = mapped_column(String, primary_key=True)
job_id: Mapped[str] = mapped_column(String, primary_key=True)
job: Mapped[dict] = mapped_column(JSON, default=dict) # the normalized ScoutJob (pre-sift/curate)
pooled_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=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()
)