Spaces:
Configuration error
feat(models): add screening run and scoring tables
Browse filesAdds the four Phase 4 persistence tables from ARCHITECTURE.md 6.6 as ORM
models plus the Alembic revision that creates them:
screening_runs one execution of the scoring pipeline over a job
candidate_scores one candidate's aggregated result within a run
requirement_verdicts the judge's call on one requirement
evidence_spans verbatim quotes backing a verdict
Three schema decisions worth recording:
- candidate_id, profile_id and triggered_by are plain uuid columns, not
foreign keys. The candidates, candidate_profiles and users tables the
architecture describes are not built in this repo, so declaring the
constraints would make the revision unrunnable. Same treatment
requirements.skill_id already documents for the unbuilt ESCO taxonomy.
- rubric_version_id and requirement_id are RESTRICT, not CASCADE. A score
is the record of which criteria produced a ranking, so the criteria must
outlive it.
- evidence_spans.chunk_id is SET NULL. Re-indexing a resume replaces its
chunks, and a citation survives that because resume_version_id plus the
character offsets locate the quote independently of any chunk row.
The ORM class is EvidenceSpanRecord rather than EvidenceSpan because
app.services.search already exposes an EvidenceSpan dataclass for
in-flight search results.
RED (commit 3a87be3):
pytest tests/unit/test_screening_models.py
33 failed - AttributeError: module 'app.models' has no attribute 'ScreeningRun'
pytest tests/unit/
FAILED test_migration_coverage.py::test_every_orm_table_is_created_by_some_migration
AssertionError: tables declared in app.models with no migration creating
them: ['candidate_scores', 'evidence_spans', 'requirement_verdicts',
'screening_runs']
GREEN:
pytest tests/unit/ all passed
pytest tests/integration/test_migrations.py 8 passed (live postgres)
pytest 615 passed
ruff check . All checks passed!
coverage 90.92% (was 90.61%), models.py 100%
The integration run covers upgrade-head, autogenerate-diff-is-empty,
idempotency and downgrade-then-upgrade reversibility against a real
database, so the revision is verified runnable in both directions.
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add screening_runs, candidate_scores, requirement_verdicts, evidence_spans
|
| 2 |
+
|
| 3 |
+
Revision ID: c3d4e5f6a7b8
|
| 4 |
+
Revises: b2c3d4e5f6a7
|
| 5 |
+
Create Date: 2026-08-02 09:40:00.000000+00:00
|
| 6 |
+
|
| 7 |
+
candidate_scores.candidate_id / .profile_id and screening_runs.triggered_by are
|
| 8 |
+
plain uuid columns rather than foreign keys: the candidates, candidate_profiles
|
| 9 |
+
and users tables described in ARCHITECTURE.md 6.3-6.4 are not built in this
|
| 10 |
+
repo, and declaring the constraints would make this revision unrunnable.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from collections.abc import Sequence
|
| 16 |
+
|
| 17 |
+
import sqlalchemy as sa
|
| 18 |
+
from alembic import op
|
| 19 |
+
from sqlalchemy.dialects import postgresql
|
| 20 |
+
|
| 21 |
+
revision: str = "c3d4e5f6a7b8"
|
| 22 |
+
down_revision: str | None = "b2c3d4e5f6a7"
|
| 23 |
+
branch_labels: str | Sequence[str] | None = None
|
| 24 |
+
depends_on: str | Sequence[str] | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def upgrade() -> None:
|
| 28 |
+
"""Apply this revision."""
|
| 29 |
+
op.create_table(
|
| 30 |
+
"screening_runs",
|
| 31 |
+
sa.Column("id", sa.UUID(), nullable=False),
|
| 32 |
+
sa.Column("tenant_id", sa.UUID(), nullable=False),
|
| 33 |
+
sa.Column("job_id", sa.UUID(), nullable=False),
|
| 34 |
+
sa.Column("rubric_version_id", sa.UUID(), nullable=False),
|
| 35 |
+
sa.Column("status", sa.String(length=32), nullable=False),
|
| 36 |
+
sa.Column("mode", sa.String(length=32), nullable=False),
|
| 37 |
+
sa.Column("candidate_count", sa.Integer(), nullable=False),
|
| 38 |
+
sa.Column("funnel_stage_counts", postgresql.JSONB(), nullable=True),
|
| 39 |
+
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
| 40 |
+
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
| 41 |
+
sa.Column("workflow_id", sa.String(length=128), nullable=True),
|
| 42 |
+
sa.Column("triggered_by", sa.UUID(), nullable=True),
|
| 43 |
+
sa.Column("cost_usd", sa.Numeric(precision=10, scale=4), nullable=False),
|
| 44 |
+
sa.Column("total_input_tokens", sa.BigInteger(), nullable=False),
|
| 45 |
+
sa.Column("total_output_tokens", sa.BigInteger(), nullable=False),
|
| 46 |
+
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False),
|
| 47 |
+
sa.Column("cache_write_tokens", sa.BigInteger(), nullable=False),
|
| 48 |
+
sa.Column(
|
| 49 |
+
"created_at",
|
| 50 |
+
sa.DateTime(timezone=True),
|
| 51 |
+
server_default=sa.text("now()"),
|
| 52 |
+
nullable=False,
|
| 53 |
+
),
|
| 54 |
+
sa.Column(
|
| 55 |
+
"updated_at",
|
| 56 |
+
sa.DateTime(timezone=True),
|
| 57 |
+
server_default=sa.text("now()"),
|
| 58 |
+
nullable=False,
|
| 59 |
+
),
|
| 60 |
+
sa.ForeignKeyConstraint(["job_id"], ["jobs.id"], ondelete="CASCADE"),
|
| 61 |
+
# RESTRICT: a run is the record of which criteria produced a ranking, so
|
| 62 |
+
# the rubric version it cites must not be deletable out from under it.
|
| 63 |
+
sa.ForeignKeyConstraint(
|
| 64 |
+
["rubric_version_id"], ["rubric_versions.id"], ondelete="RESTRICT"
|
| 65 |
+
),
|
| 66 |
+
sa.PrimaryKeyConstraint("id"),
|
| 67 |
+
)
|
| 68 |
+
op.create_index(
|
| 69 |
+
"ix_screening_runs_tenant_id", "screening_runs", ["tenant_id"], unique=False
|
| 70 |
+
)
|
| 71 |
+
op.create_index(
|
| 72 |
+
"ix_screening_runs_tenant_job",
|
| 73 |
+
"screening_runs",
|
| 74 |
+
["tenant_id", "job_id"],
|
| 75 |
+
unique=False,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
op.create_table(
|
| 79 |
+
"candidate_scores",
|
| 80 |
+
sa.Column("id", sa.UUID(), nullable=False),
|
| 81 |
+
sa.Column("tenant_id", sa.UUID(), nullable=False),
|
| 82 |
+
sa.Column("run_id", sa.UUID(), nullable=False),
|
| 83 |
+
sa.Column("candidate_id", sa.UUID(), nullable=False),
|
| 84 |
+
sa.Column("profile_id", sa.UUID(), nullable=True),
|
| 85 |
+
# Two decimals for the reported score, four for the pre-rounding
|
| 86 |
+
# weighted sum, so a score stays replayable.
|
| 87 |
+
sa.Column("overall_score", sa.Numeric(precision=5, scale=2), nullable=False),
|
| 88 |
+
sa.Column("raw_weighted", sa.Numeric(precision=5, scale=4), nullable=False),
|
| 89 |
+
sa.Column("cap_applied", sa.Integer(), nullable=True),
|
| 90 |
+
sa.Column("rank", sa.Integer(), nullable=True),
|
| 91 |
+
sa.Column("retrieval_score", sa.Float(), nullable=True),
|
| 92 |
+
sa.Column("rerank_score", sa.Float(), nullable=True),
|
| 93 |
+
sa.Column("recommendation", sa.String(length=32), nullable=True),
|
| 94 |
+
sa.Column("recommendation_confidence", sa.Float(), nullable=True),
|
| 95 |
+
sa.Column("summary", sa.Text(), nullable=True),
|
| 96 |
+
sa.Column("aggregation_formula_version", sa.String(length=32), nullable=False),
|
| 97 |
+
sa.Column(
|
| 98 |
+
"created_at",
|
| 99 |
+
sa.DateTime(timezone=True),
|
| 100 |
+
server_default=sa.text("now()"),
|
| 101 |
+
nullable=False,
|
| 102 |
+
),
|
| 103 |
+
sa.Column(
|
| 104 |
+
"updated_at",
|
| 105 |
+
sa.DateTime(timezone=True),
|
| 106 |
+
server_default=sa.text("now()"),
|
| 107 |
+
nullable=False,
|
| 108 |
+
),
|
| 109 |
+
sa.ForeignKeyConstraint(["run_id"], ["screening_runs.id"], ondelete="CASCADE"),
|
| 110 |
+
sa.PrimaryKeyConstraint("id"),
|
| 111 |
+
sa.UniqueConstraint(
|
| 112 |
+
"run_id", "candidate_id", name="uq_candidate_scores_run_candidate"
|
| 113 |
+
),
|
| 114 |
+
)
|
| 115 |
+
op.create_index(
|
| 116 |
+
"ix_candidate_scores_tenant_id", "candidate_scores", ["tenant_id"], unique=False
|
| 117 |
+
)
|
| 118 |
+
op.create_index(
|
| 119 |
+
"ix_candidate_scores_tenant_run",
|
| 120 |
+
"candidate_scores",
|
| 121 |
+
["tenant_id", "run_id"],
|
| 122 |
+
unique=False,
|
| 123 |
+
)
|
| 124 |
+
op.create_index(
|
| 125 |
+
"ix_candidate_scores_run_rank",
|
| 126 |
+
"candidate_scores",
|
| 127 |
+
["run_id", "rank"],
|
| 128 |
+
unique=False,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
op.create_table(
|
| 132 |
+
"requirement_verdicts",
|
| 133 |
+
sa.Column("id", sa.UUID(), nullable=False),
|
| 134 |
+
sa.Column("tenant_id", sa.UUID(), nullable=False),
|
| 135 |
+
sa.Column("score_id", sa.UUID(), nullable=False),
|
| 136 |
+
sa.Column("requirement_id", sa.UUID(), nullable=False),
|
| 137 |
+
sa.Column("verdict", sa.String(length=16), nullable=False),
|
| 138 |
+
sa.Column("confidence", sa.Float(), nullable=False),
|
| 139 |
+
# Stored, not derived: the rubric can mint a new version with different
|
| 140 |
+
# weights, and a verdict must stay explainable against the weights that
|
| 141 |
+
# actually produced it.
|
| 142 |
+
sa.Column("weight_at_scoring", sa.Numeric(precision=5, scale=4), nullable=False),
|
| 143 |
+
sa.Column("contribution", sa.Numeric(precision=6, scale=4), nullable=False),
|
| 144 |
+
sa.Column("reasoning", sa.Text(), nullable=True),
|
| 145 |
+
sa.Column("judge_model", sa.String(length=128), nullable=True),
|
| 146 |
+
sa.Column("judge_prompt_version", sa.String(length=32), nullable=True),
|
| 147 |
+
sa.Column("judge_effort", sa.String(length=32), nullable=True),
|
| 148 |
+
sa.Column("cache_hit", sa.Boolean(), nullable=False),
|
| 149 |
+
sa.Column("input_tokens", sa.Integer(), nullable=False),
|
| 150 |
+
sa.Column("output_tokens", sa.Integer(), nullable=False),
|
| 151 |
+
sa.Column("latency_ms", sa.Integer(), nullable=False),
|
| 152 |
+
# The exposure record: exactly what the judge saw. Without it a
|
| 153 |
+
# "missing" verdict is indistinguishable from a retrieval failure.
|
| 154 |
+
sa.Column("retrieved_chunk_ids", postgresql.ARRAY(sa.UUID()), nullable=True),
|
| 155 |
+
sa.Column("result_cache_key", sa.String(length=64), nullable=True),
|
| 156 |
+
sa.Column("overridden_by", sa.UUID(), nullable=True),
|
| 157 |
+
sa.Column("override_verdict", sa.String(length=16), nullable=True),
|
| 158 |
+
sa.Column("override_reason", sa.Text(), nullable=True),
|
| 159 |
+
sa.Column("overridden_at", sa.DateTime(timezone=True), nullable=True),
|
| 160 |
+
sa.Column(
|
| 161 |
+
"created_at",
|
| 162 |
+
sa.DateTime(timezone=True),
|
| 163 |
+
server_default=sa.text("now()"),
|
| 164 |
+
nullable=False,
|
| 165 |
+
),
|
| 166 |
+
sa.Column(
|
| 167 |
+
"updated_at",
|
| 168 |
+
sa.DateTime(timezone=True),
|
| 169 |
+
server_default=sa.text("now()"),
|
| 170 |
+
nullable=False,
|
| 171 |
+
),
|
| 172 |
+
sa.ForeignKeyConstraint(
|
| 173 |
+
["score_id"], ["candidate_scores.id"], ondelete="CASCADE"
|
| 174 |
+
),
|
| 175 |
+
sa.ForeignKeyConstraint(
|
| 176 |
+
["requirement_id"], ["requirements.id"], ondelete="RESTRICT"
|
| 177 |
+
),
|
| 178 |
+
sa.PrimaryKeyConstraint("id"),
|
| 179 |
+
sa.UniqueConstraint(
|
| 180 |
+
"score_id", "requirement_id", name="uq_requirement_verdicts_score_req"
|
| 181 |
+
),
|
| 182 |
+
)
|
| 183 |
+
op.create_index(
|
| 184 |
+
"ix_requirement_verdicts_tenant_id",
|
| 185 |
+
"requirement_verdicts",
|
| 186 |
+
["tenant_id"],
|
| 187 |
+
unique=False,
|
| 188 |
+
)
|
| 189 |
+
op.create_index(
|
| 190 |
+
"ix_requirement_verdicts_tenant_score",
|
| 191 |
+
"requirement_verdicts",
|
| 192 |
+
["tenant_id", "score_id"],
|
| 193 |
+
unique=False,
|
| 194 |
+
)
|
| 195 |
+
op.create_index(
|
| 196 |
+
"ix_requirement_verdicts_cache_key",
|
| 197 |
+
"requirement_verdicts",
|
| 198 |
+
["result_cache_key"],
|
| 199 |
+
unique=False,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
op.create_table(
|
| 203 |
+
"evidence_spans",
|
| 204 |
+
sa.Column("id", sa.UUID(), nullable=False),
|
| 205 |
+
sa.Column("tenant_id", sa.UUID(), nullable=False),
|
| 206 |
+
sa.Column("verdict_id", sa.UUID(), nullable=True),
|
| 207 |
+
sa.Column("resume_version_id", sa.UUID(), nullable=False),
|
| 208 |
+
sa.Column("chunk_id", sa.UUID(), nullable=True),
|
| 209 |
+
sa.Column("page", sa.Integer(), nullable=True),
|
| 210 |
+
sa.Column("start_char", sa.Integer(), nullable=False),
|
| 211 |
+
sa.Column("end_char", sa.Integer(), nullable=False),
|
| 212 |
+
sa.Column("quoted_text", sa.Text(), nullable=False),
|
| 213 |
+
# Set by the automated check that re-slices the resume at
|
| 214 |
+
# [start_char, end_char) and compares: the anti-hallucination gate.
|
| 215 |
+
sa.Column("verbatim_verified", sa.Boolean(), nullable=False),
|
| 216 |
+
sa.Column("relevance", sa.Float(), nullable=True),
|
| 217 |
+
sa.Column(
|
| 218 |
+
"created_at",
|
| 219 |
+
sa.DateTime(timezone=True),
|
| 220 |
+
server_default=sa.text("now()"),
|
| 221 |
+
nullable=False,
|
| 222 |
+
),
|
| 223 |
+
sa.Column(
|
| 224 |
+
"updated_at",
|
| 225 |
+
sa.DateTime(timezone=True),
|
| 226 |
+
server_default=sa.text("now()"),
|
| 227 |
+
nullable=False,
|
| 228 |
+
),
|
| 229 |
+
sa.ForeignKeyConstraint(
|
| 230 |
+
["verdict_id"], ["requirement_verdicts.id"], ondelete="CASCADE"
|
| 231 |
+
),
|
| 232 |
+
sa.ForeignKeyConstraint(
|
| 233 |
+
["resume_version_id"], ["resume_versions.id"], ondelete="RESTRICT"
|
| 234 |
+
),
|
| 235 |
+
# SET NULL: re-indexing a resume replaces its chunks, and the citation
|
| 236 |
+
# survives that because resume_version_id plus the character offsets
|
| 237 |
+
# locate the quote independently of any chunk row.
|
| 238 |
+
sa.ForeignKeyConstraint(["chunk_id"], ["resume_chunks.id"], ondelete="SET NULL"),
|
| 239 |
+
sa.PrimaryKeyConstraint("id"),
|
| 240 |
+
)
|
| 241 |
+
op.create_index(
|
| 242 |
+
"ix_evidence_spans_tenant_id", "evidence_spans", ["tenant_id"], unique=False
|
| 243 |
+
)
|
| 244 |
+
op.create_index(
|
| 245 |
+
"ix_evidence_spans_tenant_verdict",
|
| 246 |
+
"evidence_spans",
|
| 247 |
+
["tenant_id", "verdict_id"],
|
| 248 |
+
unique=False,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def downgrade() -> None:
|
| 253 |
+
"""Revert this revision."""
|
| 254 |
+
op.drop_index("ix_evidence_spans_tenant_verdict", table_name="evidence_spans")
|
| 255 |
+
op.drop_index("ix_evidence_spans_tenant_id", table_name="evidence_spans")
|
| 256 |
+
op.drop_table("evidence_spans")
|
| 257 |
+
|
| 258 |
+
op.drop_index(
|
| 259 |
+
"ix_requirement_verdicts_cache_key", table_name="requirement_verdicts"
|
| 260 |
+
)
|
| 261 |
+
op.drop_index(
|
| 262 |
+
"ix_requirement_verdicts_tenant_score", table_name="requirement_verdicts"
|
| 263 |
+
)
|
| 264 |
+
op.drop_index(
|
| 265 |
+
"ix_requirement_verdicts_tenant_id", table_name="requirement_verdicts"
|
| 266 |
+
)
|
| 267 |
+
op.drop_table("requirement_verdicts")
|
| 268 |
+
|
| 269 |
+
op.drop_index("ix_candidate_scores_run_rank", table_name="candidate_scores")
|
| 270 |
+
op.drop_index("ix_candidate_scores_tenant_run", table_name="candidate_scores")
|
| 271 |
+
op.drop_index("ix_candidate_scores_tenant_id", table_name="candidate_scores")
|
| 272 |
+
op.drop_table("candidate_scores")
|
| 273 |
+
|
| 274 |
+
op.drop_index("ix_screening_runs_tenant_job", table_name="screening_runs")
|
| 275 |
+
op.drop_index("ix_screening_runs_tenant_id", table_name="screening_runs")
|
| 276 |
+
op.drop_table("screening_runs")
|
|
@@ -13,6 +13,7 @@
|
|
| 13 |
|
| 14 |
from pgvector.sqlalchemy import HALFVEC, Vector
|
| 15 |
from sqlalchemy import (
|
|
|
|
| 16 |
Boolean,
|
| 17 |
DateTime,
|
| 18 |
Float,
|
|
@@ -25,7 +26,7 @@
|
|
| 25 |
UniqueConstraint,
|
| 26 |
func,
|
| 27 |
)
|
| 28 |
-
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
| 29 |
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
| 30 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 31 |
|
|
@@ -343,3 +344,282 @@ class Requirement(TimestampMixin, Base):
|
|
| 343 |
embedding: Mapped[list[float] | None] = mapped_column(HALFVEC(1024), nullable=True)
|
| 344 |
|
| 345 |
rubric_version: Mapped[RubricVersion] = relationship(back_populates="requirements")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
from pgvector.sqlalchemy import HALFVEC, Vector
|
| 15 |
from sqlalchemy import (
|
| 16 |
+
BigInteger,
|
| 17 |
Boolean,
|
| 18 |
DateTime,
|
| 19 |
Float,
|
|
|
|
| 26 |
UniqueConstraint,
|
| 27 |
func,
|
| 28 |
)
|
| 29 |
+
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, TSVECTOR
|
| 30 |
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
| 31 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 32 |
|
|
|
|
| 344 |
embedding: Mapped[list[float] | None] = mapped_column(HALFVEC(1024), nullable=True)
|
| 345 |
|
| 346 |
rubric_version: Mapped[RubricVersion] = relationship(back_populates="requirements")
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
class ScreeningRun(TimestampMixin, Base):
|
| 350 |
+
"""One execution of the scoring pipeline over a job's candidate pool.
|
| 351 |
+
|
| 352 |
+
``rubric_version_id`` is RESTRICT rather than CASCADE: a run is the record
|
| 353 |
+
of which criteria produced a ranking, so the rubric it cites must not be
|
| 354 |
+
deletable out from under it. The job itself CASCADEs — deleting a job is a
|
| 355 |
+
deliberate purge of everything scoped to it.
|
| 356 |
+
|
| 357 |
+
``triggered_by`` is a plain uuid, not a foreign key: the ``users`` table
|
| 358 |
+
described in ARCHITECTURE.md §6.4 does not exist in this repo yet, and
|
| 359 |
+
declaring the constraint would make the migration unrunnable.
|
| 360 |
+
"""
|
| 361 |
+
|
| 362 |
+
__tablename__ = "screening_runs"
|
| 363 |
+
__table_args__ = (Index("ix_screening_runs_tenant_job", "tenant_id", "job_id"),)
|
| 364 |
+
|
| 365 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 366 |
+
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 367 |
+
)
|
| 368 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 369 |
+
PG_UUID(as_uuid=True), nullable=False, index=True
|
| 370 |
+
)
|
| 371 |
+
job_id: Mapped[uuid.UUID] = mapped_column(
|
| 372 |
+
PG_UUID(as_uuid=True),
|
| 373 |
+
ForeignKey("jobs.id", ondelete="CASCADE"),
|
| 374 |
+
nullable=False,
|
| 375 |
+
)
|
| 376 |
+
rubric_version_id: Mapped[uuid.UUID] = mapped_column(
|
| 377 |
+
PG_UUID(as_uuid=True),
|
| 378 |
+
ForeignKey("rubric_versions.id", ondelete="RESTRICT"),
|
| 379 |
+
nullable=False,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
| 383 |
+
mode: Mapped[str] = mapped_column(String(32), nullable=False, default="interactive")
|
| 384 |
+
candidate_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 385 |
+
|
| 386 |
+
# Per-stage survivor counts for the retrieval funnel, so a shortlist can be
|
| 387 |
+
# explained as "N of M candidates reached the judge" without re-running it.
|
| 388 |
+
funnel_stage_counts: Mapped[dict[str, object] | None] = mapped_column(
|
| 389 |
+
JSONB, nullable=True
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
started_at: Mapped[datetime | None] = mapped_column(
|
| 393 |
+
DateTime(timezone=True), nullable=True
|
| 394 |
+
)
|
| 395 |
+
completed_at: Mapped[datetime | None] = mapped_column(
|
| 396 |
+
DateTime(timezone=True), nullable=True
|
| 397 |
+
)
|
| 398 |
+
workflow_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
| 399 |
+
|
| 400 |
+
triggered_by: Mapped[uuid.UUID | None] = mapped_column(
|
| 401 |
+
PG_UUID(as_uuid=True), nullable=True
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
cost_usd: Mapped[Decimal] = mapped_column(
|
| 405 |
+
Numeric(10, 4), nullable=False, default=Decimal("0")
|
| 406 |
+
)
|
| 407 |
+
total_input_tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 408 |
+
total_output_tokens: Mapped[int] = mapped_column(
|
| 409 |
+
BigInteger, nullable=False, default=0
|
| 410 |
+
)
|
| 411 |
+
cache_read_tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 412 |
+
cache_write_tokens: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
| 413 |
+
|
| 414 |
+
scores: Mapped[list[CandidateScore]] = relationship(
|
| 415 |
+
back_populates="run",
|
| 416 |
+
cascade="all, delete-orphan",
|
| 417 |
+
lazy="noload",
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
class CandidateScore(TimestampMixin, Base):
|
| 422 |
+
"""One candidate's aggregated result within a run.
|
| 423 |
+
|
| 424 |
+
``raw_weighted`` keeps four decimal places while ``overall_score`` keeps
|
| 425 |
+
two: the weighted sum is the input to capping and rounding, and storing it
|
| 426 |
+
pre-rounding is what makes a score replayable. ``cap_applied`` is NULL when
|
| 427 |
+
no must-have cap fired, so "was this candidate capped" is answerable without
|
| 428 |
+
recomputing the rubric.
|
| 429 |
+
|
| 430 |
+
``candidate_id`` and ``profile_id`` are plain uuids rather than foreign
|
| 431 |
+
keys: the ``candidates`` and ``candidate_profiles`` tables described in
|
| 432 |
+
ARCHITECTURE.md §6.3 are not built in this repo, and declaring the
|
| 433 |
+
constraints would make the migration unrunnable.
|
| 434 |
+
"""
|
| 435 |
+
|
| 436 |
+
__tablename__ = "candidate_scores"
|
| 437 |
+
__table_args__ = (
|
| 438 |
+
UniqueConstraint(
|
| 439 |
+
"run_id", "candidate_id", name="uq_candidate_scores_run_candidate"
|
| 440 |
+
),
|
| 441 |
+
Index("ix_candidate_scores_run_rank", "run_id", "rank"),
|
| 442 |
+
Index("ix_candidate_scores_tenant_run", "tenant_id", "run_id"),
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 446 |
+
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 447 |
+
)
|
| 448 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 449 |
+
PG_UUID(as_uuid=True), nullable=False, index=True
|
| 450 |
+
)
|
| 451 |
+
run_id: Mapped[uuid.UUID] = mapped_column(
|
| 452 |
+
PG_UUID(as_uuid=True),
|
| 453 |
+
ForeignKey("screening_runs.id", ondelete="CASCADE"),
|
| 454 |
+
nullable=False,
|
| 455 |
+
)
|
| 456 |
+
candidate_id: Mapped[uuid.UUID] = mapped_column(
|
| 457 |
+
PG_UUID(as_uuid=True), nullable=False
|
| 458 |
+
)
|
| 459 |
+
profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
| 460 |
+
PG_UUID(as_uuid=True), nullable=True
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
overall_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False)
|
| 464 |
+
raw_weighted: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)
|
| 465 |
+
cap_applied: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 466 |
+
|
| 467 |
+
# Assigned in a second pass once every candidate in the run is scored, so a
|
| 468 |
+
# partially-completed run has scores without ranks rather than wrong ranks.
|
| 469 |
+
rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 470 |
+
|
| 471 |
+
retrieval_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
| 472 |
+
rerank_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
| 473 |
+
|
| 474 |
+
recommendation: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
| 475 |
+
recommendation_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
| 476 |
+
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 477 |
+
|
| 478 |
+
# Provenance for replay: which aggregation formula produced this number.
|
| 479 |
+
aggregation_formula_version: Mapped[str] = mapped_column(
|
| 480 |
+
String(32), nullable=False, default="v1"
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
run: Mapped[ScreeningRun] = relationship(back_populates="scores")
|
| 484 |
+
verdicts: Mapped[list[RequirementVerdict]] = relationship(
|
| 485 |
+
back_populates="score",
|
| 486 |
+
cascade="all, delete-orphan",
|
| 487 |
+
lazy="noload",
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
class RequirementVerdict(TimestampMixin, Base):
|
| 492 |
+
"""The judge's call on one requirement for one candidate.
|
| 493 |
+
|
| 494 |
+
``weight_at_scoring`` and ``contribution`` are stored rather than derived:
|
| 495 |
+
the rubric can mint a new version with different weights, and a verdict must
|
| 496 |
+
stay explainable against the weights that actually produced it.
|
| 497 |
+
|
| 498 |
+
``retrieved_chunk_ids`` is the exposure record — exactly what the judge saw.
|
| 499 |
+
Without it a "missing" verdict is indistinguishable from a retrieval failure.
|
| 500 |
+
|
| 501 |
+
``requirement_id`` is RESTRICT: a verdict cites a criterion, so the
|
| 502 |
+
criterion must outlive it. Override columns are all nullable and set
|
| 503 |
+
together when a human corrects the judge.
|
| 504 |
+
"""
|
| 505 |
+
|
| 506 |
+
__tablename__ = "requirement_verdicts"
|
| 507 |
+
__table_args__ = (
|
| 508 |
+
UniqueConstraint(
|
| 509 |
+
"score_id", "requirement_id", name="uq_requirement_verdicts_score_req"
|
| 510 |
+
),
|
| 511 |
+
Index("ix_requirement_verdicts_tenant_score", "tenant_id", "score_id"),
|
| 512 |
+
Index("ix_requirement_verdicts_cache_key", "result_cache_key"),
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 516 |
+
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 517 |
+
)
|
| 518 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 519 |
+
PG_UUID(as_uuid=True), nullable=False, index=True
|
| 520 |
+
)
|
| 521 |
+
score_id: Mapped[uuid.UUID] = mapped_column(
|
| 522 |
+
PG_UUID(as_uuid=True),
|
| 523 |
+
ForeignKey("candidate_scores.id", ondelete="CASCADE"),
|
| 524 |
+
nullable=False,
|
| 525 |
+
)
|
| 526 |
+
requirement_id: Mapped[uuid.UUID] = mapped_column(
|
| 527 |
+
PG_UUID(as_uuid=True),
|
| 528 |
+
ForeignKey("requirements.id", ondelete="RESTRICT"),
|
| 529 |
+
nullable=False,
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
verdict: Mapped[str] = mapped_column(String(16), nullable=False)
|
| 533 |
+
confidence: Mapped[float] = mapped_column(Float, nullable=False)
|
| 534 |
+
weight_at_scoring: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)
|
| 535 |
+
contribution: Mapped[Decimal] = mapped_column(Numeric(6, 4), nullable=False)
|
| 536 |
+
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 537 |
+
|
| 538 |
+
judge_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
| 539 |
+
judge_prompt_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
| 540 |
+
judge_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
| 541 |
+
|
| 542 |
+
cache_hit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
| 543 |
+
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 544 |
+
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 545 |
+
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 546 |
+
|
| 547 |
+
retrieved_chunk_ids: Mapped[list[uuid.UUID] | None] = mapped_column(
|
| 548 |
+
ARRAY(PG_UUID(as_uuid=True)), nullable=True
|
| 549 |
+
)
|
| 550 |
+
result_cache_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 551 |
+
|
| 552 |
+
overridden_by: Mapped[uuid.UUID | None] = mapped_column(
|
| 553 |
+
PG_UUID(as_uuid=True), nullable=True
|
| 554 |
+
)
|
| 555 |
+
override_verdict: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
| 556 |
+
override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 557 |
+
overridden_at: Mapped[datetime | None] = mapped_column(
|
| 558 |
+
DateTime(timezone=True), nullable=True
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
score: Mapped[CandidateScore] = relationship(back_populates="verdicts")
|
| 562 |
+
evidence_spans: Mapped[list[EvidenceSpanRecord]] = relationship(
|
| 563 |
+
back_populates="verdict",
|
| 564 |
+
cascade="all, delete-orphan",
|
| 565 |
+
lazy="noload",
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
class EvidenceSpanRecord(TimestampMixin, Base):
|
| 570 |
+
"""A verbatim quote from a resume backing one verdict.
|
| 571 |
+
|
| 572 |
+
Named ``EvidenceSpanRecord`` rather than ``EvidenceSpan`` because
|
| 573 |
+
``app.services.search`` already exposes an ``EvidenceSpan`` dataclass for
|
| 574 |
+
in-flight search results; this is the persisted form.
|
| 575 |
+
|
| 576 |
+
``verbatim_verified`` is set by the automated check that re-slices the
|
| 577 |
+
resume text at ``[start_char, end_char)`` and compares it to
|
| 578 |
+
``quoted_text`` — the anti-hallucination gate. A row with it False is a
|
| 579 |
+
quote the judge produced that the source does not contain.
|
| 580 |
+
|
| 581 |
+
``chunk_id`` is SET NULL on delete: re-indexing a resume replaces its
|
| 582 |
+
chunks, and the citation survives that because ``resume_version_id`` plus
|
| 583 |
+
the character offsets locate the quote independently of any chunk row.
|
| 584 |
+
"""
|
| 585 |
+
|
| 586 |
+
__tablename__ = "evidence_spans"
|
| 587 |
+
__table_args__ = (
|
| 588 |
+
Index("ix_evidence_spans_tenant_verdict", "tenant_id", "verdict_id"),
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
id: Mapped[uuid.UUID] = mapped_column(
|
| 592 |
+
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
| 593 |
+
)
|
| 594 |
+
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
| 595 |
+
PG_UUID(as_uuid=True), nullable=False, index=True
|
| 596 |
+
)
|
| 597 |
+
# Nullable so a span can be recorded while its verdict is still being built.
|
| 598 |
+
verdict_id: Mapped[uuid.UUID | None] = mapped_column(
|
| 599 |
+
PG_UUID(as_uuid=True),
|
| 600 |
+
ForeignKey("requirement_verdicts.id", ondelete="CASCADE"),
|
| 601 |
+
nullable=True,
|
| 602 |
+
)
|
| 603 |
+
resume_version_id: Mapped[uuid.UUID] = mapped_column(
|
| 604 |
+
PG_UUID(as_uuid=True),
|
| 605 |
+
ForeignKey("resume_versions.id", ondelete="RESTRICT"),
|
| 606 |
+
nullable=False,
|
| 607 |
+
)
|
| 608 |
+
chunk_id: Mapped[uuid.UUID | None] = mapped_column(
|
| 609 |
+
PG_UUID(as_uuid=True),
|
| 610 |
+
ForeignKey("resume_chunks.id", ondelete="SET NULL"),
|
| 611 |
+
nullable=True,
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
page: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 615 |
+
start_char: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 616 |
+
end_char: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 617 |
+
quoted_text: Mapped[str] = mapped_column(Text, nullable=False)
|
| 618 |
+
verbatim_verified: Mapped[bool] = mapped_column(
|
| 619 |
+
Boolean, nullable=False, default=False
|
| 620 |
+
)
|
| 621 |
+
relevance: Mapped[float | None] = mapped_column(Float, nullable=True)
|
| 622 |
+
|
| 623 |
+
verdict: Mapped[RequirementVerdict | None] = relationship(
|
| 624 |
+
back_populates="evidence_spans"
|
| 625 |
+
)
|
|
@@ -17,6 +17,10 @@
|
|
| 17 |
"jobs",
|
| 18 |
"rubric_versions",
|
| 19 |
"requirements",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
|
|
|
|
| 17 |
"jobs",
|
| 18 |
"rubric_versions",
|
| 19 |
"requirements",
|
| 20 |
+
"screening_runs",
|
| 21 |
+
"candidate_scores",
|
| 22 |
+
"requirement_verdicts",
|
| 23 |
+
"evidence_spans",
|
| 24 |
}
|
| 25 |
|
| 26 |
|