From b002c4421896b467d2a7881286dac3a487c83bf1 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 24 Jun 2026 15:12:20 +0800 Subject: [PATCH] feat: add model run comparison --- .../202606240005_area_score_snapshots.sql | 34 +++ apps/api/src/handlers.rs | 247 ++++++++++++++- apps/api/src/models.rs | 31 ++ apps/api/src/routes.rs | 15 +- .../components/dashboard/market-dashboard.tsx | 284 ++++++++++++++++++ apps/web/src/lib/api.ts | 40 +++ docs/product_roadmap.md | 5 + 7 files changed, 643 insertions(+), 13 deletions(-) create mode 100644 apps/api/migrations/202606240005_area_score_snapshots.sql diff --git a/apps/api/migrations/202606240005_area_score_snapshots.sql b/apps/api/migrations/202606240005_area_score_snapshots.sql new file mode 100644 index 0000000..0fb8d35 --- /dev/null +++ b/apps/api/migrations/202606240005_area_score_snapshots.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS gold.area_score_snapshots ( + model_run_id BIGINT NOT NULL REFERENCES gold.model_runs(model_run_id) ON DELETE CASCADE, + area_id TEXT NOT NULL REFERENCES silver.areas(area_id), + name TEXT NOT NULL, + district TEXT NOT NULL, + segment TEXT NOT NULL, + month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'), + investment_score DOUBLE PRECISION NOT NULL CHECK (investment_score BETWEEN 0 AND 100), + recommendation TEXT NOT NULL, + liquidity_score DOUBLE PRECISION NOT NULL CHECK (liquidity_score BETWEEN 0 AND 100), + momentum_score DOUBLE PRECISION NOT NULL CHECK (momentum_score BETWEEN 0 AND 100), + rent_support_score DOUBLE PRECISION NOT NULL CHECK (rent_support_score BETWEEN 0 AND 100), + safety_margin_score DOUBLE PRECISION NOT NULL CHECK (safety_margin_score BETWEEN 0 AND 100), + credit_support_score DOUBLE PRECISION NOT NULL CHECK (credit_support_score BETWEEN 0 AND 100), + supply_risk_score DOUBLE PRECISION NOT NULL CHECK (supply_risk_score BETWEEN 0 AND 100), + valuation_pressure_score DOUBLE PRECISION NOT NULL CHECK (valuation_pressure_score BETWEEN 0 AND 100), + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0), + rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0), + median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0), + annual_rent_yield_pct DOUBLE PRECISION NOT NULL CHECK (annual_rent_yield_pct >= 0), + listing_pressure_ratio DOUBLE PRECISION NOT NULL CHECK (listing_pressure_ratio >= 0), + discount_pct DOUBLE PRECISION NOT NULL, + price_momentum_pct DOUBLE PRECISION NOT NULL, + volume_momentum_pct DOUBLE PRECISION NOT NULL, + model_version TEXT NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (model_run_id, area_id) +); + +CREATE INDEX IF NOT EXISTS idx_area_score_snapshots_month + ON gold.area_score_snapshots(month, investment_score DESC); diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 8506aa1..ebd3c4f 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -6,15 +6,16 @@ use crate::error::{ApiError, ApiResult}; use crate::import_templates::{execute_import_payload, validate_import_payload}; use crate::models::{ AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, - AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery, - ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, - CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource, - FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, + AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreRunDiff, AreaScoreRunDiffItem, + AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue, + CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact, + CreateWatchlistEvent, CreateWatchlistItem, DataSource, FinishIngestionRun, + ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, - MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, - NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, - NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistEvent, - WatchlistItem, WatchlistQuery, + ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison, + NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, + NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, + WatchlistEvent, WatchlistItem, WatchlistQuery, }; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::state::AppState; @@ -251,6 +252,113 @@ pub async fn compare_neighborhoods( })) } +pub async fn list_model_runs( + State(state): State, + Query(query): Query, +) -> ApiResult>> { + if let Some(month) = query.target_month.as_deref() { + validate_month(month)?; + } + if let Some(model_name) = query.model_name.as_deref() { + if has_sensitive_text(model_name) { + return Err(ApiError::BadRequest( + "model_name contains sensitive text".to_string(), + )); + } + } + + let runs = sqlx::query_as::<_, ModelRun>( + r#" + SELECT + model_run_id, + model_name, + model_version, + target_month, + status, + parameters, + started_at::text AS started_at, + finished_at::text AS finished_at, + row_count, + error_message + FROM gold.model_runs + WHERE ($1::text IS NULL OR model_name = $1) + AND ($2::text IS NULL OR target_month = $2) + ORDER BY started_at DESC, model_run_id DESC + LIMIT 100 + "#, + ) + .bind(query.model_name) + .bind(query.target_month) + .fetch_all(&state.pool) + .await?; + + Ok(Json(runs)) +} + +pub async fn diff_area_score_model_runs( + State(state): State, + Query(query): Query, +) -> ApiResult> { + if query.base_run_id == query.candidate_run_id { + return Err(ApiError::BadRequest( + "base_run_id and candidate_run_id must be different".to_string(), + )); + } + + let base_run = fetch_model_run(&state, query.base_run_id) + .await? + .ok_or_else(|| ApiError::BadRequest("base model run not found".to_string()))?; + let candidate_run = fetch_model_run(&state, query.candidate_run_id) + .await? + .ok_or_else(|| ApiError::BadRequest("candidate model run not found".to_string()))?; + if base_run.model_name != "area_scores" || candidate_run.model_name != "area_scores" { + return Err(ApiError::BadRequest( + "both model runs must be area_scores runs".to_string(), + )); + } + + let items = sqlx::query_as::<_, AreaScoreRunDiffItem>( + r#" + SELECT + COALESCE(base.area_id, candidate.area_id) AS area_id, + COALESCE(candidate.name, base.name) AS name, + COALESCE(candidate.district, base.district) AS district, + base.investment_score AS base_score, + candidate.investment_score AS candidate_score, + CASE + WHEN base.investment_score IS NULL OR candidate.investment_score IS NULL THEN NULL + ELSE candidate.investment_score - base.investment_score + END AS score_delta, + base.recommendation AS base_recommendation, + candidate.recommendation AS candidate_recommendation + FROM ( + SELECT area_id, name, district, investment_score, recommendation + FROM gold.area_score_snapshots + WHERE model_run_id = $1 + ) base + FULL JOIN ( + SELECT area_id, name, district, investment_score, recommendation + FROM gold.area_score_snapshots + WHERE model_run_id = $2 + ) candidate ON candidate.area_id = base.area_id + ORDER BY ABS( + COALESCE(candidate.investment_score, 0) - COALESCE(base.investment_score, 0) + ) DESC, + area_id ASC + "#, + ) + .bind(query.base_run_id) + .bind(query.candidate_run_id) + .fetch_all(&state.pool) + .await?; + + Ok(Json(AreaScoreRunDiff { + base_run, + candidate_run, + items, + })) +} + pub async fn create_area_score_model_run( State(state): State, Json(payload): Json, @@ -1622,6 +1730,103 @@ async fn upsert_area_score( .bind(model_run_id) .execute(&mut **tx) .await?; + + sqlx::query( + r#" + INSERT INTO gold.area_score_snapshots ( + model_run_id, + area_id, + name, + district, + segment, + month, + investment_score, + recommendation, + liquidity_score, + momentum_score, + rent_support_score, + safety_margin_score, + credit_support_score, + supply_risk_score, + valuation_pressure_score, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + annual_rent_yield_pct, + listing_pressure_ratio, + discount_pct, + price_momentum_pct, + volume_momentum_pct, + model_version + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, + $26, $27 + ) + ON CONFLICT (model_run_id, area_id) DO UPDATE + SET name = EXCLUDED.name, + district = EXCLUDED.district, + segment = EXCLUDED.segment, + investment_score = EXCLUDED.investment_score, + recommendation = EXCLUDED.recommendation, + liquidity_score = EXCLUDED.liquidity_score, + momentum_score = EXCLUDED.momentum_score, + rent_support_score = EXCLUDED.rent_support_score, + safety_margin_score = EXCLUDED.safety_margin_score, + credit_support_score = EXCLUDED.credit_support_score, + supply_risk_score = EXCLUDED.supply_risk_score, + valuation_pressure_score = EXCLUDED.valuation_pressure_score, + transaction_count = EXCLUDED.transaction_count, + transaction_price_psm = EXCLUDED.transaction_price_psm, + listing_count = EXCLUDED.listing_count, + listing_price_psm = EXCLUDED.listing_price_psm, + rent_price_psm = EXCLUDED.rent_price_psm, + median_days_on_market = EXCLUDED.median_days_on_market, + annual_rent_yield_pct = EXCLUDED.annual_rent_yield_pct, + listing_pressure_ratio = EXCLUDED.listing_pressure_ratio, + discount_pct = EXCLUDED.discount_pct, + price_momentum_pct = EXCLUDED.price_momentum_pct, + volume_momentum_pct = EXCLUDED.volume_momentum_pct, + model_version = EXCLUDED.model_version, + computed_at = now() + "#, + ) + .bind(model_run_id) + .bind(&score.area_id) + .bind(&score.name) + .bind(&score.district) + .bind(&score.segment) + .bind(&score.month) + .bind(score.investment_score) + .bind(&score.recommendation) + .bind(score.liquidity_score) + .bind(score.momentum_score) + .bind(score.rent_support_score) + .bind(score.safety_margin_score) + .bind(score.credit_support_score) + .bind(score.supply_risk_score) + .bind(score.valuation_pressure_score) + .bind(score.transaction_count) + .bind(score.transaction_price_psm) + .bind(score.listing_count) + .bind(score.listing_price_psm) + .bind(score.rent_price_psm) + .bind(score.median_days_on_market) + .bind(score.annual_rent_yield_pct) + .bind(score.listing_pressure_ratio) + .bind(score.discount_pct) + .bind(score.price_momentum_pct) + .bind(score.volume_momentum_pct) + .bind(model_version) + .execute(&mut **tx) + .await?; Ok(()) } @@ -1661,6 +1866,32 @@ async fn finish_model_run( .await } +async fn fetch_model_run( + state: &AppState, + model_run_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ModelRun>( + r#" + SELECT + model_run_id, + model_name, + model_version, + target_month, + status, + parameters, + started_at::text AS started_at, + finished_at::text AS finished_at, + row_count, + error_message + FROM gold.model_runs + WHERE model_run_id = $1 + "#, + ) + .bind(model_run_id) + .fetch_optional(&state.pool) + .await +} + fn average(values: impl Iterator) -> f64 { let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| { (sum + value, count + 1) diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 117c097..57b316b 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -36,6 +36,18 @@ pub struct IngestionRunQuery { pub source_id: Option, } +#[derive(Debug, Deserialize)] +pub struct ModelRunQuery { + pub model_name: Option, + pub target_month: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ModelRunDiffQuery { + pub base_run_id: i64, + pub candidate_run_id: i64, +} + #[derive(Debug, Deserialize)] pub struct RawArtifactQuery { pub source_id: Option, @@ -191,6 +203,25 @@ pub struct AreaScoreModelRunResponse { pub scores: Vec, } +#[derive(Debug, Serialize, FromRow)] +pub struct AreaScoreRunDiffItem { + pub area_id: String, + pub name: String, + pub district: String, + pub base_score: Option, + pub candidate_score: Option, + pub score_delta: Option, + pub base_recommendation: Option, + pub candidate_recommendation: Option, +} + +#[derive(Debug, Serialize)] +pub struct AreaScoreRunDiff { + pub base_run: ModelRun, + pub candidate_run: ModelRun, + pub items: Vec, +} + #[derive(Debug, Serialize, FromRow)] pub struct AreaScoreLineage { pub area_id: String, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index d8b4f48..1130dc3 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -6,11 +6,11 @@ use tower_http::trace::TraceLayer; use crate::handlers::{ archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run, create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event, - create_watchlist_item, execute_import, finish_ingestion_run, get_area_detail, - get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health, list_area_scores, - list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, - list_raw_artifacts, list_watchlist_events, list_watchlist_items, market_overview, ready, - update_watchlist_item, validate_import, + create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run, + get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health, + list_area_scores, list_data_sources, list_ingestion_runs, list_model_runs, + list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, list_watchlist_events, + list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import, }; use crate::state::AppState; @@ -43,6 +43,11 @@ pub fn build_router(state: AppState) -> Router { .route("/compare/areas", get(compare_areas)) .route("/compare/neighborhoods", get(compare_neighborhoods)) .route("/market/overview", get(market_overview)) + .route("/model-runs", get(list_model_runs)) + .route( + "/model-runs/area-scores/diff", + get(diff_area_score_model_runs), + ) .route( "/model-runs/area-scores", axum::routing::post(create_area_score_model_run), diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index f0aa787..0c928fb 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -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(null); + const [baseModelRunId, setBaseModelRunId] = useState(null); + const [candidateModelRunId, setCandidateModelRunId] = useState(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)} /> + createAreaScoreModelRunMutation.mutate(payload)} + /> + 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) { + event.preventDefault(); + onCreateRun({ + month: MONTH, + model_version: modelVersion.trim() || undefined, + }); + } + + return ( + + +
+ 模型运行中心 +

+ 板块评分批次与版本差异 +

+
+
+ setModelVersion(event.target.value)} + /> + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} + +function ModelRunTable({ runs, loading }: { runs: ModelRun[]; loading: boolean }) { + if (loading) { + return
; + } + + if (runs.length === 0) { + return ( +
+ 暂无模型运行 +
+ ); + } + + return ( +
+ + + + 批次 + 版本 + 月份 + 状态 + 行数 + + + + {runs.slice(0, 8).map((run) => ( + + #{run.model_run_id} + {run.model_version} + {run.target_month} + + + {run.status} + + + {run.row_count ?? "-"} + + ))} + +
+
+ ); +} + +function ModelRunSelect({ + label, + runs, + value, + onChange, +}: { + label: string; + runs: ModelRun[]; + value: number | null; + onChange: (id: number | null) => void; +}) { + return ( + + ); +} + +function AreaScoreRunDiffTable({ + diff, + loading, +}: { + diff: AreaScoreRunDiff | null; + loading: boolean; +}) { + if (loading) { + return
; + } + + if (!diff || diff.items.length === 0) { + return ( +
+ 暂无评分差异 +
+ ); + } + + return ( +
+ + + + 板块 + 行政区 + 基准 + 候选 + 差异 + 建议变化 + + + + {diff.items.map((item) => ( + + {item.name} + {item.district} + + {item.base_score === null ? "-" : formatNumber(item.base_score)} + + + {item.candidate_score === null ? "-" : formatNumber(item.candidate_score)} + + + {item.score_delta === null ? "-" : formatSignedNumber(item.score_delta)} + + + {item.base_recommendation === item.candidate_recommendation ? ( + {item.candidate_recommendation ?? "-"} + ) : ( + + {item.base_recommendation ?? "-"} → {item.candidate_recommendation ?? "-"} + + )} + + + ))} + +
+
+ ); +} + function MetricCard({ title, value, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 713a0a8..a985fad 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { + 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 { + 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, diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 67c69b4..90db7a5 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -258,6 +258,8 @@ ### M3.1 模型运行版本化 +状态:已完成。 + 目标: - 所有评分结果关联模型版本和运行批次。 @@ -267,6 +269,9 @@ - `gold.model_runs` - 评分表关联 `model_run_id` - 模型参数记录。 +- `GET /api/v1/model-runs` +- `GET /api/v1/model-runs/area-scores/diff?base_run_id=&candidate_run_id=` +- 前端模型运行中心,可触发板块评分重算并比较两个批次差异。 验收标准: