riezqidr commited on
Commit
697cff4
·
1 Parent(s): 3a87be3

feat(models): add screening run and scoring tables

Browse files

Adds 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.

serving/app/migrations/versions/20260802_0940_add_screening_run_scoring_tables.py ADDED
@@ -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")
serving/app/models.py CHANGED
@@ -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
+ )
tests/integration/test_migrations.py CHANGED
@@ -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