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::import_templates::{execute_import_payload, validate_import_payload};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
|
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
|
||||||
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
|
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreRunDiff, AreaScoreRunDiffItem,
|
||||||
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
|
AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue,
|
||||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
|
CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact,
|
||||||
FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
CreateWatchlistEvent, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||||
|
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||||
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
|
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
|
||||||
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
|
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
||||||
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistEvent,
|
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
||||||
WatchlistItem, WatchlistQuery,
|
WatchlistEvent, WatchlistItem, WatchlistQuery,
|
||||||
};
|
};
|
||||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||||
use crate::state::AppState;
|
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(
|
pub async fn create_area_score_model_run(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateAreaScoreModelRun>,
|
Json(payload): Json<CreateAreaScoreModelRun>,
|
||||||
@@ -1622,6 +1730,103 @@ async fn upsert_area_score(
|
|||||||
.bind(model_run_id)
|
.bind(model_run_id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1661,6 +1866,32 @@ async fn finish_model_run(
|
|||||||
.await
|
.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 {
|
fn average(values: impl Iterator<Item = f64>) -> f64 {
|
||||||
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
|
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
|
||||||
(sum + value, count + 1)
|
(sum + value, count + 1)
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ pub struct IngestionRunQuery {
|
|||||||
pub source_id: Option<i64>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RawArtifactQuery {
|
pub struct RawArtifactQuery {
|
||||||
pub source_id: Option<i64>,
|
pub source_id: Option<i64>,
|
||||||
@@ -191,6 +203,25 @@ pub struct AreaScoreModelRunResponse {
|
|||||||
pub scores: Vec<AreaScore>,
|
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)]
|
#[derive(Debug, Serialize, FromRow)]
|
||||||
pub struct AreaScoreLineage {
|
pub struct AreaScoreLineage {
|
||||||
pub area_id: String,
|
pub area_id: String,
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ use tower_http::trace::TraceLayer;
|
|||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
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_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event,
|
||||||
create_watchlist_item, execute_import, finish_ingestion_run, get_area_detail,
|
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
|
||||||
get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health, list_area_scores,
|
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
|
||||||
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
|
list_area_scores, list_data_sources, list_ingestion_runs, list_model_runs,
|
||||||
list_raw_artifacts, list_watchlist_events, list_watchlist_items, market_overview, ready,
|
list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, list_watchlist_events,
|
||||||
update_watchlist_item, validate_import,
|
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/compare/areas", get(compare_areas))
|
.route("/compare/areas", get(compare_areas))
|
||||||
.route("/compare/neighborhoods", get(compare_neighborhoods))
|
.route("/compare/neighborhoods", get(compare_neighborhoods))
|
||||||
.route("/market/overview", get(market_overview))
|
.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(
|
.route(
|
||||||
"/model-runs/area-scores",
|
"/model-runs/area-scores",
|
||||||
axum::routing::post(create_area_score_model_run),
|
axum::routing::post(create_area_score_model_run),
|
||||||
|
|||||||
@@ -35,14 +35,17 @@ import {
|
|||||||
createDataSource,
|
createDataSource,
|
||||||
createIngestionRun,
|
createIngestionRun,
|
||||||
createRawArtifact,
|
createRawArtifact,
|
||||||
|
createAreaScoreModelRun,
|
||||||
createWatchlistEvent,
|
createWatchlistEvent,
|
||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
fetchDataSources,
|
fetchDataSources,
|
||||||
fetchAreaScores,
|
fetchAreaScores,
|
||||||
fetchAreaComparison,
|
fetchAreaComparison,
|
||||||
fetchAreaDetail,
|
fetchAreaDetail,
|
||||||
|
fetchAreaScoreRunDiff,
|
||||||
fetchIngestionRuns,
|
fetchIngestionRuns,
|
||||||
fetchMarketOverview,
|
fetchMarketOverview,
|
||||||
|
fetchModelRuns,
|
||||||
fetchNeighborhoodComparison,
|
fetchNeighborhoodComparison,
|
||||||
fetchNeighborhoodDetail,
|
fetchNeighborhoodDetail,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
@@ -56,7 +59,9 @@ import type {
|
|||||||
AreaScore,
|
AreaScore,
|
||||||
AreaComparison,
|
AreaComparison,
|
||||||
AreaDetail,
|
AreaDetail,
|
||||||
|
AreaScoreRunDiff,
|
||||||
ComparisonMetric,
|
ComparisonMetric,
|
||||||
|
CreateAreaScoreModelRun,
|
||||||
CreateDataSource,
|
CreateDataSource,
|
||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
@@ -64,6 +69,7 @@ import type {
|
|||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
DataSource,
|
DataSource,
|
||||||
IngestionRun,
|
IngestionRun,
|
||||||
|
ModelRun,
|
||||||
NeighborhoodComparison,
|
NeighborhoodComparison,
|
||||||
NeighborhoodComparisonItem,
|
NeighborhoodComparisonItem,
|
||||||
NeighborhoodDetail,
|
NeighborhoodDetail,
|
||||||
@@ -99,6 +105,8 @@ export function MarketDashboard() {
|
|||||||
const [watchlistAreaId, setWatchlistAreaId] = useState("");
|
const [watchlistAreaId, setWatchlistAreaId] = useState("");
|
||||||
const [watchlistLabel, setWatchlistLabel] = useState("");
|
const [watchlistLabel, setWatchlistLabel] = useState("");
|
||||||
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
||||||
|
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||||||
|
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -145,6 +153,18 @@ export function MarketDashboard() {
|
|||||||
queryFn: () => fetchWatchlistEvents(expandedWatchlistItemId ?? 0),
|
queryFn: () => fetchWatchlistEvents(expandedWatchlistItemId ?? 0),
|
||||||
enabled: expandedWatchlistItemId !== null,
|
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({
|
const dataSources = useQuery({
|
||||||
queryKey: ["data-sources"],
|
queryKey: ["data-sources"],
|
||||||
queryFn: fetchDataSources,
|
queryFn: fetchDataSources,
|
||||||
@@ -190,6 +210,22 @@ export function MarketDashboard() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["watchlist-events"] });
|
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({
|
const createDataSourceMutation = useMutation({
|
||||||
mutationFn: createDataSource,
|
mutationFn: createDataSource,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -243,6 +279,17 @@ export function MarketDashboard() {
|
|||||||
}
|
}
|
||||||
}, [comparisonNeighborhoodIds.length, neighborhoodScores.data]);
|
}, [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 =
|
const isLoading =
|
||||||
overview.isLoading ||
|
overview.isLoading ||
|
||||||
scores.isLoading ||
|
scores.isLoading ||
|
||||||
@@ -253,6 +300,8 @@ export function MarketDashboard() {
|
|||||||
neighborhoodScores.isLoading ||
|
neighborhoodScores.isLoading ||
|
||||||
watchlist.isLoading ||
|
watchlist.isLoading ||
|
||||||
watchlistEvents.isLoading ||
|
watchlistEvents.isLoading ||
|
||||||
|
modelRuns.isLoading ||
|
||||||
|
areaScoreRunDiff.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
ingestionRuns.isLoading;
|
ingestionRuns.isLoading;
|
||||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||||
@@ -266,6 +315,8 @@ export function MarketDashboard() {
|
|||||||
neighborhoodScores.isError ||
|
neighborhoodScores.isError ||
|
||||||
watchlist.isError ||
|
watchlist.isError ||
|
||||||
watchlistEvents.isError ||
|
watchlistEvents.isError ||
|
||||||
|
modelRuns.isError ||
|
||||||
|
areaScoreRunDiff.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
ingestionRuns.isError ||
|
ingestionRuns.isError ||
|
||||||
rawArtifacts.isError;
|
rawArtifacts.isError;
|
||||||
@@ -296,6 +347,8 @@ export function MarketDashboard() {
|
|||||||
void dataSources.refetch();
|
void dataSources.refetch();
|
||||||
void ingestionRuns.refetch();
|
void ingestionRuns.refetch();
|
||||||
void rawArtifacts.refetch();
|
void rawArtifacts.refetch();
|
||||||
|
void modelRuns.refetch();
|
||||||
|
void areaScoreRunDiff.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
@@ -462,6 +515,18 @@ export function MarketDashboard() {
|
|||||||
onArchive={(id) => archiveMutation.mutate(id)}
|
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
|
<ImportCenterPanel
|
||||||
sources={dataSources.data ?? []}
|
sources={dataSources.data ?? []}
|
||||||
runs={ingestionRuns.data ?? []}
|
runs={ingestionRuns.data ?? []}
|
||||||
@@ -1884,6 +1949,11 @@ function formatMetricValue(value: number | null | undefined, unit: string) {
|
|||||||
return formatNumber(value);
|
return formatNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSignedNumber(value: number) {
|
||||||
|
const sign = value > 0 ? "+" : "";
|
||||||
|
return `${sign}${formatNumber(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function WatchlistPanel({
|
function WatchlistPanel({
|
||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
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({
|
function MetricCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -273,6 +273,23 @@ export type AreaScoreModelRunResponse = {
|
|||||||
scores: AreaScore[];
|
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 = {
|
export type AreaScoreLineage = {
|
||||||
area_id: string;
|
area_id: string;
|
||||||
area_name: 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(
|
export async function fetchAreaScoreLineage(
|
||||||
areaId: string,
|
areaId: string,
|
||||||
month: string,
|
month: string,
|
||||||
|
|||||||
@@ -258,6 +258,8 @@
|
|||||||
|
|
||||||
### M3.1 模型运行版本化
|
### M3.1 模型运行版本化
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 所有评分结果关联模型版本和运行批次。
|
- 所有评分结果关联模型版本和运行批次。
|
||||||
@@ -267,6 +269,9 @@
|
|||||||
- `gold.model_runs`
|
- `gold.model_runs`
|
||||||
- 评分表关联 `model_run_id`
|
- 评分表关联 `model_run_id`
|
||||||
- 模型参数记录。
|
- 模型参数记录。
|
||||||
|
- `GET /api/v1/model-runs`
|
||||||
|
- `GET /api/v1/model-runs/area-scores/diff?base_run_id=&candidate_run_id=`
|
||||||
|
- 前端模型运行中心,可触发板块评分重算并比较两个批次差异。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user