From 878d1e37769029cde7573fba4212c1f6ee48d6f3 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 19 Aug 2026 19:56:33 +0500 Subject: [PATCH 1/4] Add AI sparkles icon to the Create Job button Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Jobs.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 7625dd5..d3faf9f 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -229,7 +229,7 @@ export default function Jobs() { {can('job_board.create') && ( )} From 9eb1a5f31185f88dfb67bc6ec0766fa3b69d793b Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 19 Aug 2026 20:02:13 +0500 Subject: [PATCH 2/4] Rename assist preview button from Redo to Regenerate Co-Authored-By: Claude Fable 5 --- frontend/src/ui/AiFieldAssist.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ui/AiFieldAssist.jsx b/frontend/src/ui/AiFieldAssist.jsx index 681bc19..32ec956 100644 --- a/frontend/src/ui/AiFieldAssist.jsx +++ b/frontend/src/ui/AiFieldAssist.jsx @@ -156,7 +156,7 @@ export default function AiFieldAssist({ Dismiss {!unchanged && ( {can('job_board.create') && ( + + + + + ) +} + +/** Full LinkedIn profile: hero + about + skills + employment/education history. */ +function TalentProfileDetail({ profileId, onClose }) { + const detailQuery = useQuery({ + queryKey: qk.talent.profile(profileId), + queryFn: () => talentApi.getProfile(profileId), + }) + const p = detailQuery.data?.data ? talentApi.toProfileDetailView(detailQuery.data.data) : null + + return ( + + {p && ( + + Open LinkedIn + + )} + + + } + > + {detailQuery.isError ? ( + + {friendlyAuthError(detailQuery.error, 'Please try again.')} + + ) : detailQuery.isPending ? ( + + ) : ( + <> +
+ +
+
{p.name ?? 'Unknown'}
+
+ {[p.currentTitle, p.currentCompany].filter(Boolean).join(' at ') || p.headline || '—'} +
+
+ {p.location && {p.location}} + LinkedIn + {p.lastSeenAt && ( + Found {fmtDate(p.lastSeenAt)} + )} +
+
+
+ + {p.summary && ( + <> +
About
+

{p.summary}

+ + )} + + {p.skills.length > 0 && ( + <> +
Skills ({p.skills.length})
+
+ {p.skills.map((s) => {s})} +
+ + )} + + {p.experience.length > 0 && ( + <> +
Experience ({p.experience.length})
+ {p.experience.map((e, i) => ( +
+
+ {[e.title, e.company].filter(Boolean).join(' — ') || '—'} +
+
+ {[e.period, e.duration, e.employmentType, e.location].filter(Boolean).join(' · ')} +
+ {e.description && ( +

{e.description}

+ )} + {e.skills.length > 0 && ( +
+ {e.skills.map((s) => {s})} +
+ )} +
+ ))} + + )} + + {p.education.length > 0 && ( + <> +
Education ({p.education.length})
+ {p.education.map((e, i) => ( +
+
{e.school ?? '—'}
+
+ {[[e.degree, e.field].filter(Boolean).join(', '), e.period].filter(Boolean).join(' · ')} +
+
+ ))} + + )} + + )} +
+ ) +} + +export default function Talent() { + const { toast } = useToast() + const qc = useQueryClient() + + const [jobId, setJobId] = useState('') + const [activeRunId, setActiveRunId] = useState(null) + const [confirmOpen, setConfirmOpen] = useState(false) + const [search, setSearch] = useState('') + const [locationChoice, setLocationChoice] = useState('Pakistan') + const [customLocation, setCustomLocation] = useState('') + const [visibleCount, setVisibleCount] = useState(10) + const [viewProfileId, setViewProfileId] = useState(null) + + const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) + const jobs = jobsQuery.data ?? [] + const selectedJob = jobs.find((j) => j.id === jobId) + + const runsQuery = useQuery({ + queryKey: qk.talent.runs(jobId), + queryFn: () => talentApi.listRuns(jobId), + enabled: !!jobId, + }) + const runs = useMemo( + () => (Array.isArray(runsQuery.data?.data) ? runsQuery.data.data.map(talentApi.toRunView) : []), + [runsQuery.data], + ) + const latestRun = runs[0] ?? null + + // Resume-after-reload: adopt the newest unfinished run as the poll target. + useEffect(() => { + if (!activeRunId && latestRun && !latestRun.isTerminal) setActiveRunId(latestRun.id) + }, [activeRunId, latestRun]) + + const statusQuery = useQuery({ + queryKey: qk.talent.run(activeRunId), + queryFn: () => talentApi.getRunStatus(activeRunId), + enabled: !!activeRunId, + refetchInterval: (query) => { + const status = query.state.data?.data?.status + return status && talentApi.isTerminalRun(status) ? false : 4000 + }, + }) + const activeRun = statusQuery.data?.data ? talentApi.toRunView(statusQuery.data.data) : null + const runInFlight = !!activeRun && !activeRun.isTerminal + + // Toast + refresh exactly once per run settling. + const settledRef = useRef(null) + useEffect(() => { + if (!activeRun || !activeRun.isTerminal || settledRef.current === activeRun.id) return + settledRef.current = activeRun.id + qc.invalidateQueries({ queryKey: qk.talent.all() }) + setVisibleCount(10) + if (activeRun.status === 'succeeded') { + toast(`${activeRun.profilesFound} profile${activeRun.profilesFound === 1 ? '' : 's'} found on LinkedIn`, 'success') + } else { + toast(activeRun.error || `Talent search ${activeRun.status.replace('_', ' ')}`, 'error') + } + }, [activeRun, qc, toast]) + + const profilesQuery = useQuery({ + queryKey: qk.talent.profiles({ jobId }), + queryFn: () => talentApi.listProfiles({ jobId }), + enabled: !!jobId, + }) + const profiles = useMemo( + () => + Array.isArray(profilesQuery.data?.data) + ? profilesQuery.data.data.map(talentApi.toProfileView) + : [], + [profilesQuery.data], + ) + const visible = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return profiles + return profiles.filter((p) => + [p.name, p.headline, p.currentCompany, p.currentTitle, p.location] + .some((f) => f && f.toLowerCase().includes(q)), + ) + }, [profiles, search]) + + const effectiveLocation = + locationChoice === CUSTOM_LOCATION ? customLocation.trim() : locationChoice + const locationLabel = + locationChoice === 'Anywhere' + ? 'anywhere (no location filter)' + : `in ${effectiveLocation}` + + const starting = useMutation({ + mutationFn: () => talentApi.startRun(jobId, { location: effectiveLocation }), + onSuccess: (res) => { + setConfirmOpen(false) + const run = res?.data + if (run?.id) { + qc.setQueryData(qk.talent.run(run.id), res) + setActiveRunId(run.id) + } + qc.invalidateQueries({ queryKey: qk.talent.runs(jobId) }) + toast('Talent search started', 'success') + }, + onError: (err) => { + setConfirmOpen(false) + toast(friendlyAuthError(err, 'Could not start the talent search'), 'error') + }, + }) + + const dismissing = useMutation({ + mutationFn: (profile) => talentApi.deleteProfile(profile.id), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.talent.profiles({ jobId }) }), + onError: (err) => toast(friendlyAuthError(err, 'Could not dismiss the profile'), 'error'), + }) + + const statusRun = runInFlight || !latestRun ? activeRun : latestRun + const [badgeCls, badgeLabel] = statusRun ? (RUN_BADGE[statusRun.status] ?? ['b-gray', statusRun.status]) : [] + + return ( +
+
+
+

Talent

+

Source matching LinkedIn profiles for a job via Apify

+
+
+ LinkedIn Sourcing · Live +
+
+ +
+
+
+ + + {locationChoice === CUSTOM_LOCATION && ( + setCustomLocation(e.target.value)} + /> + )} + +
+ {jobsQuery.isError && ( +

+ {friendlyAuthError(jobsQuery.error, 'Could not load job posts')} +

+ )} + {jobId && statusRun && ( +

+ {badgeLabel} + {statusRun.status === 'succeeded' && ( + {statusRun.profilesFound} profile{statusRun.profilesFound === 1 ? '' : 's'} in the last run + )} + {statusRun.error && {statusRun.error}} + {statusRun.createdAt && · {fmtDate(statusRun.createdAt)}} +

+ )} +
+
+ + {!jobId ? ( + + Sourced LinkedIn profiles are saved per job and kept across searches. + + ) : profilesQuery.isError ? ( + + {friendlyAuthError(profilesQuery.error, 'Please try again.')} + + ) : profilesQuery.isPending ? ( + + ) : profiles.length === 0 ? ( + runInFlight ? ( + + Scanning profiles matching this job's title, skills and experience. + This usually takes a minute or two — results appear here automatically. + + ) : ( + + Run Find Talent to search LinkedIn for people matching this job. + + ) + ) : ( + <> +
+ setSearch(e.target.value)} + /> + + {visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'} + +
+
+ {visible.slice(0, visibleCount).map((p) => ( + setViewProfileId(profile.id)} + onDismiss={(profile) => dismissing.mutate(profile)} + dismissing={dismissing.isPending} + /> + ))} +
+
+ {visible.length > visibleCount ? ( + + ) : ( + + )} +
+ + )} + + {viewProfileId && ( + setViewProfileId(null)} /> + )} + + {confirmOpen && ( + setConfirmOpen(false)} + footer={ + <> + + + + } + > +

+ This starts a paid Apify search of LinkedIn for people matching + this job's title, technical requirements and experience level, {locationLabel} — + up to 25 profiles per run (roughly $0.20). Repeating the same search continues + deeper into the results, so each run surfaces new people; anyone already found + is refreshed, not duplicated. +

+
+ )} +
+ ) +} diff --git a/scripts/smoke_structured_output.py b/scripts/smoke_structured_output.py new file mode 100644 index 0000000..bbaaea0 --- /dev/null +++ b/scripts/smoke_structured_output.py @@ -0,0 +1,108 @@ +"""Live smoke test for the request shape. Makes two real API calls. + +Run this once before trusting the service against a new model or SDK version: + + python scripts/smoke_structured_output.py + +It proves the three things unit tests cannot: + +1. The schema derived from ``ATSScore`` is accepted by structured outputs, and the + configured model supports both it and the requested reasoning effort. +2. ``output_parsed`` comes back as a valid ``ATSScore``. +3. The shared job-description prefix is actually cached -- the second call reports + ``usage.input_tokens_details.cached_tokens > 0``. + +Needs OPENAI_API_KEY in the environment or .env, and spends a few cents. +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +# Running a script directly puts scripts/ on sys.path[0], not the repo root. This +# environment has another project on the path via an editable-install .pth file, and +# it also ships a top-level `app` package -- without this line `import app` silently +# resolves to that one instead. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from openai import AsyncOpenAI + +from app.core.config import get_settings, supports_reasoning +from app.core.logging import configure_logging +from app.services.llm import OpenAIScorer + +# OpenAI only caches prompts at or above 1024 tokens, so a short job description will +# report zero cached tokens no matter how stable the prefix is. This one clears it. +JOB_DESCRIPTION = ( + "Senior backend engineer.\n\n" + "Required: Python 3.12, FastAPI, asyncio, Docker, PostgreSQL, REST API design, " + "and demonstrated ownership of production services.\n" + "Preferred: AWS, Kubernetes, Terraform, observability tooling.\n\n" +) + ("Responsibilities include designing, shipping, and operating backend services. " * 200) + +RESUME_A = ( + "Ada Lovelace\nBackend engineer, 6 years.\n" + "Built FastAPI services on Python 3.12 with asyncio and PostgreSQL. " + "Owned Docker-based deploys and on-call for a payments API." +) +RESUME_B = ( + "Grace Hopper\nData engineer, 3 years.\n" + "Primarily ETL in Python with pandas and Airflow. Familiar with REST APIs. " + "No production service ownership listed." +) + + +async def main() -> int: + settings = get_settings() + configure_logging(level=settings.log_level, fmt=settings.log_format) + print( + f"model={settings.openai_model} " + f"effort={settings.openai_effort if supports_reasoning(settings.openai_model) else 'n/a'} " + f"max_output_tokens={settings.openai_max_output_tokens}" + ) + + client = AsyncOpenAI( + api_key=settings.openai_api_key or None, + timeout=settings.openai_timeout_seconds, + max_retries=settings.openai_max_retries, + ) + scorer = OpenAIScorer( + client, + model=settings.openai_model, + max_output_tokens=settings.openai_max_output_tokens, + effort=settings.openai_effort, + enable_cache=settings.openai_enable_prompt_cache, + ) + + try: + # Sequential on purpose: a cache entry is only readable once the first + # response exists, which is exactly what score_batch's priming step does. + first = await scorer.score(JOB_DESCRIPTION, RESUME_A) + print( + f"call 1 ok: score={first.match_score} name={first.candidate_name!r} " + f"title={first.job_title!r} years={first.years_experience} " + f"critique={first.summary_critique!r}" + ) + + second = await scorer.score(JOB_DESCRIPTION, RESUME_B) + print( + f"call 2 ok: score={second.match_score} name={second.candidate_name!r} " + f"title={second.job_title!r} years={second.years_experience} " + f"critique={second.summary_critique!r}" + ) + finally: + await client.close() + + print( + "\nSchema accepted and both responses parsed. " + "Check the 'candidate_scored_upstream' log lines above: call 2 should show a " + "non-zero cached_tokens. If it is zero, either the prompt is under the 1024-token " + "caching minimum or the job-description prefix is not byte-stable across calls." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29