feat: display user biomarker results with severity indicators and visual scale bars on dashboard

This commit is contained in:
2025-12-21 18:17:49 +05:30
parent 8919942322
commit 923311e650
9 changed files with 423 additions and 86 deletions

View File

@@ -6,8 +6,10 @@ use axum::{
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::bio::{biomarker, biomarker_entry};
use crate::models::bio::{biomarker, biomarker_entry, biomarker_reference_rule};
use crate::models::user::user;
/// Request to create a new biomarker entry.
#[derive(Deserialize)]
@@ -30,6 +32,23 @@ pub struct EntryResponse {
pub notes: Option<String>,
}
/// Response for biomarker result with reference info.
#[derive(Serialize)]
pub struct BiomarkerResult {
pub biomarker_id: i32,
pub name: String,
pub category_id: i32,
pub unit: String,
// Latest entry
pub value: Option<f64>,
pub measured_at: Option<String>,
// Reference info
pub ref_min: Option<f64>,
pub ref_max: Option<f64>,
pub label: String,
pub severity: i32,
}
/// POST /api/entries - Create a new biomarker entry.
pub async fn create_entry(
State(db): State<DatabaseConnection>,
@@ -103,7 +122,7 @@ pub async fn list_user_entries(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let bm_map: std::collections::HashMap<i32, String> = biomarkers
let bm_map: HashMap<i32, String> = biomarkers
.into_iter()
.map(|b| (b.id, b.name))
.collect();
@@ -122,3 +141,143 @@ pub async fn list_user_entries(
Ok(Json(items))
}
/// GET /api/users/:user_id/results - Get latest biomarker results with reference rules.
pub async fn get_user_results(
State(db): State<DatabaseConnection>,
Path(user_id): Path<i32>,
) -> Result<Json<Vec<BiomarkerResult>>, StatusCode> {
// Get user profile for sex/age matching
let user_profile = user::Entity::find_by_id(user_id)
.one(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// Calculate age from birthdate
let user_age = user_profile.birthdate.map(|bd| {
let today = chrono::Utc::now().date_naive();
let years = today.years_since(bd).unwrap_or(0) as i32;
years
});
// Fetch all biomarkers
let biomarkers = biomarker::Entity::find()
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Fetch all entries for this user, ordered by measured_at desc
let entries = biomarker_entry::Entity::find()
.filter(biomarker_entry::Column::UserId.eq(user_id))
.order_by_desc(biomarker_entry::Column::MeasuredAt)
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build map of biomarker_id -> latest entry
let mut latest_entries: HashMap<i32, &biomarker_entry::Model> = HashMap::new();
for entry in &entries {
latest_entries.entry(entry.biomarker_id).or_insert(entry);
}
// Fetch all reference rules
let rules = biomarker_reference_rule::Entity::find()
.order_by_asc(biomarker_reference_rule::Column::SortOrder)
.all(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Group rules by biomarker_id
let mut rules_map: HashMap<i32, Vec<&biomarker_reference_rule::Model>> = HashMap::new();
for rule in &rules {
rules_map.entry(rule.biomarker_id).or_default().push(rule);
}
// Build results
let mut results: Vec<BiomarkerResult> = Vec::new();
for bm in &biomarkers {
let entry = latest_entries.get(&bm.id);
let value = entry.map(|e| e.value);
let measured_at = entry.map(|e| e.measured_at.to_string());
// Find matching reference rule
let bm_rules = rules_map.get(&bm.id).map(|v| v.as_slice()).unwrap_or(&[]);
let (ref_min, ref_max, label, severity) = find_matching_rule(bm_rules, value, user_age, None);
results.push(BiomarkerResult {
biomarker_id: bm.id,
name: bm.name.clone(),
category_id: bm.category_id,
unit: bm.unit.clone(),
value,
measured_at,
ref_min,
ref_max,
label,
severity,
});
}
Ok(Json(results))
}
/// Find the best matching reference rule for a value.
fn find_matching_rule(
rules: &[&biomarker_reference_rule::Model],
value: Option<f64>,
user_age: Option<i32>,
_user_sex: Option<&str>,
) -> (Option<f64>, Option<f64>, String, i32) {
// Default: no data
if rules.is_empty() {
return (None, None, "No reference".to_string(), 0);
}
// Find the "range" type rule first (defines normal range)
let range_rule = rules.iter().find(|r| r.rule_type == "range");
let (ref_min, ref_max) = range_rule
.map(|r| (r.value_min, r.value_max))
.unwrap_or((None, None));
// If no value, return range with "No data" label
let Some(val) = value else {
return (ref_min, ref_max, "No data".to_string(), 0);
};
// Find matching scale rule based on value
for rule in rules {
// Check age bounds
if let Some(min_age) = rule.age_min {
if user_age.map(|a| a < min_age).unwrap_or(false) {
continue;
}
}
if let Some(max_age) = rule.age_max {
if user_age.map(|a| a > max_age).unwrap_or(false) {
continue;
}
}
// Check value bounds
let min_ok = rule.value_min.map(|min| val >= min).unwrap_or(true);
let max_ok = rule.value_max.map(|max| val <= max).unwrap_or(true);
if min_ok && max_ok {
return (ref_min, ref_max, rule.label.clone(), rule.severity);
}
}
// No matching rule found, determine based on range
if let (Some(min), Some(max)) = (ref_min, ref_max) {
if val < min {
return (ref_min, ref_max, "Low".to_string(), 1);
} else if val > max {
return (ref_min, ref_max, "High".to_string(), 1);
}
}
(ref_min, ref_max, "Normal".to_string(), 0)
}

View File

@@ -1,6 +1,6 @@
//! Biomarker matching and merging logic.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use strsim::jaro_winkler;
use super::types::{Biomarker, DocumentAnnotation, OcrResult};
@@ -10,49 +10,51 @@ use super::types::{Biomarker, DocumentAnnotation, OcrResult};
const FUZZY_THRESHOLD: f64 = 0.90;
/// Find a matching biomarker name from the valid set.
/// Returns the canonical name if found (exact, alias, or fuzzy match).
/// Returns the canonical name (original case) if found (exact, alias, or fuzzy match).
///
/// Matching order:
/// 1. Exact match on full name
/// 1. Exact match on full name (case-insensitive)
/// 2. Extract parenthetical alias from INPUT (e.g., `(HS-CRP)` from `HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)`)
/// 3. Extract parenthetical alias from SCHEMA (e.g., `HS-CRP` matches `HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)`)
/// 4. Fuzzy match with Jaro-Winkler (threshold 0.90)
fn find_matching_biomarker(name: &str, valid_biomarkers: &HashSet<String>) -> Option<String> {
///
/// valid_biomarkers: HashMap<uppercase_name, original_case_name>
fn find_matching_biomarker(name: &str, valid_biomarkers: &HashMap<String, String>) -> Option<String> {
let name_upper = name.to_uppercase();
// 1. Exact match first (fast path)
if valid_biomarkers.contains(&name_upper) {
return Some(name_upper);
// 1. Exact match first (fast path) - lookup by uppercase key, return original case value
if let Some(canonical) = valid_biomarkers.get(&name_upper) {
return Some(canonical.clone());
}
// 2. Try extracting parenthetical alias from INPUT
if let Some(alias) = extract_parenthetical_alias(&name_upper) {
if valid_biomarkers.contains(&alias) {
if let Some(canonical) = valid_biomarkers.get(&alias) {
tracing::debug!(
"Alias matched '{}' -> '{}' (extracted from parentheses in input)",
name, alias
name, canonical
);
return Some(alias);
return Some(canonical.clone());
}
}
// 3. Try matching input against aliases in SCHEMA
// This handles input "HS-CRP" matching schema "HIGH SENSITIVITY C-REACTIVE PROTEIN (HS-CRP)"
for valid in valid_biomarkers {
if let Some(alias) = extract_parenthetical_alias(valid) {
for (upper_key, canonical) in valid_biomarkers {
if let Some(alias) = extract_parenthetical_alias(upper_key) {
if alias == name_upper {
tracing::debug!(
"Reverse alias matched '{}' -> '{}' (input is alias in schema)",
name, valid
name, canonical
);
return Some(valid.clone());
return Some(canonical.clone());
}
}
}
// 4. Fuzzy match with threshold
// 4. Fuzzy match with threshold - compare against uppercase keys
valid_biomarkers.iter()
.map(|valid| (valid, jaro_winkler(&name_upper, valid)))
.map(|(upper_key, canonical)| (canonical, jaro_winkler(&name_upper, upper_key)))
.filter(|(_, score)| *score >= FUZZY_THRESHOLD)
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(matched_name, score)| {
@@ -88,7 +90,8 @@ fn extract_parenthetical_alias(name: &str) -> Option<String> {
/// Merge multiple OCR results into one, filtering to only known biomarkers.
/// Uses fuzzy matching to handle name variations.
pub fn merge_results(results: Vec<DocumentAnnotation>, valid_biomarkers: &HashSet<String>) -> OcrResult {
/// valid_biomarkers: HashMap<uppercase_name, original_case_name>
pub fn merge_results(results: Vec<DocumentAnnotation>, valid_biomarkers: &HashMap<String, String>) -> OcrResult {
let mut merged = OcrResult {
patient_name: None,
patient_age: None,

View File

@@ -12,9 +12,9 @@ use axum::{
http::StatusCode,
Json,
};
use sea_orm::{ActiveModelTrait, EntityTrait, Set};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::models::bio::source;
use crate::models::bio::{biomarker, biomarker_entry, source};
// Re-export public types
pub use types::{ErrorResponse, OcrState, ParseResponse};
@@ -166,6 +166,18 @@ async fn process_ocr_background(
let mut failed_chunk: Option<String> = None;
for start_page in (0..max_pages).step_by(chunk_size) {
// Check if source still exists before processing next chunk
let source_exists = source::Entity::find_by_id(source_id)
.one(&db)
.await
.map(|opt| opt.is_some())
.unwrap_or(false);
if !source_exists {
tracing::warn!("Source {} was deleted mid-parse, aborting OCR", source_id);
return Err("Source was deleted during parsing".to_string());
}
let pages: Vec<usize> = (start_page..std::cmp::min(start_page + chunk_size, max_pages)).collect();
tracing::info!("Processing OCR for pages {:?}", pages);
@@ -246,6 +258,8 @@ async fn process_ocr_background(
.map_err(|e| format!("Database error: {}", e))?
.ok_or_else(|| "Source not found".to_string())?;
let user_id = source_entity.user_id;
let mut active_model: source::ActiveModel = source_entity.into();
active_model.ocr_data = Set(Some(ocr_json));
active_model.status = Set("parsed".to_string());
@@ -254,9 +268,53 @@ async fn process_ocr_background(
active_model.update(&db).await
.map_err(|e| format!("Database update failed: {}", e))?;
// Create biomarker entries from parsed data
let mut entries_created = 0;
let now = chrono::Utc::now().naive_utc();
// Parse test_date or use current time
let measured_at = merged.test_date
.as_ref()
.and_then(|d| chrono::NaiveDate::parse_from_str(d, "%d %b %Y").ok()
.or_else(|| chrono::NaiveDate::parse_from_str(d, "%d %b, %Y").ok())
.or_else(|| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()))
.map(|date| date.and_hms_opt(0, 0, 0).unwrap())
.unwrap_or(now);
for bio in &merged.biomarkers {
// Skip if no numeric value
let Some(value) = bio.value else { continue };
// Look up biomarker ID by name
let biomarker_entity = biomarker::Entity::find()
.filter(biomarker::Column::Name.eq(&bio.name))
.one(&db)
.await
.map_err(|e| format!("Biomarker lookup error: {}", e))?;
let Some(biomarker_entity) = biomarker_entity else { continue };
// Create entry
let entry = biomarker_entry::ActiveModel {
biomarker_id: Set(biomarker_entity.id),
user_id: Set(user_id),
measured_at: Set(measured_at),
value: Set(value),
notes: Set(bio.unit.clone()),
source_id: Set(Some(source_id)),
created_at: Set(now),
};
// Insert (ignore if duplicate composite key)
if entry.insert(&db).await.is_ok() {
entries_created += 1;
}
}
tracing::info!(
"Successfully parsed {} biomarkers for source {}",
"Successfully parsed {} biomarkers, created {} entries for source {}",
merged.biomarkers.len(),
entries_created,
source_id
);

View File

@@ -1,10 +1,11 @@
//! Schema handling utilities.
use serde_json::Value;
use std::collections::HashSet;
use std::collections::HashMap;
/// Extract valid biomarker names from the ocr_schema.json enum.
pub fn extract_valid_biomarker_names() -> Result<HashSet<String>, String> {
/// Returns a HashMap where keys are UPPERCASE names (for matching) and values are original case names.
pub fn extract_valid_biomarker_names() -> Result<HashMap<String, String>, String> {
let schema_content = std::fs::read_to_string("ocr_schema.json")
.map_err(|e| format!("Failed to read ocr_schema.json: {}", e))?;
let schema: Value = serde_json::from_str(&schema_content)
@@ -21,10 +22,11 @@ pub fn extract_valid_biomarker_names() -> Result<HashSet<String>, String> {
.and_then(|e| e.as_array())
.ok_or_else(|| "Could not find biomarker name enum in schema".to_string())?;
let valid_names: HashSet<String> = names
// Key = uppercase (for matching), Value = original case (for DB lookup)
let valid_names: HashMap<String, String> = names
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_uppercase())
.map(|s| (s.to_uppercase(), s.to_string()))
.collect();
Ok(valid_names)

View File

@@ -7,13 +7,13 @@ use axum::{
};
use axum_extra::extract::Multipart;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use crate::models::bio::source;
use crate::models::bio::{biomarker_entry, source};
/// Response for a source.
#[derive(Serialize)]
@@ -215,6 +215,13 @@ pub async fn delete_source(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// Delete related biomarker entries first (cascade delete)
biomarker_entry::Entity::delete_many()
.filter(biomarker_entry::Column::SourceId.eq(id))
.exec(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Delete file from disk
if let Err(e) = fs::remove_file(&s.file_path).await {
tracing::warn!("Failed to delete file {}: {:?}", s.file_path, e);

View File

@@ -143,6 +143,7 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router {
// Entries API
.route("/api/entries", post(handlers::entries::create_entry))
.route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries))
.route("/api/users/{user_id}/results", get(handlers::entries::get_user_results))
.route_layer(middleware::from_fn(require_auth));
// Sources routes (need separate state for uploads path)