feat: implement biomarker models, reference rules, and data seeding logic
This commit is contained in:
46
backend/seed.yaml
Normal file
46
backend/seed.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Seed Data for zhealth
|
||||
# Run `zhealth seed` to sync this data to the database.
|
||||
|
||||
roles:
|
||||
- name: admin
|
||||
description: Full access to everything
|
||||
- name: user
|
||||
description: Can manage their own biomarker data
|
||||
- name: reader
|
||||
description: Read-only access to analytics
|
||||
|
||||
biomarker_categories:
|
||||
- name: blood
|
||||
description: Blood cell counts and hemogram markers
|
||||
- name: cardiac
|
||||
description: Heart and cardiovascular markers
|
||||
- name: electrolytes
|
||||
description: Electrolyte balance markers
|
||||
- name: hormones
|
||||
description: Hormone and steroid levels
|
||||
- name: inflammation
|
||||
description: Inflammation and arthritis markers
|
||||
- name: lipid_panel
|
||||
description: Cholesterol and lipid markers
|
||||
- name: liver
|
||||
description: Liver function markers
|
||||
- name: metabolic
|
||||
description: Metabolic and diabetes markers
|
||||
- name: minerals
|
||||
description: Essential mineral levels
|
||||
- name: renal
|
||||
description: Kidney function markers
|
||||
- name: thyroid
|
||||
description: Thyroid function markers
|
||||
- name: toxicology
|
||||
description: Toxic element levels
|
||||
- name: urine
|
||||
description: Urinalysis markers
|
||||
- name: vitamins
|
||||
description: Vitamin levels
|
||||
- name: vitals
|
||||
description: Vital signs
|
||||
- name: body
|
||||
description: Body measurements
|
||||
- name: activity
|
||||
description: Physical activity metrics
|
||||
1803
backend/seed_biomarkers.yaml
Normal file
1803
backend/seed_biomarkers.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,11 @@ pub struct Args {
|
||||
pub enum Command {
|
||||
Serve(ServeCommand),
|
||||
Migrate(MigrateCommand),
|
||||
Seed(SeedCommand),
|
||||
Version(VersionCommand),
|
||||
}
|
||||
|
||||
/// Start the server (runs migrations first)
|
||||
/// Start the server (runs migrations and seed first)
|
||||
#[derive(FromArgs)]
|
||||
#[argh(subcommand, name = "serve")]
|
||||
pub struct ServeCommand {}
|
||||
@@ -27,6 +28,11 @@ pub struct ServeCommand {}
|
||||
#[argh(subcommand, name = "migrate")]
|
||||
pub struct MigrateCommand {}
|
||||
|
||||
/// Sync seed data from seed.yaml
|
||||
#[derive(FromArgs)]
|
||||
#[argh(subcommand, name = "seed")]
|
||||
pub struct SeedCommand {}
|
||||
|
||||
/// Show version information
|
||||
#[derive(FromArgs)]
|
||||
#[argh(subcommand, name = "version")]
|
||||
|
||||
@@ -4,7 +4,7 @@ use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, S
|
||||
use sea_orm::sea_query::SqliteQueryBuilder;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::models::bio::{biomarker_entry, biomarker_type};
|
||||
use crate::models::bio::{biomarker, biomarker_category, biomarker_entry, biomarker_reference_rule};
|
||||
use crate::models::user::{role, session, user};
|
||||
|
||||
/// Connect to the SQLite database.
|
||||
@@ -26,10 +26,12 @@ pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
|
||||
// Create table statements (order matters for foreign keys)
|
||||
let statements = vec![
|
||||
schema.create_table_from_entity(role::Entity), // roles first
|
||||
schema.create_table_from_entity(user::Entity), // users references roles
|
||||
schema.create_table_from_entity(role::Entity),
|
||||
schema.create_table_from_entity(user::Entity),
|
||||
schema.create_table_from_entity(session::Entity),
|
||||
schema.create_table_from_entity(biomarker_type::Entity),
|
||||
schema.create_table_from_entity(biomarker_category::Entity),
|
||||
schema.create_table_from_entity(biomarker::Entity),
|
||||
schema.create_table_from_entity(biomarker_reference_rule::Entity),
|
||||
schema.create_table_from_entity(biomarker_entry::Entity),
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod cli;
|
||||
mod config;
|
||||
mod db;
|
||||
mod models;
|
||||
mod seed;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
@@ -28,6 +29,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
db::run_migrations(&db).await?;
|
||||
tracing::info!("Migrations complete.");
|
||||
}
|
||||
Command::Seed(_) => {
|
||||
let config = config::Config::load("config.yaml")?;
|
||||
init_logging(&config);
|
||||
|
||||
let db = db::connect(&config).await?;
|
||||
|
||||
tracing::info!("Syncing seed data...");
|
||||
let seed_data = seed::SeedData::load("seed.yaml")?;
|
||||
seed::sync_seed_data(&db, &seed_data).await?;
|
||||
|
||||
tracing::info!("Syncing biomarker data...");
|
||||
let biomarker_data = seed::BiomarkerSeedData::load("seed_biomarkers.yaml")?;
|
||||
seed::sync_biomarker_data(&db, &biomarker_data).await?;
|
||||
|
||||
tracing::info!("Seed data synced.");
|
||||
}
|
||||
Command::Serve(_) => {
|
||||
let config = config::Config::load("config.yaml")?;
|
||||
init_logging(&config);
|
||||
@@ -38,8 +55,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
db::run_migrations(&db).await?;
|
||||
tracing::info!("Migrations complete.");
|
||||
|
||||
// Sync seed data
|
||||
tracing::info!("Syncing seed data...");
|
||||
let seed_data = seed::SeedData::load("seed.yaml")?;
|
||||
seed::sync_seed_data(&db, &seed_data).await?;
|
||||
|
||||
tracing::info!("Syncing biomarker data...");
|
||||
let biomarker_data = seed::BiomarkerSeedData::load("seed_biomarkers.yaml")?;
|
||||
seed::sync_biomarker_data(&db, &biomarker_data).await?;
|
||||
|
||||
tracing::info!("Seed data synced.");
|
||||
|
||||
// Start server
|
||||
tracing::info!("Starting zhealth-backend...");
|
||||
tracing::info!("Starting zhealth...");
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/health", get(health_check));
|
||||
|
||||
66
backend/src/models/bio/biomarker.rs
Normal file
66
backend/src/models/bio/biomarker.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Biomarker entity - individual biomarker definitions.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "biomarkers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
|
||||
/// Foreign key to biomarker_categories (our internal category for analytics)
|
||||
pub category_id: i32,
|
||||
|
||||
/// Biomarker name: HEMOGLOBIN, LDL CHOLESTEROL, etc.
|
||||
#[sea_orm(unique)]
|
||||
pub name: String,
|
||||
|
||||
/// Lab report category: HEMOGRAM, LIPID, LIVER, etc.
|
||||
pub test_category: String,
|
||||
|
||||
/// Unit of measurement: g/dL, mg/dL, etc.
|
||||
pub unit: String,
|
||||
|
||||
/// Testing methodology: ICP-MS, HPLC, Enzymatic, etc.
|
||||
pub methodology: Option<String>,
|
||||
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::biomarker_category::Entity",
|
||||
from = "Column::CategoryId",
|
||||
to = "super::biomarker_category::Column::Id"
|
||||
)]
|
||||
Category,
|
||||
|
||||
#[sea_orm(has_many = "super::biomarker_entry::Entity")]
|
||||
Entries,
|
||||
|
||||
#[sea_orm(has_many = "super::biomarker_reference_rule::Entity")]
|
||||
ReferenceRules,
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_category::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Category.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_entry::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Entries.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_reference_rule::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ReferenceRules.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
backend/src/models/bio/biomarker_category.rs
Normal file
32
backend/src/models/bio/biomarker_category.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! BiomarkerCategory entity - categories for grouping biomarkers.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "biomarker_categories")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
|
||||
/// Category name: body, vitals, activity, lipid_panel, metabolic, vitamins, hormones
|
||||
#[sea_orm(unique)]
|
||||
pub name: String,
|
||||
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::biomarker::Entity")]
|
||||
Biomarkers,
|
||||
}
|
||||
|
||||
impl Related<super::biomarker::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Biomarkers.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! BiomarkerEntry entity - user-logged biomarker values.
|
||||
//! BiomarkerEntry entity - user biomarker measurements.
|
||||
//! Uses composite primary key: (biomarker_id, user_id, measured_at)
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -6,18 +7,22 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "biomarker_entries")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
/// Foreign key to biomarkers table (part of composite PK)
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub biomarker_id: i32,
|
||||
|
||||
/// Foreign key to users table (part of composite PK)
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i32,
|
||||
pub biomarker_type_id: i32,
|
||||
|
||||
/// Date/time of measurement (part of composite PK)
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub measured_at: DateTime,
|
||||
|
||||
/// The measured value
|
||||
pub value: f64,
|
||||
|
||||
/// Date when the measurement was taken
|
||||
pub measured_at: Date,
|
||||
|
||||
/// Optional notes about this measurement
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub notes: Option<String>,
|
||||
|
||||
@@ -26,19 +31,25 @@ pub struct Model {
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::biomarker::Entity",
|
||||
from = "Column::BiomarkerId",
|
||||
to = "super::biomarker::Column::Id"
|
||||
)]
|
||||
Biomarker,
|
||||
|
||||
#[sea_orm(
|
||||
belongs_to = "crate::models::user::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "crate::models::user::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
#[sea_orm(
|
||||
belongs_to = "super::biomarker_type::Entity",
|
||||
from = "Column::BiomarkerTypeId",
|
||||
to = "super::biomarker_type::Column::Id"
|
||||
)]
|
||||
BiomarkerType,
|
||||
impl Related<super::biomarker::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Biomarker.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<crate::models::user::user::Entity> for Entity {
|
||||
@@ -47,10 +58,4 @@ impl Related<crate::models::user::user::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_type::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::BiomarkerType.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
72
backend/src/models/bio/biomarker_reference_rule.rs
Normal file
72
backend/src/models/bio/biomarker_reference_rule.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! BiomarkerReferenceRule entity - flexible reference rules for biomarker interpretation.
|
||||
//! Supports: ranges, scales, age-based, sex-based, time-based, and life-stage-based references.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "biomarker_reference_rules")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
|
||||
/// Foreign key to biomarkers
|
||||
pub biomarker_id: i32,
|
||||
|
||||
/// Rule type: "range" (normal range), "scale" (interpretation scale), "expected" (qualitative)
|
||||
pub rule_type: String,
|
||||
|
||||
/// Sex filter: "any", "male", "female"
|
||||
#[sea_orm(default_value = "any")]
|
||||
pub sex: String,
|
||||
|
||||
/// Age range lower bound in years (NULL = no lower bound)
|
||||
pub age_min: Option<i32>,
|
||||
|
||||
/// Age range upper bound in years (NULL = no upper bound)
|
||||
pub age_max: Option<i32>,
|
||||
|
||||
/// Time of day for diurnal variation tests (e.g., "08:00", "16:00")
|
||||
pub time_of_day: Option<String>,
|
||||
|
||||
/// Life stage: "prepubertal", "adult", "follicular", "luteal", "postmenopausal", "pregnancy"
|
||||
pub life_stage: Option<String>,
|
||||
|
||||
/// Value range lower bound (NULL = no lower bound, e.g., "<15")
|
||||
pub value_min: Option<f64>,
|
||||
|
||||
/// Value range upper bound (NULL = no upper bound, e.g., ">=90")
|
||||
pub value_max: Option<f64>,
|
||||
|
||||
/// Expected value for qualitative tests: "ABSENT", "NEGATIVE", "PALE YELLOW"
|
||||
pub expected_value: Option<String>,
|
||||
|
||||
/// Label for this rule: "Normal", "Deficiency", "Kidney Failure", etc.
|
||||
pub label: String,
|
||||
|
||||
/// Severity level: 0=normal, 1=mild, 2=moderate, 3=severe, 4=critical
|
||||
#[sea_orm(default_value = 0)]
|
||||
pub severity: i32,
|
||||
|
||||
/// Sort order for scales (1, 2, 3...) to ensure correct order
|
||||
#[sea_orm(default_value = 0)]
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::biomarker::Entity",
|
||||
from = "Column::BiomarkerId",
|
||||
to = "super::biomarker::Column::Id"
|
||||
)]
|
||||
Biomarker,
|
||||
}
|
||||
|
||||
impl Related<super::biomarker::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Biomarker.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! BiomarkerType entity - the knowledge base of available biomarkers.
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "biomarker_types")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
|
||||
#[sea_orm(unique)]
|
||||
pub name: String,
|
||||
|
||||
/// Category: lipid_panel, metabolic, vitamins, hormones, etc.
|
||||
pub category: String,
|
||||
|
||||
/// Unit of measurement: mg/dL, mmol/L, ng/mL, etc.
|
||||
pub unit: String,
|
||||
|
||||
/// Lower bound of normal reference range
|
||||
pub reference_min: Option<f64>,
|
||||
|
||||
/// Upper bound of normal reference range
|
||||
pub reference_max: Option<f64>,
|
||||
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::biomarker_entry::Entity")]
|
||||
Entries,
|
||||
}
|
||||
|
||||
impl Related<super::biomarker_entry::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Entries.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Biomarker entities for health data tracking.
|
||||
//! Biomarker-related entities.
|
||||
|
||||
pub mod biomarker;
|
||||
pub mod biomarker_category;
|
||||
pub mod biomarker_entry;
|
||||
pub mod biomarker_type;
|
||||
pub mod biomarker_reference_rule;
|
||||
|
||||
pub use biomarker::Entity as Biomarker;
|
||||
pub use biomarker_category::Entity as BiomarkerCategory;
|
||||
pub use biomarker_entry::Entity as BiomarkerEntry;
|
||||
pub use biomarker_type::Entity as BiomarkerType;
|
||||
pub use biomarker_reference_rule::Entity as BiomarkerReferenceRule;
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
pub mod bio;
|
||||
pub mod user;
|
||||
|
||||
pub use bio::{BiomarkerEntry, BiomarkerType};
|
||||
pub use user::{Session, User};
|
||||
pub use bio::{Biomarker, BiomarkerCategory, BiomarkerEntry};
|
||||
pub use user::{Role, Session, User};
|
||||
|
||||
452
backend/src/seed.rs
Normal file
452
backend/src/seed.rs
Normal file
@@ -0,0 +1,452 @@
|
||||
//! Seed data loading and syncing.
|
||||
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||
use serde::Deserialize;
|
||||
use serde_yaml::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::models::bio::{biomarker, biomarker_category, biomarker_reference_rule};
|
||||
use crate::models::user::role;
|
||||
|
||||
// ============================================================================
|
||||
// Seed Data Structures
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SeedData {
|
||||
pub roles: Vec<RoleSeed>,
|
||||
pub biomarker_categories: Vec<BiomarkerCategorySeed>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BiomarkerSeedData {
|
||||
pub biomarkers: Vec<BiomarkerSeed>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RoleSeed {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BiomarkerCategorySeed {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BiomarkerSeed {
|
||||
pub name: String,
|
||||
pub test_category: String,
|
||||
pub category: String,
|
||||
pub unit: String,
|
||||
pub methodology: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub scale: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub scale_risk: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub scale_diabetic_control: Option<Value>,
|
||||
}
|
||||
|
||||
impl SeedData {
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data: SeedData = serde_yaml::from_str(&content)?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl BiomarkerSeedData {
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data: BiomarkerSeedData = serde_yaml::from_str(&content)?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reference Rule Builder
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RuleBuilder {
|
||||
rules: Vec<RuleData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RuleData {
|
||||
rule_type: String,
|
||||
sex: String,
|
||||
age_min: Option<i32>,
|
||||
age_max: Option<i32>,
|
||||
time_of_day: Option<String>,
|
||||
life_stage: Option<String>,
|
||||
value_min: Option<f64>,
|
||||
value_max: Option<f64>,
|
||||
expected_value: Option<String>,
|
||||
label: String,
|
||||
severity: i32,
|
||||
sort_order: i32,
|
||||
}
|
||||
|
||||
impl RuleBuilder {
|
||||
fn new() -> Self {
|
||||
Self { rules: Vec::new() }
|
||||
}
|
||||
|
||||
/// Parse a reference/scale value and generate rules
|
||||
fn parse_reference(&mut self, value: &Value, sex: &str, life_stage: Option<&str>) {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
// Check for special keys
|
||||
if let Some(min) = map.get(&Value::String("min".into())) {
|
||||
// Simple {min, max} structure
|
||||
let max = map.get(&Value::String("max".into()));
|
||||
self.add_range_rule(sex, life_stage,
|
||||
min.as_f64(),
|
||||
max.and_then(|v| v.as_f64()),
|
||||
"Normal", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(max_only) = map.get(&Value::String("max".into())) {
|
||||
if map.len() == 1 {
|
||||
// Only max specified
|
||||
self.add_range_rule(sex, life_stage, None, max_only.as_f64(), "Normal", 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(expected) = map.get(&Value::String("expected".into())) {
|
||||
// Expected value for qualitative tests
|
||||
self.add_expected_rule(sex, expected.as_str().unwrap_or(""), "Normal", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(by_age) = map.get(&Value::String("by_age".into())) {
|
||||
// Age-based reference
|
||||
self.parse_by_age(by_age, sex, life_stage);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(by_time) = map.get(&Value::String("by_time".into())) {
|
||||
// Time-based reference (diurnal variation)
|
||||
self.parse_by_time(by_time, sex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for sex-specific keys
|
||||
for (key, val) in map {
|
||||
if let Some(key_str) = key.as_str() {
|
||||
match key_str {
|
||||
"male" => self.parse_reference(val, "male", life_stage),
|
||||
"female" => self.parse_reference(val, "female", life_stage),
|
||||
"prepubertal" => self.parse_reference(val, sex, Some("prepubertal")),
|
||||
"adult" => self.parse_reference(val, sex, Some("adult")),
|
||||
"menstrual" => self.parse_menstrual(val, sex),
|
||||
"postmenopausal" => self.parse_reference(val, sex, Some("postmenopausal")),
|
||||
"pregnancy" => self.parse_reference(val, sex, Some("pregnancy")),
|
||||
"post_acth" => self.parse_reference(val, sex, Some("post_acth")),
|
||||
"no_hrt" => self.parse_reference(val, sex, Some("postmenopausal_no_hrt")),
|
||||
"on_hrt" => self.parse_reference(val, sex, Some("postmenopausal_on_hrt")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_menstrual(&mut self, value: &Value, sex: &str) {
|
||||
if let Value::Mapping(map) = value {
|
||||
for (key, val) in map {
|
||||
if let Some(key_str) = key.as_str() {
|
||||
match key_str {
|
||||
"follicular" => self.parse_reference(val, sex, Some("follicular")),
|
||||
"midcycle" => self.parse_reference(val, sex, Some("midcycle")),
|
||||
"luteal" => self.parse_reference(val, sex, Some("luteal")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_by_age(&mut self, value: &Value, sex: &str, life_stage: Option<&str>) {
|
||||
if let Value::Sequence(arr) = value {
|
||||
for item in arr {
|
||||
if let Value::Mapping(map) = item {
|
||||
let age_min = map.get(&Value::String("age_min".into()))
|
||||
.and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let age_max = map.get(&Value::String("age_max".into()))
|
||||
.and_then(|v| v.as_i64()).map(|v| v as i32);
|
||||
let val_min = map.get(&Value::String("min".into())).and_then(|v| v.as_f64());
|
||||
let val_max = map.get(&Value::String("max".into())).and_then(|v| v.as_f64());
|
||||
|
||||
self.rules.push(RuleData {
|
||||
rule_type: "range".into(),
|
||||
sex: sex.into(),
|
||||
age_min,
|
||||
age_max,
|
||||
time_of_day: None,
|
||||
life_stage: life_stage.map(String::from),
|
||||
value_min: val_min,
|
||||
value_max: val_max,
|
||||
expected_value: None,
|
||||
label: "Normal".into(),
|
||||
severity: 0,
|
||||
sort_order: self.rules.len() as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_by_time(&mut self, value: &Value, sex: &str) {
|
||||
if let Value::Mapping(map) = value {
|
||||
for (key, val) in map {
|
||||
if let Some(time_str) = key.as_str() {
|
||||
if let Value::Mapping(range_map) = val {
|
||||
let val_min = range_map.get(&Value::String("min".into())).and_then(|v| v.as_f64());
|
||||
let val_max = range_map.get(&Value::String("max".into())).and_then(|v| v.as_f64());
|
||||
|
||||
self.rules.push(RuleData {
|
||||
rule_type: "range".into(),
|
||||
sex: sex.into(),
|
||||
age_min: None,
|
||||
age_max: None,
|
||||
time_of_day: Some(time_str.into()),
|
||||
life_stage: None,
|
||||
value_min: val_min,
|
||||
value_max: val_max,
|
||||
expected_value: None,
|
||||
label: "Normal".into(),
|
||||
severity: 0,
|
||||
sort_order: self.rules.len() as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scale(&mut self, value: &Value, sex: &str) {
|
||||
if let Value::Sequence(arr) = value {
|
||||
for (idx, item) in arr.iter().enumerate() {
|
||||
if let Value::Mapping(map) = item {
|
||||
let val_min = map.get(&Value::String("min".into())).and_then(|v| v.as_f64());
|
||||
let val_max = map.get(&Value::String("max".into())).and_then(|v| v.as_f64());
|
||||
let label = map.get(&Value::String("label".into()))
|
||||
.and_then(|v| v.as_str()).unwrap_or("Unknown");
|
||||
|
||||
// Determine severity based on label keywords
|
||||
let severity = Self::infer_severity(label);
|
||||
|
||||
self.rules.push(RuleData {
|
||||
rule_type: "scale".into(),
|
||||
sex: sex.into(),
|
||||
age_min: None,
|
||||
age_max: None,
|
||||
time_of_day: None,
|
||||
life_stage: None,
|
||||
value_min: val_min,
|
||||
value_max: val_max,
|
||||
expected_value: None,
|
||||
label: label.into(),
|
||||
severity,
|
||||
sort_order: idx as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_range_rule(&mut self, sex: &str, life_stage: Option<&str>,
|
||||
val_min: Option<f64>, val_max: Option<f64>, label: &str, severity: i32) {
|
||||
self.rules.push(RuleData {
|
||||
rule_type: "range".into(),
|
||||
sex: sex.into(),
|
||||
age_min: None,
|
||||
age_max: None,
|
||||
time_of_day: None,
|
||||
life_stage: life_stage.map(String::from),
|
||||
value_min: val_min,
|
||||
value_max: val_max,
|
||||
expected_value: None,
|
||||
label: label.into(),
|
||||
severity,
|
||||
sort_order: self.rules.len() as i32,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_expected_rule(&mut self, sex: &str, expected: &str, label: &str, severity: i32) {
|
||||
self.rules.push(RuleData {
|
||||
rule_type: "expected".into(),
|
||||
sex: sex.into(),
|
||||
age_min: None,
|
||||
age_max: None,
|
||||
time_of_day: None,
|
||||
life_stage: None,
|
||||
value_min: None,
|
||||
value_max: None,
|
||||
expected_value: Some(expected.into()),
|
||||
label: label.into(),
|
||||
severity,
|
||||
sort_order: self.rules.len() as i32,
|
||||
});
|
||||
}
|
||||
|
||||
fn infer_severity(label: &str) -> i32 {
|
||||
let lower = label.to_lowercase();
|
||||
if lower.contains("normal") || lower.contains("optimal") || lower.contains("good")
|
||||
|| lower.contains("negative") || lower.contains("sufficiency") || lower.contains("desirable") {
|
||||
0
|
||||
} else if lower.contains("mild") || lower.contains("borderline") || lower.contains("low risk")
|
||||
|| lower.contains("insufficiency") || lower.contains("prediabetes") || lower.contains("fair") {
|
||||
1
|
||||
} else if lower.contains("moderate") || lower.contains("average risk") || lower.contains("elevated") {
|
||||
2
|
||||
} else if lower.contains("severe") || lower.contains("high risk") || lower.contains("high") {
|
||||
3
|
||||
} else if lower.contains("critical") || lower.contains("failure") || lower.contains("diabetes")
|
||||
|| lower.contains("very high") || lower.contains("toxicity") || lower.contains("poor") {
|
||||
4
|
||||
} else if lower.contains("positive") {
|
||||
2 // Positive results for autoimmune tests are concerning
|
||||
} else {
|
||||
1 // Default to mild concern for unknown labels
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Sync seed data to the database (upsert by name).
|
||||
pub async fn sync_seed_data(db: &DatabaseConnection, seed: &SeedData) -> anyhow::Result<()> {
|
||||
// Sync roles
|
||||
for role_seed in &seed.roles {
|
||||
let existing = role::Entity::find()
|
||||
.filter(role::Column::Name.eq(&role_seed.name))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
let new_role = role::ActiveModel {
|
||||
name: Set(role_seed.name.clone()),
|
||||
description: Set(role_seed.description.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
new_role.insert(db).await?;
|
||||
tracing::info!("Inserted role: {}", role_seed.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync biomarker categories
|
||||
for cat_seed in &seed.biomarker_categories {
|
||||
let existing = biomarker_category::Entity::find()
|
||||
.filter(biomarker_category::Column::Name.eq(&cat_seed.name))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
let new_cat = biomarker_category::ActiveModel {
|
||||
name: Set(cat_seed.name.clone()),
|
||||
description: Set(cat_seed.description.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
new_cat.insert(db).await?;
|
||||
tracing::info!("Inserted category: {}", cat_seed.name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync biomarker seed data from seed_biomarkers.yaml
|
||||
pub async fn sync_biomarker_data(db: &DatabaseConnection, seed: &BiomarkerSeedData) -> anyhow::Result<()> {
|
||||
for bm_seed in &seed.biomarkers {
|
||||
let existing = biomarker::Entity::find()
|
||||
.filter(biomarker::Column::Name.eq(&bm_seed.name))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up category
|
||||
let category = biomarker_category::Entity::find()
|
||||
.filter(biomarker_category::Column::Name.eq(&bm_seed.category))
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Category not found: {}", bm_seed.category))?;
|
||||
|
||||
// Insert biomarker
|
||||
let new_bm = biomarker::ActiveModel {
|
||||
category_id: Set(category.id),
|
||||
name: Set(bm_seed.name.clone()),
|
||||
test_category: Set(bm_seed.test_category.clone()),
|
||||
unit: Set(bm_seed.unit.clone()),
|
||||
methodology: Set(bm_seed.methodology.clone()),
|
||||
description: Set(bm_seed.description.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let inserted = new_bm.insert(db).await?;
|
||||
tracing::info!("Inserted biomarker: {}", bm_seed.name);
|
||||
|
||||
// Parse and insert reference rules
|
||||
let mut builder = RuleBuilder::new();
|
||||
|
||||
if let Some(ref reference) = bm_seed.reference {
|
||||
builder.parse_reference(reference, "any", None);
|
||||
}
|
||||
|
||||
if let Some(ref scale) = bm_seed.scale {
|
||||
builder.parse_scale(scale, "any");
|
||||
}
|
||||
|
||||
if let Some(ref scale_risk) = bm_seed.scale_risk {
|
||||
// For sex-specific scale_risk like Troponin
|
||||
if let Value::Mapping(map) = scale_risk {
|
||||
if let Some(male_scale) = map.get(&Value::String("male".into())) {
|
||||
builder.parse_scale(male_scale, "male");
|
||||
}
|
||||
if let Some(female_scale) = map.get(&Value::String("female".into())) {
|
||||
builder.parse_scale(female_scale, "female");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert all generated rules
|
||||
for rule in builder.rules {
|
||||
let new_rule = biomarker_reference_rule::ActiveModel {
|
||||
biomarker_id: Set(inserted.id),
|
||||
rule_type: Set(rule.rule_type),
|
||||
sex: Set(rule.sex),
|
||||
age_min: Set(rule.age_min),
|
||||
age_max: Set(rule.age_max),
|
||||
time_of_day: Set(rule.time_of_day),
|
||||
life_stage: Set(rule.life_stage),
|
||||
value_min: Set(rule.value_min),
|
||||
value_max: Set(rule.value_max),
|
||||
expected_value: Set(rule.expected_value),
|
||||
label: Set(rule.label),
|
||||
severity: Set(rule.severity),
|
||||
sort_order: Set(rule.sort_order),
|
||||
..Default::default()
|
||||
};
|
||||
new_rule.insert(db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user