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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"MD013": false
}
1 change: 1 addition & 0 deletions Models/ConditionMatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> MatchedSymptoms { get; set; } = new();
}
}
12 changes: 11 additions & 1 deletion Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Windows.Forms;
using SymptomCheckerApp.Services;

namespace SymptomCheckerApp
{
Expand All @@ -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 { }
}
}
}
96 changes: 74 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions Services/LoggerService.cs
Original file line number Diff line number Diff line change
@@ -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 { }
}
}
}
37 changes: 37 additions & 0 deletions Services/SchemaValidator.cs
Original file line number Diff line number Diff line change
@@ -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<string?> 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}";
}
}
}
}
37 changes: 37 additions & 0 deletions Services/SettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,double>? CategoryWeights { get; set; }
public double? NaiveBayesTemperature { get; set; }
// UI layout prefs
public bool? LeftPanelCollapsed { get; set; }
}

private readonly string _settingsPath;
Expand Down Expand Up @@ -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();
}
}
Loading
Loading