Skip to content

feat: multiple profiles fully wired (per-profile scoring + Discover queue) - #35

Merged
Harikeshav-R merged 5 commits into
mainfrom
feat/multi-profile-scoring
Aug 6, 2026
Merged

feat: multiple profiles fully wired (per-profile scoring + Discover queue)#35
Harikeshav-R merged 5 commits into
mainfrom
feat/multi-profile-scoring

Conversation

@Harikeshav-R

Copy link
Copy Markdown
Owner

What & why

Wires multiple search profiles all the way through scoring and the Discover queue (PROJECT.md §2.1/§5.6; the top unchecked Phase 2 roadmap item). Profiles already existed, and the schema was already multi-profile (match_score.profile_id), but profile_id was written and never read — scoring and the queue behaved single-active-profile. Now the daemon scores every profile, each profile has its own ranked queue, and the user can switch profiles from the TUI.

Key commits

  1. feat(db): add busy_timeout PRAGMA — a 5 s busy timeout alongside WAL + foreign keys, so a contended daemon-writer / TUI-reader waits rather than failing with "database is locked".
  2. feat(matching): scope fit scoring to a profilelist_unscored_postings / list_scored_postings / get_latest_match_score are per-profile; score_posting(..., profile=...) takes the profile from its caller; atlas score --profile <id>; the Discover queue + application detail read their profile's scores.
  3. feat(daemon): score every profile with a row-claim leaseScoreClaim table + Alembic migration + atlas.matching.claims (PROJECT.md §4.1's "owned by" lease); run_scoring_poll loops list_profiles, claiming each (posting, profile) pair so a concurrent writer never double-scores; the daemon's pid is the owner.
  4. feat(tui): switch profiles from the Discover queueProfilePickerScreen modal (press p), build_profile_choices, and AtlasApp.switch_profile re-rank the queue in place.
  5. docs: record multiple-profile scoring + claim convention.

Design notes

  • No score-table migrationmatch_score.profile_id already existed; the change is reading it as a filter.
  • Score every profile (chosen scope): each profile's queue is ready the instant the user switches, at the cost of more AI calls (cost controls are a separate deferred item; the suite is hermetic).
  • The Profile.active single-active invariant is unchanged — "active" stays the default/UI selection; scoring iterates list_profiles explicitly.
  • The claim lease is a lightweight backstop (the PID-file already prevents a second daemon); try_claim steals a claim older than its lease so a crashed worker can't wedge a pair.

Follow-ups (out of scope)

The daemon IPC surface (the last unchecked "Daemon + scheduler + IPC" sub-item) and desktop notifications finish Phase 2.

Testing

  • ruff format --check + ruff check — clean
  • mypy --strict on Linux and win32 — no issues (274 files each)
  • pytest --cov=atlas --cov-branch --cov-fail-under=1001083 passed, 100% line + branch

New/changed logic is exercised hermetically: try_claim lease states with an injected clock; run_scoring_poll scoring two profiles × two postings and skipping a pair another owner holds; the migration verified against a temp DB (score_claim + its unique constraint); and a Textual Pilot test driving the p switcher so the queue re-ranks to the chosen profile.

Refs PROJECT.md §2.1, §4.1, §5.6, §15 (Phase 2).

Set PRAGMA busy_timeout=5000 alongside WAL + foreign_keys in the
per-connection listener, so a connection waits up to 5s for a contended
write lock instead of failing immediately with "database is locked". WAL
keeps readers non-blocking, but the daemon writer and a TUI-driven write can
still briefly contend (PROJECT.md §4.1, §17) — the more so now that the
daemon scores every profile. A test asserts the pragma on a fresh engine.
Read match_score.profile_id as a filter (it was written but never read), so
scoring and the Discover queue are per-profile rather than single-active:

- repository: list_unscored_postings(session, profile_id) and
  list_scored_postings(session, profile_id) filter by profile — a posting is
  "unscored"/"scored" *for a profile*, not globally; get_latest_match_score
  gains an optional profile_id.
- service: score_posting(session, posting_id, *, profile, ...) takes the
  profile from the caller instead of resolving the active one internally, so
  the same posting can be scored under several profiles (each keeps its own
  append-only history). NoActiveProfileError moves to the callers.
- CLI: `atlas score <id> [--profile <id>]` scores a chosen profile (active by
  default; unknown id → exit 1); `_score_after_add` resolves the active one.
- TUI: build_discover_queue shows the active profile's queue (empty when none
  active); build_application_detail shows the fit for the application's own
  profile.

The daemon poll still scores the active profile this commit (it becomes
score-every-profile with the claim lease next). 100% line+branch;
mypy --strict incl. win32.
The daemon poll now clears every profile's backlog, not just the active
one, so each profile has a complete ranked queue the instant the user
switches to it — guarded by a lightweight scoring lease (PROJECT.md §4.1's
"owned by" convention) so a concurrent writer never double-scores.

- ScoreClaim table + Alembic migration: one row per (posting, profile) pair
  being scored, unique on the pair.
- matching.claims: try_claim (fresh → claim; stale past the lease → steal;
  live → refuse) and release_claim, pure over a session with an injected
  clock + lease TTL.
- run_scoring_poll(session, *, provider, owner, clock): loops list_profiles
  and, per unscored pair, claims → scores → releases; a pair another worker
  holds live is skipped and counted in the new PollOutcome.claimed. No
  profiles → a benign empty poll.
- daemon start threads its pid as the claim owner.

100% line+branch; mypy --strict incl. win32; migration verified against a
temp DB (score_claim + uq_score_claim_pair present).
The Discover queue now shows the active profile's ranked scores, and a
profile switcher lets the user re-rank it to another profile in place —
without dropping to the CLI. Since the daemon scores every profile, the
chosen profile's queue is already populated.

- ProfilePickerScreen: a modal (mirroring StatusPickerScreen) listing the
  profiles (the active one marked); dismisses with the chosen id or None.
- build_profile_choices: the pure builder behind it (testable off-Textual).
- DiscoverScreen: a `p` binding → push the picker; on choice, AtlasApp's new
  switch_profile (set_active_profile in a session_scope) applies it and the
  queue re-ranks. An empty profile list is a safe no-op.

100% line+branch on the touched TUI modules; mypy --strict incl. win32;
full suite green (1083 tests).
Tick the Phase 2 "Multiple profiles fully wired" roadmap item (PROJECT.md
§15), record the per-profile scoring + score_claim "owned by" lease +
busy_timeout under §4.1 / §6, add a STATUS.md "What has landed" entry and
phase-table update, move "Next up" to the IPC surface, and add the
Unreleased changelog entries.
@Harikeshav-R
Harikeshav-R merged commit 34e6d6b into main Aug 6, 2026
10 checks passed
@Harikeshav-R
Harikeshav-R deleted the feat/multi-profile-scoring branch August 6, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant