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.
79 lines
4.9 KiB
Python
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()
|
|
)
|