feat(web): score an already-annotated case without re-running the pipeline

A case analysed before a model existed stayed unscored forever: the only control on the page
was "Re-analyse case", which re-runs five minutes of VEP to obtain a score that takes a second.
When any candidate is unscored the page now offers "Score variants" on its own.

Tests: web 34; svelte-check clean.
This commit is contained in:
Kemal Yaylali
2026-09-12 09:57:01 +01:00
parent 197975cc42
commit 6924b32486
3 changed files with 22 additions and 2 deletions
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { barWidth, candidateQuery, evidenceChips, funnelSteps, plural, scoreBarPercent } from './candidates';
import { barWidth, candidateQuery, evidenceChips, funnelSteps, needsScoring, plural, scoreBarPercent } from './candidates';
import type { Candidate, Funnel } from './api';
const candidate = (over: Partial<Candidate> = {}): Candidate => ({
@@ -113,3 +113,14 @@ describe('plural', () => {
expect(plural(12, 'candidate')).toBe('12 candidates');
});
});
describe('needsScoring', () => {
it('spots candidates analysed before a model existed', () => {
expect(needsScoring([candidate({ scored: false })])).toBe(true);
});
it('is quiet once everything carries a score', () => {
expect(needsScoring([candidate({ scored: true })])).toBe(false);
expect(needsScoring([])).toBe(false);
});
});
+3
View File
@@ -76,3 +76,6 @@ export function barWidth(value: number, total: number): number {
}
export const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`;
/** True when a case was analysed before a model existed: scoring can be run on its own. */
export const needsScoring = (items: Candidate[]): boolean => items.some((c) => !c.scored);
+7 -1
View File
@@ -3,7 +3,7 @@
import { api, type Candidate, type CandidatePage, type Case, type Job, type VariantDetail } from '$lib/api';
import { poll } from '$lib/poll';
import { formatElapsed, latestStep } from '$lib/progress';
import { plural } from '$lib/candidates';
import { needsScoring, plural } from '$lib/candidates';
import CandidateRow from '$lib/components/CandidateRow.svelte';
import Funnel from '$lib/components/Funnel.svelte';
import VariantPanel from '$lib/components/VariantPanel.svelte';
@@ -31,6 +31,9 @@
const elapsedMs = $derived(job ? now - Date.parse(job.created_at) : 0);
const step = $derived(scoring ? 'scoring variants with the model' : latestStep(job?.log ?? null));
const termCount = $derived(kase?.phenotypes.length ?? 0);
// Annotation is expensive and scoring is not: a case annotated before a model existed should
// not need a five-minute re-run of VEP to get its score.
const unscored = $derived(!!page && page.items.length > 0 && needsScoring(page.items));
// Tick the elapsed time while a run is in flight; polling refreshes the step itself.
$effect(() => {
@@ -154,6 +157,9 @@
{:else}
<p class="actions">
<button onclick={analyse} disabled={busy}>{job ? 'Re-analyse case' : 'Analyse case'}</button>
{#if unscored}
<button class="quiet" onclick={scoreThenLoad}>Score variants</button>
{/if}
{#if page && page.funnel.total > 0}
<a class="reportlink" href="/cases/{data.caseId}/report">Case report →</a>
{/if}