feat: add model run comparison
This commit is contained in:
34
apps/api/migrations/202606240005_area_score_snapshots.sql
Normal file
34
apps/api/migrations/202606240005_area_score_snapshots.sql
Normal file
@@ -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);
|
||||
@@ -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<AppState>,
|
||||
Query(query): Query<ModelRunQuery>,
|
||||
) -> ApiResult<Json<Vec<ModelRun>>> {
|
||||
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<AppState>,
|
||||
Query(query): Query<ModelRunDiffQuery>,
|
||||
) -> ApiResult<Json<AreaScoreRunDiff>> {
|
||||
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<AppState>,
|
||||
Json(payload): Json<CreateAreaScoreModelRun>,
|
||||
@@ -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<Option<ModelRun>, 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<Item = f64>) -> f64 {
|
||||
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
|
||||
(sum + value, count + 1)
|
||||
|
||||
@@ -36,6 +36,18 @@ pub struct IngestionRunQuery {
|
||||
pub source_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelRunQuery {
|
||||
pub model_name: Option<String>,
|
||||
pub target_month: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
@@ -191,6 +203,25 @@ pub struct AreaScoreModelRunResponse {
|
||||
pub scores: Vec<AreaScore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct AreaScoreRunDiffItem {
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub district: String,
|
||||
pub base_score: Option<f64>,
|
||||
pub candidate_score: Option<f64>,
|
||||
pub score_delta: Option<f64>,
|
||||
pub base_recommendation: Option<String>,
|
||||
pub candidate_recommendation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaScoreRunDiff {
|
||||
pub base_run: ModelRun,
|
||||
pub candidate_run: ModelRun,
|
||||
pub items: Vec<AreaScoreRunDiffItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct AreaScoreLineage {
|
||||
pub area_id: String,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user