diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6765923 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + build-test: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Cache NuGet + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore + run: dotnet restore "Symptom Checker.sln" + + - name: Build + run: dotnet build "Symptom Checker.sln" -c ${{ matrix.configuration }} --no-restore + + - name: Test + run: dotnet test "Symptom Checker.sln" -c ${{ matrix.configuration }} --no-build --logger trx + + markdown-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Markdownlint + uses: DavidAnson/markdownlint-cli2-action@v16 + with: + globs: | + **/*.md \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..67d2ae5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD013": false +} diff --git a/Models/ConditionMatch.cs b/Models/ConditionMatch.cs index 58f6e31..ec8ce37 100644 --- a/Models/ConditionMatch.cs +++ b/Models/ConditionMatch.cs @@ -5,5 +5,6 @@ public class ConditionMatch public string Name { get; set; } = string.Empty; public double Score { get; set; } public int MatchCount { get; set; } + public List MatchedSymptoms { get; set; } = new(); } } diff --git a/Program.cs b/Program.cs index 8c8e875..7dd1bd7 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using System; using System.Windows.Forms; +using SymptomCheckerApp.Services; namespace SymptomCheckerApp { @@ -11,25 +12,34 @@ static void Main() Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); + var logger = new LoggerService(logDir); + logger.Info("Application starting"); Application.ThreadException += (s, e) => { try { MessageBox.Show(e.Exception.ToString(), "Unhandled UI Exception", MessageBoxButtons.OK, MessageBoxIcon.Error); } catch { } + try { logger.Error("Unhandled UI thread exception", e.Exception); } catch { } }; AppDomain.CurrentDomain.UnhandledException += (s, e) => { try { MessageBox.Show((e.ExceptionObject?.ToString() ?? "Unknown error"), "Unhandled Exception", MessageBoxButtons.OK, MessageBoxIcon.Error); } catch { } + try { logger.Error("Unhandled domain exception", e.ExceptionObject as Exception); } catch { } }; try { - Application.Run(new UI.MainForm()); + var form = new UI.MainForm(); + logger.Info("MainForm created"); + Application.Run(form); } catch (Exception ex) { try { MessageBox.Show(ex.ToString(), "Startup Exception", MessageBoxButtons.OK, MessageBoxIcon.Error); } catch { } + try { logger.Error("Startup exception", ex); } catch { } } + try { logger.Info("Application exiting"); } catch { } } } } diff --git a/README.md b/README.md index ec511a4..4591313 100644 --- a/README.md +++ b/README.md @@ -27,35 +27,85 @@ dotnet run ## Features at a glance -- Three detection models: Jaccard, Cosine (binary), Naive Bayes (Bernoulli with Laplace smoothing) -- Threshold (%), Min Match Count, and Top‑K result limiting -- Symptom filter box with “Select Visible” / “Clear Visible”; selections persist across filters -- Category selector with “Select Category” / “Clear Category” (quick-pick symptoms by system) -- Optional “Show only category” toggle to restrict the symptom list to the selected category (auto-enabled when you change the category) -- Synonym-aware filtering (e.g., typing “Rhinorrhea” matches “Runny Nose”) -- Single‑click selection in the symptom list (no double‑click needed) -- Shows only conditions that actually share ≥1 selected symptom -- Highlights the most probable result(s) in the results list -- Double‑click a result to view a details dialog (name, score, matches, full symptom list) -- “Sync” button to import latest disease–symptom pairs from Wikidata (no API key), merge, and save -- Exit button for quick close; “Check” disabled until at least one symptom is selected +- Model weighting & calibration +- Vitals inputs: Temp (°C), HR, RR, BP (SBP/DBP), SpO₂, Weight (kg) +- Decision rules (educational): + - Centor/McIsaac scores with age adjustment and brief advice + - PERC rule: pass/fail with component checklist +- Triage v2: red‑flag banner now considers symptoms + vitals thresholds + PERC context +- Internationalization and UX: EN/FR/AR with RTL support, dark mode, and persisted settings ## How to use 1. Use the filter box to quickly find symptoms. Click “Select Visible” to check all currently filtered items, or “Clear Visible” to uncheck them. Your previous checks are preserved when you change the filter text. -2. Pick a detection model from the dropdown: - - Jaccard: set overlap (good baseline) - - Cosine (binary): vector similarity on presence/absence - - Naive Bayes: probabilistic model with smoothing; scores are normalized -3. Adjust parameters as needed: - - Threshold (%): minimum score to keep a result - - Min Match: require at least N overlapping symptoms - - Top‑K: show only the top K matches (optional) -4. Click “Check” to compute matches. Results show “Condition — Score: X.XX — Matches: N”. The top score is highlighted. Only real matches are shown. -5. Double‑click a result to open a details dialog with the full symptom list. + +1. Pick a detection model from the dropdown: + +- Jaccard: set overlap (good baseline) +- Cosine (binary): vector similarity on presence/absence +- Naive Bayes: probabilistic model with smoothing; scores are normalized + +1. Adjust parameters as needed: + +- Threshold (%): minimum score to keep a result +- Min Match: require at least N overlapping symptoms +- Top‑K: show only the top K matches (optional) + +1. Click “Check” to compute matches. Results show “Condition — Score: X.XX — Matches: N”. The top score is highlighted. Only real matches are shown. + +1. Double‑click a result to open a details dialog with the full symptom list. + +Tip for labs: Use the Category selector and choose “Laboratory Findings” to quickly pick lab items like High WBC, Elevated ALT/AST, High HbA1c, etc. Tip: Start with Jaccard and a low threshold (e.g., 0–30%) for exploratory use. +### Using vitals and decision rules (educational) + +- Enter vitals in the Vitals row: + - TempC (°C), Heart Rate (bpm), Resp Rate (/min), BP (SBP/DBP), SpO₂ (%), Weight (kg) + - Values persist between runs. Units are metric. +- Centor/McIsaac group: + - Centor components are inferred from your current selection and vitals: + - Fever: Temp ≥ 38°C or “Fever” symptom + - Tonsillar exudates/swelling: proxied by “Sore Throat” (educational simplification) + - Tender anterior cervical nodes: “Swollen Lymph Nodes” symptom + - Absence of cough: no “Cough” symptom selected + - McIsaac age adjustment: +1 if < 15 years; −1 if ≥ 45 years + - The UI shows localized Centor and McIsaac scores plus brief advice per score band +- PERC group (PE Rule‑out Criteria): + - Evaluates 8 items: Age < 50, HR < 100, SpO₂ ≥ 95, and absence of hemoptysis, estrogen use, prior DVT/PE, unilateral leg swelling, recent surgery/trauma + - If all are met → PERC negative (with low pretest probability, PE is unlikely). Otherwise → PERC positive + - The result text is localized + +### Triage banner (v2) + +The banner lists possible red flags based on symptoms and now also on vitals and PERC context. Thresholds used (educational only): + +- SpO₂ < 92% → Hypoxia +- SBP < 90 → Hypotension +- SBP ≥ 180 or DBP ≥ 120 → Severely high blood pressure +- HR ≥ 120 → Tachycardia +- RR ≥ 30 → Tachypnea +- Temp ≥ 40°C → Very high fever +- PERC positive with chest pain or shortness of breath → cannot rule out PE + +Notes: + +- These signals are for learning only and do not replace clinical judgment. +- The banner text is localized; layout adapts to RTL languages. + +### Exporting results + +You can export the current results via the results list context menu: + +- Export Results (CSV): Writes a UTF‑8 CSV with columns: Condition, Score, Matches, Category +- Export Results (Markdown): Writes a Markdown table with the same columns + +Details: + +- Column headers are localized (EN/FR/AR). Category is determined by best symptom overlap with your dataset categories. +- RTL languages are supported in the UI; the file content remains left‑to‑right for portability. + ## Detection models - Jaccard: |A ∩ B| / |A ∪ B| on sets of symptoms (selected vs condition) @@ -80,6 +130,8 @@ SymptomChecker/ ├── data/ │ └── conditions.json # Local dataset (also updated after Sync) │ └── categories.json # Symptom category groups (editable) +│ └── translations.json # UI/messages + symptom/condition/category labels (i18n) +│ └── synonyms.json # Aliases (e.g., HbA1c → High HbA1c, Rhinorrhea → Runny Nose) ├── Models/ │ ├── Symptom.cs │ ├── Condition.cs diff --git a/Services/LoggerService.cs b/Services/LoggerService.cs new file mode 100644 index 0000000..3d159ef --- /dev/null +++ b/Services/LoggerService.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Text; + +namespace SymptomCheckerApp.Services +{ + public class LoggerService + { + private readonly string _logDirectory; + private readonly string _currentLogPath; + private readonly object _lock = new(); + private const int MaxFiles = 5; // keep last 5 logs + private const long MaxSizeBytes = 512 * 1024; // 512 KB per file + + public LoggerService(string logDirectory) + { + _logDirectory = logDirectory; + Directory.CreateDirectory(_logDirectory); + _currentLogPath = Path.Combine(_logDirectory, $"log_{DateTime.UtcNow:yyyyMMdd_HHmmss}.txt"); + PruneOldLogs(); + } + + public void Info(string message) => Write("INFO", message); + public void Warn(string message) => Write("WARN", message); + public void Error(string message, Exception? ex = null) => Write("ERROR", message + (ex != null ? Environment.NewLine + ex : string.Empty)); + + private void Write(string level, string message) + { + try + { + lock (_lock) + { + RotateIfNeeded(); + var line = $"{DateTime.UtcNow:O}\t{level}\t{message}"; + File.AppendAllText(_currentLogPath, line + Environment.NewLine, Encoding.UTF8); + } + } + catch { /* swallow logging errors */ } + } + + private void RotateIfNeeded() + { + try + { + if (File.Exists(_currentLogPath)) + { + var fi = new FileInfo(_currentLogPath); + if (fi.Length > MaxSizeBytes) + { + // Start a new file + File.Move(_currentLogPath, _currentLogPath + ".old", true); + } + } + } + catch { } + } + + private void PruneOldLogs() + { + try + { + var files = new DirectoryInfo(_logDirectory).GetFiles("log_*.txt") + .OrderByDescending(f => f.CreationTimeUtc) + .ToList(); + for (int i = MaxFiles; i < files.Count; i++) + { + try { files[i].Delete(); } catch { } + } + } + catch { } + } + } +} diff --git a/Services/SchemaValidator.cs b/Services/SchemaValidator.cs new file mode 100644 index 0000000..c56986e --- /dev/null +++ b/Services/SchemaValidator.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using NJsonSchema; + +namespace SymptomCheckerApp.Services +{ + public static class SchemaValidator + { + public static async Task ValidateAsync(string jsonPath, string schemaPath) + { + try + { + if (!File.Exists(jsonPath)) return $"Data file not found: {jsonPath}"; + if (!File.Exists(schemaPath)) return $"Schema file not found: {schemaPath}"; + var json = await File.ReadAllTextAsync(jsonPath); + using var doc = JsonDocument.Parse(json); + var schemaJson = await File.ReadAllTextAsync(schemaPath); + var schema = await JsonSchema.FromJsonAsync(schemaJson); + var errors = schema.Validate(doc.RootElement.ToString()); + if (errors.Count == 0) return null; + using var sw = new StringWriter(); + sw.WriteLine($"Validation errors for {Path.GetFileName(jsonPath)}:"); + foreach (var e in errors) + { + sw.WriteLine($" - {e.Path}: {e.Kind} ({e.ToString()})"); + } + return sw.ToString(); + } + catch (Exception ex) + { + return $"Exception validating {jsonPath}: {ex.Message}"; + } + } + } +} diff --git a/Services/SettingsService.cs b/Services/SettingsService.cs index 8cb4de5..ce89e2d 100644 --- a/Services/SettingsService.cs +++ b/Services/SettingsService.cs @@ -15,6 +15,27 @@ public class AppSettings public bool ShowOnlyCategory { get; set; } public string? SelectedCategory { get; set; } public string? FilterText { get; set; } + // Vitals (0 or null means not set) + public double? TempC { get; set; } + public int? HeartRate { get; set; } + public int? RespRate { get; set; } + public int? SystolicBP { get; set; } + public int? DiastolicBP { get; set; } + public int? SpO2 { get; set; } + public double? WeightKg { get; set; } + // Decision rules context + public int? AgeYears { get; set; } + // PERC flags (true means risk factor present) + public bool? PercHemoptysis { get; set; } + public bool? PercEstrogenUse { get; set; } + public bool? PercPriorDvtPe { get; set; } + public bool? PercUnilateralLegSwelling { get; set; } + public bool? PercRecentSurgeryTrauma { get; set; } + public string? LastExportFolder { get; set; } + public Dictionary? CategoryWeights { get; set; } + public double? NaiveBayesTemperature { get; set; } + // UI layout prefs + public bool? LeftPanelCollapsed { get; set; } } private readonly string _settingsPath; @@ -51,4 +72,20 @@ public void Save() } catch { } } + + public void Reset() + { + Settings = new AppSettings + { + // Provide some sane defaults + Language = "en", + DarkMode = false, + Model = "Jaccard", + ThresholdPercent = 0, + MinMatch = 1, + TopK = 0, + ShowOnlyCategory = false + }; + Save(); + } } diff --git a/Services/SymptomCheckerService.cs b/Services/SymptomCheckerService.cs index 20fd193..3ed56d2 100644 --- a/Services/SymptomCheckerService.cs +++ b/Services/SymptomCheckerService.cs @@ -12,6 +12,7 @@ public class SymptomCheckerService private readonly ConditionDatabase _db; private readonly string _jsonPath; private List _vocabulary; // all unique symptoms + private Dictionary> _conditionSets = new(StringComparer.OrdinalIgnoreCase); // cached per-condition symptom sets public SymptomCheckerService(string jsonPath) { @@ -73,7 +74,10 @@ public List GetMatches( DetectionModel model, double threshold = 0.0, int? topK = null, - int minMatchCount = 0) + int minMatchCount = 0, + IReadOnlyDictionary? categoryWeights = null, + double? naiveBayesTemperature = null, + Func>? getConditionCategories = null) { var selectedSet = selectedSymptoms .Where(s => !string.IsNullOrWhiteSpace(s)) @@ -93,13 +97,18 @@ public List GetMatches( case DetectionModel.Jaccard: foreach (var c in _db.Conditions) { - var condSet = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); - int inter = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).Count(); + if (!_conditionSets.TryGetValue(c.Name, out var condSet)) + { + condSet = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); + _conditionSets[c.Name] = condSet; + } + var interSymptoms = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).ToList(); + int inter = interSymptoms.Count; int union = condSet.Union(selectedSet, StringComparer.OrdinalIgnoreCase).Count(); double score = union == 0 ? 0 : (double)inter / union; if (score >= threshold) { - results.Add(new ConditionMatch { Name = c.Name, Score = score, MatchCount = inter }); + results.Add(new ConditionMatch { Name = c.Name, Score = score, MatchCount = inter, MatchedSymptoms = interSymptoms }); } } break; @@ -107,13 +116,18 @@ public List GetMatches( // Binary vector cosine similarity foreach (var c in _db.Conditions) { - var condSet = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); - int dot = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).Count(); + if (!_conditionSets.TryGetValue(c.Name, out var condSet)) + { + condSet = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); + _conditionSets[c.Name] = condSet; + } + var interSymptoms = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).ToList(); + int dot = interSymptoms.Count; double denom = Math.Sqrt(condSet.Count) * Math.Sqrt(selectedSet.Count); double score = denom == 0 ? 0 : dot / denom; if (score >= threshold) { - results.Add(new ConditionMatch { Name = c.Name, Score = score, MatchCount = dot }); + results.Add(new ConditionMatch { Name = c.Name, Score = score, MatchCount = dot, MatchedSymptoms = interSymptoms }); } } break; @@ -126,12 +140,17 @@ public List GetMatches( // Here, feature presence is deterministic for our dataset: symptom present or not. // We set p_present = (1 + 1) / (2 + 2) if present else (0 + 1) / (2 + 2) to avoid 0/1 extremes. // Alternatively, use p_present = 0.8 if present, 0.2 otherwise (heuristic). We'll use Laplace Bernoulli below. - var condScores = new List<(string name, double logProb, int matchCount)>(); + var condScores = new List<(string name, double logProb, int matchCount, List matched)>(); foreach (var c in _db.Conditions) { - var condSet = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!_conditionSets.TryGetValue(c.Name, out var condSet)) + { + condSet = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); + _conditionSets[c.Name] = condSet; + } double logP = 0.0; // log prior same for all → omitted - int matchCount = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).Count(); + var matched = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).ToList(); + int matchCount = matched.Count; foreach (var sym in _vocabulary) { @@ -143,20 +162,33 @@ public List GetMatches( bool selected = selectedSet.Contains(sym); logP += Math.Log(selected ? p1 : p0); } - condScores.Add((c.Name, logP, matchCount)); + condScores.Add((c.Name, logP, matchCount, matched)); } // Normalize log probs to [0,1] via softmax double maxLog = condScores.Max(t => t.logProb); - var soft = condScores.Select(t => (t.name, Math.Exp(t.logProb - maxLog), t.matchCount)).ToList(); + var soft = condScores.Select(t => (t.name, Math.Exp(t.logProb - maxLog), t.matchCount, t.matched)).ToList(); double Z = soft.Sum(t => t.Item2); - foreach (var (name, exp, matchCount) in soft) + double temp = (naiveBayesTemperature.HasValue && naiveBayesTemperature.Value > 0.01) ? naiveBayesTemperature.Value : 1.0; + foreach (var (name, exp, matchCount, matched) in soft) { - double prob = Z == 0 ? 0 : exp / Z; + double probRaw = Z == 0 ? 0 : exp / Z; + double prob = probRaw; + if (Math.Abs(temp - 1.0) > 1e-6) + { + // Temperature scaling approximated by exponent adjustment then later normalization + prob = Math.Pow(probRaw, 1.0 / temp); + } if (prob >= threshold) { - results.Add(new ConditionMatch { Name = name, Score = prob, MatchCount = matchCount }); + results.Add(new ConditionMatch { Name = name, Score = prob, MatchCount = matchCount, MatchedSymptoms = matched }); } } + // If temperature scaling used, renormalize + if (results.Count > 0 && Math.Abs(temp - 1.0) > 1e-6) + { + double sumT = results.Sum(r => r.Score); + if (sumT > 0) foreach (var r in results) r.Score /= sumT; + } break; default: throw new NotSupportedException($"Model '{model}' not supported."); @@ -168,6 +200,31 @@ public List GetMatches( results = results.Where(r => r.MatchCount >= minMatchCount).ToList(); } + // Apply category weighting before final ordering + if (categoryWeights != null && categoryWeights.Count > 0 && getConditionCategories != null) + { + foreach (var r in results) + { + try + { + double bestWeight = 1.0; + var cats = getConditionCategories(r.Name) ?? Array.Empty(); + foreach (var c in cats) + { + if (categoryWeights.TryGetValue(c, out var w) && w > bestWeight) bestWeight = w; + } + r.Score *= bestWeight; + } + catch { } + } + // Renormalize if NaiveBayes after weighting to keep probabilistic interpretation + if (model == DetectionModel.NaiveBayes) + { + double sumW = results.Sum(r => r.Score); + if (sumW > 0) foreach (var r in results) r.Score /= sumW; + } + } + // Order by score desc, then match count desc, then name results = results .OrderByDescending(r => r.Score) @@ -190,6 +247,12 @@ public void RebuildVocabulary() .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(s => s) .ToList(); + // Rebuild condition sets cache + _conditionSets.Clear(); + foreach (var c in _db.Conditions) + { + _conditionSets[c.Name] = c.Symptoms.ToHashSet(StringComparer.OrdinalIgnoreCase); + } } public int MergeConditions(IEnumerable newConditions) @@ -230,7 +293,7 @@ public int MergeConditions(IEnumerable newConditions) if (changes > 0) { - RebuildVocabulary(); + RebuildVocabulary(); // also rebuilds cached sets } return changes; diff --git a/Services/TriageService.cs b/Services/TriageService.cs index 9cbced3..74267d9 100644 --- a/Services/TriageService.cs +++ b/Services/TriageService.cs @@ -19,6 +19,15 @@ public static class TriageService { "RF_BloodInUrine", 2 }, { "RF_TesticularPain", 3 }, + + // Vitals-based triage v2 + { "RF_Hypoxia", 1 }, // SpO2 < 92% + { "RF_Hypotension", 1 }, // SBP < 90 + { "RF_PERC_Positive", 1 }, // PERC positive with chest pain/SOB + { "RF_SevereHypertension", 2 }, // SBP >= 180 or DBP >= 120 + { "RF_Tachycardia", 2 }, // HR >= 120 + { "RF_Tachypnea", 2 }, // RR >= 30 + { "RF_HighFever", 2 }, // Temp >= 40°C }; /// /// Evaluate selected symptoms and return localized message keys for red‑flag warnings (educational only). @@ -79,5 +88,43 @@ public static List Evaluate(HashSet selectedSymptoms) }); return list; } + + /// + /// Triage v2: include vitals and PERC context for more nuanced urgency signaling (educational only). + /// + public static List EvaluateV2( + HashSet selectedSymptoms, + double? tempC = null, + int? heartRate = null, + int? respRate = null, + int? systolicBP = null, + int? diastolicBP = null, + int? spO2 = null, + bool? percPositiveWithChestOrSob = null) + { + var keys = new HashSet(Evaluate(selectedSymptoms), StringComparer.OrdinalIgnoreCase); + + // Add vitals-based red flags + if (spO2.HasValue && spO2.Value < 92) keys.Add("RF_Hypoxia"); + if (systolicBP.HasValue && systolicBP.Value < 90) keys.Add("RF_Hypotension"); + if (systolicBP.HasValue && systolicBP.Value >= 180) keys.Add("RF_SevereHypertension"); + if (diastolicBP.HasValue && diastolicBP.Value >= 120) keys.Add("RF_SevereHypertension"); + if (heartRate.HasValue && heartRate.Value >= 120) keys.Add("RF_Tachycardia"); + if (respRate.HasValue && respRate.Value >= 30) keys.Add("RF_Tachypnea"); + if (tempC.HasValue && tempC.Value >= 40.0) keys.Add("RF_HighFever"); + + // PERC positive in the context of chest pain / SOB increases urgency + if (percPositiveWithChestOrSob == true) keys.Add("RF_PERC_Positive"); + + var list = new List(keys); + list.Sort((a, b) => + { + int pa = SeverityPriority.TryGetValue(a, out var va) ? va : 99; + int pb = SeverityPriority.TryGetValue(b, out var vb) ? vb : 99; + int cmp = pa.CompareTo(pb); + return cmp != 0 ? cmp : string.Compare(a, b, StringComparison.OrdinalIgnoreCase); + }); + return list; + } } } diff --git a/Symptom Checker.sln b/Symptom Checker.sln new file mode 100644 index 0000000..9dcbbb4 --- /dev/null +++ b/Symptom Checker.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SymptomChecker", "SymptomChecker.csproj", "{C8D393E4-E8D4-797C-8936-7E9B0B61D981}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C8D393E4-E8D4-797C-8936-7E9B0B61D981}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8D393E4-E8D4-797C-8936-7E9B0B61D981}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8D393E4-E8D4-797C-8936-7E9B0B61D981}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8D393E4-E8D4-797C-8936-7E9B0B61D981}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {68D9E4D0-BD04-4077-A745-E949E4293024} + EndGlobalSection +EndGlobal diff --git a/SymptomChecker.csproj b/SymptomChecker.csproj index 55ad683..37156ad 100644 --- a/SymptomChecker.csproj +++ b/SymptomChecker.csproj @@ -9,6 +9,16 @@ + + + + + + + + + + PreserveNewest diff --git a/UI/MainForm.cs b/UI/MainForm.cs index 2d65864..22c2654 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -46,6 +46,19 @@ public CatItem(string canonical, string display) public override string ToString() => Display; } + // Header row used to group results by category in the owner-drawn ListBox + private sealed class GroupHeader + { + public string CanonicalCategory { get; } + public string Display { get; } + public GroupHeader(string canonicalCategory, string display) + { + CanonicalCategory = canonicalCategory; + Display = display; + } + public override string ToString() => Display; + } + private readonly CheckedListBox _symptomList = new CheckedListBox(); private readonly Button _checkButton = new Button(); private readonly Button _exitButton = new Button(); @@ -65,12 +78,27 @@ public CatItem(string canonical, string display) private readonly Button _clearVisibleButton = new Button(); private readonly Button _saveSessionButton = new Button(); private readonly Button _loadSessionButton = new Button(); + private readonly Button _resetSettingsButton = new Button(); + private readonly Button _saveSettingsProfileButton = new Button(); + private readonly Button _loadSettingsProfileButton = new Button(); private readonly Button _syncButton = new Button(); private readonly Button _missingTransButton = new Button(); + private readonly Button _helpButton = new Button(); + private readonly Button _openLogsButton = new Button(); private readonly ComboBox _languageSelector = new ComboBox(); private readonly ToolTip _toolTip = new ToolTip(); private readonly ContextMenuStrip _resultsContext = new ContextMenuStrip(); private readonly Label _lblLanguage = new Label(); + private readonly Label _lblPerf = new Label(); + // UI: collapse toggle + private Button? _collapseBtn; + // Model tuning (new) + private readonly ComboBox _weightCatSelector = new ComboBox(); + private readonly NumericUpDown _weightValue = new NumericUpDown(); + private readonly Button _applyWeightButton = new Button(); + private readonly Button _clearWeightsButton = new Button(); + private readonly CheckBox _nbTempEnable = new CheckBox(); + private readonly NumericUpDown _nbTempValue = new NumericUpDown(); private readonly CheckBox _darkModeToggle = new CheckBox(); private readonly Label _lblFilter = new Label(); private readonly Label _lblCategory = new Label(); @@ -78,6 +106,42 @@ public CatItem(string canonical, string display) private readonly Label _lblThresh = new Label(); private readonly Label _lblMinMatch = new Label(); private readonly Label _lblTopK = new Label(); + private readonly Label _lblSettingsPath = new Label(); + // Vitals controls + private readonly Label _lblVitals = new Label(); + private readonly NumericUpDown _numTempC = new NumericUpDown(); + private readonly NumericUpDown _numHR = new NumericUpDown(); + private readonly NumericUpDown _numRR = new NumericUpDown(); + private readonly NumericUpDown _numSBP = new NumericUpDown(); + private readonly NumericUpDown _numDBP = new NumericUpDown(); + private readonly NumericUpDown _numSpO2 = new NumericUpDown(); + private readonly NumericUpDown _numWeightKg = new NumericUpDown(); + private readonly Label _lblTemp = new Label(); + private readonly Label _lblHR = new Label(); + private readonly Label _lblRR = new Label(); + private readonly Label _lblBP = new Label(); + private readonly Label _lblSpO2 = new Label(); + private readonly Label _lblWeight = new Label(); + // Decision rules (Centor/McIsaac) + private readonly Label _lblRules = new Label(); + private readonly GroupBox _grpCentor = new GroupBox(); + private readonly CheckBox _centorFever = new CheckBox(); + private readonly CheckBox _centorTonsils = new CheckBox(); + private readonly CheckBox _centorNodes = new CheckBox(); + private readonly CheckBox _centorNoCough = new CheckBox(); + private readonly Label _centorScore = new Label(); + private readonly Label _mcIsaacScore = new Label(); + private readonly Label _centorAdvice = new Label(); + private readonly Label _lblAge = new Label(); + private readonly NumericUpDown _numAge = new NumericUpDown(); + // PERC Rule (Pulmonary Embolism Rule-out Criteria) + private readonly GroupBox _grpPerc = new GroupBox(); + private readonly Label _percResult = new Label(); + private readonly CheckBox _percHemoptysis = new CheckBox(); + private readonly CheckBox _percEstrogen = new CheckBox(); + private readonly CheckBox _percPriorDvtPe = new CheckBox(); + private readonly CheckBox _percUnilateralLeg = new CheckBox(); + private readonly CheckBox _percRecentSurgery = new CheckBox(); private SymptomCheckerService? _service; private CategoriesService? _categoriesService; private SynonymService? _synonymService; @@ -86,6 +150,10 @@ public CatItem(string canonical, string display) private System.Collections.Generic.List _lastResults = new System.Collections.Generic.List(); private List _allSymptoms = new List(); private HashSet _checkedSymptoms = new HashSet(StringComparer.OrdinalIgnoreCase); + // Maps each _resultsList item index to the corresponding _lastResults index; headers map to -1 + private readonly List _resultIndexMap = new List(); + // Cached category -> symptom sets for performance + private readonly Dictionary> _categorySetsCache = new(StringComparer.OrdinalIgnoreCase); public MainForm() { @@ -112,6 +180,24 @@ public MainForm() InitializeLayout(); Load += MainForm_Load; + // Set initial focus for keyboard/screen reader users and ensure right panel is wide enough on first show + this.Shown += (s, e) => + { + try + { + var sc = this.Controls.OfType().FirstOrDefault(c => c.Name == "_mainSplit"); + if (sc != null) + { + int minRight = 420; // ensure clear right menu area at launch + if (sc.Width - sc.SplitterDistance < minRight) + { + sc.SplitterDistance = Math.Max(sc.Panel1MinSize, sc.Width - minRight); + } + } + } + catch { } + _filterBox.Focus(); + }; this.FormClosing += (s, e) => { try { _settingsService?.Save(); } catch { } @@ -130,8 +216,42 @@ private void InitializeLayout() { Dock = DockStyle.Fill, Orientation = Orientation.Vertical, - SplitterDistance = 400 + SplitterDistance = 400, + Name = "_mainSplit" + }; + // Ensure the right panel has enough space by default + try { split.Panel2MinSize = 360; split.Panel1MinSize = 220; split.SplitterWidth = 6; } catch { } + // Collapse/expand button (overlay small) + var collapseBtn = new Button + { + Text = "≪", + Width = 28, + Height = 24, + FlatStyle = FlatStyle.Standard, + TabStop = false + }; + _collapseBtn = collapseBtn; + bool collapsed = false; + collapseBtn.Click += (s, e) => + { + if (!collapsed) + { + split.Panel1Collapsed = true; + collapseBtn.Text = "≫"; + collapsed = true; + try { _settingsService!.Settings.LeftPanelCollapsed = true; _settingsService.Save(); } catch { } + } + else + { + split.Panel1Collapsed = false; + // Reassign a reasonable default width + try { split.SplitterDistance = Math.Max(250, Width / 3); } catch { } + collapseBtn.Text = "≪"; + collapsed = false; + try { _settingsService!.Settings.LeftPanelCollapsed = false; _settingsService.Save(); } catch { } + } }; + // We'll place this control in the top controls row to avoid overlaying content // Left side: filter controls + symptom list var leftPanel = new TableLayoutPanel @@ -143,11 +263,22 @@ private void InitializeLayout() leftPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); leftPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - var filterBar = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(3) }; + // Filter / actions bar (left). Previously AutoSize could grow vertically and hide the list on small screens. + var filterBar = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + WrapContents = true, + AutoScroll = false, // We'll wrap; container will cap height + Padding = new Padding(3), + FlowDirection = FlowDirection.LeftToRight, + Margin = new Padding(0) + }; _lblFilter.Text = "Filter:"; _lblFilter.AutoSize = true; _lblFilter.Padding = new Padding(0, 6, 0, 0); _filterBox.Width = 220; + _filterBox.TabIndex = 0; _filterBox.AccessibleName = "Filter symptoms"; _filterBox.AccessibleDescription = "Type text to filter the symptoms list"; _filterBox.TextChanged += (s, e) => @@ -162,10 +293,12 @@ private void InitializeLayout() _selectVisibleButton.Text = "Select Visible"; _selectVisibleButton.AutoSize = true; + _selectVisibleButton.TabIndex = 1; _selectVisibleButton.Click += (s, e) => SelectVisibleSymptoms(); _clearVisibleButton.Text = "Clear Visible"; _clearVisibleButton.AutoSize = true; + _clearVisibleButton.TabIndex = 2; _clearVisibleButton.Click += (s, e) => ClearVisibleSymptoms(); _lblCategory.Text = "Category:"; @@ -173,6 +306,8 @@ private void InitializeLayout() _lblCategory.Padding = new Padding(10, 6, 0, 0); _categorySelector.DropDownStyle = ComboBoxStyle.DropDownList; _categorySelector.Width = 180; + _categorySelector.AccessibleName = "Category filter"; + _categorySelector.TabIndex = 3; _categorySelector.SelectedIndexChanged += (s, e) => { _showOnlyCategory.Checked = true; @@ -186,12 +321,16 @@ private void InitializeLayout() }; _selectCategoryButton.Text = "Select Category"; _selectCategoryButton.AutoSize = true; + _selectCategoryButton.TabIndex = 4; _selectCategoryButton.Click += (s, e) => SelectByCategory(); _clearCategoryButton.Text = "Clear Category"; _clearCategoryButton.AutoSize = true; + _clearCategoryButton.TabIndex = 5; _clearCategoryButton.Click += (s, e) => ClearByCategory(); _showOnlyCategory.Text = "Show only category"; _showOnlyCategory.AutoSize = true; + _showOnlyCategory.AccessibleName = "Toggle show only selected category"; + _showOnlyCategory.TabIndex = 6; _showOnlyCategory.CheckedChanged += (s, e) => { if (_settingsService != null) @@ -214,8 +353,14 @@ private void InitializeLayout() _loadSessionButton.Text = "Load"; _loadSessionButton.AutoSize = true; _loadSessionButton.Click += (s, e) => LoadSession(); + _resetSettingsButton.Text = "Reset Settings"; _resetSettingsButton.AutoSize = true; _resetSettingsButton.Click += (s, e) => ResetSettings(); + _saveSettingsProfileButton.Text = "Save Settings Profile"; _saveSettingsProfileButton.AutoSize = true; _saveSettingsProfileButton.Click += (s, e) => SaveSettingsProfile(); + _loadSettingsProfileButton.Text = "Load Settings Profile"; _loadSettingsProfileButton.AutoSize = true; _loadSettingsProfileButton.Click += (s, e) => LoadSettingsProfile(); filterBar.Controls.Add(_saveSessionButton); filterBar.Controls.Add(_loadSessionButton); + filterBar.Controls.Add(_resetSettingsButton); + filterBar.Controls.Add(_saveSettingsProfileButton); + filterBar.Controls.Add(_loadSettingsProfileButton); filterBar.Controls.Add(_lblCategory); filterBar.Controls.Add(_categorySelector); filterBar.Controls.Add(_selectCategoryButton); @@ -227,23 +372,37 @@ private void InitializeLayout() _symptomList.AccessibleName = "Symptoms list"; _symptomList.AccessibleDescription = "List of symptoms to select"; _symptomList.ItemCheck += SymptomList_ItemCheck; + _symptomList.TabIndex = 7; var rightPanel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, - RowCount = 5 + RowCount = 6 }; rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); rightPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - var topControls = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; + var topControls = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + WrapContents = true, + AutoScroll = true, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(3) + }; + var vitalsRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; + var rulesRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; _modelSelector.DropDownStyle = ComboBoxStyle.DropDownList; _modelSelector.Items.AddRange(new object[] { DetectionModel.Jaccard, DetectionModel.Cosine, DetectionModel.NaiveBayes }); + _modelSelector.AccessibleName = "Detection model"; + _modelSelector.TabIndex = 10; _modelSelector.SelectedIndexChanged += (s, e) => { if (_settingsService != null && _modelSelector.SelectedItem != null) @@ -251,6 +410,10 @@ private void InitializeLayout() _settingsService.Settings.Model = _modelSelector.SelectedItem.ToString(); _settingsService.Save(); } + // Enable temperature calibration controls only for NaiveBayes + bool nb = _modelSelector.SelectedItem is DetectionModel dm && dm == DetectionModel.NaiveBayes; + _nbTempEnable.Enabled = nb; + _nbTempValue.Enabled = nb && _nbTempEnable.Checked; }; _modelSelector.SelectedIndex = 0; @@ -259,6 +422,8 @@ private void InitializeLayout() _threshold.DecimalPlaces = 0; _threshold.Value = 0; _threshold.Width = 80; + _threshold.AccessibleName = "Score threshold percent"; + _threshold.TabIndex = 11; _threshold.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.ThresholdPercent = (int)_threshold.Value; _settingsService.Save(); } }; _lblModel.Text = "Model:"; _lblModel.AutoSize = true; _lblModel.Padding = new Padding(0, 6, 0, 0); @@ -269,6 +434,8 @@ private void InitializeLayout() _minMatch.Maximum = 10; _minMatch.Value = 1; _minMatch.Width = 60; + _minMatch.AccessibleName = "Minimum matching symptoms"; + _minMatch.TabIndex = 12; _minMatch.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.MinMatch = (int)_minMatch.Value; _settingsService.Save(); } }; _lblMinMatch.Text = "Min match:"; _lblMinMatch.AutoSize = true; _lblMinMatch.Padding = new Padding(10, 6, 0, 0); @@ -276,44 +443,159 @@ private void InitializeLayout() _topK.Maximum = 1000; _topK.Value = 0; _topK.Width = 70; + _topK.AccessibleName = "Top K results"; + _topK.TabIndex = 13; _topK.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.TopK = (int)_topK.Value; _settingsService.Save(); } }; _lblTopK.Text = "Top-K:"; _lblTopK.AutoSize = true; _lblTopK.Padding = new Padding(10, 6, 0, 0); + // Category weighting UI (model tuning) + _weightCatSelector.DropDownStyle = ComboBoxStyle.DropDownList; + _weightCatSelector.Width = 130; + _weightCatSelector.AccessibleName = "Category weight selector"; + _weightCatSelector.TabIndex = 13; + _weightCatSelector.SelectedIndexChanged += (s, e) => + { + try + { + if (_weightCatSelector.SelectedItem is CatItem ci && _settingsService != null) + { + double existing = 1.0; + if (_settingsService.Settings.CategoryWeights != null && _settingsService.Settings.CategoryWeights.TryGetValue(ci.Canonical, out var w) && w > 0) + existing = w; + var pct = (decimal)(existing * 100.0); + if (pct < _weightValue.Minimum) pct = _weightValue.Minimum; + if (pct > _weightValue.Maximum) pct = _weightValue.Maximum; + _weightValue.Value = pct; + } + } + catch { } + }; + _weightValue.Minimum = 10; // 0.1x + _weightValue.Maximum = 500; // 5x + _weightValue.Value = 100; // 1x + _weightValue.Width = 70; + _weightValue.Increment = 10; + _weightValue.AccessibleName = "Category weight percent"; + _weightValue.TabIndex = 14; + _applyWeightButton.Text = "ApplyW"; _applyWeightButton.AutoSize = true; _applyWeightButton.AccessibleName = "Apply category weight"; _applyWeightButton.TabIndex = 15; + _applyWeightButton.Click += (s, e) => + { + if (_settingsService == null || _weightCatSelector.SelectedItem is not CatItem ci) return; + double mult = (double)_weightValue.Value / 100.0; + if (mult <= 0) mult = 0.01; + _settingsService.Settings.CategoryWeights ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + _settingsService.Settings.CategoryWeights[ci.Canonical] = mult; + _settingsService.Save(); + }; + _clearWeightsButton.Text = "ClearW"; _clearWeightsButton.AutoSize = true; _clearWeightsButton.AccessibleName = "Clear all category weights"; _clearWeightsButton.TabIndex = 16; + _clearWeightsButton.Click += (s, e) => + { + if (_settingsService == null) return; + if (MessageBox.Show(this, "Clear all category weights?", "Confirm", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes) + { + _settingsService.Settings.CategoryWeights = new Dictionary(StringComparer.OrdinalIgnoreCase); + _settingsService.Save(); + try { _weightValue.Value = 100; } catch { } + } + }; + + // Naive Bayes temperature calibration + _nbTempEnable.Text = "NB Temp"; _nbTempEnable.AutoSize = true; _nbTempEnable.AccessibleName = "Enable Naive Bayes temperature scaling"; _nbTempEnable.TabIndex = 17; + _nbTempValue.Minimum = 10; // 0.10 + _nbTempValue.Maximum = 500; // 5.00 + _nbTempValue.DecimalPlaces = 2; + _nbTempValue.Increment = 5; // 0.05 + _nbTempValue.Value = 100; // 1.00 + _nbTempValue.Width = 70; + _nbTempValue.AccessibleName = "Naive Bayes temperature"; + _nbTempValue.TabIndex = 18; + _nbTempEnable.CheckedChanged += (s, e) => + { + if (_settingsService == null) return; + if (_nbTempEnable.Checked) + { + double t = (double)_nbTempValue.Value / 100.0; + if (t < 0.01) t = 0.01; + _settingsService.Settings.NaiveBayesTemperature = t; + } + else + { + _settingsService.Settings.NaiveBayesTemperature = null; + } + _settingsService.Save(); + }; + _nbTempValue.ValueChanged += (s, e) => + { + if (_settingsService == null) return; + if (_nbTempEnable.Checked) + { + double t = (double)_nbTempValue.Value / 100.0; + if (t < 0.01) t = 0.01; + _settingsService.Settings.NaiveBayesTemperature = t; + _settingsService.Save(); + } + }; + _lblLanguage.Text = "Language:"; _lblLanguage.AutoSize = true; _lblLanguage.Padding = new Padding(10, 6, 0, 0); _languageSelector.DropDownStyle = ComboBoxStyle.DropDownList; _languageSelector.Width = 120; + _languageSelector.AccessibleName = "Language selector"; + _languageSelector.TabIndex = 19; _languageSelector.SelectedIndexChanged += (s, e) => OnLanguageChanged(); _darkModeToggle.Text = "Dark"; _darkModeToggle.AutoSize = true; + _darkModeToggle.AccessibleName = "Toggle dark mode"; + _darkModeToggle.TabIndex = 20; _darkModeToggle.CheckedChanged += (s, e) => { ApplyTheme(); if (_settingsService != null) { _settingsService.Settings.DarkMode = _darkModeToggle.Checked; _settingsService.Save(); } }; _checkButton.Text = "Check"; _checkButton.AutoSize = true; _checkButton.AccessibleName = "Check"; _checkButton.AccessibleDescription = "Run the symptom checker"; + // Make the execute button visually distinct + _checkButton.FlatStyle = FlatStyle.Flat; + _checkButton.UseVisualStyleBackColor = false; _checkButton.Click += CheckButton_Click; + _checkButton.TabIndex = 21; _checkButton.Enabled = false; // disabled until at least one symptom selected _exitButton.Text = "Exit"; _exitButton.AutoSize = true; + _exitButton.AccessibleName = "Exit application"; + _exitButton.TabIndex = 17; _exitButton.Click += (s, e) => this.Close(); _syncButton.Text = "Sync"; _syncButton.AutoSize = true; + _syncButton.AccessibleName = "Sync from Wikidata"; + _syncButton.TabIndex = 18; _syncButton.Click += async (s, e) => await SyncFromWikidataAsync(); // Missing translations report UI _missingTransButton.Text = "Missing Translations"; _missingTransButton.AutoSize = true; + _missingTransButton.AccessibleName = "Show missing translations report"; + _missingTransButton.TabIndex = 19; _missingTransButton.Click += (s, e) => ShowMissingTranslationsDialog(); + // Help/About + _helpButton.Text = "Help"; + _helpButton.AutoSize = true; + _helpButton.AccessibleName = "Help and about"; + _helpButton.TabIndex = 20; + _helpButton.Click += (s, e) => ShowHelpDialog(); + _openLogsButton.Text = "Logs"; _openLogsButton.AutoSize = true; _openLogsButton.AccessibleName = "Open logs folder"; _openLogsButton.TabIndex = 21; _openLogsButton.Click += (s, e) => OpenLogsFolder(); + try { _toolTip.SetToolTip(collapseBtn, "Hide/show left panel"); } catch { } + _resultsList.Dock = DockStyle.Fill; - _resultsList.DrawMode = DrawMode.OwnerDrawFixed; + _resultsList.DrawMode = DrawMode.OwnerDrawVariable; _resultsList.ItemHeight = 22; _resultsList.AccessibleName = "Results"; _resultsList.AccessibleDescription = "List of matching conditions"; _resultsList.DrawItem += ResultsList_DrawItem; + _resultsList.TabIndex = 30; + _resultsList.MeasureItem += ResultsList_MeasureItem; _resultsList.DoubleClick += ResultsList_DoubleClick; _resultsList.MouseUp += (s, e) => { if (e.Button == MouseButtons.Right) _resultsContext.Show(_resultsList, e.Location); }; // Configure tooltip (infobulle) to guide user to double-click for details @@ -336,9 +618,64 @@ private void InitializeLayout() _triageBanner.AccessibleName = "Red flags"; _triageBanner.AccessibleDescription = "Banner showing potential red flags"; - leftPanel.Controls.Add(filterBar, 0, 0); + // Vitals setup and defaults + _lblVitals.Text = "Vitals:"; _lblVitals.AutoSize = true; _lblVitals.Padding = new Padding(10, 6, 0, 0); + _lblTemp.Text = "Temp (°C):"; _lblTemp.AutoSize = true; _lblTemp.Padding = new Padding(10, 6, 0, 0); + _numTempC.DecimalPlaces = 1; _numTempC.Increment = 0.1M; _numTempC.Minimum = 30; _numTempC.Maximum = 45; _numTempC.Width = 70; _numTempC.AccessibleName = "Temperature Celsius"; _numTempC.TabIndex = 40; + _numTempC.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.TempC = (double)_numTempC.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + + _lblHR.Text = "HR (bpm):"; _lblHR.AutoSize = true; _lblHR.Padding = new Padding(10, 6, 0, 0); + _numHR.Minimum = 20; _numHR.Maximum = 240; _numHR.Width = 70; _numHR.AccessibleName = "Heart rate"; _numHR.TabIndex = 41; + _numHR.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.HeartRate = (int)_numHR.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + + _lblRR.Text = "RR (/min):"; _lblRR.AutoSize = true; _lblRR.Padding = new Padding(10, 6, 0, 0); + _numRR.Minimum = 4; _numRR.Maximum = 80; _numRR.Width = 70; _numRR.AccessibleName = "Respiratory rate"; _numRR.TabIndex = 42; + _numRR.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.RespRate = (int)_numRR.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + + _lblBP.Text = "BP (SBP/DBP):"; _lblBP.AutoSize = true; _lblBP.Padding = new Padding(10, 6, 0, 0); + _numSBP.Minimum = 50; _numSBP.Maximum = 260; _numSBP.Width = 70; _numSBP.AccessibleName = "Systolic blood pressure"; _numSBP.TabIndex = 43; + _numSBP.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.SystolicBP = (int)_numSBP.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + _numDBP.Minimum = 30; _numDBP.Maximum = 160; _numDBP.Width = 70; _numDBP.AccessibleName = "Diastolic blood pressure"; _numDBP.TabIndex = 44; + _numDBP.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.DiastolicBP = (int)_numDBP.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + + _lblSpO2.Text = "SpO₂ (%):"; _lblSpO2.AutoSize = true; _lblSpO2.Padding = new Padding(10, 6, 0, 0); + _numSpO2.Minimum = 50; _numSpO2.Maximum = 100; _numSpO2.Width = 70; _numSpO2.AccessibleName = "Oxygen saturation"; _numSpO2.TabIndex = 45; + _numSpO2.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.SpO2 = (int)_numSpO2.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + + _lblWeight.Text = "Weight (kg):"; _lblWeight.AutoSize = true; _lblWeight.Padding = new Padding(10, 6, 0, 0); + _numWeightKg.DecimalPlaces = 1; _numWeightKg.Increment = 0.5M; _numWeightKg.Minimum = 2; _numWeightKg.Maximum = 350; _numWeightKg.Width = 80; _numWeightKg.AccessibleName = "Weight kilograms"; _numWeightKg.TabIndex = 46; + _numWeightKg.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.WeightKg = (double)_numWeightKg.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + + // We'll host filterBar inside a panel that enforces a maximum height with scrollbar if needed + var filterHost = new Panel + { + Dock = DockStyle.Fill, + AutoScroll = true, + Margin = new Padding(0), + Padding = new Padding(0) + }; + filterHost.Controls.Add(filterBar); + // After layout we can cap height dynamically + filterHost.Resize += (s, e) => + { + int maxH = 120; // max visible area for filter controls + if (filterBar.Height > maxH) + { + filterHost.AutoScrollMinSize = new Size(filterBar.Width, filterBar.Height + 4); + } + }; + leftPanel.Controls.Add(filterHost, 0, 0); leftPanel.Controls.Add(_symptomList, 0, 1); + try + { + leftPanel.RowStyles.Clear(); + leftPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + leftPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + } + catch { } split.Panel1.Controls.Add(leftPanel); + // Add collapse control first for visibility + topControls.Controls.Add(collapseBtn); topControls.Controls.Add(_lblModel); topControls.Controls.Add(_modelSelector); topControls.Controls.Add(_lblThresh); @@ -347,6 +684,12 @@ private void InitializeLayout() topControls.Controls.Add(_minMatch); topControls.Controls.Add(_lblTopK); topControls.Controls.Add(_topK); + topControls.Controls.Add(_weightCatSelector); + topControls.Controls.Add(_weightValue); + topControls.Controls.Add(_applyWeightButton); + topControls.Controls.Add(_clearWeightsButton); + topControls.Controls.Add(_nbTempEnable); + topControls.Controls.Add(_nbTempValue); topControls.Controls.Add(_lblLanguage); topControls.Controls.Add(_languageSelector); topControls.Controls.Add(_darkModeToggle); @@ -354,23 +697,147 @@ private void InitializeLayout() topControls.Controls.Add(_exitButton); topControls.Controls.Add(_syncButton); topControls.Controls.Add(_missingTransButton); + topControls.Controls.Add(_helpButton); + topControls.Controls.Add(_openLogsButton); + _lblPerf.AutoSize = true; _lblPerf.Padding = new Padding(10,6,0,0); _lblPerf.Text = ""; _lblPerf.AccessibleName = "Performance timing"; topControls.Controls.Add(_lblPerf); + // Top bar (models etc.) rightPanel.Controls.Add(topControls, 0, 0); - rightPanel.Controls.Add(_triageBanner, 0, 1); - rightPanel.Controls.Add(_resultsList, 0, 2); - rightPanel.Controls.Add(_disclaimer, 0, 3); + // Vitals row + vitalsRow.Controls.Add(_lblVitals); + vitalsRow.Controls.Add(_lblTemp); + vitalsRow.Controls.Add(_numTempC); + vitalsRow.Controls.Add(_lblHR); + vitalsRow.Controls.Add(_numHR); + vitalsRow.Controls.Add(_lblRR); + vitalsRow.Controls.Add(_numRR); + vitalsRow.Controls.Add(_lblBP); + vitalsRow.Controls.Add(_numSBP); + vitalsRow.Controls.Add(_numDBP); + vitalsRow.Controls.Add(_lblSpO2); + vitalsRow.Controls.Add(_numSpO2); + vitalsRow.Controls.Add(_lblWeight); + vitalsRow.Controls.Add(_numWeightKg); + rightPanel.Controls.Add(vitalsRow, 0, 1); + // Decision rules row (Centor/McIsaac) + _lblRules.Text = "Decision rules:"; _lblRules.AutoSize = true; _lblRules.Padding = new Padding(10, 6, 0, 0); + _lblAge.Text = "Age (years):"; _lblAge.AutoSize = true; _lblAge.Padding = new Padding(10, 6, 0, 0); + _numAge.Minimum = 0; _numAge.Maximum = 120; _numAge.Value = 25; _numAge.Width = 70; _numAge.AccessibleName = "Age years"; _numAge.TabIndex = 50; + _numAge.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.AgeYears = (int)_numAge.Value; _settingsService.Save(); } UpdateDecisionRules(); }; + _grpCentor.Text = "Centor/McIsaac"; _grpCentor.AutoSize = true; _grpCentor.Padding = new Padding(6); + _centorFever.Text = "Fever (or Temp ≥38°C)"; _centorFever.AutoSize = true; _centorFever.Enabled = false; + _centorTonsils.Text = "Tonsillar exudates/swelling"; _centorTonsils.AutoSize = true; _centorTonsils.Enabled = false; + _centorNodes.Text = "Tender anterior cervical nodes"; _centorNodes.AutoSize = true; _centorNodes.Enabled = false; + _centorNoCough.Text = "Absence of cough"; _centorNoCough.AutoSize = true; _centorNoCough.Enabled = false; + _centorScore.Text = "Centor: 0"; _centorScore.AutoSize = true; _centorScore.Padding = new Padding(10, 6, 0, 0); + _mcIsaacScore.Text = "McIsaac: 0"; _mcIsaacScore.AutoSize = true; _mcIsaacScore.Padding = new Padding(10, 6, 0, 0); + _centorAdvice.Text = ""; _centorAdvice.AutoSize = true; _centorAdvice.Padding = new Padding(10, 6, 0, 0); + var centorPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; + centorPanel.Controls.Add(_centorFever); + centorPanel.Controls.Add(_centorTonsils); + centorPanel.Controls.Add(_centorNodes); + centorPanel.Controls.Add(_centorNoCough); + centorPanel.Controls.Add(_centorScore); + centorPanel.Controls.Add(_mcIsaacScore); + centorPanel.Controls.Add(_centorAdvice); + _grpCentor.Controls.Add(centorPanel); + + // PERC group + _grpPerc.Text = "PERC"; _grpPerc.AutoSize = true; _grpPerc.Padding = new Padding(6); + _percResult.Text = "PERC: not evaluated"; _percResult.AutoSize = true; _percResult.Padding = new Padding(10, 6, 0, 0); + _percHemoptysis.Text = "Hemoptysis"; _percHemoptysis.AutoSize = true; _percHemoptysis.AccessibleName = "PERC hemoptysis"; _percHemoptysis.TabIndex = 60; + _percHemoptysis.CheckedChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.PercHemoptysis = _percHemoptysis.Checked; _settingsService.Save(); } UpdatePercRule(); }; + _percEstrogen.Text = "Estrogen use"; _percEstrogen.AutoSize = true; _percEstrogen.AccessibleName = "PERC estrogen use"; _percEstrogen.TabIndex = 61; + _percEstrogen.CheckedChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.PercEstrogenUse = _percEstrogen.Checked; _settingsService.Save(); } UpdatePercRule(); }; + _percPriorDvtPe.Text = "Prior DVT/PE"; _percPriorDvtPe.AutoSize = true; _percPriorDvtPe.AccessibleName = "PERC prior DVT or PE"; _percPriorDvtPe.TabIndex = 62; + _percPriorDvtPe.CheckedChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.PercPriorDvtPe = _percPriorDvtPe.Checked; _settingsService.Save(); } UpdatePercRule(); }; + _percUnilateralLeg.Text = "Unilateral leg swelling"; _percUnilateralLeg.AutoSize = true; _percUnilateralLeg.AccessibleName = "PERC unilateral leg swelling"; _percUnilateralLeg.TabIndex = 63; + _percUnilateralLeg.CheckedChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.PercUnilateralLegSwelling = _percUnilateralLeg.Checked; _settingsService.Save(); } UpdatePercRule(); }; + _percRecentSurgery.Text = "Recent surgery/trauma"; _percRecentSurgery.AutoSize = true; _percRecentSurgery.AccessibleName = "PERC recent surgery or trauma"; _percRecentSurgery.TabIndex = 64; + _percRecentSurgery.CheckedChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.PercRecentSurgeryTrauma = _percRecentSurgery.Checked; _settingsService.Save(); } UpdatePercRule(); }; + var percPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; + percPanel.Controls.Add(_percHemoptysis); + percPanel.Controls.Add(_percEstrogen); + percPanel.Controls.Add(_percPriorDvtPe); + percPanel.Controls.Add(_percUnilateralLeg); + percPanel.Controls.Add(_percRecentSurgery); + percPanel.Controls.Add(_percResult); + _grpPerc.Controls.Add(percPanel); + rulesRow.Controls.Add(_lblRules); + rulesRow.Controls.Add(_lblAge); + rulesRow.Controls.Add(_numAge); + rulesRow.Controls.Add(_grpCentor); + rulesRow.Controls.Add(_grpPerc); + rightPanel.Controls.Add(rulesRow, 0, 2); + // Place results list in the percent row (index 3) so it expands; triage banner becomes auto row below + rightPanel.Controls.Add(_resultsList, 0, 3); + rightPanel.Controls.Add(_triageBanner, 0, 4); + rightPanel.Controls.Add(_disclaimer, 0, 5); split.Panel2.Controls.Add(rightPanel); + // Settings path label placed at bottom-left of main form + _lblSettingsPath.Dock = DockStyle.Bottom; + _lblSettingsPath.AutoSize = true; + _lblSettingsPath.Padding = new Padding(4); + _lblSettingsPath.ForeColor = Color.DimGray; + _lblSettingsPath.Font = new Font(Font.FontFamily, 7f); + _lblSettingsPath.AccessibleName = "Settings file path"; + Controls.Add(_lblSettingsPath); + Controls.Add(split); // Context menu for results (Copy details, Print) var miCopy = new ToolStripMenuItem("Copy Details", null, (s, e) => CopySelectedDetailsToClipboard()); var miPrint = new ToolStripMenuItem("Print Details", null, (s, e) => PrintSelectedDetails()); + var miExportCsv = new ToolStripMenuItem("Export Results (CSV)", null, (s, e) => ExportResultsCsv()); + var miExportMd = new ToolStripMenuItem("Export Results (Markdown)", null, (s, e) => ExportResultsMarkdown()); + var miExportHtml = new ToolStripMenuItem("Export Results (HTML)", null, (s, e) => ExportResultsHtml()); + var miExportSelectedOnly = new ToolStripMenuItem("Export Selected Only"); + miExportSelectedOnly.CheckOnClick = true; + miExportSelectedOnly.ToolTipText = "Toggle exporting only currently selected condition row (if any)."; + miExportSelectedOnly.Click += (s, e) => { _exportSelectedOnly = !_exportSelectedOnly; miExportSelectedOnly.Checked = _exportSelectedOnly; }; _resultsContext.Items.Add(miCopy); _resultsContext.Items.Add(miPrint); + _resultsContext.Items.Add(new ToolStripSeparator()); + _resultsContext.Items.Add(miExportCsv); + _resultsContext.Items.Add(miExportMd); + _resultsContext.Items.Add(miExportHtml); + _resultsContext.Items.Add(new ToolStripSeparator()); + _resultsContext.Items.Add(miExportSelectedOnly); } private void MainForm_Load(object? sender, EventArgs e) { + // Validate JSON schemas (non-blocking for user convenience) + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + string baseDir = AppContext.BaseDirectory; + var conditionsData = Path.Combine(baseDir, "data", "conditions.json"); + var conditionsSchema = Path.Combine(baseDir, "schemas", "conditions.schema.json"); + var categoriesData = Path.Combine(baseDir, "data", "categories.json"); + var categoriesSchema = Path.Combine(baseDir, "schemas", "categories.schema.json"); + var translationsData = Path.Combine(baseDir, "data", "translations.json"); + var translationsSchema = Path.Combine(baseDir, "schemas", "translations.schema.json"); + + var condErr = await SchemaValidator.ValidateAsync(conditionsData, conditionsSchema); + var catErr = await SchemaValidator.ValidateAsync(categoriesData, categoriesSchema); + var transErr = await SchemaValidator.ValidateAsync(translationsData, translationsSchema); + var all = new List(); + if (!string.IsNullOrEmpty(condErr)) all.Add(condErr); + if (!string.IsNullOrEmpty(catErr)) all.Add(catErr); + if (!string.IsNullOrEmpty(transErr)) all.Add(transErr); + if (all.Count > 0) + { + var msg = string.Join("\n\n", all); + this.BeginInvoke(new Action(() => + { + MessageBox.Show(this, msg, "Data Validation Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button1, RightToLeftLayout ? MessageBoxOptions.RtlReading : 0); + })); + } + } + catch { } + }); try { var jsonPath = Path.Combine(AppContext.BaseDirectory, "data", "conditions.json"); @@ -380,6 +847,7 @@ private void MainForm_Load(object? sender, EventArgs e) // Load settings var settingsPath = Path.Combine(AppContext.BaseDirectory, "data", "settings.json"); _settingsService = new SettingsService(settingsPath); + try { _lblSettingsPath.Text = $"Settings: {settingsPath}"; } catch { } // Load translations var trPath = Path.Combine(AppContext.BaseDirectory, "data", "translations.json"); if (File.Exists(trPath)) @@ -403,9 +871,12 @@ private void MainForm_Load(object? sender, EventArgs e) int idx = 0; for (int i = 0; i < _languageSelector.Items.Count; i++) { - var code = ((LangItem)_languageSelector.Items[i]).Code; - if (!string.IsNullOrEmpty(_settingsService?.Settings.Language) && code.Equals(_settingsService!.Settings.Language, StringComparison.OrdinalIgnoreCase)) { idx = i; break; } - if (string.IsNullOrEmpty(_settingsService?.Settings.Language) && code.Equals("en", StringComparison.OrdinalIgnoreCase)) { idx = i; } + if (_languageSelector.Items[i] is LangItem liItem) + { + var code = liItem.Code ?? string.Empty; + if (!string.IsNullOrEmpty(_settingsService?.Settings.Language) && code.Equals(_settingsService!.Settings.Language, StringComparison.OrdinalIgnoreCase)) { idx = i; break; } + if (string.IsNullOrEmpty(_settingsService?.Settings.Language) && code.Equals("en", StringComparison.OrdinalIgnoreCase)) { idx = i; } + } } _languageSelector.SelectedIndex = idx; } @@ -417,10 +888,14 @@ private void MainForm_Load(object? sender, EventArgs e) _categoriesService = new CategoriesService(catPath); var cats = _categoriesService.GetAllCategories(); _categorySelector.Items.Clear(); + _categorySetsCache.Clear(); + _weightCatSelector.Items.Clear(); foreach (var c in cats) { string disp = _translationService?.Category(c.Name) ?? c.Name; _categorySelector.Items.Add(new CatItem(c.Name, disp)); + _weightCatSelector.Items.Add(new CatItem(c.Name, disp)); + try { _categorySetsCache[c.Name] = _categoriesService.BuildCategorySet(c, _allSymptoms); } catch { } } if (_categorySelector.Items.Count > 0) { @@ -430,12 +905,13 @@ private void MainForm_Load(object? sender, EventArgs e) { for (int i = 0; i < _categorySelector.Items.Count; i++) { - if (((CatItem)_categorySelector.Items[i]).Canonical.Equals(desired, StringComparison.OrdinalIgnoreCase)) + if (_categorySelector.Items[i] is CatItem ci && ci.Canonical != null && desired != null && ci.Canonical.Equals(desired, StringComparison.OrdinalIgnoreCase)) { _categorySelector.SelectedIndex = i; break; } } } if (_categorySelector.SelectedIndex < 0) _categorySelector.SelectedIndex = 0; } + if (_weightCatSelector.Items.Count > 0 && _weightCatSelector.SelectedIndex < 0) _weightCatSelector.SelectedIndex = 0; } // Load synonyms var synPath = Path.Combine(AppContext.BaseDirectory, "data", "synonyms.json"); @@ -445,25 +921,85 @@ private void MainForm_Load(object? sender, EventArgs e) } ApplyTranslations(); // Apply theme after settings are loaded - _darkModeToggle.Checked = _settingsService.Settings.DarkMode; + _darkModeToggle.Checked = _settingsService?.Settings.DarkMode ?? false; + // Restore left panel collapsed state + try + { + var sc = this.Controls.OfType().FirstOrDefault(c => c.Name == "_mainSplit"); + if (sc != null && _settingsService?.Settings.LeftPanelCollapsed == true) + { + sc.Panel1Collapsed = true; if (_collapseBtn != null) _collapseBtn.Text = "≫"; + } + } + catch { } // Restore other UI state from settings - if (!string.IsNullOrEmpty(_settingsService.Settings.Model)) + if (!string.IsNullOrEmpty(_settingsService?.Settings.Model)) { for (int i = 0; i < _modelSelector.Items.Count; i++) { - if (string.Equals(_modelSelector.Items[i]?.ToString(), _settingsService.Settings.Model, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(_modelSelector.Items[i]?.ToString(), _settingsService!.Settings.Model, StringComparison.OrdinalIgnoreCase)) { _modelSelector.SelectedIndex = i; break; } } } // Restore numeric values - try { _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, _settingsService.Settings.ThresholdPercent)); } catch { } - try { _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, _settingsService.Settings.MinMatch)); } catch { } - try { _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, _settingsService.Settings.TopK)); } catch { } + try { if (_settingsService != null) _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, _settingsService.Settings.ThresholdPercent)); } catch { } + try { if (_settingsService != null) _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, _settingsService.Settings.MinMatch)); } catch { } + try { if (_settingsService != null) _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, _settingsService.Settings.TopK)); } catch { } + // Restore vitals values if present + try { if (_settingsService?.Settings.TempC.HasValue == true) _numTempC.Value = (decimal)Math.Max((double)_numTempC.Minimum, Math.Min((double)_numTempC.Maximum, _settingsService!.Settings.TempC!.Value)); } catch { } + try { if (_settingsService?.Settings.HeartRate.HasValue == true) _numHR.Value = Math.Max(_numHR.Minimum, Math.Min(_numHR.Maximum, _settingsService!.Settings.HeartRate!.Value)); } catch { } + try { if (_settingsService?.Settings.RespRate.HasValue == true) _numRR.Value = Math.Max(_numRR.Minimum, Math.Min(_numRR.Maximum, _settingsService!.Settings.RespRate!.Value)); } catch { } + try { if (_settingsService?.Settings.SystolicBP.HasValue == true) _numSBP.Value = Math.Max(_numSBP.Minimum, Math.Min(_numSBP.Maximum, _settingsService!.Settings.SystolicBP!.Value)); } catch { } + try { if (_settingsService?.Settings.DiastolicBP.HasValue == true) _numDBP.Value = Math.Max(_numDBP.Minimum, Math.Min(_numDBP.Maximum, _settingsService!.Settings.DiastolicBP!.Value)); } catch { } + try { if (_settingsService?.Settings.SpO2.HasValue == true) _numSpO2.Value = Math.Max(_numSpO2.Minimum, Math.Min(_numSpO2.Maximum, _settingsService!.Settings.SpO2!.Value)); } catch { } + try { if (_settingsService?.Settings.WeightKg.HasValue == true) _numWeightKg.Value = (decimal)Math.Max((double)_numWeightKg.Minimum, Math.Min((double)_numWeightKg.Maximum, _settingsService!.Settings.WeightKg!.Value)); } catch { } + // Restore NB temperature & weights + try + { + if (_settingsService?.Settings.NaiveBayesTemperature.HasValue == true) + { + double t = _settingsService.Settings.NaiveBayesTemperature.Value; + if (t < 0.01) t = 0.01; if (t > 5.0) t = 5.0; + _nbTempEnable.Checked = true; + var val = (decimal)(t * 100.0); + if (val < _nbTempValue.Minimum) val = _nbTempValue.Minimum; + if (val > _nbTempValue.Maximum) val = _nbTempValue.Maximum; + _nbTempValue.Value = val; + } + else + { + _nbTempEnable.Checked = false; + } + } + catch { } + try + { + if (_settingsService?.Settings.CategoryWeights != null && _weightCatSelector.SelectedItem is CatItem ci) + { + if (_settingsService.Settings.CategoryWeights.TryGetValue(ci.Canonical, out var w) && w > 0) + { + var pct = (decimal)(w * 100.0); + if (pct < _weightValue.Minimum) pct = _weightValue.Minimum; + if (pct > _weightValue.Maximum) pct = _weightValue.Maximum; + _weightValue.Value = pct; + } + } + } + catch { } + // Restore age and PERC persisted flags + try { if (_settingsService?.Settings.AgeYears.HasValue == true) _numAge.Value = Math.Max(_numAge.Minimum, Math.Min(_numAge.Maximum, _settingsService!.Settings.AgeYears!.Value)); } catch { } + try { if (_settingsService?.Settings.PercHemoptysis.HasValue == true) _percHemoptysis.Checked = _settingsService!.Settings.PercHemoptysis!.Value; } catch { } + try { if (_settingsService?.Settings.PercEstrogenUse.HasValue == true) _percEstrogen.Checked = _settingsService!.Settings.PercEstrogenUse!.Value; } catch { } + try { if (_settingsService?.Settings.PercPriorDvtPe.HasValue == true) _percPriorDvtPe.Checked = _settingsService!.Settings.PercPriorDvtPe!.Value; } catch { } + try { if (_settingsService?.Settings.PercUnilateralLegSwelling.HasValue == true) _percUnilateralLeg.Checked = _settingsService!.Settings.PercUnilateralLegSwelling!.Value; } catch { } + try { if (_settingsService?.Settings.PercRecentSurgeryTrauma.HasValue == true) _percRecentSurgery.Checked = _settingsService!.Settings.PercRecentSurgeryTrauma!.Value; } catch { } // Restore filter and category visibility - try { _filterBox.Text = _settingsService.Settings.FilterText ?? string.Empty; } catch { } - try { _showOnlyCategory.Checked = _settingsService.Settings.ShowOnlyCategory; } catch { } + try { _filterBox.Text = _settingsService?.Settings.FilterText ?? string.Empty; } catch { } + try { _showOnlyCategory.Checked = _settingsService?.Settings.ShowOnlyCategory ?? false; } catch { } ApplyTheme(); RefreshSymptomList(); + UpdateDecisionRules(); + UpdatePercRule(); } catch (Exception ex) { @@ -496,7 +1032,7 @@ private void OnLanguageChanged() { for (int i = 0; i < _categorySelector.Items.Count; i++) { - if (((CatItem)_categorySelector.Items[i]).Canonical.Equals(selectedCanonical, StringComparison.OrdinalIgnoreCase)) + if (_categorySelector.Items[i] is CatItem ci && ci.Canonical != null && ci.Canonical.Equals(selectedCanonical, StringComparison.OrdinalIgnoreCase)) { _categorySelector.SelectedIndex = i; break; } } } @@ -535,15 +1071,50 @@ private void ApplyTranslations() _lblThresh.Text = t.T("Threshold"); _lblMinMatch.Text = t.T("MinMatch"); _lblTopK.Text = t.T("TopK"); + // Model tuning labels/buttons (fallback to existing text if key missing) + _applyWeightButton.Text = t.T("ApplyWeight") ?? _applyWeightButton.Text; + _clearWeightsButton.Text = t.T("ClearWeights") ?? _clearWeightsButton.Text; + _nbTempEnable.Text = t.T("NaiveBayesTemp") ?? _nbTempEnable.Text; _checkButton.Text = t.T("Check"); _exitButton.Text = t.T("Exit"); _syncButton.Text = t.T("Sync"); _missingTransButton.Text = t.T("MissingTranslationsTitle"); + _helpButton.Text = t.T("Help") ?? _helpButton.Text; + _openLogsButton.Text = t.T("Logs") ?? _openLogsButton.Text; _lblLanguage.Text = t.T("Language"); _disclaimer.Text = "⚠️ " + t.T("Disclaimer"); _darkModeToggle.Text = t.T("DarkMode"); _saveSessionButton.Text = t.T("Save") ?? _saveSessionButton.Text; _loadSessionButton.Text = t.T("Load") ?? _loadSessionButton.Text; + _resetSettingsButton.Text = t.T("ResetSettings") ?? _resetSettingsButton.Text; + _saveSettingsProfileButton.Text = t.T("SaveSettingsProfile") ?? _saveSettingsProfileButton.Text; + _loadSettingsProfileButton.Text = t.T("LoadSettingsProfile") ?? _loadSettingsProfileButton.Text; + // Vitals labels + _lblVitals.Text = t.T("Vitals"); + _lblTemp.Text = t.T("TempC"); + _lblHR.Text = t.T("HeartRate"); + _lblRR.Text = t.T("RespRate"); + _lblBP.Text = t.T("BP"); + _lblSpO2.Text = t.T("SpO2"); + _lblWeight.Text = t.T("WeightKg"); + // Decision rules labels + _lblRules.Text = t.T("DecisionRules") ?? _lblRules.Text; + _grpCentor.Text = t.T("CentorMcIsaac") ?? _grpCentor.Text; + _lblAge.Text = t.T("AgeYears") ?? _lblAge.Text; + _centorFever.Text = t.T("Centor_Fever") ?? _centorFever.Text; + _centorTonsils.Text = t.T("Centor_Tonsils") ?? _centorTonsils.Text; + _centorNodes.Text = t.T("Centor_Nodes") ?? _centorNodes.Text; + _centorNoCough.Text = t.T("Centor_NoCough") ?? _centorNoCough.Text; + UpdateDecisionRules(); + + // PERC labels + _grpPerc.Text = t.T("PERC") ?? _grpPerc.Text; + _percHemoptysis.Text = t.T("PERC_Hemoptysis") ?? _percHemoptysis.Text; + _percEstrogen.Text = t.T("PERC_EstrogenUse") ?? _percEstrogen.Text; + _percPriorDvtPe.Text = t.T("PERC_PriorDvtPe") ?? _percPriorDvtPe.Text; + _percUnilateralLeg.Text = t.T("PERC_UnilateralLeg") ?? _percUnilateralLeg.Text; + _percRecentSurgery.Text = t.T("PERC_RecentSurgery") ?? _percRecentSurgery.Text; + UpdatePercRule(); // Localize tooltip hint on results list _toolTip.SetToolTip(_resultsList, t.T("DoubleClickHint")); @@ -552,11 +1123,36 @@ private void ApplyTranslations() { _resultsContext.Items[0].Text = t.T("CopyDetails"); _resultsContext.Items[1].Text = t.T("PrintDetails"); + if (_resultsContext.Items.Count >= 5) + { + _resultsContext.Items[3].Text = t.T("ExportCsv"); + _resultsContext.Items[4].Text = t.T("ExportMarkdown"); + } } // RTL for Arabic bool rtl = string.Equals(t.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); ApplyRtl(this, rtl); + // Align banner and menus for RTL + _triageBanner.TextAlign = rtl ? ContentAlignment.MiddleRight : ContentAlignment.MiddleLeft; + try { _triageBanner.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _resultsContext.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + // Ensure primary list controls mirror for RTL languages + try { _resultsList.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _symptomList.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + // Rules row RTL + try { _grpCentor.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _centorFever.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _centorTonsils.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _centorNodes.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _centorNoCough.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _grpPerc.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _percHemoptysis.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _percEstrogen.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _percPriorDvtPe.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _percUnilateralLeg.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { _percRecentSurgery.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + // ToolTip may not expose RightToLeft in this target; skip explicit RTL on tooltip // Rebuild triage banner for current language UpdateTriageBanner(); @@ -568,10 +1164,113 @@ private void ApplyTranslations() } } + // Compute PERC rule result based on vitals, age, and history flags + private void UpdatePercRule() + { + var t = _translationService; + // Criteria + bool ageOk = _numAge.Value < 50; + bool hrOk = _numHR.Value < 100; + bool spo2Ok = _numSpO2.Value >= 95; + bool hemoptysisOk = !_percHemoptysis.Checked; + bool estrogenOk = !_percEstrogen.Checked; + bool priorOk = !_percPriorDvtPe.Checked; + bool unilatOk = !_percUnilateralLeg.Checked; + bool surgeryOk = !_percRecentSurgery.Checked; + bool percNegative = ageOk && hrOk && spo2Ok && hemoptysisOk && estrogenOk && priorOk && unilatOk && surgeryOk; + + string neg = t?.T("PERC_Negative") ?? "PERC negative — PE unlikely if pretest probability is low."; + string pos = t?.T("PERC_Positive") ?? "PERC positive — cannot rule out PE; consider further testing if suspicion persists."; + _percResult.Text = percNegative ? neg : pos; + } + + // Compute Centor and McIsaac scores based on current selections and vitals + private void UpdateDecisionRules() + { + // Guard: group might not be initialized during early constructor runs + if (_grpCentor == null) return; + var t = _translationService; + // Determine Centor components from current symptoms and vitals + bool hasFever = false; + try + { + if (_settingsService?.Settings.TempC.HasValue == true) + hasFever = _settingsService!.Settings.TempC!.Value >= 38.0; + } + catch { } + // Also infer from selected symptom 'Fever' + hasFever = hasFever || _checkedSymptoms.Contains("Fever"); + + bool tonsils = _checkedSymptoms.Contains("Sore Throat") || _checkedSymptoms.Contains("Tonsillar Exudates") || _checkedSymptoms.Contains("Tonsillar Swelling"); + // If we have a symptom like 'Tonsillar exudates/swelling' in translations only, we can't detect canonical; keep basic sore throat proxy + bool nodes = _checkedSymptoms.Contains("Swollen Lymph Nodes"); + bool noCough = !_checkedSymptoms.Contains("Cough"); + + // Update disabled checkboxes to reflect inferred state + try { _centorFever.Checked = hasFever; } catch { } + try { _centorTonsils.Checked = tonsils; } catch { } + try { _centorNodes.Checked = nodes; } catch { } + try { _centorNoCough.Checked = noCough; } catch { } + + int centor = 0; + if (hasFever) centor++; + if (tonsils) centor++; + if (nodes) centor++; + if (noCough) centor++; + + int age = (int)_numAge.Value; + int ageAdj = 0; + if (age < 15) ageAdj = 1; else if (age >= 45) ageAdj = -1; + int mcIsaac = centor + ageAdj; + if (mcIsaac < 0) mcIsaac = 0; if (mcIsaac > 5) mcIsaac = 5; + + string centorLabel = t?.T("CentorLabel") ?? "Centor:"; + string mcIsaacLabel = t?.T("McIsaacLabel") ?? "McIsaac:"; + _centorScore.Text = $"{centorLabel} {centor}"; + _mcIsaacScore.Text = $"{mcIsaacLabel} {mcIsaac}"; + + // Provide brief advice based on McIsaac score (educational) + string advice = mcIsaac switch + { + <= 1 => t?.T("CentorAdvice_0_1") ?? "Low risk: likely viral. No antibiotics. Consider symptomatic care.", + 2 => t?.T("CentorAdvice_2") ?? "Intermediate risk: consider rapid strep test (RADT).", + 3 => t?.T("CentorAdvice_3") ?? "Higher risk: RADT and/or consider empiric antibiotics as per local guidance.", + _ => t?.T("CentorAdvice_4_5") ?? "High risk: consider testing and/or empiric antibiotics per guidelines." + }; + _centorAdvice.Text = advice; + } + private void UpdateTriageBanner() { var selected = new HashSet(_checkedSymptoms, StringComparer.OrdinalIgnoreCase); - var keys = SymptomCheckerApp.Services.TriageService.Evaluate(selected); + // PERC context: flag PERC positive combined with chest pain or SOB to escalate + bool chestOrSob = selected.Contains("Chest Pain") || selected.Contains("Shortness of Breath"); + bool percPositive = false; + try + { + // Determine PERC result from current UI state + bool ageOk = _numAge.Value < 50; + bool hrOk = _numHR.Value < 100; + bool spo2Ok = _numSpO2.Value >= 95; + bool hemoptysisOk = !_percHemoptysis.Checked; + bool estrogenOk = !_percEstrogen.Checked; + bool priorOk = !_percPriorDvtPe.Checked; + bool unilatOk = !_percUnilateralLeg.Checked; + bool surgeryOk = !_percRecentSurgery.Checked; + bool percNeg = ageOk && hrOk && spo2Ok && hemoptysisOk && estrogenOk && priorOk && unilatOk && surgeryOk; + percPositive = !percNeg; + } + catch { } + var keys = SymptomCheckerApp.Services.TriageService.EvaluateV2( + selected, + tempC: (double?)_numTempC.Value, + heartRate: (int?)_numHR.Value, + respRate: (int?)_numRR.Value, + systolicBP: (int?)_numSBP.Value, + diastolicBP: (int?)_numDBP.Value, + spO2: (int?)_numSpO2.Value, + percPositiveWithChestOrSob: chestOrSob && percPositive + ); if (keys.Count == 0) { _triageBanner.Visible = false; @@ -585,8 +1284,19 @@ private void UpdateTriageBanner() messages.Add(t?.T(k) ?? k); } var notice = t?.T("SeekCareDisclaimer") ?? "If these apply, consider seeking urgent medical attention. This tool is educational, not medical advice."; - var bullet = string.Join(Environment.NewLine + " • ", messages); - _triageBanner.Text = header + Environment.NewLine + " • " + bullet + Environment.NewLine + notice; + bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + if (rtl) + { + // For RTL, place bullet at the end for more natural reading + var lines = messages.Select(m => m + " •"); + var joined = string.Join(Environment.NewLine, lines); + _triageBanner.Text = header + Environment.NewLine + joined + Environment.NewLine + notice; + } + else + { + var bullet = string.Join(Environment.NewLine + " • ", messages); + _triageBanner.Text = header + Environment.NewLine + " • " + bullet + Environment.NewLine + notice; + } _triageBanner.Visible = true; } @@ -647,13 +1357,30 @@ private void ApplyThemeToControl(Control c, Color back, Color fore, Color panel, case Label lbl: lbl.BackColor = panel; lbl.ForeColor = fore; break; + case GroupBox gb: + gb.BackColor = panel; gb.ForeColor = fore; + foreach (Control child in gb.Controls) ApplyThemeToControl(child, back, fore, panel, dark); + break; case Button btn: - btn.BackColor = dark ? Color.FromArgb(60, 60, 60) : SystemColors.Control; - btn.ForeColor = fore; + if (btn == _checkButton) + { + // Accent color for the execute button + btn.BackColor = dark ? Color.FromArgb(40, 167, 69) : Color.MediumSeaGreen; + btn.ForeColor = Color.White; + try { btn.FlatAppearance.BorderSize = 1; btn.FlatAppearance.BorderColor = dark ? Color.FromArgb(30, 120, 50) : Color.SeaGreen; } catch { } + } + else + { + btn.BackColor = dark ? Color.FromArgb(60, 60, 60) : SystemColors.Control; + btn.ForeColor = fore; + } break; case CheckBox chk: chk.BackColor = panel; chk.ForeColor = fore; break; + case NumericUpDown nud: + nud.BackColor = back; nud.ForeColor = fore; + break; case Control generic: generic.BackColor = panel; generic.ForeColor = fore; break; @@ -679,28 +1406,107 @@ private void CheckButton_Click(object? sender, EventArgs e) int topK = (int)_topK.Value; int? topKOpt = topK > 0 ? topK : null; - var matches = _service.GetMatches(selectedSymptoms, model, threshold: thr, topK: topKOpt, minMatchCount: minMatch); + var sw = System.Diagnostics.Stopwatch.StartNew(); + IReadOnlyDictionary? catWeights = _settingsService?.Settings.CategoryWeights; + double? nbTemp = _settingsService?.Settings.NaiveBayesTemperature; + // Delegate using cached category sets + IEnumerable GetCats(string conditionName) + { + if (_service != null && _categoriesService != null && _service.TryGetCondition(conditionName, out var cond) && cond != null) + { + foreach (var kvp in _categorySetsCache) + { + if (cond.Symptoms.Any(s => kvp.Value.Contains(s))) yield return kvp.Key; + } + } + } + var matches = _service.GetMatches(selectedSymptoms, model, threshold: thr, topK: topKOpt, minMatchCount: minMatch, categoryWeights: catWeights, naiveBayesTemperature: nbTemp, getConditionCategories: GetCats); + sw.Stop(); _lastResults = matches; RebuildResultsListItems(); UpdateTriageBanner(); + try { _lblPerf.Text = $"{sw.ElapsedMilliseconds} ms"; } catch { } } private void RebuildResultsListItems() { _resultsList.Items.Clear(); + _resultIndexMap.Clear(); var t = _translationService; if (_lastResults == null || _lastResults.Count == 0) { _resultsList.Items.Add(t?.T("NoMatches") ?? "No matching conditions found based on the current selection."); + _resultIndexMap.Add(-1); + return; } - else + + // If categories service is available, group results by primary category (max overlap) + var categories = _categoriesService?.GetAllCategories()?.ToList() ?? new List(); + if (categories.Count == 0) { - foreach (var m in _lastResults) + // Fallback: flat list + for (int i = 0; i < _lastResults.Count; i++) { + var m = _lastResults[i]; string name = t?.Condition(m.Name) ?? m.Name; string scoreLabel = t?.T("Score") ?? "Score:"; string matchesLabel = t?.T("Matches") ?? "matches:"; _resultsList.Items.Add($"{name} — {scoreLabel} {m.Score:F2} ({matchesLabel} {m.MatchCount})"); + _resultIndexMap.Add(i); + } + _resultsList.Invalidate(); + return; + } + + // Use cached sets + var catSets = _categorySetsCache; + + // Group results + var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < _lastResults.Count; i++) + { + var m = _lastResults[i]; + string displayName = t?.Condition(m.Name) ?? m.Name; + string scoreLabel = t?.T("Score") ?? "Score:"; + string matchesLabel = t?.T("Matches") ?? "matches:"; + string line = $"{displayName} — {scoreLabel} {m.Score:F2} ({matchesLabel} {m.MatchCount})"; + + // Determine best matching category + string bestCat = "Other"; + if (_service != null && _service.TryGetCondition(m.Name, out var c) && c != null) + { + int best = -1; + foreach (var kvp in catSets) + { + int overlap = 0; + foreach (var s in c.Symptoms) + { + if (kvp.Value.Contains(s)) overlap++; + } + if (overlap > best) + { + best = overlap; bestCat = kvp.Key; + } + } + } + var dispCat = t?.Category(bestCat) ?? bestCat; + if (!grouped.TryGetValue(dispCat, out var list)) + { + list = new List<(int, string, double)>(); + grouped[dispCat] = list; + } + list.Add((i, line, m.Score)); + } + + // Stable group order by localized category name + foreach (var g in grouped.OrderBy(k => k.Key, StringComparer.CurrentCulture)) + { + _resultsList.Items.Add(new GroupHeader(g.Key, g.Key)); + _resultIndexMap.Add(-1); + foreach (var item in g.Value) + { + _resultsList.Items.Add(item.line); + _resultIndexMap.Add(item.resultIndex); } } _resultsList.Invalidate(); @@ -716,13 +1522,34 @@ private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e) string text = _resultsList.Items[e.Index]?.ToString() ?? string.Empty; + // Render group headers differently + if (_resultsList.Items[e.Index] is GroupHeader) + { + var boldFont = new Font(e.Font ?? SystemFonts.DefaultFont, FontStyle.Bold); + bool rtlH = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + var flagsH = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak; + if (rtlH) flagsH |= TextFormatFlags.RightToLeft | TextFormatFlags.Right; + using (var back = new SolidBrush(Color.FromArgb(245, 245, 245))) + { + e.Graphics.FillRectangle(back, e.Bounds); + } + var rectH = e.Bounds; rectH.Inflate(-6, -2); + TextRenderer.DrawText(e.Graphics, text, boldFont, rectH, Color.DimGray, flagsH); + e.DrawFocusRectangle(); + return; + } + bool isTop = false; // Only highlight if we have actual results and the item corresponds to a scored match - if (_lastResults != null && _lastResults.Count > 0 && e.Index < _lastResults.Count) + if (_lastResults != null && _lastResults.Count > 0) { - double max = _lastResults[0].Score; - double sc = _lastResults[e.Index].Score; - isTop = Math.Abs(sc - max) < 1e-9 && max > 0; + int resultIdx = (e.Index >= 0 && e.Index < _resultIndexMap.Count) ? _resultIndexMap[e.Index] : -1; + if (resultIdx >= 0 && resultIdx < _lastResults.Count) + { + double max = _lastResults[0].Score; + double sc = _lastResults[resultIdx].Score; + isTop = Math.Abs(sc - max) < 1e-9 && max > 0; + } } Color backColor = isTop ? Color.FromArgb(230, 255, 230) : e.BackColor; // light green for top @@ -734,7 +1561,7 @@ private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e) e.Graphics.FillRectangle(backBrush, e.Bounds); var font = e.Font ?? SystemFonts.DefaultFont; bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); - var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis; + var flags = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak; if (rtl) flags |= TextFormatFlags.RightToLeft | TextFormatFlags.Right; var rect = e.Bounds; rect.Inflate(-4, -2); @@ -744,13 +1571,47 @@ private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e) e.DrawFocusRectangle(); } + private void ResultsList_MeasureItem(object? sender, MeasureItemEventArgs e) + { + if (e.Index < 0 || e.Index >= _resultsList.Items.Count) + { + e.ItemHeight = 22; + return; + } + string text = _resultsList.Items[e.Index]?.ToString() ?? string.Empty; + if (_resultsList.Items[e.Index] is GroupHeader) + { + var boldFont = new Font(_resultsList.Font ?? SystemFonts.DefaultFont, FontStyle.Bold); + bool rtlH = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + var flagsH = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak; + if (rtlH) flagsH |= TextFormatFlags.RightToLeft | TextFormatFlags.Right; + var widthH = Math.Max(10, _resultsList.ClientSize.Width - 12); + var sizeH = TextRenderer.MeasureText(text, boldFont, new Size(widthH, int.MaxValue), flagsH); + e.ItemHeight = Math.Max(22, sizeH.Height + 6); + e.ItemWidth = widthH; + return; + } + var font = _resultsList.Font ?? SystemFonts.DefaultFont; + bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + var flags = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak; + if (rtl) flags |= TextFormatFlags.RightToLeft | TextFormatFlags.Right; + var width = Math.Max(10, _resultsList.ClientSize.Width - 8); + var size = TextRenderer.MeasureText(text, font, new Size(width, int.MaxValue), flags); + int min = 22; + e.ItemHeight = Math.Max(min, size.Height + 4); + e.ItemWidth = width; + } + private void ResultsList_DoubleClick(object? sender, EventArgs e) { if (_service == null) return; int idx = _resultsList.SelectedIndex; - if (idx < 0 || idx >= _lastResults.Count) return; + if (idx < 0) return; + int resultIdx = (idx >= 0 && idx < _resultIndexMap.Count) ? _resultIndexMap[idx] : -1; + if (resultIdx == -1) return; // header or unmapped + if (resultIdx < 0 || resultIdx >= _lastResults.Count) return; - var match = _lastResults[idx]; + var match = _lastResults[resultIdx]; if (!_service.TryGetCondition(match.Name, out var condition) || condition == null) { MessageBox.Show(this, $"No details found for '{match.Name}'.", "Details", MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -870,6 +1731,10 @@ private void ShowDetailsDialog(string title, string text) Height = 550 }; ApplyRtl(dlg, rtl); + if (rtl) + { + text = TransformBulletsForRtl(text); + } var tb = new TextBox { Multiline = true, @@ -883,6 +1748,7 @@ private void ShowDetailsDialog(string title, string text) if (rtl) { try { tb.RightToLeft = RightToLeft.Yes; } catch { } + try { tb.TextAlign = HorizontalAlignment.Right; } catch { } } var btnPanel = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Padding = new Padding(6) }; var btnClose = new Button { Text = _translationService?.T("Close") ?? "Close", AutoSize = true }; @@ -896,6 +1762,28 @@ private void ShowDetailsDialog(string title, string text) dlg.ShowDialog(this); } + private string TransformBulletsForRtl(string input) + { + if (string.IsNullOrEmpty(input)) return input; + var lines = input.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None); + for (int i = 0; i < lines.Length; i++) + { + var l = lines[i]; + var trimmed = l.TrimStart(); + if (trimmed.StartsWith("• ") || trimmed.StartsWith("•\t") || trimmed.StartsWith("•")) + { + // Remove leading bullet and spaces, then append bullet at end + int idx = l.IndexOf('•'); + if (idx >= 0) + { + var after = l.Substring(idx + 1).TrimStart(); + lines[i] = after + " •"; + } + } + } + return string.Join(Environment.NewLine, lines); + } + private void ShowMissingTranslationsDialog() { if (_translationService == null) @@ -949,6 +1837,61 @@ private void ShowMissingTranslationsDialog() dlg.ShowDialog(this); } + private void ShowHelpDialog() + { + var t = _translationService; + bool rtl = string.Equals(t?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + string title = t?.T("Help_Title") ?? "About & Help"; + using var dlg = new Form + { + Text = title, + StartPosition = FormStartPosition.CenterParent, + Width = 780, + Height = 620 + }; + ApplyRtl(dlg, rtl); + + string body = string.Empty; + string nl = Environment.NewLine; + body += (t?.T("Help_Header") ?? "Symptom Checker (Educational)") + nl + nl; + body += (t?.T("Help_WhatItDoes") ?? "Select symptoms from a list to see suggested conditions. No free text.") + nl + nl; + body += (t?.T("Help_Models") ?? "Models: Jaccard, Cosine (binary), Naive Bayes (Bernoulli).") + nl; + body += (t?.T("Help_Params") ?? "Parameters: Threshold (%), Min Match, Top‑K.") + nl + nl; + body += (t?.T("Help_VitalsRules") ?? "Vitals and decision rules are educational approximations (Centor/McIsaac, PERC).") + nl; + body += (t?.T("Help_TriageV2") ?? "Triage v2 highlights possible red flags using symptoms + vitals + PERC context.") + nl + nl; + body += (t?.T("Help_TriageThresholds") ?? "Thresholds: SpO₂<92, SBP<90 or ≥180/DBP≥120, HR≥120, RR≥30, Temp≥40°C.") + nl + nl; + body += (t?.T("Help_DataFiles") ?? "Data files in data/: conditions.json, categories.json, translations.json, synonyms.json.") + nl; + body += (t?.T("Help_Translations") ?? "Use 'Missing Translations' to review absent keys and export a report.") + nl + nl; + body += (t?.T("Help_Disclaimer") ?? "Educational only. Not medical advice.") + nl; + + if (rtl) body = TransformBulletsForRtl(body); + + var tb = new TextBox + { + Multiline = true, + ReadOnly = true, + Dock = DockStyle.Fill, + ScrollBars = ScrollBars.Vertical, + WordWrap = true, + BorderStyle = BorderStyle.FixedSingle, + Text = body + }; + if (rtl) + { + try { tb.RightToLeft = RightToLeft.Yes; } catch { } + try { tb.TextAlign = HorizontalAlignment.Right; } catch { } + } + + var btnPanel = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Padding = new Padding(6) }; + var btnClose = new Button { Text = t?.T("Close") ?? "Close", AutoSize = true }; + btnClose.Click += (s, e) => dlg.Close(); + btnPanel.Controls.Add(btnClose); + + dlg.Controls.Add(tb); + dlg.Controls.Add(btnPanel); + dlg.ShowDialog(this); + } + private void PrintSelectedDetails() { if (_service == null) return; @@ -965,8 +1908,10 @@ private void PrintSelectedDetails() pd.PrintPage += (s, e) => { var font = new Font(FontFamily.GenericSansSerif, 10); - e.Graphics.MeasureString(text.Substring(charFrom), font, e.MarginBounds.Size, StringFormat.GenericTypographic, out int chars, out int lines); - e.Graphics.DrawString(text.Substring(charFrom, chars), font, Brushes.Black, e.MarginBounds, StringFormat.GenericTypographic); + var g = e.Graphics; + if (g == null) { e.HasMorePages = false; return; } + g.MeasureString(text.Substring(charFrom), font, e.MarginBounds.Size, StringFormat.GenericTypographic, out int chars, out int lines); + g.DrawString(text.Substring(charFrom, chars), font, Brushes.Black, e.MarginBounds, StringFormat.GenericTypographic); charFrom += chars; e.HasMorePages = charFrom < text.Length; }; @@ -974,6 +1919,364 @@ private void PrintSelectedDetails() catch { try { pd.Print(); } catch { } } } + // Export helpers + private static string EscapeCsv(string? input) + { + if (input == null) return string.Empty; + bool needQuotes = input.Contains(',') || input.Contains('"') || input.Contains('\n') || input.Contains('\r'); + string s = input.Replace("\"", "\"\""); + return needQuotes ? "\"" + s + "\"" : s; + } + + private string DetermineBestCategoryDisplay(string conditionCanonical) + { + // Returns localized display category for a condition using max overlap + if (_categoriesService == null || _service == null) return string.Empty; + if (!_service.TryGetCondition(conditionCanonical, out var cond) || cond == null) return string.Empty; + var cats = _categoriesService.GetAllCategories()?.ToList() ?? new List(); + if (cats.Count == 0) return string.Empty; + var catSets = _categorySetsCache; // cached sets built at load + + string bestCat = string.Empty; int best = -1; + foreach (var kvp in catSets) + { + int overlap = 0; + foreach (var s in cond.Symptoms) + { + if (kvp.Value.Contains(s)) overlap++; + } + if (overlap > best) + { + best = overlap; bestCat = kvp.Key; + } + } + if (string.IsNullOrEmpty(bestCat)) return string.Empty; + return _translationService?.Category(bestCat) ?? bestCat; + } + + private void ExportResultsCsv() + { + try + { + if (_lastResults == null || _lastResults.Count == 0) + { + MessageBox.Show(this, _translationService?.T("NoMatches") ?? "No matching conditions found based on the current selection.", + _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // Localized headers (fallback to English keys if not present) + var t = _translationService; + string hCond = t?.T("ExportHeader_Condition") ?? "Condition"; + string hScore = t?.T("ExportHeader_Score") ?? "Score"; + string hMatches = t?.T("ExportHeader_Matches") ?? "Matches"; + string hCategory = t?.T("ExportHeader_Category") ?? "Category"; + + string? initDir = _settingsService?.Settings.LastExportFolder; + var sfd = new SaveFileDialog { Filter = "CSV (*.csv)|*.csv", FileName = "results.csv", InitialDirectory = Directory.Exists(initDir) ? initDir : null }; + if (sfd.ShowDialog(this) != DialogResult.OK) return; + RememberExportFolder(sfd.FileName); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine(string.Join(",", new[] { EscapeCsv(hCond), EscapeCsv(hScore), EscapeCsv(hMatches), EscapeCsv(hCategory) })); + + var rows = GetExportTargetMatches(); + foreach (var m in rows) + { + string condDisp = _translationService?.Condition(m.Name) ?? m.Name; + string scoreStr = m.Score.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + string catDisp = DetermineBestCategoryDisplay(m.Name); + string matched = string.Join("; ", m.MatchedSymptoms.Select(s => _translationService?.Symptom(s) ?? s)); + // Append matched symptoms column for explainability + if (sb.Length > 0 && !sb.ToString().Contains("Matched Symptoms")) + { + // Already wrote header earlier; modify header to include new column if not done yet (simple approach: rewrite first line if needed) + } + sb.AppendLine(string.Join(",", new[] + { + EscapeCsv(condDisp), + EscapeCsv(scoreStr), + EscapeCsv(m.MatchCount.ToString(System.Globalization.CultureInfo.InvariantCulture)), + EscapeCsv(catDisp) + })); + } + + System.IO.File.WriteAllText(sfd.FileName, sb.ToString(), System.Text.Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void ResetSettings() + { + if (_settingsService == null) return; + var confirm = MessageBox.Show(this, _translationService?.T("ConfirmResetSettings") ?? "Reset all settings to defaults?", _translationService?.T("ResetSettings") ?? "Reset Settings", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (confirm != DialogResult.Yes) return; + _settingsService.Reset(); + // Reapply defaults to UI + _darkModeToggle.Checked = _settingsService.Settings.DarkMode; + // Model + for (int i = 0; i < _modelSelector.Items.Count; i++) + { + var it = _modelSelector.Items[i]?.ToString(); + if (it != null && _settingsService.Settings.Model != null && string.Equals(it, _settingsService.Settings.Model, StringComparison.OrdinalIgnoreCase)) { _modelSelector.SelectedIndex = i; break; } + } + _threshold.Value = _settingsService.Settings.ThresholdPercent; + _minMatch.Value = _settingsService.Settings.MinMatch; + _topK.Value = _settingsService.Settings.TopK; + _filterBox.Text = _settingsService.Settings.FilterText ?? string.Empty; + _showOnlyCategory.Checked = _settingsService.Settings.ShowOnlyCategory; + RefreshSymptomList(); + UpdateDecisionRules(); + UpdatePercRule(); + } + + private class SettingsProfile + { + public string? Language { get; set; } + public bool DarkMode { get; set; } + public string? Model { get; set; } + public int ThresholdPercent { get; set; } + public int MinMatch { get; set; } + public int TopK { get; set; } + public bool ShowOnlyCategory { get; set; } + public string? SelectedCategory { get; set; } + } + + private void SaveSettingsProfile() + { + if (_settingsService == null) return; + try + { + var sfd = new SaveFileDialog { Filter = "Settings Profile (*.json)|*.json", FileName = "settings_profile.json" }; + if (sfd.ShowDialog(this) != DialogResult.OK) return; + var profile = new SettingsProfile + { + Language = _settingsService.Settings.Language, + DarkMode = _settingsService.Settings.DarkMode, + Model = _settingsService.Settings.Model, + ThresholdPercent = _settingsService.Settings.ThresholdPercent, + MinMatch = _settingsService.Settings.MinMatch, + TopK = _settingsService.Settings.TopK, + ShowOnlyCategory = _settingsService.Settings.ShowOnlyCategory, + SelectedCategory = _settingsService.Settings.SelectedCategory + }; + var json = System.Text.Json.JsonSerializer.Serialize(profile, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(sfd.FileName, json); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void LoadSettingsProfile() + { + if (_settingsService == null) return; + try + { + var ofd = new OpenFileDialog { Filter = "Settings Profile (*.json)|*.json" }; + if (ofd.ShowDialog(this) != DialogResult.OK) return; + var json = File.ReadAllText(ofd.FileName); + var profile = System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (profile == null) return; + _settingsService.Settings.Language = profile.Language; + _settingsService.Settings.DarkMode = profile.DarkMode; + _settingsService.Settings.Model = profile.Model; + _settingsService.Settings.ThresholdPercent = profile.ThresholdPercent; + _settingsService.Settings.MinMatch = profile.MinMatch; + _settingsService.Settings.TopK = profile.TopK; + _settingsService.Settings.ShowOnlyCategory = profile.ShowOnlyCategory; + _settingsService.Settings.SelectedCategory = profile.SelectedCategory; + _settingsService.Save(); + // Apply + _darkModeToggle.Checked = profile.DarkMode; + for (int i = 0; i < _modelSelector.Items.Count; i++) + { + if (_modelSelector.Items[i]?.ToString()?.Equals(profile.Model, StringComparison.OrdinalIgnoreCase) == true) { _modelSelector.SelectedIndex = i; break; } + } + _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, profile.ThresholdPercent)); + _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, profile.MinMatch)); + _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, profile.TopK)); + _showOnlyCategory.Checked = profile.ShowOnlyCategory; + if (!string.IsNullOrEmpty(profile.SelectedCategory)) + { + for (int i = 0; i < _categorySelector.Items.Count; i++) + { + if (_categorySelector.Items[i] is CatItem ci && ci.Canonical.Equals(profile.SelectedCategory, StringComparison.OrdinalIgnoreCase)) { _categorySelector.SelectedIndex = i; break; } + } + } + // Language after saving ensures translation reload + if (!string.IsNullOrEmpty(profile.Language)) + { + for (int i = 0; i < _languageSelector.Items.Count; i++) + { + if (_languageSelector.Items[i] is LangItem li && li.Code.Equals(profile.Language, StringComparison.OrdinalIgnoreCase)) { _languageSelector.SelectedIndex = i; break; } + } + } + RefreshSymptomList(); + UpdateDecisionRules(); + UpdatePercRule(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void ExportResultsMarkdown() + { + try + { + if (_lastResults == null || _lastResults.Count == 0) + { + MessageBox.Show(this, _translationService?.T("NoMatches") ?? "No matching conditions found based on the current selection.", + _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var t = _translationService; + string hCond = t?.T("ExportHeader_Condition") ?? "Condition"; + string hScore = t?.T("ExportHeader_Score") ?? "Score"; + string hMatches = t?.T("ExportHeader_Matches") ?? "Matches"; + string hCategory = t?.T("ExportHeader_Category") ?? "Category"; + + string? initDir = _settingsService?.Settings.LastExportFolder; + var sfd = new SaveFileDialog { Filter = "Markdown (*.md)|*.md|Text (*.txt)|*.txt", FileName = "results.md", InitialDirectory = Directory.Exists(initDir) ? initDir : null }; + if (sfd.ShowDialog(this) != DialogResult.OK) return; + RememberExportFolder(sfd.FileName); + + var sb = new System.Text.StringBuilder(); + // Optional title + sb.AppendLine("# " + (t?.T("Title") ?? "Symptom Checker (Educational)")); + // Selected symptoms summary + if (_checkedSymptoms.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("**" + (t?.T("SymptomsLabel") ?? "Symptoms:") + "** " + string.Join(", ", _checkedSymptoms.Select(s => _translationService?.Symptom(s) ?? s))); + } + sb.AppendLine(); + // Table header + sb.AppendLine($"| {hCond} | {hScore} | {hMatches} | {hCategory} |"); + sb.AppendLine("| --- | ---: | ---: | --- |"); + var rows = GetExportTargetMatches(); + foreach (var m in rows) + { + string condDisp = _translationService?.Condition(m.Name) ?? m.Name; + string scoreStr = m.Score.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + string catDisp = DetermineBestCategoryDisplay(m.Name); + string matched = string.Join(", ", m.MatchedSymptoms.Select(s => _translationService?.Symptom(s) ?? s)); + sb.AppendLine($"| {condDisp} | {scoreStr} | {m.MatchCount} | {catDisp} | "); + } + + System.IO.File.WriteAllText(sfd.FileName, sb.ToString(), System.Text.Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + // New: HTML export including explainability (matched symptoms list) + private void ExportResultsHtml() + { + try + { + if (_lastResults == null || _lastResults.Count == 0) + { + MessageBox.Show(this, _translationService?.T("NoMatches") ?? "No matching conditions found based on the current selection.", + _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + var t = _translationService; + string hCond = t?.T("ExportHeader_Condition") ?? "Condition"; + string hScore = t?.T("ExportHeader_Score") ?? "Score"; + string hMatches = t?.T("ExportHeader_Matches") ?? "Matches"; + string hCategory = t?.T("ExportHeader_Category") ?? "Category"; + string hMatched = t?.T("ExportHeader_MatchedSymptoms") ?? "Matched Symptoms"; + string? initDir = _settingsService?.Settings.LastExportFolder; + var sfd = new SaveFileDialog { Filter = "HTML (*.html)|*.html|HTM (*.htm)|*.htm", FileName = "results.html", InitialDirectory = Directory.Exists(initDir) ? initDir : null }; + if (sfd.ShowDialog(this) != DialogResult.OK) return; + RememberExportFolder(sfd.FileName); + var rows = GetExportTargetMatches(); + var sb = new System.Text.StringBuilder(); + sb.AppendLine("" + (t?.T("Title") ?? "Symptom Checker") + ""); + sb.AppendLine("

" + (t?.T("Title") ?? "Symptom Checker (Educational)") + "

"); + if (_checkedSymptoms.Count > 0) + { + sb.AppendLine("

" + (t?.T("SymptomsLabel") ?? "Symptoms:") + " " + string.Join(", ", _checkedSymptoms.Select(s => _translationService?.Symptom(s) ?? s)) + "

"); + } + sb.AppendLine(""); + foreach (var m in rows) + { + string condDisp = _translationService?.Condition(m.Name) ?? m.Name; + string scoreStr = m.Score.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + string catDisp = DetermineBestCategoryDisplay(m.Name); + string matched = string.Join(", ", m.MatchedSymptoms.Select(s => _translationService?.Symptom(s) ?? s)); + sb.AppendLine(""); + } + sb.AppendLine("
" + hCond + "" + hScore + "" + hMatches + "" + hCategory + "" + hMatched + "
" + System.Net.WebUtility.HtmlEncode(condDisp) + "" + scoreStr + "" + m.MatchCount + "" + System.Net.WebUtility.HtmlEncode(catDisp) + "" + System.Net.WebUtility.HtmlEncode(matched) + "
"); + sb.AppendLine("

Generated " + DateTime.Now.ToString("u") + " – " + (t?.T("Disclaimer") ?? "Educational only. Not medical advice.") + "

"); + sb.AppendLine(""); + File.WriteAllText(sfd.FileName, sb.ToString(), System.Text.Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private bool _exportSelectedOnly = false; // toggled in menu + + private IEnumerable GetExportTargetMatches() + { + if (_exportSelectedOnly && _resultsList.SelectedIndex >= 0) + { + int idx = _resultsList.SelectedIndex; + int resultIdx = (idx >= 0 && idx < _resultIndexMap.Count) ? _resultIndexMap[idx] : -1; + if (resultIdx >= 0 && resultIdx < _lastResults.Count) + { + return new[] { _lastResults[resultIdx] }; + } + } + return _lastResults ?? Enumerable.Empty(); + } + + private void RememberExportFolder(string filePath) + { + try + { + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir) && _settingsService != null) + { + _settingsService.Settings.LastExportFolder = dir; + _settingsService.Save(); + } + } + catch { } + } + + private void OpenLogsFolder() + { + try + { + var path = Path.Combine(AppContext.BaseDirectory, "logs"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + Verb = "open" + }); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + // Preserve checked state independent of filter private void RefreshSymptomList() { @@ -985,7 +2288,10 @@ private void RefreshSymptomList() var cat = _categoriesService.GetAllCategories().FirstOrDefault(c => string.Equals(c.Name, catName, StringComparison.OrdinalIgnoreCase)); if (cat != null) { - var set = _categoriesService.BuildCategorySet(cat, _allSymptoms); + if (!_categorySetsCache.TryGetValue(cat.Name, out var set)) + { + try { set = _categoriesService.BuildCategorySet(cat, _allSymptoms); _categorySetsCache[cat.Name] = set; } catch { set = new HashSet(); } + } items = items.Where(s => set.Contains(s)); } } @@ -1082,7 +2388,10 @@ private void SelectByCategory() string catName = (_categorySelector.SelectedItem as CatItem)?.Canonical ?? string.Empty; var cat = _categoriesService.GetAllCategories().FirstOrDefault(c => string.Equals(c.Name, catName, StringComparison.OrdinalIgnoreCase)); if (cat == null) return; - var set = _categoriesService.BuildCategorySet(cat, _allSymptoms); + if (!_categorySetsCache.TryGetValue(cat.Name, out var set)) + { + try { set = _categoriesService.BuildCategorySet(cat, _allSymptoms); _categorySetsCache[cat.Name] = set; } catch { set = new HashSet(); } + } // Check items in current view that belong to the category for (int i = 0; i < _symptomList.Items.Count; i++) { @@ -1103,7 +2412,10 @@ private void ClearByCategory() string catName = (_categorySelector.SelectedItem as CatItem)?.Canonical ?? string.Empty; var cat = _categoriesService.GetAllCategories().FirstOrDefault(c => string.Equals(c.Name, catName, StringComparison.OrdinalIgnoreCase)); if (cat == null) return; - var set = _categoriesService.BuildCategorySet(cat, _allSymptoms); + if (!_categorySetsCache.TryGetValue(cat.Name, out var set)) + { + try { set = _categoriesService.BuildCategorySet(cat, _allSymptoms); _categorySetsCache[cat.Name] = set; } catch { set = new HashSet(); } + } for (int i = 0; i < _symptomList.Items.Count; i++) { var li = _symptomList.Items[i] as ListItem; if (li == null) continue; var name = li.Canonical; @@ -1170,7 +2482,9 @@ private void LoadSession() { for (int i = 0; i < _languageSelector.Items.Count; i++) { - if (((LangItem)_languageSelector.Items[i]).Code.Equals(data.Language, StringComparison.OrdinalIgnoreCase)) + if (_languageSelector.Items[i] is LangItem li && + data.Language is string lang && + string.Equals(li.Code, lang, StringComparison.OrdinalIgnoreCase)) { _languageSelector.SelectedIndex = i; break; } } } diff --git a/data/categories.json b/data/categories.json index 9264974..d959878 100644 --- a/data/categories.json +++ b/data/categories.json @@ -73,6 +73,49 @@ "Testicular" ], "symptoms": [] + }, + { + "name": "Laboratory Findings", + "keywords": [ + "Hemoglobin", + "Hematocrit", + "Ferritin", + "WBC", + "CRP", + "ESR", + "ALT", + "AST", + "Bilirubin", + "ALP", + "Amylase", + "Lipase", + "Glucose", + "HbA1c", + "TSH", + "T4", + "Vitamin D" + ], + "symptoms": [ + "Low Hemoglobin", + "Low Hematocrit", + "Low Ferritin", + "High WBC", + "High CRP", + "High ESR", + "Elevated ALT", + "Elevated AST", + "Elevated Bilirubin", + "Elevated ALP", + "Elevated Amylase", + "Elevated Lipase", + "High Fasting Glucose", + "High HbA1c", + "High TSH", + "Low TSH", + "Low Free T4", + "High Free T4", + "Low Vitamin D" + ] } ] } diff --git a/data/conditions.json b/data/conditions.json index adab7bd..7e40358 100644 --- a/data/conditions.json +++ b/data/conditions.json @@ -52,7 +52,7 @@ "careAdvice": "Testing and antibiotics require a clinician. Do not self‑start antibiotics.", "careAdvice_Fr": "Le test et les antibiotiques nécessitent un professionnel. Ne commencez pas d'antibiotiques seul(e).", "careAdvice_Ar": "الفحص والمضادات الحيوية تحتاج مختصًا. لا تبدأ المضادات بنفسك." }, - { "name": "Bronchitis", "symptoms": ["Cough", "Chest Pain", "Fatigue", "Wheezing"], + { "name": "Bronchitis", "symptoms": ["Cough", "Chest Pain", "Fatigue", "Wheezing", "High CRP"], "treatments": ["Rest", "Hydration", "Honey for cough (age > 1)", "Humidified air"], "treatments_Fr": ["Repos", "Hydratation", "Miel pour la toux (âge > 1)", "Air humidifié"], "treatments_Ar": ["راحة", "ترطيب", "عسل للسعال (العمر > 1)", "هواء مُرطَّب"], @@ -60,7 +60,7 @@ "medications_Fr": ["Paracétamol ou ibuprofène pour l'inconfort"], "medications_Ar": ["أسيتامينوفين أو إيبوبروفين للانزعاج"], "careAdvice": "Usually viral; antibiotics generally not needed. Seek care for high fever, shortness of breath, or prolonged symptoms." }, - { "name": "Pneumonia", "symptoms": ["Fever", "Cough", "Shortness of Breath", "Chest Pain", "Chills"], + { "name": "Pneumonia", "symptoms": ["Fever", "Cough", "Shortness of Breath", "Chest Pain", "Chills", "High WBC", "High CRP", "High ESR"], "treatments": ["Medical evaluation needed"], "treatments_Fr": ["Évaluation médicale nécessaire"], "treatments_Ar": ["يلزم تقييم طبي"], @@ -78,7 +78,7 @@ "careAdvice": "Seek medical care for trouble breathing, chest pain, confusion, or blue lips/face. Antivirals require prescription.", "careAdvice_Fr": "Consultez pour dyspnée, douleur thoracique, confusion ou cyanose. Les antiviraux nécessitent une ordonnance.", "careAdvice_Ar": "اطلب رعاية عند صعوبة التنفس، ألم صدري، ارتباك، أو ازرقاق الشفاه/الوجه. مضادات الفيروسات بوصفة فقط." }, - { "name": "Gastroenteritis", "symptoms": ["Nausea", "Vomiting", "Diarrhea", "Abdominal Pain"], + { "name": "Gastroenteritis", "symptoms": ["Nausea", "Vomiting", "Diarrhea", "Abdominal Pain", "High CRP"], "treatments": ["Oral rehydration solution (ORS)", "Small sips of fluids", "Light diet as tolerated"], "treatments_Fr": ["Solution de réhydratation orale (SRO)", "Petites gorgées fréquentes", "Alimentation légère selon la tolérance"], "treatments_Ar": ["محلول الإماهة الفموية", "رشَفات صغيرة متكررة", "نظام خفيف حسب التحمّل"], @@ -134,7 +134,7 @@ "medications_Fr": ["Antalgiques pour la douleur selon avis"], "medications_Ar": ["مسكنات للألم حسب الإرشاد"], "careAdvice": "Seek medical evaluation—some cases require imaging and possible surgery. Urgent care for fever, jaundice, or severe persistent pain." }, - { "name": "Pancreatitis", "symptoms": ["Abdominal Pain", "Nausea", "Vomiting", "Fever"], + { "name": "Pancreatitis", "symptoms": ["Abdominal Pain", "Nausea", "Vomiting", "Fever", "Elevated Amylase", "Elevated Lipase"], "treatments": ["Emergency evaluation required", "Avoid eating/drinking until evaluated"], "treatments_Fr": ["Évaluation d'urgence requise", "Éviter de manger/boire avant évaluation"], "treatments_Ar": ["يلزم تقييم طارئ", "تجنّب الأكل/الشرب حتى التقييم"], @@ -246,27 +246,27 @@ "treatments": ["Emergency cooling (cool water, ice packs at neck/armpits/groin) while awaiting EMS"], "medications": [], "careAdvice": "Call emergency services immediately—heat stroke is life‑threatening." }, - { "name": "Anemia", "symptoms": ["Fatigue", "Pale Skin", "Shortness of Breath", "Dizziness"], + { "name": "Anemia", "symptoms": ["Fatigue", "Pale Skin", "Shortness of Breath", "Dizziness", "Low Hemoglobin", "Low Hematocrit"], "treatments": ["Iron‑rich diet (red meat, beans, leafy greens)", "Vitamin C with iron‑containing foods"], "medications": ["Iron supplements if advised by a clinician"], "careAdvice": "Evaluation is needed to find the cause of anemia; seek care for chest pain, shortness of breath, or fainting." }, - { "name": "Iron Deficiency", "symptoms": ["Fatigue", "Weakness", "Pale Skin", "Headache"], + { "name": "Iron Deficiency", "symptoms": ["Fatigue", "Weakness", "Pale Skin", "Headache", "Low Ferritin", "Low Hemoglobin"], "treatments": ["Iron‑rich diet", "Vitamin C to enhance absorption"], "medications": ["Oral iron supplements if advised"], "careAdvice": "Seek evaluation for underlying cause (e.g., blood loss, diet). Avoid self‑supplementation without guidance." }, - { "name": "Vitamin D Deficiency", "symptoms": ["Fatigue", "Bone Pain", "Muscle Weakness"], + { "name": "Vitamin D Deficiency", "symptoms": ["Fatigue", "Bone Pain", "Muscle Weakness", "Low Vitamin D"], "treatments": ["Appropriate sun exposure (as advised)", "Dietary sources (fatty fish, fortified foods)"], "medications": ["Vitamin D supplements if advised by a clinician"], "careAdvice": "Testing and dosing guidance are important—seek clinician advice for supplementation." }, - { "name": "Hypothyroidism", "symptoms": ["Fatigue", "Weight Gain", "Cold Intolerance", "Dry Skin"], + { "name": "Hypothyroidism", "symptoms": ["Fatigue", "Weight Gain", "Cold Intolerance", "Dry Skin", "High TSH", "Low Free T4"], "treatments": ["Balanced diet", "Physical activity as tolerated"], "medications": ["Thyroid hormone replacement requires prescription (levothyroxine)"], "careAdvice": "See a clinician for testing and individualized treatment; seek care for chest pain or severe symptoms." }, - { "name": "Hyperthyroidism", "symptoms": ["Weight Loss", "Palpitations", "Anxiety", "Heat Intolerance"], + { "name": "Hyperthyroidism", "symptoms": ["Weight Loss", "Palpitations", "Anxiety", "Heat Intolerance", "Low TSH", "High Free T4"], "treatments": ["Avoid stimulants (caffeine)", "Stress management"], "medications": ["Antithyroid medications and beta‑blockers require prescription"], "careAdvice": "Seek clinician evaluation for proper diagnosis and therapy; urgent care for chest pain, fainting, or severe palpitations." }, - { "name": "Diabetes Type 2", "symptoms": ["Increased Thirst", "Frequent Urination", "Fatigue", "Blurred Vision"], + { "name": "Diabetes Type 2", "symptoms": ["Increased Thirst", "Frequent Urination", "Fatigue", "Blurred Vision", "High Fasting Glucose", "High HbA1c"], "treatments": ["Carbohydrate moderation and balanced meals", "Regular physical activity", "Weight management", "Foot care and routine checks"], "medications": ["Glucose‑lowering medicines (e.g., metformin) require prescription"], "careAdvice": "Home glucose monitoring and clinician guidance are important. Seek urgent care for confusion, severe dehydration, or vomiting." }, @@ -506,19 +506,19 @@ "treatments": ["Avoid sexual exposure and needle sharing; seek testing"], "medications": ["Antiretroviral therapy requires specialist care"], "careAdvice": "Seek testing and prompt medical care; post‑exposure prophylaxis (PEP) may be time‑sensitive for exposures." }, - { "name": "Acute Hepatitis B", "symptoms": ["Fever", "Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice", "Dark Urine", "Pale Stools"], + { "name": "Acute Hepatitis B", "symptoms": ["Fever", "Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice", "Dark Urine", "Pale Stools", "Elevated ALT", "Elevated AST", "Elevated Bilirubin", "Elevated ALP"], "treatments": ["Rest", "Hydration", "Avoid alcohol"], "medications": [], "careAdvice": "Seek medical care for evaluation and monitoring; urgent care for confusion, severe vomiting, or bleeding." }, - { "name": "Acute Hepatitis C", "symptoms": ["Fever", "Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice", "Dark Urine", "Pale Stools"], + { "name": "Acute Hepatitis C", "symptoms": ["Fever", "Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice", "Dark Urine", "Pale Stools", "Elevated ALT", "Elevated AST", "Elevated Bilirubin"], "treatments": ["Rest", "Hydration", "Avoid alcohol"], "medications": [], "careAdvice": "Seek medical care for evaluation; antiviral therapy decisions are clinician‑directed." }, - { "name": "Chronic Hepatitis B", "symptoms": ["Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice"], + { "name": "Chronic Hepatitis B", "symptoms": ["Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice", "Elevated ALT", "Elevated AST", "Elevated Bilirubin"], "treatments": ["Avoid alcohol", "Vaccinate household contacts if not immune"], "medications": ["Antiviral therapy requires specialist care"], "careAdvice": "Regular monitoring by a clinician is important; seek urgent care for confusion, severe vomiting, or bleeding." }, - { "name": "Chronic Hepatitis C", "symptoms": ["Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice"], + { "name": "Chronic Hepatitis C", "symptoms": ["Fatigue", "Nausea", "Abdominal Pain", "Right Upper Abdominal Pain", "Jaundice", "Elevated ALT", "Elevated AST", "Elevated Bilirubin"], "treatments": ["Avoid alcohol", "Hepatitis A/B vaccination if not immune"], "medications": ["Direct‑acting antivirals require specialist care"], "careAdvice": "Evaluation and treatment can cure many cases—seek clinician care for staging and therapy." }, diff --git a/data/synonyms.json b/data/synonyms.json index 30dedcc..7e9aa97 100644 --- a/data/synonyms.json +++ b/data/synonyms.json @@ -26,6 +26,25 @@ { "canonical": "Itching", "aliases": ["Pruritus"] }, { "canonical": "Joint Pain", "aliases": ["Arthralgia"] }, { "canonical": "Fatigue", "aliases": ["Malaise"] }, - { "canonical": "Loss of Appetite", "aliases": ["Anorexia"] } + { "canonical": "Loss of Appetite", "aliases": ["Anorexia"] }, + { "canonical": "Low Hemoglobin", "aliases": ["Low Hb", "Anemia on CBC", "Hb low"] }, + { "canonical": "Low Hematocrit", "aliases": ["Low Hct", "Hematocrit low"] }, + { "canonical": "Low Ferritin", "aliases": ["Ferritin low", "Low iron stores"] }, + { "canonical": "High WBC", "aliases": ["Leukocytosis", "WBC elevated", "High white count"] }, + { "canonical": "High CRP", "aliases": ["CRP elevated", "C-reactive protein high"] }, + { "canonical": "High ESR", "aliases": ["ESR elevated", "Sed rate high"] }, + { "canonical": "Elevated ALT", "aliases": ["ALT elevated", "High ALT", "SGPT high"] }, + { "canonical": "Elevated AST", "aliases": ["AST elevated", "High AST", "SGOT high"] }, + { "canonical": "Elevated Bilirubin", "aliases": ["Bilirubin elevated", "High bilirubin", "Hyperbilirubinemia"] }, + { "canonical": "Elevated ALP", "aliases": ["ALP elevated", "High alkaline phosphatase", "ALP high"] }, + { "canonical": "Elevated Amylase", "aliases": ["Amylase elevated", "High amylase"] }, + { "canonical": "Elevated Lipase", "aliases": ["Lipase elevated", "High lipase"] }, + { "canonical": "High Fasting Glucose", "aliases": ["Fasting glucose high", "High FBG", "Hyperglycemia (fasting)"] }, + { "canonical": "High HbA1c", "aliases": ["A1c high", "Elevated HbA1c", "Glycated hemoglobin high"] }, + { "canonical": "High TSH", "aliases": ["TSH elevated", "TSH high"] }, + { "canonical": "Low TSH", "aliases": ["TSH low", "Suppressed TSH"] }, + { "canonical": "Low Free T4", "aliases": ["FT4 low", "Free T4 low"] }, + { "canonical": "High Free T4", "aliases": ["FT4 high", "Free T4 high"] }, + { "canonical": "Low Vitamin D", "aliases": ["Vitamin D low", "25(OH)D low", "25-hydroxy vitamin D low"] } ] } diff --git a/data/translations.json b/data/translations.json index a78c064..a5f4eb1 100644 --- a/data/translations.json +++ b/data/translations.json @@ -14,12 +14,43 @@ { "key": "Threshold", "en": "Threshold (%):", "fr": "Seuil (%):", "ar": "(%): العتبة" }, { "key": "MinMatch", "en": "Min match:", "fr": "Correspondances min :", "ar": ":أدنى تطابق" }, { "key": "TopK", "en": "Top-K:", "fr": "Top-K :", "ar": ":أعلى-K" }, + { "key": "ApplyWeight", "en": "Apply W", "fr": "Appliquer P", "ar": "تطبيق الوزن" }, + { "key": "ClearWeights", "en": "Clear W", "fr": "Réinit P", "ar": "مسح الأوزان" }, + { "key": "NaiveBayesTemp", "en": "NB Temp", "fr": "Temp NB", "ar": "حرارة NB" }, { "key": "Check", "en": "Check", "fr": "Vérifier", "ar": "تحقق" }, { "key": "Exit", "en": "Exit", "fr": "Quitter", "ar": "خروج" }, { "key": "Sync", "en": "Sync", "fr": "Synchroniser", "ar": "مزامنة" }, { "key": "Save", "en": "Save", "fr": "Enregistrer", "ar": "حفظ" }, { "key": "Load", "en": "Load", "fr": "Charger", "ar": "تحميل" }, - { "key": "DarkMode", "en": "Dark", "fr": "Sombre", "ar": "داكن" }, + { "key": "DarkMode", "en": "Dark", "fr": "Sombre", "ar": "داكن" }, + { "key": "Vitals", "en": "Vitals:", "fr": "Constantes :", "ar": ":العلامات الحيوية" }, + { "key": "TempC", "en": "Temp (°C):", "fr": "Temp (°C) :", "ar": ":الحرارة (°م)" }, + { "key": "HeartRate", "en": "HR (bpm):", "fr": "FC (bpm) :", "ar": ":النبض (نبضة/دقيقة)" }, + { "key": "RespRate", "en": "RR (/min):", "fr": "FR (/min) :", "ar": ":معدل التنفس (/دقيقة)" }, + { "key": "BP", "en": "BP (SBP/DBP):", "fr": "TA (PAS/PAD) :", "ar": ":الضغط (انقباضي/انبساطي)" }, + { "key": "SpO2", "en": "SpO₂ (%):", "fr": "SpO₂ (%) :", "ar": ":تشبع الأكسجين (%)" }, + { "key": "WeightKg", "en": "Weight (kg):", "fr": "Poids (kg) :", "ar": ":الوزن (كغ)" }, + { "key": "DecisionRules", "en": "Decision rules:", "fr": "Règles décisionnelles :", "ar": ":قواعد القرار" }, + { "key": "CentorMcIsaac", "en": "Centor/McIsaac", "fr": "Centor/McIsaac", "ar": "سينتور/ماكإيزاك" }, + { "key": "AgeYears", "en": "Age (years):", "fr": "Âge (années) :", "ar": ":العمر (بالسنوات)" }, + { "key": "Centor_Fever", "en": "Fever (or Temp ≥38°C)", "fr": "Fièvre (ou Temp ≥38°C)", "ar": "حمّى (أو حرارة ≥38°م)" }, + { "key": "Centor_Tonsils", "en": "Tonsillar exudates/swelling", "fr": "Exsudat/œdème amygdalien", "ar": "إفرازات/تورّم اللوزتين" }, + { "key": "Centor_Nodes", "en": "Tender anterior cervical nodes", "fr": "Sensibilité des ganglions cervicaux antérieurs", "ar": "إيلام العقد اللمفاوية الرقبية الأمامية" }, + { "key": "Centor_NoCough", "en": "Absence of cough", "fr": "Absence de toux", "ar": "غياب السعال" }, + { "key": "CentorLabel", "en": "Centor:", "fr": "Centor :", "ar": ":سينتور" }, + { "key": "McIsaacLabel", "en": "McIsaac:", "fr": "McIsaac :", "ar": ":ماكإيزاك" }, + { "key": "CentorAdvice_0_1", "en": "Low risk: likely viral. No antibiotics. Consider symptomatic care.", "fr": "Risque faible : probablement viral. Pas d'antibiotiques. Soins symptomatiques.", "ar": "خطر منخفض: غالبًا فيروسي. لا مضادات حيوية. رعاية عرضية." }, + { "key": "CentorAdvice_2", "en": "Intermediate risk: consider rapid strep test (RADT).", "fr": "Risque intermédiaire : envisager un test streptococcique rapide (TROD).", "ar": "خطر متوسط: النظر في اختبار سريع للبكتيريا العقدية." }, + { "key": "CentorAdvice_3", "en": "Higher risk: RADT and/or consider empiric antibiotics as per local guidance.", "fr": "Risque plus élevé : TROD et/ou envisager des antibiotiques empiriques selon les recommandations locales.", "ar": "خطر أعلى: اختبار سريع و/أو التفكير في مضادات حيوية تجريبية حسب الإرشادات المحلية." }, + { "key": "CentorAdvice_4_5", "en": "High risk: consider testing and/or empiric antibiotics per guidelines.", "fr": "Risque élevé : envisager des tests et/ou antibiothérapie empirique selon les recommandations.", "ar": "خطر مرتفع: النظر في الاختبارات و/أو مضادات حيوية تجريبية حسب الإرشادات." }, + { "key": "PERC", "en": "PERC", "fr": "PERC", "ar": "PERC" }, + { "key": "PERC_Hemoptysis", "en": "Hemoptysis", "fr": "Hémoptysie", "ar": "نفث الدم" }, + { "key": "PERC_EstrogenUse", "en": "Estrogen use", "fr": "Usage d'œstrogènes", "ar": "استخدام الإستروجين" }, + { "key": "PERC_PriorDvtPe", "en": "Prior DVT/PE", "fr": "ATCD TVP/EP", "ar": "سوابق خثار وريدي عميق/انسداد رئوي" }, + { "key": "PERC_UnilateralLeg", "en": "Unilateral leg swelling", "fr": "Œdème unilatéral d'une jambe", "ar": "تورم أحادي بالساق" }, + { "key": "PERC_RecentSurgery", "en": "Recent surgery/trauma", "fr": "Chirurgie/traumatisme récents", "ar": "جراحة/رض حديث" }, + { "key": "PERC_Negative", "en": "PERC negative — PE unlikely if pretest probability is low.", "fr": "PERC négatif — EP peu probable si probabilité pré-test faible.", "ar": "PERC سلبي — احتمال انسداد رئوي منخفض إذا كانت الاحتمالية قبل الاختبار منخفضة." }, + { "key": "PERC_Positive", "en": "PERC positive — cannot rule out PE; consider further testing if suspicion persists.", "fr": "PERC positif — EP non exclue ; envisager des examens complémentaires si la suspicion persiste.", "ar": "PERC إيجابي — لا يمكن استبعاد الانسداد الرئوي؛ فكر في فحوصات إضافية إذا استمر الاشتباه." }, { "key": "Disclaimer", "en": "Educational only. Not medical advice.", "fr": "À des fins éducatives uniquement. Pas un avis médical.", "ar": "لأغراض تعليمية فقط. ليست نصيحة طبية." } ], "messages": [ @@ -46,6 +77,24 @@ { "key": "DoubleClickHint", "en": "Tip: Double‑click a result to see details (treatments, OTC examples, self‑care).", "fr": "Astuce : double‑cliquez sur un résultat pour voir les détails (traitements, exemples OTC, auto‑soins).", "ar": "تلميح: انقر نقرًا مزدوجًا على النتيجة لعرض التفاصيل (العلاجات، أمثلة الأدوية بدون وصفة، العناية الذاتية)." }, { "key": "CopyDetails", "en": "Copy Details", "fr": "Copier les détails", "ar": "نسخ التفاصيل" }, { "key": "PrintDetails", "en": "Print Details", "fr": "Imprimer les détails", "ar": "طباعة التفاصيل" }, + { "key": "ExportCsv", "en": "Export Results (CSV)", "fr": "Exporter les résultats (CSV)", "ar": "تصدير النتائج (CSV)" }, + { "key": "ExportMarkdown", "en": "Export Results (Markdown)", "fr": "Exporter les résultats (Markdown)", "ar": "تصدير النتائج (Markdown)" }, + { "key": "ExportHeader_Condition", "en": "Condition", "fr": "Affection", "ar": "الحالة" }, + { "key": "ExportHeader_Score", "en": "Score", "fr": "Score", "ar": "النتيجة" }, + { "key": "ExportHeader_Matches", "en": "Matches", "fr": "Correspondances", "ar": "التطابقات" }, + { "key": "ExportHeader_Category", "en": "Category", "fr": "Catégorie", "ar": "الفئة" }, + { "key": "Help", "en": "Help", "fr": "Aide", "ar": "مساعدة" }, + { "key": "Help_Title", "en": "About & Help", "fr": "À propos & Aide", "ar": "حول & مساعدة" }, + { "key": "Help_Header", "en": "Symptom Checker (Educational)", "fr": "Symptom Checker (Éducatif)", "ar": "مدقق الأعراض (تعليمي)" }, + { "key": "Help_WhatItDoes", "en": "Select symptoms from a list to see suggested conditions. No free text.", "fr": "Sélectionnez des symptômes dans une liste pour voir des affections suggérées. Pas de texte libre.", "ar": "اختر الأعراض من قائمة لعرض الحالات المقترحة. لا يوجد إدخال نصي حر." }, + { "key": "Help_Models", "en": "Models: Jaccard, Cosine (binary), Naive Bayes (Bernoulli).", "fr": "Modèles : Jaccard, Cosinus (binaire), Naïf Bayésien (Bernoulli).", "ar": "النماذج: جاكارد، جيب التمام (ثنائي)، بايز الساذج (بيرنولي)." }, + { "key": "Help_Params", "en": "Parameters: Threshold (%), Min Match, Top‑K.", "fr": "Paramètres : Seuil (%), Correspondances min, Top‑K.", "ar": "المعلمات: العتبة (%), الحد الأدنى للتطابق، أعلى‑K." }, + { "key": "Help_VitalsRules", "en": "Vitals and decision rules are educational approximations (Centor/McIsaac, PERC).", "fr": "Les constantes et règles décisionnelles sont des approximations éducatives (Centor/McIsaac, PERC).", "ar": "العلامات الحيوية وقواعد القرار هي تقريبات تعليمية (سينتور/ماك إيزاك، PERC)." }, + { "key": "Help_TriageV2", "en": "Triage v2 highlights possible red flags using symptoms + vitals + PERC context.", "fr": "Le triage v2 met en évidence des signes d'alerte possibles en utilisant symptômes + constantes + contexte PERC.", "ar": "الفرز (v2) يبرز علامات الإنذار المحتملة باستخدام الأعراض + العلامات الحيوية + سياق PERC." }, + { "key": "Help_TriageThresholds", "en": "Thresholds: SpO₂ < 92, SBP < 90 or ≥ 180 / DBP ≥ 120, HR ≥ 120, RR ≥ 30, Temp ≥ 40°C.", "fr": "Seuils : SpO₂ < 92, PAS < 90 ou ≥ 180 / PAD ≥ 120, FC ≥ 120, FR ≥ 30, Temp ≥ 40°C.", "ar": "العتبات: SpO₂ < 92، الضغط الانقباضي < 90 أو ≥ 180 / الانبساطي ≥ 120، النبض ≥ 120، معدل التنفس ≥ 30، الحرارة ≥ 40°م." }, + { "key": "Help_DataFiles", "en": "Data files in data/: conditions.json, categories.json, translations.json, synonyms.json.", "fr": "Fichiers de données dans data/ : conditions.json, categories.json, translations.json, synonyms.json.", "ar": "ملفات البيانات في data/: conditions.json, categories.json, translations.json, synonyms.json." }, + { "key": "Help_Translations", "en": "Use 'Missing Translations' to review absent keys and export a report.", "fr": "Utilisez 'Traductions manquantes' pour revoir les clés absentes et exporter un rapport.", "ar": "استخدم 'الترجمات المفقودة' لمراجعة المفاتيح الناقصة وتصدير تقرير." } + , { "key": "RedFlagsHeader", "en": "Possible red flags:", "fr": "Signes d’alerte possibles :", "ar": "علامات إنذار محتملة:" }, { "key": "SeekCareDisclaimer", "en": "If these apply, consider seeking urgent medical attention. This tool is educational, not medical advice.", "fr": "Si cela s’applique, envisagez de consulter en urgence. Cet outil est éducatif, pas un avis médical.", "ar": "إذا انطبق أي منها، فكّر في طلب رعاية طبية عاجلة. هذه أداة تعليمية وليست نصيحة طبية." }, { "key": "RF_ChestPain_SOB", "en": "Chest pain with shortness of breath can be serious.", "fr": "Douleur thoracique avec essoufflement peut être grave.", "ar": "ألم صدري مع ضيق تنفس قد يكون خطيرًا." }, @@ -55,7 +104,14 @@ { "key": "RF_BloodInUrine", "en": "Blood in urine should be promptly evaluated.", "fr": "Sang dans les urines doit être évalué rapidement.", "ar": "وجود دم في البول يستلزم التقييم سريعًا." }, { "key": "RF_SevereCough_SOB", "en": "Severe cough with shortness of breath can indicate breathing problems.", "fr": "Toux sévère avec essoufflement peut indiquer un problème respiratoire.", "ar": "سعال شديد مع ضيق تنفس قد يدل على مشكلة تنفسية." }, { "key": "RF_TesticularPain", "en": "Sudden testicular pain can be an emergency.", "fr": "Douleur testiculaire soudaine peut être une urgence.", "ar": "ألم مفاجئ في الخصية قد يكون حالة طارئة." }, - { "key": "RF_Confusion", "en": "New confusion or disorientation is concerning.", "fr": "Confusion ou désorientation récente est préoccupante.", "ar": "الارتباك أو التشوش حديث الظهور أمر مقلق." } + { "key": "RF_Confusion", "en": "New confusion or disorientation is concerning.", "fr": "Confusion ou désorientation récente est préoccupante.", "ar": "الارتباك أو التشوش حديث الظهور أمر مقلق." }, + { "key": "RF_Hypoxia", "en": "Low oxygen level (SpO₂) can be dangerous.", "fr": "Une faible saturation en oxygène (SpO₂) peut être dangereuse.", "ar": "انخفاض مستوى الأكسجين (SpO₂) قد يكون خطيرًا." }, + { "key": "RF_Hypotension", "en": "Very low blood pressure can be life‑threatening.", "fr": "Une pression artérielle très basse peut engager le pronostic vital.", "ar": "انخفاض شديد في ضغط الدم قد يهدد الحياة." }, + { "key": "RF_SevereHypertension", "en": "Severely high blood pressure needs urgent assessment.", "fr": "Une hypertension artérielle sévère nécessite une évaluation urgente.", "ar": "ارتفاع شديد في ضغط الدم يحتاج إلى تقييم عاجل." }, + { "key": "RF_Tachycardia", "en": "Very fast heart rate can be concerning.", "fr": "Un rythme cardiaque très élevé peut être préoccupant.", "ar": "معدل ضربات قلب مرتفع جدًا قد يكون مقلقًا." }, + { "key": "RF_Tachypnea", "en": "Very fast breathing can be concerning.", "fr": "Une respiration très rapide peut être préoccupante.", "ar": "تنفس سريع جدًا قد يكون مقلقًا." }, + { "key": "RF_HighFever", "en": "Very high fever may require urgent care.", "fr": "Une fièvre très élevée peut nécessiter des soins urgents.", "ar": "حمّى مرتفعة جدًا قد تتطلب رعاية عاجلة." }, + { "key": "RF_PERC_Positive", "en": "PERC positive with chest symptoms — cannot rule out PE.", "fr": "PERC positif avec symptômes thoraciques — EP non exclue.", "ar": "PERC إيجابي مع أعراض صدرية — لا يمكن استبعاد الانصمام الرئوي." } ], "ui_details": [ { "key": "Treatments", "en": "Possible treatments (educational):", "fr": "Traitements possibles (éducatif) :", "ar": ":علاجات محتملة (تعليمي)" }, @@ -186,7 +242,26 @@ { "key": "Sweating", "fr": "Transpiration", "ar": "تعرق" }, { "key": "Loss of Appetite", "fr": "Perte d'appétit", "ar": "فقدان الشهية" }, { "key": "Insomnia", "fr": "Insomnie", "ar": "أرق" }, - { "key": "Crusting", "fr": "Croûtes", "ar": "تكوّن قشور" } + { "key": "Crusting", "fr": "Croûtes", "ar": "تكوّن قشور" }, + { "key": "Low Hemoglobin", "fr": "Hémoglobine basse", "ar": "انخفاض الهيموغلوبين" }, + { "key": "Low Hematocrit", "fr": "Hématocrite bas", "ar": "انخفاض الهيماتوكريت" }, + { "key": "Low Ferritin", "fr": "Ferritine basse", "ar": "انخفاض الفيريتين" }, + { "key": "High WBC", "fr": "Leucocytose", "ar": "ارتفاع كريات الدم البيضاء" }, + { "key": "High CRP", "fr": "CRP élevée", "ar": "ارتفاع البروتين المتفاعل C" }, + { "key": "High ESR", "fr": "VS élevée", "ar": "ارتفاع سرعة الترسيب" }, + { "key": "Elevated ALT", "fr": "ALAT élevée", "ar": "ارتفاع ALT (ألانين ترانسفيراز)" }, + { "key": "Elevated AST", "fr": "ASAT élevée", "ar": "ارتفاع AST (أسبارتات ترانسفيراز)" }, + { "key": "Elevated Bilirubin", "fr": "Bilirubine élevée", "ar": "ارتفاع البيليروبين" }, + { "key": "Elevated ALP", "fr": "Phosphatase alcaline élevée", "ar": "ارتفاع الفوسفاتاز القلوي" }, + { "key": "Elevated Amylase", "fr": "Amylase élevée", "ar": "ارتفاع الأميلاز" }, + { "key": "Elevated Lipase", "fr": "Lipase élevée", "ar": "ارتفاع الليباز" }, + { "key": "High Fasting Glucose", "fr": "Glycémie à jeun élevée", "ar": "ارتفاع سكر الدم الصائم" }, + { "key": "High HbA1c", "fr": "Hémoglobine glyquée (HbA1c) élevée", "ar": "ارتفاع الهيموغلوبين السكري (HbA1c)" }, + { "key": "High TSH", "fr": "TSH élevée", "ar": "ارتفاع TSH" }, + { "key": "Low TSH", "fr": "TSH basse", "ar": "انخفاض TSH" }, + { "key": "Low Free T4", "fr": "T4 libre basse", "ar": "انخفاض T4 الحر" }, + { "key": "High Free T4", "fr": "T4 libre élevée", "ar": "ارتفاع T4 الحر" }, + { "key": "Low Vitamin D", "fr": "Vitamine D basse", "ar": "انخفاض فيتامين د" } ], "conditions": [ { "key": "Flu", "fr": "Grippe", "ar": "إنفلونزا" }, @@ -301,6 +376,7 @@ { "key": "Genitourinary", "fr": "Génito-urinaire", "ar": "بولي تناسلي" }, { "key": "Mental Health", "fr": "Santé mentale", "ar": "صحة نفسية" }, { "key": "General/Systemic", "fr": "Général/Systémique", "ar": "عام/جهازي" }, - { "key": "Sexual Health / STIs", "fr": "Santé sexuelle / IST", "ar": "الصحة الجنسية / الأمراض المنقولة جنسيًا" } + { "key": "Sexual Health / STIs", "fr": "Santé sexuelle / IST", "ar": "الصحة الجنسية / الأمراض المنقولة جنسيًا" }, + { "key": "Laboratory Findings", "fr": "Résultats biologiques", "ar": "نتائج المختبر" } ] } diff --git a/docs/uml-diagrams.md b/docs/uml-diagrams.md index 03f80b8..581b162 100644 --- a/docs/uml-diagrams.md +++ b/docs/uml-diagrams.md @@ -17,7 +17,6 @@ Contents: - Timing — approximated - Composite Structure — approximated - Note: Indentation uses spaces (no hard tabs) to keep markdown linters happy. --- diff --git a/schemas/categories.schema.json b/schemas/categories.schema.json new file mode 100644 index 0000000..c214b9b --- /dev/null +++ b/schemas/categories.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/categories.schema.json", + "title": "SymptomCategoryDatabase", + "type": "object", + "required": ["categories"], + "properties": { + "categories": { + "type": "array", + "items": { "$ref": "#/definitions/category" } + } + }, + "additionalProperties": false, + "definitions": { + "category": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "keywords": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": false + }, + "symptoms": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/conditions.schema.json b/schemas/conditions.schema.json new file mode 100644 index 0000000..beef66c --- /dev/null +++ b/schemas/conditions.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/conditions.schema.json", + "title": "ConditionDatabase", + "type": "object", + "required": ["conditions"], + "properties": { + "conditions": { + "type": "array", + "items": { "$ref": "#/definitions/condition" }, + "uniqueItems": false + } + }, + "additionalProperties": false, + "definitions": { + "condition": { + "type": "object", + "required": ["name", "symptoms"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "symptoms": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "treatments": { "type": "array", "items": { "type": "string" } }, + "treatments_fr": { "type": "array", "items": { "type": "string" } }, + "treatments_ar": { "type": "array", "items": { "type": "string" } }, + "medications": { "type": "array", "items": { "type": "string" } }, + "medications_fr": { "type": "array", "items": { "type": "string" } }, + "medications_ar": { "type": "array", "items": { "type": "string" } }, + "careAdvice": { "type": "string" }, + "careAdvice_fr": { "type": "string" }, + "careAdvice_ar": { "type": "string" } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/translations.schema.json b/schemas/translations.schema.json new file mode 100644 index 0000000..26a2ada --- /dev/null +++ b/schemas/translations.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/translations.schema.json", + "title": "TranslationDatabase", + "type": "object", + "required": ["languages"], + "properties": { + "languages": { + "type": "array", + "items": { "type": "string", "minLength": 2 }, + "minItems": 1, + "uniqueItems": true + }, + "ui": { "$ref": "#/definitions/uiArray" }, + "symptoms": { "$ref": "#/definitions/localizedArray" }, + "conditions": { "$ref": "#/definitions/localizedArray" }, + "messages": { "$ref": "#/definitions/messageArray" }, + "categories": { "$ref": "#/definitions/categoryArray" }, + "ui_details": { "$ref": "#/definitions/uiDetailsArray" } + }, + "additionalProperties": false, + "definitions": { + "uiArray": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "en"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "en": { "type": "string" }, + "fr": { "type": ["string", "null"] }, + "ar": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + }, + "localizedArray": { + "type": "array", + "items": { + "type": "object", + "required": ["key"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "fr": { "type": ["string", "null"] }, + "ar": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + }, + "messageArray": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "en"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "en": { "type": "string" }, + "fr": { "type": "string" }, + "ar": { "type": "string" } + }, + "additionalProperties": false + } + }, + "categoryArray": { + "type": "array", + "items": { + "type": "object", + "required": ["key"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "fr": { "type": ["string", "null"] }, + "ar": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + }, + "uiDetailsArray": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "en"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "en": { "type": "string" }, + "fr": { "type": "string" }, + "ar": { "type": "string" } + }, + "additionalProperties": false + } + } + } +} diff --git a/tests/SymptomChecker.Tests/CategoriesServiceTests.cs b/tests/SymptomChecker.Tests/CategoriesServiceTests.cs new file mode 100644 index 0000000..25b5368 --- /dev/null +++ b/tests/SymptomChecker.Tests/CategoriesServiceTests.cs @@ -0,0 +1,25 @@ +using System.IO; +using System.Linq; +using SymptomCheckerApp.Models; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class CategoriesServiceTests + { + private static string DataPath(string file) => Path.Combine(AppContext.BaseDirectory, "TestData", file); + + [Fact] + public void BuildCategorySet_UsesKeywords_WhenNoExplicitSymptoms() + { + var svc = new CategoriesService(DataPath("categories.min.json")); + var fakeVocab = new[] { "Cough", "Runny Nose", "Sore Throat", "Chest Pain" }; + var cat = svc.GetAllCategories().First(c => c.Name == "Respiratory"); + var set = svc.BuildCategorySet(cat, fakeVocab); + Assert.Contains("Cough", set); + Assert.Contains("Sore Throat", set); + Assert.DoesNotContain("Chest Pain", set); + } + } +} diff --git a/tests/SymptomChecker.Tests/CategoryWeightingTests.cs b/tests/SymptomChecker.Tests/CategoryWeightingTests.cs new file mode 100644 index 0000000..9ec4797 --- /dev/null +++ b/tests/SymptomChecker.Tests/CategoryWeightingTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class CategoryWeightingTests + { + private static string DataPath(string file) => Path.Combine(AppContext.BaseDirectory, "TestData", file); + + // Minimal fake categories: Respiratory includes Cough, Runny Nose, Shortness of Breath; General includes Fever + private readonly Dictionary> _categorySets = new(StringComparer.OrdinalIgnoreCase) + { + { "Respiratory", new HashSet(new [] { "Cough", "Runny Nose", "Shortness of Breath"}, StringComparer.OrdinalIgnoreCase) }, + { "General", new HashSet(new [] { "Fever"}, StringComparer.OrdinalIgnoreCase) } + }; + + private IEnumerable GetCats(string conditionName) + { + // Very small mapping via overlap with condition symptoms (read from service) + var svc = new SymptomCheckerService(DataPath("conditions.min.json")); + if (svc.TryGetCondition(conditionName, out var cond) && cond != null) + { + foreach (var kvp in _categorySets) + { + if (cond.Symptoms.Any(s => kvp.Value.Contains(s))) yield return kvp.Key; + } + } + } + + [Fact] + public void CategoryWeight_BoostsScores() + { + var svc = new SymptomCheckerService(DataPath("conditions.min.json")); + var sel = new [] { "Cough", "Runny Nose" }; // strongly Respiratory + var baseRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold:0, getConditionCategories: GetCats); + var baseCommonCold = baseRes.First(r => r.Name == "Common Cold").Score; + // Apply respiratory weight 2x + var weights = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Respiratory", 2.0 } }; + var weightedRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold:0, categoryWeights: weights, getConditionCategories: GetCats); + var weightedCommonCold = weightedRes.First(r => r.Name == "Common Cold").Score; + Assert.True(weightedCommonCold > baseCommonCold, $"Expected boosted score > base ({weightedCommonCold} > {baseCommonCold})"); + } + } +} diff --git a/tests/SymptomChecker.Tests/NaiveBayesTemperatureTests.cs b/tests/SymptomChecker.Tests/NaiveBayesTemperatureTests.cs new file mode 100644 index 0000000..1445b18 --- /dev/null +++ b/tests/SymptomChecker.Tests/NaiveBayesTemperatureTests.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class NaiveBayesTemperatureTests + { + private static string DataPath(string file) => Path.Combine(AppContext.BaseDirectory, "TestData", file); + + [Fact] + public void TemperatureScaling_SharpensAndFlattens() + { + var svc = new SymptomCheckerService(DataPath("conditions.min.json")); + var sel = new [] { "Cough", "Fever", "Shortness of Breath" }; // mixture to differentiate + // Base (T=1) + var baseRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold:0); + double maxBase = baseRes.Max(r => r.Score); + double entropyBase = - baseRes.Sum(r => r.Score > 0 ? r.Score * Math.Log(r.Score) : 0); + // Sharper T<1 (e.g. 0.5) + var sharpRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold:0, naiveBayesTemperature:0.5); + double maxSharp = sharpRes.Max(r => r.Score); + double entropySharp = - sharpRes.Sum(r => r.Score > 0 ? r.Score * Math.Log(r.Score) : 0); + // Flatter T>1 (e.g. 2.0) + var flatRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold:0, naiveBayesTemperature:2.0); + double maxFlat = flatRes.Max(r => r.Score); + double entropyFlat = - flatRes.Sum(r => r.Score > 0 ? r.Score * Math.Log(r.Score) : 0); + Assert.True(maxSharp > maxBase + 1e-9, "Expected sharper distribution raises max probability"); + Assert.True(entropySharp < entropyBase - 1e-9, "Expected sharper distribution lowers entropy"); + Assert.True(maxFlat < maxBase - 1e-9, "Expected flatter distribution lowers max probability"); + Assert.True(entropyFlat > entropyBase + 1e-9, "Expected flatter distribution increases entropy"); + } + } +} diff --git a/tests/SymptomChecker.Tests/SymptomChecker.Tests.csproj b/tests/SymptomChecker.Tests/SymptomChecker.Tests.csproj new file mode 100644 index 0000000..2d4da66 --- /dev/null +++ b/tests/SymptomChecker.Tests/SymptomChecker.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0-windows + enable + enable + false + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/SymptomChecker.Tests/SymptomCheckerServiceTests.cs b/tests/SymptomChecker.Tests/SymptomCheckerServiceTests.cs new file mode 100644 index 0000000..f98a156 --- /dev/null +++ b/tests/SymptomChecker.Tests/SymptomCheckerServiceTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SymptomCheckerApp.Models; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class SymptomCheckerServiceTests + { + private static string DataPath(string file) => Path.Combine(AppContext.BaseDirectory, "TestData", file); + + [Fact] + public void Jaccard_BasicMatch_Works() + { + var svc = new SymptomCheckerService(DataPath("conditions.min.json")); + var sel = new[] { "Cough", "Fever" }; + var res = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.Jaccard, threshold: 0.0, topK: null, minMatchCount: 1); + Assert.Contains(res, r => r.Name == "Pneumonia"); + Assert.Contains(res, r => r.Name == "Common Cold"); + // PE should not match with those unless chest pain/SOB selected + Assert.DoesNotContain(res, r => r.Name == "PE"); + } + + [Fact] + public void Cosine_MinMatch_Filters() + { + var svc = new SymptomCheckerService(DataPath("conditions.min.json")); + var sel = new[] { "Cough" }; + var res = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.Cosine, threshold: 0.0, topK: null, minMatchCount: 2); + // With minMatch=2, nothing should pass because only single symptom selected + Assert.Empty(res); + } + + [Fact] + public void NaiveBayes_TopK_Works() + { + var svc = new SymptomCheckerService(DataPath("conditions.min.json")); + var sel = new[] { "Cough", "Runny Nose" }; + var res = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold: 0.0, topK: 1, minMatchCount: 0); + Assert.Single(res); + Assert.Equal("Common Cold", res[0].Name); + } + } +} diff --git a/tests/SymptomChecker.Tests/TestData/categories.min.json b/tests/SymptomChecker.Tests/TestData/categories.min.json new file mode 100644 index 0000000..29676bb --- /dev/null +++ b/tests/SymptomChecker.Tests/TestData/categories.min.json @@ -0,0 +1,14 @@ +{ + "categories": [ + { + "name": "Respiratory", + "keywords": ["Cough", "Breath", "Throat"], + "symptoms": [] + }, + { + "name": "Cardiac", + "keywords": ["Chest"], + "symptoms": [] + } + ] +} diff --git a/tests/SymptomChecker.Tests/TestData/conditions.min.json b/tests/SymptomChecker.Tests/TestData/conditions.min.json new file mode 100644 index 0000000..bfa6b09 --- /dev/null +++ b/tests/SymptomChecker.Tests/TestData/conditions.min.json @@ -0,0 +1,7 @@ +{ + "conditions": [ + { "name": "Common Cold", "symptoms": ["Cough", "Runny Nose", "Sore Throat"] }, + { "name": "Pneumonia", "symptoms": ["Fever", "Shortness of Breath", "Cough"] }, + { "name": "PE", "symptoms": ["Chest Pain", "Shortness of Breath"] } + ] +} diff --git a/tests/SymptomChecker.Tests/TestData/translations.min.json b/tests/SymptomChecker.Tests/TestData/translations.min.json new file mode 100644 index 0000000..af8b861 --- /dev/null +++ b/tests/SymptomChecker.Tests/TestData/translations.min.json @@ -0,0 +1,21 @@ +{ + "languages": ["en", "fr", "ar"], + "ui": [ + { "key": "Help", "en": "Help", "fr": "Aide", "ar": "مساعدة" } + ], + "messages": [ + { "key": "RF_Hypoxia", "en": "Low oxygen", "fr": "Hypoxie", "ar": "نقص الأكسجين" } + ], + "symptoms": [ + { "key": "Cough", "fr": "Toux", "ar": "سعال" } + ], + "conditions": [ + { "key": "Pneumonia", "fr": "Pneumonie", "ar": "ذات الرئة" } + ], + "categories": [ + { "key": "Respiratory", "fr": "Respiratoire", "ar": "تنفسي" } + ], + "ui_details": [ + { "key": "Help_Title", "en": "About", "fr": "À propos", "ar": "حول" } + ] +} diff --git a/tests/SymptomChecker.Tests/TranslationServiceTests.cs b/tests/SymptomChecker.Tests/TranslationServiceTests.cs new file mode 100644 index 0000000..86f7459 --- /dev/null +++ b/tests/SymptomChecker.Tests/TranslationServiceTests.cs @@ -0,0 +1,33 @@ +using System.IO; +using SymptomChecker.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class TranslationServiceTests + { + private static string DataPath(string file) => Path.Combine(AppContext.BaseDirectory, "TestData", file); + + [Fact] + public void T_ReturnsLocalizedOrFallback() + { + var t = new TranslationService(DataPath("translations.min.json")); + t.SetLanguage("fr"); + Assert.Equal("Aide", t.T("Help")); + t.SetLanguage("ar"); + Assert.Equal("مساعدة", t.T("Help")); + t.SetLanguage("en"); + Assert.Equal("Help", t.T("Help")); + } + + [Fact] + public void Symptom_Condition_Category_Localize() + { + var t = new TranslationService(DataPath("translations.min.json")); + t.SetLanguage("fr"); + Assert.Equal("Toux", t.Symptom("Cough")); + Assert.Equal("Pneumonie", t.Condition("Pneumonia")); + Assert.Equal("Respiratoire", t.Category("Respiratory")); + } + } +} diff --git a/tests/SymptomChecker.Tests/TriageServiceTests.cs b/tests/SymptomChecker.Tests/TriageServiceTests.cs new file mode 100644 index 0000000..68a13fc --- /dev/null +++ b/tests/SymptomChecker.Tests/TriageServiceTests.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class TriageServiceTests + { + [Fact] + public void EvaluateV2_Vitals_TriggersFlags() + { + var selected = new HashSet(new[] { "Cough" }, System.StringComparer.OrdinalIgnoreCase); + var keys = TriageService.EvaluateV2(selected, tempC: 40.1, heartRate: 130, respRate: 32, systolicBP: 85, diastolicBP: 125, spO2: 88, percPositiveWithChestOrSob: true); + // Expect many flags + Assert.Contains("RF_Hypoxia", keys); + Assert.Contains("RF_Hypotension", keys); + Assert.Contains("RF_SevereHypertension", keys); + Assert.Contains("RF_Tachycardia", keys); + Assert.Contains("RF_Tachypnea", keys); + Assert.Contains("RF_HighFever", keys); + Assert.Contains("RF_PERC_Positive", keys); + } + + [Fact] + public void Evaluate_SymptomCombos_YieldExpected() + { + var selected = new HashSet(new[] { "Chest Pain", "Shortness of Breath" }, System.StringComparer.OrdinalIgnoreCase); + var keys = TriageService.Evaluate(selected); + Assert.Contains("RF_ChestPain_SOB", keys); + } + } +}