insiode data ai match wired
parent
664b33b94b
commit
4297badb43
|
|
@ -698,7 +698,7 @@ async def fetch_pipeline_candidates(
|
|||
@router.get("/pipeline/candidate/score/fetch")
|
||||
async def fetch_pipeline_candidate_score(
|
||||
user_id:uuid.UUID=Query(...),
|
||||
job_post_id:uuid.UUID=Query(...),
|
||||
job_post_id:uuid.UUID=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-D0GY3L2I.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BsDQzhFd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,38 @@ export function listTransitions({ inboxId, manualUploadId, transitionId } = {})
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One candidate's CURRENT ATS score — GET /pipeline/candidate/score/fetch
|
||||
* (pipeline.view). Envelope is `{ data: { manual, inbox }, total, status_code }`,
|
||||
* where each side is `{overall_score, band, job_post_id, computed_at,
|
||||
* candidate_id, user_id}` or null.
|
||||
*
|
||||
* Score only. It carries no matched/missing keywords and no critique — those
|
||||
* live on the scored-candidate row (/candidate/scored/fetch).
|
||||
*
|
||||
* `jobPostId` pins the score to one application; omitted (as the talent pool
|
||||
* does, where a candidate need not have an assigned post) the newest current
|
||||
* score across the candidate's applications wins.
|
||||
*/
|
||||
export function fetchCandidateScore({ userId, jobPostId } = {}) {
|
||||
return request('/pipeline/candidate/score/fetch', {
|
||||
params: { user_id: userId, job_post_id: jobPostId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The two-sided envelope -> one score row, or null.
|
||||
*
|
||||
* A candidate is reached either through the inbox (they mailed us) or through
|
||||
* Add Candidate (manual_upload); both sides resolve the same ats_results table,
|
||||
* so whichever side answered is the score. Inbox wins a tie because an emailed
|
||||
* application is the one the pipeline board is showing.
|
||||
*/
|
||||
export function toAtsScore(res) {
|
||||
const data = res?.data ?? {}
|
||||
return data.inbox ?? data.manual ?? null
|
||||
}
|
||||
|
||||
function sourceFields(row, kind) {
|
||||
if (kind === 'manual') {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export const qk = {
|
|||
all: () => ['pipeline'],
|
||||
board: (p = {}) => ['pipeline', 'board', p],
|
||||
transitions: (inboxId) => ['pipeline', 'transitions', inboxId],
|
||||
// One candidate's current ats_results score. Fetched on demand (a card
|
||||
// click), never as part of a list, so it is keyed per candidate + job.
|
||||
candidateScore: (p = {}) => ['pipeline', 'candidate-score', p],
|
||||
},
|
||||
analytics: {
|
||||
all: () => ['analytics'],
|
||||
|
|
|
|||
|
|
@ -102,7 +102,16 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
|||
})
|
||||
}
|
||||
|
||||
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
|
||||
/**
|
||||
* `atsScore` / `recommendation` are the CURRENT ats_results row, fetched by the
|
||||
* caller (Talent Pool reads GET /pipeline/candidate/score/fetch on card click).
|
||||
* They are optional: a caller that does not fetch it passes nothing and the hero
|
||||
* falls back to the detail payload's denormalised ai_score, then to the row's.
|
||||
* When present they WIN, because ats_results is the source the denorm copies.
|
||||
*/
|
||||
export default function CandidateProfile({
|
||||
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [tab, setTab] = useState('Overview')
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
|
|
@ -213,8 +222,10 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
|
|||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<ScoreChip score={live?.ai_score ?? c.aiScore} />
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
|
||||
<ScoreChip score={atsScore ?? live?.ai_score ?? c.aiScore} />
|
||||
{/* The band caption replaces the static label only when the caller
|
||||
actually fetched one — every other screen keeps "AI Match". */}
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>{recommendation || 'AI Match'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@
|
|||
interviews / notes / activity / feedback collections, all writable from their
|
||||
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
|
||||
whole trigger.
|
||||
|
||||
A SECOND read fires on that same click: GET /pipeline/candidate/score/fetch,
|
||||
the pipeline board's score endpoint, which returns the candidate's current
|
||||
ats_results row. The two run concurrently — this screen owns the score call,
|
||||
CandidateProfile owns the detail call — and the score wins over both the
|
||||
list row's denormalised ai_score and the seed placeholder. It is deliberately
|
||||
NOT fetched for the grid: 100 cards would be 100 requests, and the card only
|
||||
ever needed a number good enough to sort by eye.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -33,6 +41,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { avatarColor, departments, initials as initialsOf } from '../data/seed'
|
||||
|
||||
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
|
||||
|
|
@ -124,6 +133,28 @@ export default function TalentPool() {
|
|||
[query.data, templates],
|
||||
)
|
||||
|
||||
/**
|
||||
* The clicked candidate's current ATS score, from the pipeline board's own
|
||||
* endpoint. `enabled` is the "only on click" rule: with no open profile there
|
||||
* is no userId, and the query never runs. React Query caches it per user, so
|
||||
* re-opening the same card repaints from cache.
|
||||
*
|
||||
* Sent WITHOUT job_post_id on purpose. The pool is a cross-job view — it has
|
||||
* no job filter and most rows carry no assigned post — so pinning would only
|
||||
* ever hide a score that exists under some other post. Unpinned, the endpoint
|
||||
* answers with the newest current score the candidate has anywhere.
|
||||
*/
|
||||
const scoreQuery = useQuery({
|
||||
queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }),
|
||||
queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }),
|
||||
select: pipelineApi.toAtsScore,
|
||||
enabled: Boolean(profileFor?.userId),
|
||||
})
|
||||
|
||||
// A candidate with no ats_results row answers `null`, which must read as "no
|
||||
// live score" and leave the existing value alone — not as a score of zero.
|
||||
const atsScore = scoreQuery.data?.overall_score ?? null
|
||||
|
||||
const list = useMemo(
|
||||
() =>
|
||||
pool.filter((c) => {
|
||||
|
|
@ -242,6 +273,8 @@ export default function TalentPool() {
|
|||
{profileFor && (
|
||||
<CandidateProfile
|
||||
candidate={profileFor}
|
||||
atsScore={atsScore}
|
||||
recommendation={scoreQuery.data?.band ?? null}
|
||||
onClose={() => setProfileFor(null)}
|
||||
onAdvance={advance}
|
||||
onToggleFav={toggleFav}
|
||||
|
|
|
|||
Loading…
Reference in New Issue