feat: add model run comparison

This commit is contained in:
2026-06-24 15:12:20 +08:00
parent f1ad5a7d15
commit b002c44218
7 changed files with 643 additions and 13 deletions

View File

@@ -35,14 +35,17 @@ import {
createDataSource,
createIngestionRun,
createRawArtifact,
createAreaScoreModelRun,
createWatchlistEvent,
createWatchlistItem,
fetchDataSources,
fetchAreaScores,
fetchAreaComparison,
fetchAreaDetail,
fetchAreaScoreRunDiff,
fetchIngestionRuns,
fetchMarketOverview,
fetchModelRuns,
fetchNeighborhoodComparison,
fetchNeighborhoodDetail,
fetchNeighborhoodScores,
@@ -56,7 +59,9 @@ import type {
AreaScore,
AreaComparison,
AreaDetail,
AreaScoreRunDiff,
ComparisonMetric,
CreateAreaScoreModelRun,
CreateDataSource,
CreateIngestionRun,
CreateRawArtifact,
@@ -64,6 +69,7 @@ import type {
CreateWatchlistItem,
DataSource,
IngestionRun,
ModelRun,
NeighborhoodComparison,
NeighborhoodComparisonItem,
NeighborhoodDetail,
@@ -99,6 +105,8 @@ export function MarketDashboard() {
const [watchlistAreaId, setWatchlistAreaId] = useState("");
const [watchlistLabel, setWatchlistLabel] = useState("");
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
const overview = useQuery({
queryKey: ["market-overview", MONTH],
queryFn: () => fetchMarketOverview(MONTH),
@@ -145,6 +153,18 @@ export function MarketDashboard() {
queryFn: () => fetchWatchlistEvents(expandedWatchlistItemId ?? 0),
enabled: expandedWatchlistItemId !== null,
});
const modelRuns = useQuery({
queryKey: ["model-runs", "area_scores"],
queryFn: () => fetchModelRuns({ model_name: "area_scores" }),
});
const areaScoreRunDiff = useQuery({
queryKey: ["area-score-run-diff", baseModelRunId, candidateModelRunId],
queryFn: () => fetchAreaScoreRunDiff(baseModelRunId ?? 0, candidateModelRunId ?? 0),
enabled:
baseModelRunId !== null &&
candidateModelRunId !== null &&
baseModelRunId !== candidateModelRunId,
});
const dataSources = useQuery({
queryKey: ["data-sources"],
queryFn: fetchDataSources,
@@ -190,6 +210,22 @@ export function MarketDashboard() {
await queryClient.invalidateQueries({ queryKey: ["watchlist-events"] });
},
});
const createAreaScoreModelRunMutation = useMutation({
mutationFn: createAreaScoreModelRun,
onSuccess: async (response) => {
setCandidateModelRunId(response.model_run.model_run_id);
if (baseModelRunId === null && modelRuns.data?.[0]) {
setBaseModelRunId(modelRuns.data[0].model_run_id);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["model-runs"] }),
queryClient.invalidateQueries({ queryKey: ["area-scores"] }),
queryClient.invalidateQueries({ queryKey: ["market-overview"] }),
queryClient.invalidateQueries({ queryKey: ["area-detail"] }),
queryClient.invalidateQueries({ queryKey: ["area-score-run-diff"] }),
]);
},
});
const createDataSourceMutation = useMutation({
mutationFn: createDataSource,
onSuccess: async () => {
@@ -243,6 +279,17 @@ export function MarketDashboard() {
}
}, [comparisonNeighborhoodIds.length, neighborhoodScores.data]);
useEffect(() => {
if (modelRuns.data && modelRuns.data.length >= 2) {
if (candidateModelRunId === null) {
setCandidateModelRunId(modelRuns.data[0].model_run_id);
}
if (baseModelRunId === null) {
setBaseModelRunId(modelRuns.data[1].model_run_id);
}
}
}, [baseModelRunId, candidateModelRunId, modelRuns.data]);
const isLoading =
overview.isLoading ||
scores.isLoading ||
@@ -253,6 +300,8 @@ export function MarketDashboard() {
neighborhoodScores.isLoading ||
watchlist.isLoading ||
watchlistEvents.isLoading ||
modelRuns.isLoading ||
areaScoreRunDiff.isLoading ||
dataSources.isLoading ||
ingestionRuns.isLoading;
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
@@ -266,6 +315,8 @@ export function MarketDashboard() {
neighborhoodScores.isError ||
watchlist.isError ||
watchlistEvents.isError ||
modelRuns.isError ||
areaScoreRunDiff.isError ||
dataSources.isError ||
ingestionRuns.isError ||
rawArtifacts.isError;
@@ -296,6 +347,8 @@ export function MarketDashboard() {
void dataSources.refetch();
void ingestionRuns.refetch();
void rawArtifacts.refetch();
void modelRuns.refetch();
void areaScoreRunDiff.refetch();
}}
disabled={isLoading}
aria-label="刷新"
@@ -462,6 +515,18 @@ export function MarketDashboard() {
onArchive={(id) => archiveMutation.mutate(id)}
/>
<ModelRunPanel
runs={modelRuns.data ?? []}
diff={areaScoreRunDiff.data ?? null}
baseRunId={baseModelRunId}
candidateRunId={candidateModelRunId}
loading={modelRuns.isLoading || areaScoreRunDiff.isLoading}
creating={createAreaScoreModelRunMutation.isPending}
onBaseRunChange={setBaseModelRunId}
onCandidateRunChange={setCandidateModelRunId}
onCreateRun={(payload) => createAreaScoreModelRunMutation.mutate(payload)}
/>
<ImportCenterPanel
sources={dataSources.data ?? []}
runs={ingestionRuns.data ?? []}
@@ -1884,6 +1949,11 @@ function formatMetricValue(value: number | null | undefined, unit: string) {
return formatNumber(value);
}
function formatSignedNumber(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${formatNumber(value)}`;
}
function WatchlistPanel({
scores,
neighborhoodScores,
@@ -2337,6 +2407,220 @@ function WatchlistTable({
);
}
function ModelRunPanel({
runs,
diff,
baseRunId,
candidateRunId,
loading,
creating,
onBaseRunChange,
onCandidateRunChange,
onCreateRun,
}: {
runs: ModelRun[];
diff: AreaScoreRunDiff | null;
baseRunId: number | null;
candidateRunId: number | null;
loading: boolean;
creating: boolean;
onBaseRunChange: (id: number | null) => void;
onCandidateRunChange: (id: number | null) => void;
onCreateRun: (payload: CreateAreaScoreModelRun) => void;
}) {
const [modelVersion, setModelVersion] = useState("area-score-rust-v1");
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
onCreateRun({
month: MONTH,
model_version: modelVersion.trim() || undefined,
});
}
return (
<Card>
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle></CardTitle>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
</p>
</div>
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={submit}>
<input
className="h-9 w-full rounded-md border border-[var(--border)] bg-white px-3 text-sm sm:w-56"
placeholder="模型版本"
value={modelVersion}
onChange={(event) => setModelVersion(event.target.value)}
/>
<Button disabled={creating} aria-label="重算板块评分" title="重算板块评分">
<RefreshCw className="h-4 w-4" />
</Button>
</form>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[420px_minmax(0,1fr)]">
<ModelRunTable runs={runs} loading={loading} />
<div className="grid gap-3">
<div className="grid gap-2 sm:grid-cols-2">
<ModelRunSelect
label="基准批次"
runs={runs}
value={baseRunId}
onChange={onBaseRunChange}
/>
<ModelRunSelect
label="候选批次"
runs={runs}
value={candidateRunId}
onChange={onCandidateRunChange}
/>
</div>
<AreaScoreRunDiffTable diff={diff} loading={loading} />
</div>
</CardContent>
</Card>
);
}
function ModelRunTable({ runs, loading }: { runs: ModelRun[]; loading: boolean }) {
if (loading) {
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
}
if (runs.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.slice(0, 8).map((run) => (
<TableRow key={run.model_run_id}>
<TableCell className="font-medium">#{run.model_run_id}</TableCell>
<TableCell className="max-w-40 truncate">{run.model_version}</TableCell>
<TableCell>{run.target_month}</TableCell>
<TableCell>
<Badge variant={run.status === "succeeded" ? "success" : "warning"}>
{run.status}
</Badge>
</TableCell>
<TableCell className="text-right">{run.row_count ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function ModelRunSelect({
label,
runs,
value,
onChange,
}: {
label: string;
runs: ModelRun[];
value: number | null;
onChange: (id: number | null) => void;
}) {
return (
<label className="grid gap-1">
<span className="text-sm font-medium text-slate-950">{label}</span>
<select
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
value={value ?? ""}
onChange={(event) => onChange(event.target.value ? Number(event.target.value) : null)}
>
<option value=""></option>
{runs.map((run) => (
<option key={run.model_run_id} value={run.model_run_id}>
#{run.model_run_id} · {run.model_version} · {run.target_month}
</option>
))}
</select>
</label>
);
}
function AreaScoreRunDiffTable({
diff,
loading,
}: {
diff: AreaScoreRunDiff | null;
loading: boolean;
}) {
if (loading) {
return <div className="h-56 rounded-md bg-[var(--muted)]" />;
}
if (!diff || diff.items.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diff.items.map((item) => (
<TableRow key={item.area_id}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.district}</TableCell>
<TableCell className="text-right">
{item.base_score === null ? "-" : formatNumber(item.base_score)}
</TableCell>
<TableCell className="text-right">
{item.candidate_score === null ? "-" : formatNumber(item.candidate_score)}
</TableCell>
<TableCell className="text-right font-semibold">
{item.score_delta === null ? "-" : formatSignedNumber(item.score_delta)}
</TableCell>
<TableCell>
{item.base_recommendation === item.candidate_recommendation ? (
<Badge variant="muted">{item.candidate_recommendation ?? "-"}</Badge>
) : (
<Badge variant="warning">
{item.base_recommendation ?? "-"} {item.candidate_recommendation ?? "-"}
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function MetricCard({
title,
value,

View File

@@ -273,6 +273,23 @@ export type AreaScoreModelRunResponse = {
scores: AreaScore[];
};
export type AreaScoreRunDiffItem = {
area_id: string;
name: string;
district: string;
base_score: number | null;
candidate_score: number | null;
score_delta: number | null;
base_recommendation: string | null;
candidate_recommendation: string | null;
};
export type AreaScoreRunDiff = {
base_run: ModelRun;
candidate_run: ModelRun;
items: AreaScoreRunDiffItem[];
};
export type AreaScoreLineage = {
area_id: string;
area_name: string;
@@ -527,6 +544,29 @@ export async function createAreaScoreModelRun(
});
}
export async function fetchModelRuns(filters: {
model_name?: string;
target_month?: string;
} = {}): Promise<ModelRun[]> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value) {
params.set(key, value);
}
}
const query = params.toString();
return fetchJson(`/api/v1/model-runs${query ? `?${query}` : ""}`);
}
export async function fetchAreaScoreRunDiff(
baseRunId: number,
candidateRunId: number,
): Promise<AreaScoreRunDiff> {
return fetchJson(
`/api/v1/model-runs/area-scores/diff?base_run_id=${baseRunId}&candidate_run_id=${candidateRunId}`,
);
}
export async function fetchAreaScoreLineage(
areaId: string,
month: string,