Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions crates/core/src/types/condition_chip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Condition chip type used by the condition-chip sync feature.
//!
//! A condition chip is a practice-wide quick-add preset shown under "Known
//! conditions" (e.g. "Hypertension"). Each chip has a deterministic ID derived
//! from its normalized text so that two machines independently adding the same
//! condition produce the same row — enabling per-item last-write-wins merge.

use serde::{Deserialize, Serialize};

/// Fixed namespace for UUID v5 generation of condition chip IDs.
/// Generated once and hardcoded — must never change (would break ID stability).
const CONDITION_NAMESPACE: uuid::Uuid = uuid::Uuid::from_bytes([
0x4a, 0x3e, 0xc1, 0x07, 0x9b, 0x2d, 0x4f, 0x6a,
0xa1, 0x10, 0xd8, 0x4f, 0xa2, 0xb3, 0xc5, 0xe7,
]);

/// A condition chip entry with sync metadata.
///
/// - `id`: deterministic UUID v5 from `normalize_for_id(&text)`. Two machines
/// adding "Hypertension" produce the same id.
/// - `updated_at`: ISO 8601 UTC string — the last-write-wins clock.
/// - `deleted_at`: tombstone timestamp. `None` means active.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConditionChip {
pub id: String,
pub text: String,
pub updated_at: String,
pub deleted_at: Option<String>,
}

/// Normalize condition text for deterministic ID generation.
///
/// Lowercases and trims so "Hypertension", "hypertension ", and
/// "HYPERTENSION" all produce the same id.
pub fn normalize_for_id(text: &str) -> String {
text.trim().to_lowercase()
}

/// Generate a deterministic UUID v5 from normalized condition text.
///
/// Same text always produces the same UUID, across machines and restarts.
pub fn deterministic_id(text: &str) -> String {
uuid::Uuid::new_v5(&CONDITION_NAMESPACE, normalize_for_id(text).as_bytes())
.to_string()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deterministic_id_is_stable() {
assert_eq!(deterministic_id("Hypertension"), deterministic_id("Hypertension"));
}

#[test]
fn deterministic_id_is_case_insensitive() {
assert_eq!(deterministic_id("Hypertension"), deterministic_id("hypertension"));
}

#[test]
fn deterministic_id_ignores_whitespace() {
assert_eq!(deterministic_id("Hypertension"), deterministic_id(" Hypertension "));
}

#[test]
fn different_conditions_have_different_ids() {
assert_ne!(deterministic_id("Hypertension"), deterministic_id("Diabetes"));
}
}
3 changes: 3 additions & 0 deletions crates/core/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! | [`processing`] | Queue tasks, batch processing, priority |
//! | [`agent`] | Agent context, tools, patient context |
//! | [`ai`] | Completion request/response, messages, streaming |
//! | [`condition_chip`] | [`condition_chip::ConditionChip`] with deterministic UUID v5 id |
//! | [`stt`] | Audio data, transcription config/results |
//! | [`tts`] | TTS config, voice info |
//! | [`rag`] | RAG results, search config, knowledge graph types |
Expand All @@ -20,6 +21,7 @@

pub mod agent;
pub mod ai;
pub mod condition_chip;
pub mod endpoint;
pub mod letter_audience;
pub mod processing;
Expand All @@ -32,6 +34,7 @@ pub mod vocabulary;

pub use agent::*;
pub use ai::*;
pub use condition_chip::*;
pub use endpoint::*;
pub use letter_audience::LetterAudience;
pub use processing::*;
Expand Down
15 changes: 15 additions & 0 deletions crates/core/src/types/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,13 @@ pub struct AppConfig {
/// docs/superpowers/specs/2026-05-11-training-corpus-design.md.
#[serde(default)]
pub capture_for_training: bool,

// Condition chip sync
/// When true, condition chip presets sync two-way between this machine
/// and the paired server via the vocab API. Defaults to false — each
/// machine keeps its own list unless the user opts in.
#[serde(default)]
pub sync_condition_chips: bool,
}

impl Default for AppConfig {
Expand Down Expand Up @@ -729,6 +736,14 @@ mod tests {
assert!(!cfg.capture_for_training, "default must be false");
}

#[test]
fn sync_condition_chips_defaults_to_false_in_older_configs() {
let old_json = r#"{"ai_provider":"ollama","stt_mode":"local"}"#;
let cfg: AppConfig =
serde_json::from_str(old_json).expect("should parse with serde defaults");
assert!(!cfg.sync_condition_chips, "default must be false");
}

#[test]
fn capture_for_training_round_trips() {
let mut cfg = AppConfig::default();
Expand Down
Loading
Loading