Utkrusht · Résumé Fit · Architecture & design plan

Résumé Fit — architecture

How the scoring agent is built and run: a decoupled domain core (easy to add signals / change the formula) behind a thin Airflow-DAG + Fargate adapter (background, notify, scales to 500+). Companion to the spec-kit plan in specs/024-resume-fit/.

🔗 Live, commentable copy: html-docs.com/site/ra-suma-fit-a-architecture-design-plan

Input → a position (its competencies + proficiency + band + JD + prefs) and a batch of résumés (uploaded to applications).
Output → per candidate, a fit_result stored on the application — fit_score (0–100) · bucket (Strong/Potential/Weak) · confidence · summary · signals[] — the batch ranked Strong→Weak (or a "couldn't assess" state), plus a "ready" notification. The recruiter's report reads fit_result directly.
1The two halves 2What the recruiter sees 3How the fit score works 4Delivery: Airflow + Fargate 5Extraction vs scoring (caching) 6Data model & migrations 7Concurrency & re-runs 8Module skeleton 9Adding a signal / changing the formula

1The two halves

The whole design rests on one seam: a pure domain core that knows nothing about infra, and a thin adapter that delivers it. Dependencies point inward — the adapter depends on the core, never the reverse.

Domain core — resume_fit/ (pure · no infra)

Delivery adapter — Airflow DAG + Fargate

each task calls

Recruiter changes the positionadd résumés · edit competencies/band/JD/prefs

backend callstrigger_dag {position_id, reason}

ops · manual re-runAirflow UI: Trigger DAG w/ config {force}

1 · prepare (Airflow worker)compute ctx_hash · find the stale set

2 · mapN Fargate tasks, in parallel

3 · reduce (Airflow worker)aggregate + rank

Registry(signals) · ScoringFormula · config

notify recruiterresults ready

Why the seam matters: the adapter (Airflow DAG + Fargate runner) depends on the core; the core depends on nothing infra. The heavy LLM scoring runs in Fargate (isolated, elastic); prepare/reduce are light Airflow-worker steps. Add a signal without touching the adapter; unit-test the core with no DB, no Airflow, no LLM keys.

3How the fit score works

Three questions: which signals, how they make a 0–100 score, and what reaches the report. The model is a scored skill spine with two adjusters, plus two company signals that never hurt a candidate.

The signals

SignalWhat it measuresRoleSurfaces as
skill_matchContextual (semantic, not keyword) match vs the position’s competencies — right skill, right usage; works on a blank JDScored spine (0–1)Skills n/m matched chip
evidenceHow well the résumé backs each matched skill× adjuster ≤1hover on the chip
recencyHow recently each matched skill was used× adjuster ≤1 + tiebreakerhover on the chip
company_loyaltyTenure / job-hopping patternInformational, weight 0own chip, high-conf only
company_pedigreeEmployer tier from company names (no fetch)Opt-in capped bonus, never penalizesown chip, opt-in + high-conf

Because skill_match is contextual, not lexical (“built ETL in Python” matches; “Python” in a skills list doesn’t), a separate relevance signal is redundant. evidence and recency stay their own signals — each has its own confidence, tuning knob, and failure mode (a skill can match yet be thinly-backed or stale) — but as × adjusters they only ever qualify a match, so they share the one Skills chip rather than earning their own. Guardrails: missing ≠ negative, Unknown ≠ Low, a standard degree earns nothing.

The formula

fit_score = skill_match × evidence_adj × recency_adj × 100  +  pedigree_bonus
#             skills fit    backed?       current?              opt-in, capped, ≥0

Example — QA role, 3 competencies; Playwright + Selenium match strongly, API testing missed:

StepFactorValueTotal
1skill_match — 2 of 3 matched, both strong0.820.82
2× evidence_adj — both backed by project lines1.000.82
3× recency_adj — Selenium ~2 yrs ago0.940.77
4× 10077
5+ pedigree_bonus — opt-in off+077

0.82 × 1.00 × 0.94 × 100 = 77Strong (fit ≥ 75). The card shows Skills 2/3 matched; the hover carries the per-skill evidence + recency and the API-testing miss.

Every number lives in one config file

The model returns categories it can judge reliably (an evidence tier, a recency bucket); config maps each to a factor. So no number is hallucinated — each is a seeded default, then calibrated from the annotation sheet (§9). That is the whole answer to “where does the 0.85 come from”:

# resume_fit/config.py — every tunable number in ONE place
MODELS = {"extract": "cheap-tier",   # résumé → facts, once per résumé
          "score":   "strong-tier"}  # contextual match + confidence

# evidence tier → × adjuster  (≤1: a miss, never a negative)
EVIDENCE_ADJ = {"demonstrated": 1.00, "in_context": 0.92,
                "bare_mention": 0.85, "unsupported": 0.70}
# recency bucket → × adjuster  (freshest wins)
RECENCY_ADJ  = {"this_year": 1.00, "yrs_1_2": 0.94,
                "yrs_3_5": 0.85, "yrs_5_plus": 0.72}

# skill_match aggregation
MISS_PENALTY   = 0.09   # per unmatched required competency
PARTIAL_CREDIT = 0.6    # a partial match vs. a full one

# company signals
LOYALTY_WEIGHT = 0      # informational only — never moves fit
PEDIGREE = {"enabled": False, "bonus_cap": 5,  # opt-in, additive, ≥0
            "tier1": 5, "tier2": 3}

# confidence gate + buckets (0–100)
CONFIDENCE_MIN = "high"                    # only high-conf signals score/show
BUCKETS = {"strong": 75, "potential": 55}  # calibrated, not legacy 85/70
SCORING_VERSION = "v1"                     # bump on change; old results pinned

What reaches the report

Each signal carries show ∈ {chip, detail, hidden} and a derived confidence (evidence spans + rubric, never self-reported). Two gates: only high-confidence signals score & show; and if skill_match itself isn’t high-confidence → “Couldn’t assess”, never a low score.

showMeaningWhich signals
chipown pill on the cardskill_match (n/m); loyalty / pedigree when they pass their gate
detailhover onlyevidence, recency
hiddennot shown, doesn’t scoreany low-confidence signal; un-opted pedigree; unknown tier
The report does no computing. Chip colour, the bucket, and the n/m count are all derived at render time from the stored signals[] — nothing about presentation is persisted.

2What the recruiter sees

One run's output, rendered from fit_result alone — no separate report, no modal. Both surfaces show the same lean chip set: a bucket pill + one aggregate Skills n/m matched chip, reusing the chip shape already in the product (e.g. the Location differs from profile chip). Per-skill detail lives in the hover, never as separate chips.

Position page — ranked session cards

Position page cards with bucket pill + aggregate Skills chip

Proposed on the real cards. Each card leads with the bucket pill (● Strong fit / ◐ Potential fit / Weak), then one Skills n/m matched chip (n competencies of m), then any informational chip (e.g. 4 yrs · 1 company loyalty — weight 0). The list sorts Strong→Weak. (Names are placeholders.)

Candidate report — chips on the profile card, per-skill detail on hover

Task report profile card with bucket pill + Skills chip + hover

Proposed on the real report (redacted). The bucket pill + Skills 2/3 matched chip sit in the profile-card tag row beside the existing location chip. Hovering the Skills chip expands the aggregate into the per-skill breakdown — each matched competency with its relevance (strong/partial), recency (year used), and an evidence quote, plus the misses and the derived confidence.

Only high-confidence signals appear; a résumé we can't read shows a "Couldn't assess" state, not a fake score. Chip colour + the n/m count are derived from the stored signals[] — the report does no computing.

4Delivery: Airflow + Fargate

The decision rule, and where Résumé Fit lands:

Use…For…In this repo
Airflow + Fargatebackground, heavy-parallel LLM batches; scheduled pipelines; backfillstask_analysis_fargate — and Résumé Fit
Queue + workerinteractive, low-latency, light-per-run LLM jobstask-builder (generation_jobs)

Résumé Fit scores up to 500+ résumés in the background — heavy parallel LLM. That's task_analysis-shaped, not task-builder-shaped. The team convention is "all LLM work on Fargate," and only Fargate lets a big position (a) not hog app/worker servers and (b) scale out instead of draining on one worker. So: an Airflow DAG that fans scoring out to Fargate (mirroring task_analysis_fargate) — prepare → map (N Fargate) → reduce → notify.

Trade-offs recorded — Airflow + Fargate (chosen) vs a DB queue-runner

Airflow + Fargate (chosen)DB queue-runner
LLM computeisolated in Fargate — never hogs app/worker serverson workers; to not hog, workers must be Fargate anyway
Scale a 500+ positionfan out N Fargate tasks — elasticcandidate fan-out + a worker pool you manage
Retries · observability · manual re-runfree (Airflow UI: "Trigger DAG w/ config")build / bolt on
Queue · claim · sweeper · coalescenone — Airflow provides itbuild fit_jobs/fit_items + claim + sweeper
Matches team patterntask_analysis_fargate; "LLM → Fargate"task-builder (but that's interactive)
Latencycold-start per run (~30–60s)low (persistent workers)
Per-position serializationAirflow poolSQL claim
Supersession on context changelet-finish + queue a fresh run (no StopTask, no mid-run cancel)in-process check (simplest)
Tiny-batch costFargate spin-up overhead (overkill for ~3)cheap per item

Net: background + heavy-parallel-LLM + the "LLM on Fargate" convention → Airflow + Fargate. A queue-runner would win only for interactive, low-latency LLM (task-builder), which this isn't. (Earlier drafts leaned queue-worker by mis-analogizing to task-builder; the 500-scale requirement + Fargate convention correct that — a stable classification.)

5Extraction vs scoring — the caching split

The single most important efficiency: separate the expensive, position-independent extraction (cached forever by resume_hash) from the per-position scoring (re-runs on any context change). Changing competencies re-scores but never re-extracts.

yes

no

résumé PDF

resume_hash = sha256(normalized text)

facts cached?

reuse cached ResumeFacts(position-independent)

EXTRACT profileskills · experience · tenure · dates(expensive · once per résumé ever)

SCORE vs THIS positioncompetencies · band · context(re-runs on context change)

signals[] + fit

Recruiter changeRecomputesCost
Add résumésnew candidates onlyextract (cache-miss only) + score
Competencies / bandskill_match (+adjusters), all candidatesre-score from cached facts (no re-extract)
JD / descriptionaffected context signalsre-score affected
Pref toggle offdrop signal, re-aggregateno LLM
Pref toggle onthat one signal × candidates1 signal's LLM

6Data model & migrations

Additive columns only — no new table. Airflow is the queue (run state lives in the DAG run, not the DB); the result is a blob, not a per-signal child table.

has

positions

jsonb

competencies

existing

text

experience

existing (band)

text

description

existing (JD)

jsonb

evaluation_context

NEW — editable prefs/inferred

applications

text

resume

existing (URL)

text

resume_hash

NEW — reuse key

jsonb

resume_facts

NEW — cached profile

jsonb

fit_result

NEW — blob (aggregate + signals[])

text

fit_context_hash

NEW — staleness marker

Why no fit_jobs table

Airflow is the queue. Run state, retries, per-task progress, and manual re-trigger all live in Airflow — so there's no claim/sweeper/coalesce table to build. Idempotency comes from the DAG run id "{position_id}:{ctx_hash}" and the fit_context_hash stale-set on applications. (A DB queue-runner would need a fit_jobs/fit_items table for exactly this — one of the trade-offs in §2.)

Columns & when they fill

ColumnPurposePopulated when
applications.resume_hashhash of the résumé's normalized text — reuse keyfirst extraction
applications.resume_factscached, position-independent profileon extract (cache-miss); reused by hash after
applications.fit_resultresult blob (aggregate + signals[])after scoring; overwritten on re-score
applications.fit_context_hashcontext version that produced the resultalongside fit_result
positions.evaluation_contextinferred + editable prefs (env, domain, pedigree)inferred at create; edited by recruiter

Staleness key: fit_context_hash = hash(competencies + experience + description + evaluation_context). Any of those changing flips it → the stale-set query (fit_result IS NULL OR fit_context_hash ≠ current OR scoring_version ≠ current) re-scores exactly the affected rows and nothing else. The two clauses split the job cleanly: the hash catches context edits, scoring_version catches formula changes — together they are the full “is this result current?” test.

fit_context_hash is the convergence mechanism, not the run id. It records which context version each result was scored against, so under let-finish a soon-stale in-flight run and a fresh run coexist with no locks: the old run’s rows carry the old hash and are simply re-scored by the next run — that is what makes the batch converge to “every row at the latest context.” Because reverting the context reproduces an old hash, it must not key the dag_run_id — a reverted hash would collide with an already-finished run and silently skip newly-added candidates. So the run id is a plain unique handle, the Airflow pool enforces ≤1 run/position, and idempotency comes from prepare finding an empty stale-set (the data is the source of truth, not the run id).

Total & per-signal scores both live in the blob. The total is fit_result.fit_score; each signal keeps its own fit_result.signals[].score (+ confidence + evidence) in the same JSONB. They’re one blob rather than columns because the signal set is dynamic — the Registry adds/removes signals, so a fixed column per signal would need a migration each time; scoring_version records which formula produced the numbers. Trade: the DB can’t ORDER BY fit_score or filter by bucket cheaply. Fine today — ranking happens in reduce (in memory over the batch) and the report reads the blob directly — but if DB-level sort/filter is ever needed (a leaderboard query, “Strong first” in SQL), promote fit_score (and maybe bucket) to a generated, indexed column extracted from the blob.

Lifecycle

applicationsFargate (map)Airflow DAGRecruiterapplicationsFargate (map)Airflow DAGRecruitertrigger_dag {position_id, reason} (or UI "Trigger DAG w/ config")prepare — ctx_hash · stale-set (pool: ≤1/position)map — N tasks over shardsper stale app — resume_hash + facts (extract or reuse)score → fit_result + fit_context_hash (blob)reduce — aggregate + ranknotify

Migration mechanics: additive nullable columns on applications+positions only (inherit table RLS) — no new table. One file, forward-only.

7Concurrency & re-runs

What happens when events overlap — you add résumés (a run starts), then change competencies or add more before it finishes. Under Airflow + Fargate:

MechanismGuarantees
Per-position serializationan Airflow pool keyed by position≤1 run per position
Idempotent triggera unique run id + the per-position pool; a re-trigger is a no-op because prepare finds an empty stale-set — idempotency lives in the data, not the run id (the run id must not be ctx_hash: a reverted context reproduces an old hash and would skip newly-added candidates)
Self-correcting stale-setfit_result IS NULL OR fit_context_hash ≠ current OR scoring_version ≠ current → final state is always "scored vs the latest context and formula," whatever the ordering
Supersession (let-finish)a context change flips ctx_hash → the in-flight run is left to finish (no mid-run cancel), and the change queues a fresh run that re-scores all résumés vs the new requirements once the pool frees
Notify suppressionat reduce, a run whose ctx_hash ≠ the position's current skips its notifyone "ready" ping from the latest run, never a stale one

No mid-run cancel — chosen for simplicity. We’re not at the scale where a superseded run’s wasted scoring matters, so we drop the whole cancellation mechanism (no cooperative ctx_hash check, no ECS StopTask): the in-flight run just finishes and the new context runs after it. The only guard kept is the one-line notify-suppression above. Idempotency doesn’t depend on this choice — it comes entirely from the content-addressed stale-set + per-candidate upserts (the data is the checkpoint; there is no run cursor to lose), so cancel-vs-let-finish is a wasted-compute trade, not a correctness one. Adding candidates (no ctx_hash change) never cancels anything either.

Overlapping events — one flow

With let-finish, a second event during a run is always the same story: the running one finishes, a queued run follows, one ping at the end. The trigger only changes what the queued run re-scores — and whether the first run notifies.

Fargate (map)Airflow DAGRecruiterFargate (map)Airflow DAGRecruiterevent 1 → run1 running (pool: ≤1 / position)event 2 (add résumés OR change ctx) → run2 queued, waitsrun1 FINISHES — never cancelledrun1 reduce → notify only if still current & nothing pendingrun2 · prepare stale-set → score (reuse cached facts)run2 done → one "ready" ping (latest context)
The 2nd event is…run2 re-scoresrun1’s notify
add résumés (ctx_hash unchanged)only the new candidatessuppressed — more pending
change competencies/context (ctx_hash flips)all résumés (hash ≠ new) — cached facts reusedsuppressed — ctx now stale

Only cost is wasted compute (run1 finishing against a soon-stale context) — accepted at current scale. Correctness is independent of it: the stale-set (keyed on fit_context_hash) makes the system converge — run1’s rows are just stale and re-scored by run2; per-candidate upserts → no corruption. The recruiter sees "in progress," then one "ready" with the latest context.

8Module skeleton

# domain core — pure, unit-testable, imports NO db/Airflow/Fargate
resume_fit/
  run.py          # run(position_id, reason) — the one entrypoint
  config.py       # model tiers · 12 calibration knobs (defaults) · scoring_version
  schemas.py      # Signal · SignalResult · EvalContext · ResumeFacts · FitResult
  extract.py      # résumé → ResumeFacts (cached by resume_hash)
  registry.py     # @register_signal + active(ctx) selection
  formula.py      # ScoringFormula + SkillSpineFormula
  confidence.py   # evidence-grounded + rubric-derived; optional verify
  rank.py         # aggregate · coverage tiers · buckets · refusal pile
  persist.py      # DAO writes (facts cache · signals[] blob · fit_context_hash)
  signals/
    skill_match.py · evidence.py · recency.py
    company_loyalty.py · company_pedigree.py

# delivery adapter — depends on the core, not vice-versa
airflow/dags/resume_fit_dag.py            # prepare → map (EcsRunTask × N) → reduce → notify; pool=position
airflow/dags/resume_fit_runner/           # Fargate: core.py (calls run() per shard) · runner.py · Dockerfile
flask_service/…                           # add-candidates / position-update paths call trigger_dag(...)
supabase/migrations/<ts>_resume_fit.sql    # applications.* cols + positions.evaluation_context (NO fit_jobs)

9Adding a signal / changing the formula

The extensibility guarantee, concretely. Two tiny surfaces; nothing else moves.

TermIn one line
Registrya central list the pipeline loops over. @register_signal adds your class to the list — so you never edit the pipeline to add a signal.
Strategyan interchangeable "how to do X" object. ScoringFormula is the strategy for "combine signals → a fit score" — swap the object to change the behaviour, callers don't change.
ProtocolPython's structural interface: any class with these methods/attributes satisfies it (no subclassing needed). It's the contract every registry entry and formula follows, so the pipeline can treat them uniformly.
# registry.py — ADD A SIGNAL = one class + one decorator
class Signal(Protocol):
    key: str
    def active(self, ctx: EvalContext) -> bool: ...          # in play for this position?
    def evaluate(self, facts: ResumeFacts, ctx) -> SignalResult: ...

@register_signal
class SkillMatch(Signal):
    key = "skill_match"
    def active(self, ctx): return True
    def evaluate(self, facts, ctx): ...

# formula.py — CHANGE SCORING = swap the object, bump scoring_version
class ScoringFormula(Protocol):
    def combine(self, results: list[SignalResult], ctx) -> FitResult: ...

class SkillSpineFormula(ScoringFormula):   # skill × evidence_adj × recency_adj + bonus
    ...
Add a signal: a file in signals/ + @register_signal. Change the formula: a new ScoringFormula + a scoring_version bump. Tune a number: edit config.py. None touch run(), the DAG, or the Fargate runner — and old fit_result rows stay on their version until re-run.

Tuning the config knobs — where the annotation sheet comes in

The 12 tunable numbers (confidence threshold, band cutoffs, adjuster factors, …) live in config.py with defaults, so the agent runs immediately. To replace a default with a measured value you fill the annotation sheet (specs/024-resume-fit/eval-annotation-sheet.md) — it is not a runtime input, only a calibration tool:

annotation sheetrecruiter blind-scores 10–30 résumésStrong / Potential / Weak · sure?

offline compare

agent output(run on the SAME résumés)

read off knob values:• where recruiter buckets fall → band cutoffs• when 'high' was right → confidence threshold• how they weight proof → adjuster factors

write the numbers into config.py

So the loop is sheet → run agent on the same résumés → offline compare → knob values → config.py. The recruiter fills columns blind first (before seeing the agent's answer), then you compare. Details + the full 12-knob list: eval-annotation-sheet.md and decision-log.md → Calibration knobs.

Utkrusht — internal design · Résumé Fit architecture · specs/024-resume-fit