diff --git a/Models/OllamaModels.cs b/Models/OllamaModels.cs new file mode 100644 index 0000000..2c50eb3 --- /dev/null +++ b/Models/OllamaModels.cs @@ -0,0 +1,158 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SymptomCheckerApp.Models +{ + /// Request body for the Ollama /api/chat endpoint. + public class OllamaChatRequest + { + [JsonPropertyName("model")] + public string Model { get; set; } = "llama3"; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = new(); + + [JsonPropertyName("stream")] + public bool Stream { get; set; } = false; + + [JsonPropertyName("options")] + public OllamaOptions? Options { get; set; } + } + + public class OllamaChatMessage + { + [JsonPropertyName("role")] + public string Role { get; set; } = "user"; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + /// Base64-encoded images for multimodal models (e.g. LLaVA). + [JsonPropertyName("images")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Images { get; set; } + } + + public class OllamaOptions + { + [JsonPropertyName("temperature")] + public double Temperature { get; set; } = 0.3; + + [JsonPropertyName("num_predict")] + public int NumPredict { get; set; } = 1024; + } + + /// Response from the Ollama /api/chat endpoint. + public class OllamaChatResponse + { + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("message")] + public OllamaChatMessage? Message { get; set; } + + [JsonPropertyName("done")] + public bool Done { get; set; } + + [JsonPropertyName("total_duration")] + public long? TotalDuration { get; set; } + } + + /// Response from the Ollama /api/tags endpoint (list models). + public class OllamaTagsResponse + { + [JsonPropertyName("models")] + public List? Models { get; set; } + } + + public class OllamaModelInfo + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("modified_at")] + public string? ModifiedAt { get; set; } + } + + /// Structured AI diagnosis result parsed from Ollama response. + public class AiDiagnosisResult + { + public string RawResponse { get; set; } = string.Empty; + public string DiagnosticAssessment { get; set; } = string.Empty; + public List Medications { get; set; } = new(); + public List RedFlags { get; set; } = new(); + public string SelfCareAdvice { get; set; } = string.Empty; + public string Disclaimer { get; set; } = string.Empty; + public double? ConfidenceReinforcement { get; set; } + public long ElapsedMs { get; set; } + } + + public class MedicationProposal + { + public string Name { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; // OTC, Prescription-only, etc. + public string Purpose { get; set; } = string.Empty; + public string Dosage { get; set; } = string.Empty; + public string Warning { get; set; } = string.Empty; + } + + /// Result of AI-powered image analysis for symptom detection. + public class ImageAnalysisResult + { + public string RawResponse { get; set; } = string.Empty; + /// Body region detected (skin, throat, eye, etc.). + public string BodyRegion { get; set; } = string.Empty; + /// Visual observations from the image. + public List Observations { get; set; } = new(); + /// Symptoms deduced from visual analysis. + public List DeducedSymptoms { get; set; } = new(); + /// Possible conditions suggested by the image. + public List PossibleConditions { get; set; } = new(); + /// Severity assessment (mild, moderate, severe). + public string Severity { get; set; } = string.Empty; + /// Recommendation to see a doctor or self-care. + public string Recommendation { get; set; } = string.Empty; + public string Disclaimer { get; set; } = string.Empty; + public long ElapsedMs { get; set; } + } + + /// Result of AI-powered blood microscope image analysis. + public class BloodAnalysisResult + { + public string RawResponse { get; set; } = string.Empty; + /// Stain or preparation type detected (e.g. Giemsa, Wright, unstained). + public string StainType { get; set; } = string.Empty; + /// Magnification estimated from image context. + public string Magnification { get; set; } = string.Empty; + + /// Red blood cell (erythrocyte) findings. + public List RbcFindings { get; set; } = new(); + /// White blood cell (leukocyte) findings. + public List WbcFindings { get; set; } = new(); + /// Platelet findings. + public List PlateletFindings { get; set; } = new(); + /// Other observations (parasites, inclusions, artefacts). + public List OtherFindings { get; set; } = new(); + + /// Estimated differential count if identifiable. + public List DifferentialCount { get; set; } = new(); + /// Morphological abnormalities detected. + public List Abnormalities { get; set; } = new(); + + /// Possible haematological conditions. + public List PossibleConditions { get; set; } = new(); + /// Deduced symptoms that may relate to the findings. + public List DeducedSymptoms { get; set; } = new(); + + /// Severity (normal / mild / moderate / severe). + public string Severity { get; set; } = string.Empty; + /// Clinical recommendation. + public string Recommendation { get; set; } = string.Empty; + public string Disclaimer { get; set; } = string.Empty; + public long ElapsedMs { get; set; } + } +} diff --git a/Program.cs b/Program.cs index 7dd1bd7..a3a1daa 100644 --- a/Program.cs +++ b/Program.cs @@ -11,6 +11,7 @@ static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); var logger = new LoggerService(logDir); diff --git a/Services/CosineModel.cs b/Services/CosineModel.cs new file mode 100644 index 0000000..45fc7f1 --- /dev/null +++ b/Services/CosineModel.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Binary-vector cosine similarity: dot(A,B) / (|A| * |B|) + /// + public class CosineModel : IMatchingModel + { + public string Name => "Cosine"; + + public List ComputeMatches( + HashSet selectedSymptoms, + IReadOnlyList conditions, + IReadOnlyDictionary> conditionSets, + IReadOnlyList vocabulary, + double threshold, + MatchingOptions? options = null) + { + var results = new List(); + + foreach (var c in conditions) + { + if (!conditionSets.TryGetValue(c.Name, out var condSet)) continue; + + var interSymptoms = condSet.Intersect(selectedSymptoms, StringComparer.OrdinalIgnoreCase).ToList(); + int dot = interSymptoms.Count; + double denom = Math.Sqrt(condSet.Count) * Math.Sqrt(selectedSymptoms.Count); + double score = denom == 0 ? 0 : dot / denom; + + if (score >= threshold) + { + results.Add(new ConditionMatch + { + Name = c.Name, + Score = score, + MatchCount = dot, + MatchedSymptoms = interSymptoms + }); + } + } + + return results; + } + } +} diff --git a/Services/IDataProvider.cs b/Services/IDataProvider.cs new file mode 100644 index 0000000..f3afeff --- /dev/null +++ b/Services/IDataProvider.cs @@ -0,0 +1,16 @@ +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Abstraction for loading / saving application data. + /// Enables swapping JSON files for database, API, or in-memory test sources. + /// + public interface IDataProvider + { + ConditionDatabase LoadConditions(); + void SaveConditions(ConditionDatabase db); + SymptomCategoryDatabase LoadCategories(); + SynonymDatabase LoadSynonyms(); + } +} diff --git a/Services/IMatchingModel.cs b/Services/IMatchingModel.cs new file mode 100644 index 0000000..22c3b62 --- /dev/null +++ b/Services/IMatchingModel.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Abstraction for symptom-condition matching algorithms. + /// Each implementation computes scored matches between a set of selected symptoms + /// and a collection of conditions. + /// + public interface IMatchingModel + { + /// Human-readable name used for display and serialization. + string Name { get; } + + /// + /// Compute scored matches for the selected symptoms against all conditions. + /// + /// Case-insensitive set of selected symptom names. + /// All known conditions. + /// Pre-built per-condition symptom sets (cache). + /// Full vocabulary of unique symptoms. + /// Minimum score to include (0..1). + /// Optional model-specific parameters. + /// Unordered list of matches above threshold. + List ComputeMatches( + HashSet selectedSymptoms, + IReadOnlyList conditions, + IReadOnlyDictionary> conditionSets, + IReadOnlyList vocabulary, + double threshold, + MatchingOptions? options = null); + } + + /// + /// Optional parameters that individual models may use. + /// + public class MatchingOptions + { + /// Temperature scaling for Naive Bayes softmax (default 1.0). + public double? NaiveBayesTemperature { get; set; } + } +} diff --git a/Services/JaccardModel.cs b/Services/JaccardModel.cs new file mode 100644 index 0000000..8e2201d --- /dev/null +++ b/Services/JaccardModel.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Jaccard similarity: |A ∩ B| / |A ∪ B| + /// + public class JaccardModel : IMatchingModel + { + public string Name => "Jaccard"; + + public List ComputeMatches( + HashSet selectedSymptoms, + IReadOnlyList conditions, + IReadOnlyDictionary> conditionSets, + IReadOnlyList vocabulary, + double threshold, + MatchingOptions? options = null) + { + var results = new List(); + + foreach (var c in conditions) + { + if (!conditionSets.TryGetValue(c.Name, out var condSet)) continue; + + var interSymptoms = condSet.Intersect(selectedSymptoms, StringComparer.OrdinalIgnoreCase).ToList(); + int inter = interSymptoms.Count; + int union = condSet.Union(selectedSymptoms, 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, + MatchedSymptoms = interSymptoms + }); + } + } + + return results; + } + } +} diff --git a/Services/JsonFileDataProvider.cs b/Services/JsonFileDataProvider.cs new file mode 100644 index 0000000..fdde83c --- /dev/null +++ b/Services/JsonFileDataProvider.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Text.Json; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Default IDataProvider that reads/writes JSON files from disk. + /// + public class JsonFileDataProvider : IDataProvider + { + private readonly string _dataDir; + private static readonly JsonSerializerOptions _readOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions _writeOptions = new() { WriteIndented = true }; + + public JsonFileDataProvider(string dataDirectory) + { + _dataDir = dataDirectory ?? throw new ArgumentNullException(nameof(dataDirectory)); + } + + public ConditionDatabase LoadConditions() + { + var path = Path.Combine(_dataDir, "conditions.json"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Conditions JSON not found at '{path}'"); + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, _readOptions) ?? new ConditionDatabase(); + } + + public void SaveConditions(ConditionDatabase db) + { + var path = Path.Combine(_dataDir, "conditions.json"); + var json = JsonSerializer.Serialize(db, _writeOptions); + File.WriteAllText(path, json); + } + + public SymptomCategoryDatabase LoadCategories() + { + var path = Path.Combine(_dataDir, "categories.json"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Categories JSON not found at '{path}'"); + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, _readOptions) ?? new SymptomCategoryDatabase(); + } + + public SynonymDatabase LoadSynonyms() + { + var path = Path.Combine(_dataDir, "synonyms.json"); + if (!File.Exists(path)) + throw new FileNotFoundException($"Synonyms JSON not found at '{path}'"); + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, _readOptions) ?? new SynonymDatabase(); + } + } +} diff --git a/Services/NaiveBayesModel.cs b/Services/NaiveBayesModel.cs new file mode 100644 index 0000000..a87c18b --- /dev/null +++ b/Services/NaiveBayesModel.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Bernoulli Naive Bayes with Laplace smoothing and optional temperature scaling. + /// P(c|S) ∝ P(c) · Π P(x_sym|c), normalized via softmax. + /// + public class NaiveBayesModel : IMatchingModel + { + public string Name => "NaiveBayes"; + + public List ComputeMatches( + HashSet selectedSymptoms, + IReadOnlyList conditions, + IReadOnlyDictionary> conditionSets, + IReadOnlyList vocabulary, + double threshold, + MatchingOptions? options = null) + { + var results = new List(); + double temp = (options?.NaiveBayesTemperature.HasValue == true && options.NaiveBayesTemperature.Value > 0.01) + ? options.NaiveBayesTemperature.Value + : 1.0; + + var condScores = new List<(string name, double logProb, int matchCount, List matched)>(); + + foreach (var c in conditions) + { + if (!conditionSets.TryGetValue(c.Name, out var condSet)) continue; + + double logP = 0.0; + var matched = condSet.Intersect(selectedSymptoms, StringComparer.OrdinalIgnoreCase).ToList(); + int matchCount = matched.Count; + + foreach (var sym in vocabulary) + { + bool presentInCond = condSet.Contains(sym); + // Laplace: P(x=1|c) = (count_present + 1) / (N + 2) + double p1 = (presentInCond ? 2.0 : 1.0) / 3.0; + double p0 = 1 - p1; + + bool selected = selectedSymptoms.Contains(sym); + logP += Math.Log(selected ? p1 : p0); + } + + condScores.Add((c.Name, logP, matchCount, matched)); + } + + if (condScores.Count == 0) return results; + + // Normalize via softmax + double maxLog = condScores.Max(t => t.logProb); + var soft = condScores + .Select(t => (t.name, exp: Math.Exp(t.logProb - maxLog), t.matchCount, t.matched)) + .ToList(); + double Z = soft.Sum(t => t.exp); + + foreach (var (name, exp, matchCount, matched) in soft) + { + double probRaw = Z == 0 ? 0 : exp / Z; + double prob = probRaw; + + if (Math.Abs(temp - 1.0) > 1e-6) + { + prob = Math.Pow(probRaw, 1.0 / temp); + } + + if (prob >= threshold) + { + 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; + } + } + + return results; + } + } +} diff --git a/Services/OllamaService.cs b/Services/OllamaService.cs new file mode 100644 index 0000000..ad7ef26 --- /dev/null +++ b/Services/OllamaService.cs @@ -0,0 +1,794 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.Services +{ + /// + /// Service layer for communicating with a local Ollama instance. + /// Provides AI-powered diagnostic reinforcement and medication proposals. + /// + public class OllamaService : IDisposable + { + private readonly HttpClient _http; + private string _baseUrl; + private string _model; + private bool _disposed; + + /// Whether the last connectivity check succeeded. + public bool IsAvailable { get; private set; } + + /// Currently selected model name. + public string ModelName => _model; + + /// Base URL of the Ollama server. + public string BaseUrl => _baseUrl; + + public OllamaService(string baseUrl = "http://localhost:11434", string model = "llama3") + { + _baseUrl = baseUrl.TrimEnd('/'); + _model = model; + _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + } + + public void SetModel(string model) => _model = model; + public void SetBaseUrl(string url) => _baseUrl = url.TrimEnd('/'); + + /// Ping the Ollama server to check availability. + public async Task CheckAvailabilityAsync(CancellationToken ct = default) + { + try + { + var resp = await _http.GetAsync($"{_baseUrl}/api/tags", ct); + IsAvailable = resp.IsSuccessStatusCode; + return IsAvailable; + } + catch + { + IsAvailable = false; + return false; + } + } + + /// List locally available models. + public async Task> ListModelsAsync(CancellationToken ct = default) + { + try + { + var resp = await _http.GetAsync($"{_baseUrl}/api/tags", ct); + if (!resp.IsSuccessStatusCode) return new List(); + var json = await resp.Content.ReadAsStringAsync(ct); + var tags = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return tags?.Models?.Select(m => m.Name).ToList() ?? new List(); + } + catch + { + return new List(); + } + } + + /// + /// Send a chat completion request to Ollama and return the raw assistant message. + /// + public async Task ChatAsync(List messages, double temperature = 0.3, + int maxTokens = 1500, CancellationToken ct = default) + { + var request = new OllamaChatRequest + { + Model = _model, + Messages = messages, + Stream = false, + Options = new OllamaOptions + { + Temperature = temperature, + NumPredict = maxTokens + } + }; + + var payload = JsonSerializer.Serialize(request); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + + var resp = await _http.PostAsync($"{_baseUrl}/api/chat", content, ct); + if (!resp.IsSuccessStatusCode) return null; + + var respJson = await resp.Content.ReadAsStringAsync(ct); + var chatResp = JsonSerializer.Deserialize(respJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return chatResp?.Message?.Content; + } + + /// + /// Run AI-powered diagnostic reinforcement given the selected symptoms and + /// the algorithmic matches. Returns a structured result with assessment, + /// medication proposals, and red flags. + /// + public async Task GetDiagnosisReinforcementAsync( + IReadOnlyList selectedSymptoms, + IReadOnlyList algorithmicMatches, + string language = "en", + double? patientAge = null, + double? tempC = null, + int? heartRate = null, + int? respRate = null, + int? spO2 = null, + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + + string symptomsStr = string.Join(", ", selectedSymptoms); + string matchesStr = string.Join("\n", algorithmicMatches.Select(m => + $" - {m.Name} (score: {m.Score:F3}, matched symptoms: {string.Join(", ", m.MatchedSymptoms)})")); + + var vitalsInfo = new StringBuilder(); + if (patientAge.HasValue) vitalsInfo.Append($"Age: {patientAge.Value} years. "); + if (tempC.HasValue) vitalsInfo.Append($"Temperature: {tempC.Value:F1}°C. "); + if (heartRate.HasValue) vitalsInfo.Append($"Heart rate: {heartRate.Value} bpm. "); + if (respRate.HasValue) vitalsInfo.Append($"Respiratory rate: {respRate.Value}/min. "); + if (spO2.HasValue) vitalsInfo.Append($"SpO2: {spO2.Value}%. "); + + string langInstruction = language.ToLowerInvariant() switch + { + "fr" => "Respond entirely in French.", + "ar" => "Respond entirely in Arabic.", + _ => "Respond in English." + }; + + string systemPrompt = $@"You are a medical education assistant integrated into a symptom checker application. +Your role is to provide EDUCATIONAL analysis only — never replace professional medical advice. +{langInstruction} + +IMPORTANT RULES: +1. Always include a disclaimer that this is educational only. +2. For medication suggestions, clearly distinguish OTC (over-the-counter) from prescription-only. +3. Include basic dosage guidance for OTC medications when applicable. +4. Highlight red flags (symptoms requiring urgent medical attention). +5. Be factual, concise and evidence-based. + +Respond in the following structured format (use these exact section headers): + +## DIAGNOSTIC ASSESSMENT +[Your assessment of the algorithmic results and the symptoms] + +## CONFIDENCE +[A number between 0 and 1 indicating how much you agree with the top algorithmic diagnosis] + +## MEDICATIONS +For each medication, use this format (one per line): +- MEDICATION: [name] | CATEGORY: [OTC/Prescription] | PURPOSE: [why] | DOSAGE: [standard adult dosage if OTC] | WARNING: [key contraindications] + +## RED FLAGS +- [List any symptoms or combinations that need urgent medical attention] + +## SELF-CARE +[Brief self-care advice] + +## DISCLAIMER +[Educational disclaimer]"; + + string userPrompt = $@"Patient selected symptoms: {symptomsStr} + +{(vitalsInfo.Length > 0 ? $"Vitals: {vitalsInfo}" : "")} + +Algorithmic analysis results (from mathematical models): +{matchesStr} + +Please analyze these symptoms and algorithmic results. Provide: +1. Your assessment of the diagnosis (reinforcing or questioning the algorithmic results) +2. Appropriate medication suggestions (clearly marking OTC vs prescription) +3. Any red flags +4. Self-care advice"; + + var messages = new List + { + new() { Role = "system", Content = systemPrompt }, + new() { Role = "user", Content = userPrompt } + }; + + try + { + var rawResponse = await ChatAsync(messages, 0.3, 1500, ct); + sw.Stop(); + + if (string.IsNullOrEmpty(rawResponse)) + { + return new AiDiagnosisResult + { + RawResponse = "", + DiagnosticAssessment = "Ollama did not return a response.", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + + return ParseDiagnosisResponse(rawResponse, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + sw.Stop(); + return new AiDiagnosisResult + { + RawResponse = ex.Message, + DiagnosticAssessment = $"Error communicating with Ollama: {ex.Message}", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + } + + /// + /// Ask Ollama for targeted medication recommendations for a specific condition. + /// + public async Task GetMedicationAdviceAsync( + string conditionName, + IReadOnlyList matchedSymptoms, + string language = "en", + double? patientAge = null, + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + + string langInstruction = language.ToLowerInvariant() switch + { + "fr" => "Respond entirely in French.", + "ar" => "Respond entirely in Arabic.", + _ => "Respond in English." + }; + + string systemPrompt = $@"You are a medical education assistant. {langInstruction} +Provide EDUCATIONAL medication information for the given condition. NEVER replace professional medical advice. + +Respond using this exact format: + +## MEDICATIONS +For each medication: +- MEDICATION: [name] | CATEGORY: [OTC/Prescription] | PURPOSE: [why] | DOSAGE: [dosage if OTC] | WARNING: [key warnings] + +## RED FLAGS +- [When to seek immediate medical help] + +## SELF-CARE +[Self-care advice] + +## DISCLAIMER +[Educational disclaimer]"; + + var ageStr = patientAge.HasValue ? $" Patient age: {patientAge.Value} years." : ""; + string userPrompt = $"Condition: {conditionName}\nPresenting symptoms: {string.Join(", ", matchedSymptoms)}{ageStr}\n\nProvide educational medication information and self-care guidance."; + + var messages = new List + { + new() { Role = "system", Content = systemPrompt }, + new() { Role = "user", Content = userPrompt } + }; + + try + { + var rawResponse = await ChatAsync(messages, 0.3, 1200, ct); + sw.Stop(); + + if (string.IsNullOrEmpty(rawResponse)) + { + return new AiDiagnosisResult + { + RawResponse = "", + DiagnosticAssessment = "No response received.", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + + return ParseDiagnosisResponse(rawResponse, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + sw.Stop(); + return new AiDiagnosisResult + { + RawResponse = ex.Message, + DiagnosticAssessment = $"Error: {ex.Message}", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + } + + /// Parse the structured markdown response into an AiDiagnosisResult. + private static AiDiagnosisResult ParseDiagnosisResponse(string raw, long elapsedMs) + { + var result = new AiDiagnosisResult + { + RawResponse = raw, + ElapsedMs = elapsedMs + }; + + // Split into sections by ## headers + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + string currentSection = ""; + var sb = new StringBuilder(); + + foreach (var line in raw.Split('\n')) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("## ")) + { + if (!string.IsNullOrEmpty(currentSection)) + sections[currentSection] = sb.ToString().Trim(); + currentSection = trimmed.Substring(3).Trim().ToUpperInvariant(); + sb.Clear(); + } + else + { + sb.AppendLine(line); + } + } + if (!string.IsNullOrEmpty(currentSection)) + sections[currentSection] = sb.ToString().Trim(); + + // Parse DIAGNOSTIC ASSESSMENT + if (sections.TryGetValue("DIAGNOSTIC ASSESSMENT", out var assessment)) + result.DiagnosticAssessment = assessment; + else if (sections.TryGetValue("DIAGNOSTIC", out var diag)) + result.DiagnosticAssessment = diag; + + // Parse CONFIDENCE + if (sections.TryGetValue("CONFIDENCE", out var confStr)) + { + var match = Regex.Match(confStr, @"(0?\.\d+|1\.0|1|0)"); + if (match.Success && double.TryParse(match.Value, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var conf)) + { + result.ConfidenceReinforcement = Math.Clamp(conf, 0, 1); + } + } + + // Parse MEDICATIONS + if (sections.TryGetValue("MEDICATIONS", out var medsSection)) + { + foreach (var line in medsSection.Split('\n')) + { + var t = line.Trim().TrimStart('-', '*', '•').Trim(); + if (string.IsNullOrEmpty(t)) continue; + if (!t.Contains("MEDICATION:", StringComparison.OrdinalIgnoreCase) && + !t.Contains("|")) continue; + + var med = new MedicationProposal(); + var parts = t.Split('|'); + foreach (var part in parts) + { + var p = part.Trim(); + if (p.StartsWith("MEDICATION:", StringComparison.OrdinalIgnoreCase)) + med.Name = p.Substring("MEDICATION:".Length).Trim(); + else if (p.StartsWith("CATEGORY:", StringComparison.OrdinalIgnoreCase)) + med.Category = p.Substring("CATEGORY:".Length).Trim(); + else if (p.StartsWith("PURPOSE:", StringComparison.OrdinalIgnoreCase)) + med.Purpose = p.Substring("PURPOSE:".Length).Trim(); + else if (p.StartsWith("DOSAGE:", StringComparison.OrdinalIgnoreCase)) + med.Dosage = p.Substring("DOSAGE:".Length).Trim(); + else if (p.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase)) + med.Warning = p.Substring("WARNING:".Length).Trim(); + } + + if (!string.IsNullOrEmpty(med.Name)) + result.Medications.Add(med); + } + } + + // Parse RED FLAGS + if (sections.TryGetValue("RED FLAGS", out var flagsSection)) + { + foreach (var line in flagsSection.Split('\n')) + { + var t = line.Trim().TrimStart('-', '*', '•').Trim(); + if (!string.IsNullOrEmpty(t)) + result.RedFlags.Add(t); + } + } + + // Parse SELF-CARE + if (sections.TryGetValue("SELF-CARE", out var care)) + result.SelfCareAdvice = care; + else if (sections.TryGetValue("SELF CARE", out var care2)) + result.SelfCareAdvice = care2; + + // Parse DISCLAIMER + if (sections.TryGetValue("DISCLAIMER", out var disc)) + result.Disclaimer = disc; + + return result; + } + + /// + /// Analyze an image using a multimodal vision model (e.g. LLaVA, llava-llama3, bakllava). + /// Returns deduced symptoms, possible conditions, and observations. + /// + public async Task AnalyzeImageAsync( + string base64Image, + string language = "en", + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + + string langInstruction = language.ToLowerInvariant() switch + { + "fr" => "Respond entirely in French.", + "ar" => "Respond entirely in Arabic.", + _ => "Respond in English." + }; + + string systemPrompt = $@"You are a medical image analysis assistant integrated into a symptom checker application. +Your role is to analyze medical images and identify visible symptoms and possible conditions for EDUCATIONAL purposes only. +{langInstruction} + +You can analyze images of: +- Skin conditions (rashes, lesions, discoloration, swelling, acne, eczema, psoriasis, etc.) +- Throat/mouth (redness, swelling, white patches, ulcers, etc.) +- Eyes (redness, swelling, discharge, discoloration, etc.) +- Wounds, bruises, insect bites +- Any other visible medical condition + +IMPORTANT RULES: +1. This is EDUCATIONAL only — never replace professional medical advice. +2. Be descriptive about what you observe in the image. +3. List specific symptoms that can be deduced from visual observation. +4. Suggest possible conditions but always recommend professional consultation. + +Respond in the following structured format (use these EXACT section headers): + +## BODY REGION +[The body region shown: skin, throat, eye, mouth, nail, scalp, ear, etc.] + +## OBSERVATIONS +- [Visual observation 1] +- [Visual observation 2] +- [etc.] + +## DEDUCED SYMPTOMS +- [symptom 1] +- [symptom 2] +- [etc.] + +## POSSIBLE CONDITIONS +- [condition 1] +- [condition 2] +- [etc.] + +## SEVERITY +[mild / moderate / severe] + +## RECOMMENDATION +[Brief recommendation — e.g. self-care, see a dermatologist, urgent care, etc.] + +## DISCLAIMER +[Educational disclaimer]"; + + string userPrompt = "Please analyze this medical image. Identify the body region, describe what you observe, list the symptoms visible in the image, and suggest possible conditions. Keep symptom names simple and lowercase (e.g. 'skin rash', 'redness', 'swelling', 'itching', 'pain')."; + + var messages = new List + { + new() { Role = "system", Content = systemPrompt }, + new() { Role = "user", Content = userPrompt, Images = new List { base64Image } } + }; + + try + { + var rawResponse = await ChatAsync(messages, 0.3, 2000, ct); + sw.Stop(); + + if (string.IsNullOrEmpty(rawResponse)) + { + return new ImageAnalysisResult + { + RawResponse = "", + Recommendation = "The vision model did not return a response. Make sure you are using a multimodal model (e.g. llava, bakllava, llava-llama3).", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + + return ParseImageAnalysisResponse(rawResponse, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + sw.Stop(); + return new ImageAnalysisResult + { + RawResponse = ex.Message, + Recommendation = $"Error communicating with Ollama: {ex.Message}", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + } + + /// Parse the structured image analysis response. + private static ImageAnalysisResult ParseImageAnalysisResponse(string raw, long elapsedMs) + { + var result = new ImageAnalysisResult + { + RawResponse = raw, + ElapsedMs = elapsedMs + }; + + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + string currentSection = ""; + var sb = new StringBuilder(); + + foreach (var line in raw.Split('\n')) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("## ")) + { + if (!string.IsNullOrEmpty(currentSection)) + sections[currentSection] = sb.ToString().Trim(); + currentSection = trimmed.Substring(3).Trim().ToUpperInvariant(); + sb.Clear(); + } + else + { + sb.AppendLine(line); + } + } + if (!string.IsNullOrEmpty(currentSection)) + sections[currentSection] = sb.ToString().Trim(); + + if (sections.TryGetValue("BODY REGION", out var region)) + result.BodyRegion = region.Trim(); + + if (sections.TryGetValue("OBSERVATIONS", out var obs)) + { + foreach (var line in obs.Split('\n')) + { + var t = line.Trim().TrimStart('-', '*', '•').Trim(); + if (!string.IsNullOrEmpty(t)) + result.Observations.Add(t); + } + } + + if (sections.TryGetValue("DEDUCED SYMPTOMS", out var symp)) + { + foreach (var line in symp.Split('\n')) + { + var t = line.Trim().TrimStart('-', '*', '•').Trim(); + if (!string.IsNullOrEmpty(t)) + result.DeducedSymptoms.Add(t); + } + } + + if (sections.TryGetValue("POSSIBLE CONDITIONS", out var conds)) + { + foreach (var line in conds.Split('\n')) + { + var t = line.Trim().TrimStart('-', '*', '•').Trim(); + if (!string.IsNullOrEmpty(t)) + result.PossibleConditions.Add(t); + } + } + + if (sections.TryGetValue("SEVERITY", out var sev)) + result.Severity = sev.Trim(); + + if (sections.TryGetValue("RECOMMENDATION", out var rec)) + result.Recommendation = rec.Trim(); + + if (sections.TryGetValue("DISCLAIMER", out var disc)) + result.Disclaimer = disc.Trim(); + + return result; + } + + // ───────────────────────────────────────────────────────────── + // Blood Microscope Image Analysis + // ───────────────────────────────────────────────────────────── + + /// + /// Analyze a blood smear / blood under microscope image using a multimodal vision model. + /// Returns cell morphology, differential count, abnormalities, and possible haematological conditions. + /// + public async Task AnalyzeBloodMicroscopeAsync( + string base64Image, + string language = "en", + CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + + string langInstruction = language.ToLowerInvariant() switch + { + "fr" => "Respond entirely in French.", + "ar" => "Respond entirely in Arabic.", + _ => "Respond in English." + }; + + string systemPrompt = $@"You are a haematology image analysis assistant integrated into a medical symptom checker. +Your role is to analyze blood smear / blood microscope images and identify cell morphology, +abnormalities, and possible haematological conditions for EDUCATIONAL purposes only. +{langInstruction} + +You are an expert at reading peripheral blood smear images. You can identify: +- Red blood cells (erythrocytes): shape, size, colour, inclusions (e.g. target cells, sickle cells, + spherocytes, schistocytes, rouleaux, Howell-Jolly bodies, basophilic stippling, polychromasia) +- White blood cells (leukocytes): neutrophils, lymphocytes, monocytes, eosinophils, basophils, + blast cells, atypical lymphocytes, hyper-segmented neutrophils, toxic granulations +- Platelets: count estimate (adequate / decreased / increased), giant platelets, clumping +- Parasites: malaria (Plasmodium species, ring forms, trophozoites, gametocytes), + Babesia, trypanosomes, microfilaria +- Other: nucleated red blood cells, Auer rods, circulating tumour cells + +IMPORTANT RULES: +1. This is EDUCATIONAL only — never replace professional haematological interpretation. +2. Describe what you observe with haematology terminology. +3. Give an estimated differential count if individual WBCs are recognisable. +4. List symptoms that could correlate with the blood findings. +5. Always recommend professional laboratory confirmation. + +Respond in the following structured format (use these EXACT section headers): + +## STAIN TYPE +[Identified stain: Giemsa, Wright, May-Grünwald-Giemsa, unstained, unknown, etc.] + +## MAGNIFICATION +[Estimated magnification: 40x, 100x (oil immersion), etc., or unknown] + +## RBC FINDINGS +- [finding 1] +- [finding 2] + +## WBC FINDINGS +- [finding 1] +- [finding 2] + +## PLATELET FINDINGS +- [finding 1] + +## OTHER FINDINGS +- [parasites, inclusions, artefacts, or 'None observed'] + +## DIFFERENTIAL COUNT +- [cell type]: [estimated percentage or count] + +## ABNORMALITIES +- [abnormality 1] +- [abnormality 2] + +## POSSIBLE CONDITIONS +- [condition 1] +- [condition 2] + +## DEDUCED SYMPTOMS +- [symptom 1 — e.g. fatigue, pallor, bruising, fever] +- [symptom 2] + +## SEVERITY +[normal / mild / moderate / severe] + +## RECOMMENDATION +[Brief clinical recommendation] + +## DISCLAIMER +[Educational disclaimer]"; + + string userPrompt = "Please analyze this blood microscope image (peripheral blood smear). " + + "Identify all visible cell types, describe their morphology, note any abnormalities, " + + "estimate differential counts if possible, and suggest possible haematological conditions. " + + "Keep symptom names simple and lowercase (e.g. 'fatigue', 'pallor', 'bruising', 'fever')."; + + var messages = new List + { + new() { Role = "system", Content = systemPrompt }, + new() { Role = "user", Content = userPrompt, Images = new List { base64Image } } + }; + + try + { + var rawResponse = await ChatAsync(messages, 0.3, 2500, ct); + sw.Stop(); + + if (string.IsNullOrEmpty(rawResponse)) + { + return new BloodAnalysisResult + { + RawResponse = "", + Recommendation = "The vision model did not return a response. Make sure you are using a multimodal model (e.g. llava, bakllava, llava-llama3).", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + + return ParseBloodAnalysisResponse(rawResponse, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + sw.Stop(); + return new BloodAnalysisResult + { + RawResponse = ex.Message, + Recommendation = $"Error communicating with Ollama: {ex.Message}", + ElapsedMs = sw.ElapsedMilliseconds + }; + } + } + + /// Parse the structured blood analysis response. + private static BloodAnalysisResult ParseBloodAnalysisResponse(string raw, long elapsedMs) + { + var result = new BloodAnalysisResult + { + RawResponse = raw, + ElapsedMs = elapsedMs + }; + + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + string currentSection = ""; + var sb2 = new StringBuilder(); + + foreach (var line in raw.Split('\n')) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("## ")) + { + if (!string.IsNullOrEmpty(currentSection)) + sections[currentSection] = sb2.ToString().Trim(); + currentSection = trimmed.Substring(3).Trim().ToUpperInvariant(); + sb2.Clear(); + } + else + { + sb2.AppendLine(line); + } + } + if (!string.IsNullOrEmpty(currentSection)) + sections[currentSection] = sb2.ToString().Trim(); + + List ParseBullets(string text) + { + var items = new List(); + foreach (var line in text.Split('\n')) + { + var t = line.Trim().TrimStart('-', '*', '•').Trim(); + if (!string.IsNullOrEmpty(t)) + items.Add(t); + } + return items; + } + + if (sections.TryGetValue("STAIN TYPE", out var stain)) + result.StainType = stain.Trim(); + if (sections.TryGetValue("MAGNIFICATION", out var mag)) + result.Magnification = mag.Trim(); + if (sections.TryGetValue("RBC FINDINGS", out var rbc)) + result.RbcFindings = ParseBullets(rbc); + if (sections.TryGetValue("WBC FINDINGS", out var wbc)) + result.WbcFindings = ParseBullets(wbc); + if (sections.TryGetValue("PLATELET FINDINGS", out var plt)) + result.PlateletFindings = ParseBullets(plt); + if (sections.TryGetValue("OTHER FINDINGS", out var other)) + result.OtherFindings = ParseBullets(other); + if (sections.TryGetValue("DIFFERENTIAL COUNT", out var diff)) + result.DifferentialCount = ParseBullets(diff); + if (sections.TryGetValue("ABNORMALITIES", out var abn)) + result.Abnormalities = ParseBullets(abn); + if (sections.TryGetValue("POSSIBLE CONDITIONS", out var cond)) + result.PossibleConditions = ParseBullets(cond); + if (sections.TryGetValue("DEDUCED SYMPTOMS", out var sym)) + result.DeducedSymptoms = ParseBullets(sym); + if (sections.TryGetValue("SEVERITY", out var sev2)) + result.Severity = sev2.Trim(); + if (sections.TryGetValue("RECOMMENDATION", out var rec2)) + result.Recommendation = rec2.Trim(); + if (sections.TryGetValue("DISCLAIMER", out var disc2)) + result.Disclaimer = disc2.Trim(); + + return result; + } + + public void Dispose() + { + if (!_disposed) + { + _http.Dispose(); + _disposed = true; + } + } + } +} diff --git a/Services/SettingsService.cs b/Services/SettingsService.cs index ce89e2d..42ace0d 100644 --- a/Services/SettingsService.cs +++ b/Services/SettingsService.cs @@ -34,6 +34,10 @@ public class AppSettings public string? LastExportFolder { get; set; } public Dictionary? CategoryWeights { get; set; } public double? NaiveBayesTemperature { get; set; } + // Ollama AI settings + public string? OllamaUrl { get; set; } + public string? OllamaModel { get; set; } + public bool AutoAi { get; set; } // UI layout prefs public bool? LeftPanelCollapsed { get; set; } } diff --git a/Services/SymptomCheckerService.cs b/Services/SymptomCheckerService.cs index 3ed56d2..37ea5e0 100644 --- a/Services/SymptomCheckerService.cs +++ b/Services/SymptomCheckerService.cs @@ -14,6 +14,12 @@ public class SymptomCheckerService private List _vocabulary; // all unique symptoms private Dictionary> _conditionSets = new(StringComparer.OrdinalIgnoreCase); // cached per-condition symptom sets + // Model registry: maps DetectionModel enum to IMatchingModel implementation + private readonly Dictionary _models; + + /// All registered matching models. + public IReadOnlyDictionary Models => _models; + public SymptomCheckerService(string jsonPath) { if (!File.Exists(jsonPath)) @@ -29,6 +35,15 @@ public SymptomCheckerService(string jsonPath) _db = JsonSerializer.Deserialize(json, options) ?? new ConditionDatabase(); _jsonPath = jsonPath; _vocabulary = new List(); + + // Register default models + _models = new Dictionary + { + { DetectionModel.Jaccard, new JaccardModel() }, + { DetectionModel.Cosine, new CosineModel() }, + { DetectionModel.NaiveBayes, new NaiveBayesModel() } + }; + RebuildVocabulary(); } @@ -90,109 +105,24 @@ public List GetMatches( return new List(); } - var results = new List(); - - switch (model) + // Delegate to the registered IMatchingModel + if (!_models.TryGetValue(model, out var matchingModel)) { - case DetectionModel.Jaccard: - foreach (var c in _db.Conditions) - { - 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, MatchedSymptoms = interSymptoms }); - } - } - break; - case DetectionModel.Cosine: - // Binary vector cosine similarity - foreach (var c in _db.Conditions) - { - 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, MatchedSymptoms = interSymptoms }); - } - } - break; - case DetectionModel.NaiveBayes: - // Simple Bernoulli Naive Bayes with Laplace smoothing. - // P(c|S) ∝ P(c) * Π_{sym in V} P(x_sym|c), where x_sym=1 for selected, else 0 - // Use equal priors and normalize across conditions. - int V = _vocabulary.Count; - // Per condition, estimate P(sym=1|c) as (#sym in condition + 1) / (1 + 2) with Bernoulli smoothing. - // 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, List matched)>(); - foreach (var c in _db.Conditions) - { - 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 - var matched = condSet.Intersect(selectedSet, StringComparer.OrdinalIgnoreCase).ToList(); - int matchCount = matched.Count; + throw new NotSupportedException($"Model '{model}' not supported."); + } - foreach (var sym in _vocabulary) - { - bool presentInCond = condSet.Contains(sym); - // Laplace: P(x=1|c) = (count_present + 1) / (N + 2). Here count_present is 1 if presentInCond else 0, N=1 feature per symptom. - double p1 = (presentInCond ? 2.0 : 1.0) / 3.0; // (1+1)/3=0.666.. if present, (0+1)/3≈0.333.. if absent - double p0 = 1 - p1; + var options = new MatchingOptions + { + NaiveBayesTemperature = naiveBayesTemperature + }; - bool selected = selectedSet.Contains(sym); - logP += Math.Log(selected ? p1 : p0); - } - 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, t.matched)).ToList(); - double Z = soft.Sum(t => t.Item2); - double temp = (naiveBayesTemperature.HasValue && naiveBayesTemperature.Value > 0.01) ? naiveBayesTemperature.Value : 1.0; - foreach (var (name, exp, matchCount, matched) in soft) - { - 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, 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."); - } + var results = matchingModel.ComputeMatches( + selectedSet, + _db.Conditions, + _conditionSets, + _vocabulary, + threshold, + options); // Apply minimum match count filter if (minMatchCount > 0) diff --git a/Services/TranslationService.cs b/Services/TranslationService.cs index a52741d..36b39f0 100644 --- a/Services/TranslationService.cs +++ b/Services/TranslationService.cs @@ -27,16 +27,58 @@ public class TranslationDatabase private readonly HashSet _missing = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MissingKeys => _missing; + // O(1) lookup dictionaries built from flat lists + private readonly Dictionary _uiMap; + private readonly Dictionary _msgMap; + private readonly Dictionary _detailsMap; + private readonly Dictionary _symptomMap; + private readonly Dictionary _conditionMap; + private readonly Dictionary _categoryMap; + public TranslationService(string path) { if (!File.Exists(path)) { _db = new TranslationDatabase(); - return; } - var json = File.ReadAllText(path); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - _db = JsonSerializer.Deserialize(json, options) ?? new TranslationDatabase(); + else + { + var json = File.ReadAllText(path); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + _db = JsonSerializer.Deserialize(json, options) ?? new TranslationDatabase(); + } + + // Build O(1) dictionaries + var cmp = StringComparer.OrdinalIgnoreCase; + _uiMap = new Dictionary(cmp); + foreach (var u in _db.Ui) + if (!string.IsNullOrEmpty(u.Key) && !_uiMap.ContainsKey(u.Key)) + _uiMap[u.Key] = u; + + _msgMap = new Dictionary(cmp); + foreach (var m in _db.Messages) + if (!string.IsNullOrEmpty(m.Key) && !_msgMap.ContainsKey(m.Key)) + _msgMap[m.Key] = m; + + _detailsMap = new Dictionary(cmp); + foreach (var d in _db.Ui_Details) + if (!string.IsNullOrEmpty(d.Key) && !_detailsMap.ContainsKey(d.Key)) + _detailsMap[d.Key] = d; + + _symptomMap = new Dictionary(cmp); + foreach (var s in _db.Symptoms) + if (!string.IsNullOrEmpty(s.Key) && !_symptomMap.ContainsKey(s.Key)) + _symptomMap[s.Key] = s; + + _conditionMap = new Dictionary(cmp); + foreach (var c in _db.Conditions) + if (!string.IsNullOrEmpty(c.Key) && !_conditionMap.ContainsKey(c.Key)) + _conditionMap[c.Key] = c; + + _categoryMap = new Dictionary(cmp); + foreach (var c in _db.Categories) + if (!string.IsNullOrEmpty(c.Key) && !_categoryMap.ContainsKey(c.Key)) + _categoryMap[c.Key] = c; } public IEnumerable GetSupportedLanguages() => _db.Languages.Count > 0 ? _db.Languages : new[] { "en", "fr", "ar" }; @@ -49,9 +91,8 @@ public void SetLanguage(string lang) public string T(string key) { - // UI translations by key - var ui = _db.Ui.FirstOrDefault(u => string.Equals(u.Key, key, StringComparison.OrdinalIgnoreCase)); - if (ui != null) + // UI translations by key (O(1)) + if (_uiMap.TryGetValue(key, out var ui)) { var val = CurrentLanguage switch { @@ -62,9 +103,8 @@ public string T(string key) if ((CurrentLanguage == "ar" && string.IsNullOrEmpty(ui.Ar)) || (CurrentLanguage == "fr" && string.IsNullOrEmpty(ui.Fr))) _missing.Add($"ui:{key}:{CurrentLanguage}"); return val; } - // Messages fallback - var msg = _db.Messages.FirstOrDefault(m => string.Equals(m.Key, key, StringComparison.OrdinalIgnoreCase)); - if (msg != null) + // Messages fallback (O(1)) + if (_msgMap.TryGetValue(key, out var msg)) { var val = CurrentLanguage switch { @@ -80,8 +120,7 @@ public string T(string key) public string TDetails(string key) { - var d = _db.Ui_Details.FirstOrDefault(u => string.Equals(u.Key, key, StringComparison.OrdinalIgnoreCase)); - if (d != null) + if (_detailsMap.TryGetValue(key, out var d)) { var val = CurrentLanguage switch { @@ -97,8 +136,11 @@ public string TDetails(string key) public string Symptom(string canonical) { - var s = _db.Symptoms.FirstOrDefault(x => string.Equals(x.Key, canonical, StringComparison.OrdinalIgnoreCase)); - if (s == null) { _missing.Add($"sym:{canonical}:{CurrentLanguage}"); return canonical; } + if (!_symptomMap.TryGetValue(canonical, out var s)) + { + _missing.Add($"sym:{canonical}:{CurrentLanguage}"); + return canonical; + } var val = CurrentLanguage switch { "fr" => string.IsNullOrEmpty(s.Fr) ? canonical : s.Fr!, @@ -111,8 +153,11 @@ public string Symptom(string canonical) public string Condition(string canonical) { - var c = _db.Conditions.FirstOrDefault(x => string.Equals(x.Key, canonical, StringComparison.OrdinalIgnoreCase)); - if (c == null) { _missing.Add($"cond:{canonical}:{CurrentLanguage}"); return canonical; } + if (!_conditionMap.TryGetValue(canonical, out var c)) + { + _missing.Add($"cond:{canonical}:{CurrentLanguage}"); + return canonical; + } var val = CurrentLanguage switch { "fr" => string.IsNullOrEmpty(c.Fr) ? canonical : c.Fr!, @@ -125,8 +170,11 @@ public string Condition(string canonical) public string Category(string canonical) { - var c = _db.Categories.FirstOrDefault(x => string.Equals(x.Key, canonical, StringComparison.OrdinalIgnoreCase)); - if (c == null) { _missing.Add($"cat:{canonical}:{CurrentLanguage}"); return canonical; } + if (!_categoryMap.TryGetValue(canonical, out var c)) + { + _missing.Add($"cat:{canonical}:{CurrentLanguage}"); + return canonical; + } var val = CurrentLanguage switch { "fr" => string.IsNullOrEmpty(c.Fr) ? canonical : c.Fr, diff --git a/UI/MainForm.BloodAnalysis.cs b/UI/MainForm.BloodAnalysis.cs new file mode 100644 index 0000000..45b2620 --- /dev/null +++ b/UI/MainForm.BloodAnalysis.cs @@ -0,0 +1,599 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.UI +{ + /// + /// Blood microscope image analysis feature: upload a blood smear / blood under microscope + /// image and use a multimodal AI model (e.g. LLaVA) to identify cell morphology, + /// abnormalities, and possible haematological conditions. + /// + public partial class MainForm + { + // Blood analysis UI controls + private readonly GroupBox _grpBloodAnalysis = new GroupBox(); + private readonly Button _bloodUploadButton = new Button(); + private readonly Button _bloodAnalyzeButton = new Button(); + private readonly Button _bloodClearButton = new Button(); + private readonly Button _bloodApplySymptomsButton = new Button(); + private readonly PictureBox _bloodPreview = new PictureBox(); + private readonly RichTextBox _bloodResultBox = new RichTextBox(); + private readonly ProgressBar _bloodProgress = new ProgressBar(); + private readonly Label _bloodTimingLabel = new Label(); + private readonly Label _bloodInfoLabel = new Label(); + + private string? _currentBloodImageBase64; + private BloodAnalysisResult? _lastBloodAnalysisResult; + private CancellationTokenSource? _bloodAnalysisCts; + + /// Build and wire the Blood Analysis panel. Called from InitializeLayout. + private void InitializeBloodAnalysisPanel(Control parent) + { + _grpBloodAnalysis.Text = "🔬 Blood Microscope Analysis (Vision AI)"; + _grpBloodAnalysis.Dock = DockStyle.Fill; + _grpBloodAnalysis.Padding = new Padding(6); + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 4 + }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30)); + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 70)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // toolbar + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // progress + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // content + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // timing + + // Toolbar row + var toolbar = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + WrapContents = true, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(2) + }; + + _bloodUploadButton.Text = "📁 Upload Blood Image"; + _bloodUploadButton.AutoSize = true; + _bloodUploadButton.FlatStyle = FlatStyle.Flat; + _bloodUploadButton.BackColor = Color.FromArgb(240, 230, 230); + _bloodUploadButton.AccessibleName = "Upload a blood microscope image for analysis"; + _bloodUploadButton.Click += (s, e) => UploadBloodImage(); + + _bloodAnalyzeButton.Text = "🔬 Analyze Blood"; + _bloodAnalyzeButton.AutoSize = true; + _bloodAnalyzeButton.FlatStyle = FlatStyle.Flat; + _bloodAnalyzeButton.BackColor = Color.FromArgb(255, 230, 230); + _bloodAnalyzeButton.AccessibleName = "Analyze the uploaded blood image with AI"; + _bloodAnalyzeButton.Enabled = false; + _bloodAnalyzeButton.Click += async (s, e) => await RunBloodAnalysisAsync(); + + _bloodApplySymptomsButton.Text = "✅ Apply Symptoms"; + _bloodApplySymptomsButton.AutoSize = true; + _bloodApplySymptomsButton.FlatStyle = FlatStyle.Flat; + _bloodApplySymptomsButton.BackColor = Color.FromArgb(230, 255, 230); + _bloodApplySymptomsButton.AccessibleName = "Select deduced symptoms in the symptom list"; + _bloodApplySymptomsButton.Enabled = false; + _bloodApplySymptomsButton.Click += (s, e) => ApplyBloodDeducedSymptoms(); + + _bloodClearButton.Text = "🗑 Clear"; + _bloodClearButton.AutoSize = true; + _bloodClearButton.FlatStyle = FlatStyle.Flat; + _bloodClearButton.AccessibleName = "Clear uploaded blood image"; + _bloodClearButton.Enabled = false; + _bloodClearButton.Click += (s, e) => ClearBloodAnalysis(); + + _bloodInfoLabel.Text = "Supported: peripheral blood smear, thick/thin film, bone marrow smear — use a vision model (llava, bakllava, etc.)"; + _bloodInfoLabel.AutoSize = true; + _bloodInfoLabel.ForeColor = Color.DimGray; + _bloodInfoLabel.Padding = new Padding(6, 6, 0, 0); + _bloodInfoLabel.Font = new Font(Font.FontFamily, 8f); + + toolbar.Controls.Add(_bloodUploadButton); + toolbar.Controls.Add(_bloodAnalyzeButton); + toolbar.Controls.Add(_bloodApplySymptomsButton); + toolbar.Controls.Add(_bloodClearButton); + toolbar.Controls.Add(_bloodInfoLabel); + + layout.Controls.Add(toolbar, 0, 0); + layout.SetColumnSpan(toolbar, 2); + + // Progress bar + _bloodProgress.Dock = DockStyle.Top; + _bloodProgress.Style = ProgressBarStyle.Marquee; + _bloodProgress.MarqueeAnimationSpeed = 30; + _bloodProgress.Height = 4; + _bloodProgress.Visible = false; + layout.Controls.Add(_bloodProgress, 0, 1); + layout.SetColumnSpan(_bloodProgress, 2); + + // Image preview (left column) + _bloodPreview.Dock = DockStyle.Fill; + _bloodPreview.SizeMode = PictureBoxSizeMode.Zoom; + _bloodPreview.BorderStyle = BorderStyle.FixedSingle; + _bloodPreview.BackColor = Color.FromArgb(245, 240, 240); + _bloodPreview.AccessibleName = "Blood image preview"; + _bloodPreview.Paint += (s, e) => + { + if (_bloodPreview.Image == null) + { + var text = "Drop or upload\na blood smear\nmicroscope image"; + using var font = new Font("Segoe UI", 10f, FontStyle.Italic); + using var brush = new SolidBrush(Color.FromArgb(150, 130, 130)); + var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + e.Graphics.DrawString(text, font, brush, _bloodPreview.ClientRectangle, sf); + } + }; + _bloodPreview.AllowDrop = true; + _bloodPreview.DragEnter += (s, e) => + { + if (e.Data != null && e.Data.GetDataPresent(DataFormats.FileDrop)) + e.Effect = DragDropEffects.Copy; + }; + _bloodPreview.DragDrop += (s, e) => + { + if (e.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0) + LoadBloodImageFile(files[0]); + }; + layout.Controls.Add(_bloodPreview, 0, 2); + + // Results box (right column) + _bloodResultBox.Dock = DockStyle.Fill; + _bloodResultBox.ReadOnly = true; + _bloodResultBox.BorderStyle = BorderStyle.FixedSingle; + _bloodResultBox.BackColor = SystemColors.Window; + _bloodResultBox.Font = new Font("Segoe UI", 9.5f); + _bloodResultBox.AccessibleName = "Blood analysis results"; + _bloodResultBox.Text = + "Upload a blood microscope image (peripheral blood smear, thick/thin film) and click 'Analyze Blood'.\n\n" + + "The AI vision model will:\n" + + " • Identify stain type and magnification\n" + + " • Describe red blood cell morphology\n" + + " • Identify white blood cell types & differential count\n" + + " • Assess platelet count and morphology\n" + + " • Detect parasites (malaria, babesia, etc.)\n" + + " • List morphological abnormalities\n" + + " • Suggest possible haematological conditions\n\n" + + "You can then apply deduced symptoms to the symptom checker.\n\n" + + "⚠ Requires a multimodal model in Ollama (e.g. llava, bakllava, llava-llama3)."; + layout.Controls.Add(_bloodResultBox, 1, 2); + + // Timing label + _bloodTimingLabel.Text = ""; + _bloodTimingLabel.AutoSize = true; + _bloodTimingLabel.Padding = new Padding(4); + _bloodTimingLabel.ForeColor = Color.DimGray; + _bloodTimingLabel.AccessibleName = "Blood analysis timing"; + layout.Controls.Add(_bloodTimingLabel, 0, 3); + layout.SetColumnSpan(_bloodTimingLabel, 2); + + _grpBloodAnalysis.Controls.Add(layout); + parent.Controls.Add(_grpBloodAnalysis); + } + + /// Open a file dialog to pick a blood microscope image. + private void UploadBloodImage() + { + using var dlg = new OpenFileDialog + { + Title = "Select a blood microscope image", + Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp;*.tif;*.tiff)|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp;*.tif;*.tiff|All files (*.*)|*.*", + RestoreDirectory = true + }; + + if (dlg.ShowDialog(this) == DialogResult.OK) + LoadBloodImageFile(dlg.FileName); + } + + /// Load a blood image file, display preview, and prepare base64. + private void LoadBloodImageFile(string filePath) + { + try + { + if (!File.Exists(filePath)) return; + + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var validExts = new HashSet { ".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tif", ".tiff" }; + if (!validExts.Contains(ext)) + { + MessageBox.Show(this, "Unsupported image format. Please use JPG, PNG, BMP, GIF, TIFF or WebP.", + "Invalid Format", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length > 20 * 1024 * 1024) + { + MessageBox.Show(this, "Image file is too large. Maximum size is 20 MB.", + "File Too Large", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var imageBytes = File.ReadAllBytes(filePath); + _currentBloodImageBase64 = Convert.ToBase64String(imageBytes); + + using var ms = new MemoryStream(imageBytes); + var oldImage = _bloodPreview.Image; + _bloodPreview.Image = Image.FromStream(ms); + oldImage?.Dispose(); + + _bloodAnalyzeButton.Enabled = true; + _bloodClearButton.Enabled = true; + _bloodApplySymptomsButton.Enabled = false; + _lastBloodAnalysisResult = null; + + _bloodResultBox.Clear(); + _bloodResultBox.Text = $"Blood image loaded: {Path.GetFileName(filePath)}\n" + + $"Size: {fileInfo.Length / 1024} KB\n\n" + + "Click 'Analyze Blood' to start AI haematology analysis.\n" + + "Make sure a vision model (llava, bakllava) is selected in the AI panel."; + _bloodTimingLabel.Text = ""; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Error loading image: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + /// Clear the uploaded blood image and results. + private void ClearBloodAnalysis() + { + var oldImage = _bloodPreview.Image; + _bloodPreview.Image = null; + oldImage?.Dispose(); + + _currentBloodImageBase64 = null; + _lastBloodAnalysisResult = null; + _bloodAnalyzeButton.Enabled = false; + _bloodClearButton.Enabled = false; + _bloodApplySymptomsButton.Enabled = false; + _bloodTimingLabel.Text = ""; + + _bloodResultBox.Clear(); + _bloodResultBox.Text = "Upload a blood microscope image to analyze."; + _bloodPreview.Invalidate(); + } + + /// Run the AI blood microscope analysis using the Ollama vision model. + private async Task RunBloodAnalysisAsync() + { + if (_ollamaService == null || string.IsNullOrEmpty(_currentBloodImageBase64)) + { + _bloodResultBox.Text = "Please upload a blood image and ensure Ollama is connected."; + return; + } + + var selectedModel = _ollamaModelSelector.SelectedItem?.ToString(); + if (!string.IsNullOrEmpty(selectedModel)) + { + _ollamaService.SetModel(selectedModel); + } + else + { + _bloodResultBox.Text = "Please select a vision model (e.g. llava) in the AI panel."; + return; + } + + _bloodAnalysisCts?.Cancel(); + _bloodAnalysisCts = new CancellationTokenSource(); + + SetBloodAnalysisLoading(true); + + try + { + var lang = _translationService?.CurrentLanguage ?? "en"; + var result = await _ollamaService.AnalyzeBloodMicroscopeAsync( + _currentBloodImageBase64, lang, _bloodAnalysisCts.Token); + + _lastBloodAnalysisResult = result; + DisplayBloodAnalysisResult(result); + + _bloodApplySymptomsButton.Enabled = result.DeducedSymptoms.Count > 0; + } + catch (OperationCanceledException) + { + _bloodResultBox.Text = "Blood analysis cancelled."; + } + catch (Exception ex) + { + _bloodResultBox.Text = $"Error: {ex.Message}"; + } + finally + { + SetBloodAnalysisLoading(false); + } + } + + private void SetBloodAnalysisLoading(bool loading) + { + _bloodProgress.Visible = loading; + _bloodAnalyzeButton.Enabled = !loading && !string.IsNullOrEmpty(_currentBloodImageBase64); + _bloodUploadButton.Enabled = !loading; + _bloodClearButton.Enabled = !loading && _bloodPreview.Image != null; + if (loading) + { + _bloodResultBox.Clear(); + _bloodResultBox.Text = "🔬 AI is analyzing the blood smear... please wait...\n\n" + + "(This may take 30-120 seconds depending on the model and hardware)"; + _bloodTimingLabel.Text = ""; + } + } + + /// Render the blood analysis result with rich formatting. + private void DisplayBloodAnalysisResult(BloodAnalysisResult result) + { + _bloodResultBox.Clear(); + var rtb = _bloodResultBox; + + void AppendHeader(string text) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 11f, FontStyle.Bold); + rtb.SelectionColor = Color.FromArgb(140, 20, 20); + rtb.AppendText(text + "\n"); + } + + void AppendBody(string text) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular); + rtb.SelectionColor = rtb.ForeColor; + rtb.AppendText(text + "\n"); + } + + void AppendBullet(string text, Color color) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular); + rtb.SelectionColor = color; + rtb.AppendText($" • {text}\n"); + } + + // Stain & Magnification + if (!string.IsNullOrWhiteSpace(result.StainType) || !string.IsNullOrWhiteSpace(result.Magnification)) + { + AppendHeader("🔬 Slide Information"); + if (!string.IsNullOrWhiteSpace(result.StainType)) + AppendBody($" Stain: {result.StainType}"); + if (!string.IsNullOrWhiteSpace(result.Magnification)) + AppendBody($" Magnification: {result.Magnification}"); + rtb.AppendText("\n"); + } + + // RBC Findings + if (result.RbcFindings.Count > 0) + { + AppendHeader("🔴 Red Blood Cells (Erythrocytes)"); + foreach (var f in result.RbcFindings) + AppendBullet(f, Color.FromArgb(180, 40, 40)); + rtb.AppendText("\n"); + } + + // WBC Findings + if (result.WbcFindings.Count > 0) + { + AppendHeader("⚪ White Blood Cells (Leukocytes)"); + foreach (var f in result.WbcFindings) + AppendBullet(f, Color.FromArgb(40, 80, 160)); + rtb.AppendText("\n"); + } + + // Platelet Findings + if (result.PlateletFindings.Count > 0) + { + AppendHeader("🟣 Platelets"); + foreach (var f in result.PlateletFindings) + AppendBullet(f, Color.FromArgb(120, 60, 140)); + rtb.AppendText("\n"); + } + + // Other Findings + if (result.OtherFindings.Count > 0) + { + AppendHeader("🔎 Other Findings"); + foreach (var f in result.OtherFindings) + AppendBullet(f, Color.FromArgb(100, 100, 40)); + rtb.AppendText("\n"); + } + + // Differential Count + if (result.DifferentialCount.Count > 0) + { + AppendHeader("📊 Differential Count"); + foreach (var d in result.DifferentialCount) + AppendBullet(d, Color.FromArgb(60, 60, 60)); + rtb.AppendText("\n"); + } + + // Abnormalities + if (result.Abnormalities.Count > 0) + { + AppendHeader("⚠️ Abnormalities"); + foreach (var a in result.Abnormalities) + AppendBullet(a, Color.FromArgb(200, 80, 0)); + rtb.AppendText("\n"); + } + + // Possible Conditions + if (result.PossibleConditions.Count > 0) + { + AppendHeader("🏥 Possible Haematological Conditions"); + foreach (var c in result.PossibleConditions) + AppendBullet(c, Color.FromArgb(160, 60, 0)); + rtb.AppendText("\n"); + } + + // Deduced Symptoms + if (result.DeducedSymptoms.Count > 0) + { + AppendHeader("🩺 Deduced Symptoms"); + foreach (var symptom in result.DeducedSymptoms) + { + bool isKnown = _allSymptoms.Any(s => + s.Contains(symptom, StringComparison.OrdinalIgnoreCase) || + symptom.Contains(s, StringComparison.OrdinalIgnoreCase)); + var color = isKnown ? Color.FromArgb(0, 120, 60) : Color.FromArgb(180, 100, 0); + var suffix = isKnown ? " ✓ (in database)" : " (not in database)"; + AppendBullet(symptom + suffix, color); + } + rtb.AppendText("\n"); + + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic); + rtb.SelectionColor = Color.FromArgb(0, 100, 150); + rtb.AppendText(" 💡 Click 'Apply Symptoms' to auto-select matching symptoms in the checker.\n\n"); + } + + // Severity + if (!string.IsNullOrWhiteSpace(result.Severity)) + { + AppendHeader("⚡ Severity"); + var sevColor = result.Severity.ToLowerInvariant() switch + { + var s when s.Contains("severe") => Color.FromArgb(200, 0, 0), + var s when s.Contains("moderate") => Color.FromArgb(200, 140, 0), + var s when s.Contains("normal") => Color.FromArgb(0, 128, 0), + _ => Color.FromArgb(0, 128, 0) + }; + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 10f, FontStyle.Bold); + rtb.SelectionColor = sevColor; + rtb.AppendText($" {result.Severity}\n\n"); + } + + // Recommendation + if (!string.IsNullOrWhiteSpace(result.Recommendation)) + { + AppendHeader("💡 Recommendation"); + AppendBody($" {result.Recommendation}"); + rtb.AppendText("\n"); + } + + // Disclaimer + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic); + rtb.SelectionColor = Color.Gray; + string disclaimer = !string.IsNullOrWhiteSpace(result.Disclaimer) + ? result.Disclaimer + : "⚠️ This blood analysis is for educational purposes only. Always consult a haematologist or pathologist for proper interpretation."; + rtb.AppendText($"\n{disclaimer}\n"); + + // Timing + _bloodTimingLabel.Text = $"Blood analysis: {result.ElapsedMs} ms | Model: {_ollamaService?.ModelName ?? "?"}"; + + rtb.SelectionStart = 0; + rtb.ScrollToCaret(); + } + + /// + /// Apply the deduced symptoms from blood analysis to the symptom checker. + /// Uses fuzzy matching against known symptoms in the database. + /// + private void ApplyBloodDeducedSymptoms() + { + if (_lastBloodAnalysisResult == null || _lastBloodAnalysisResult.DeducedSymptoms.Count == 0) + { + MessageBox.Show(this, "No symptoms deduced from blood analysis yet.", + "No Symptoms", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + int matched = 0; + var matchedNames = new List(); + var unmatchedNames = new List(); + + foreach (var deducedSymptom in _lastBloodAnalysisResult.DeducedSymptoms) + { + string? bestMatch = null; + int bestScore = 0; + + foreach (var knownSymptom in _allSymptoms) + { + if (string.Equals(knownSymptom, deducedSymptom, StringComparison.OrdinalIgnoreCase)) + { + bestMatch = knownSymptom; + bestScore = 100; + break; + } + + if (knownSymptom.Contains(deducedSymptom, StringComparison.OrdinalIgnoreCase) || + deducedSymptom.Contains(knownSymptom, StringComparison.OrdinalIgnoreCase)) + { + int score = 80; + if (score > bestScore) + { + bestScore = score; + bestMatch = knownSymptom; + } + } + + var deducedWords = deducedSymptom.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var knownWords = knownSymptom.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var commonWords = deducedWords.Intersect(knownWords).Where(w => w.Length > 3).ToList(); + if (commonWords.Count > 0) + { + int score = 40 + (commonWords.Count * 15); + if (score > bestScore) + { + bestScore = score; + bestMatch = knownSymptom; + } + } + } + + if (bestScore < 60 && _synonymService != null) + { + var aliasMap = _synonymService.BuildAliasToCanonical(_allSymptoms); + if (aliasMap.TryGetValue(deducedSymptom, out var canonical) && + _allSymptoms.Contains(canonical, StringComparer.OrdinalIgnoreCase)) + { + bestMatch = canonical; + bestScore = 90; + } + } + + if (bestMatch != null && bestScore >= 40) + { + if (_checkedSymptoms.Add(bestMatch)) + { + matched++; + matchedNames.Add($"{deducedSymptom} → {bestMatch}"); + } + } + else + { + unmatchedNames.Add(deducedSymptom); + } + } + + RefreshSymptomList(); + _checkButton.Enabled = _checkedSymptoms.Count > 0; + + var msg = $"Applied {matched} symptom(s) from blood analysis.\n\n"; + if (matchedNames.Count > 0) + msg += "Matched:\n" + string.Join("\n", matchedNames.Select(m => $" ✓ {m}")) + "\n\n"; + if (unmatchedNames.Count > 0) + msg += "Not matched (not in database):\n" + string.Join("\n", unmatchedNames.Select(u => $" ✗ {u}")); + + MessageBox.Show(this, msg, "Blood Symptoms Applied", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + /// Apply translations to blood analysis panel labels. + private void ApplyBloodAnalysisTranslations() + { + var t = _translationService; + if (t == null) return; + + _grpBloodAnalysis.Text = t.T("BloodAnalysisTitle") ?? "🔬 Blood Microscope Analysis (Vision AI)"; + _bloodUploadButton.Text = t.T("BloodUpload") ?? "📁 Upload Blood Image"; + _bloodAnalyzeButton.Text = t.T("BloodAnalyze") ?? "🔬 Analyze Blood"; + _bloodApplySymptomsButton.Text = t.T("BloodApplySymptoms") ?? "✅ Apply Symptoms"; + _bloodClearButton.Text = t.T("BloodClear") ?? "🗑 Clear"; + } + } +} diff --git a/UI/MainForm.DecisionRules.cs b/UI/MainForm.DecisionRules.cs new file mode 100644 index 0000000..82d18d1 --- /dev/null +++ b/UI/MainForm.DecisionRules.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Forms; +using SymptomCheckerApp.Services; + +namespace SymptomCheckerApp.UI +{ + // Decision rules: PERC, Centor/McIsaac, Triage banner + public partial class MainForm + { + private void UpdatePercRule() + { + var t = _translationService; + 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; + } + + private void UpdateDecisionRules() + { + if (_grpCentor == null) return; + var t = _translationService; + bool hasFever = false; + try + { + if (_settingsService?.Settings.TempC.HasValue == true) + hasFever = _settingsService!.Settings.TempC!.Value >= 38.0; + } + catch { } + hasFever = hasFever || _checkedSymptoms.Contains("Fever"); + + bool tonsils = _checkedSymptoms.Contains("Sore Throat") || _checkedSymptoms.Contains("Tonsillar Exudates") || _checkedSymptoms.Contains("Tonsillar Swelling"); + bool nodes = _checkedSymptoms.Contains("Swollen Lymph Nodes"); + bool noCough = !_checkedSymptoms.Contains("Cough"); + + 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}"; + + 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); + bool chestOrSob = selected.Contains("Chest Pain") || selected.Contains("Shortness of Breath"); + bool percPositive = false; + try + { + 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 = 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; + return; + } + var t = _translationService; + var header = t?.T("RedFlagsHeader") ?? "Possible red flags:"; + var messages = new List(); + foreach (var k in keys) + { + 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."; + bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + if (rtl) + { + 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; + } + } +} diff --git a/UI/MainForm.Dialogs.cs b/UI/MainForm.Dialogs.cs new file mode 100644 index 0000000..5886d64 --- /dev/null +++ b/UI/MainForm.Dialogs.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using SymptomCheckerApp.Models; +using SymptomCheckerApp.Services; +using SymptomChecker.Services; + +namespace SymptomCheckerApp.UI +{ + // Dialogs: details, help, missing translations, print + public partial class MainForm + { + private void ResultsList_DoubleClick(object? sender, EventArgs e) + { + if (_service == null) return; + int idx = _resultsList.SelectedIndex; + if (idx < 0) return; + int resultIdx = (idx >= 0 && idx < _resultIndexMap.Count) ? _resultIndexMap[idx] : -1; + if (resultIdx == -1) return; + if (resultIdx < 0 || resultIdx >= _lastResults.Count) return; + + 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); + return; + } + + var t = _translationService; + var sb = new System.Text.StringBuilder(); + BuildDetailsText(sb, t, match, condition); + + ShowDetailsDialog(t?.T("DetailsTitle") ?? "Condition Details", sb.ToString()); + } + + private void BuildDetailsText(System.Text.StringBuilder sb, TranslationService? t, ConditionMatch match, Condition condition) + { + string name = t?.Condition(match.Name) ?? match.Name; + string scoreLabel = t?.T("Score") ?? "Score:"; + string matchedLabel = t?.T("MatchedSymptoms") ?? "Matched symptoms:"; + string symptomsLabel = t?.T("SymptomsLabel") ?? "Symptoms:"; + sb.AppendLine(name); + sb.AppendLine($"{scoreLabel} {match.Score:F3}"); + sb.AppendLine($"{matchedLabel} {match.MatchCount}"); + sb.AppendLine(); + sb.AppendLine(symptomsLabel); + foreach (var s in condition.Symptoms) + { + sb.AppendLine($" • {(t?.Symptom(s) ?? s)}"); + } + + var model = (SymptomCheckerService.DetectionModel)_modelSelector.SelectedItem!; + sb.AppendLine(); + sb.AppendLine(t?.T("ExplainabilityHeader") ?? "How this score was computed:"); + if (model == SymptomCheckerService.DetectionModel.Jaccard || model == SymptomCheckerService.DetectionModel.Cosine) + { + sb.AppendLine($" • {(t?.T("Explain_MatchedOverlap") ?? "Matched overlap")}: {match.MatchCount}"); + sb.AppendLine($" • {(t?.T("Explain_Similarity") ?? "Similarity")}: {match.Score:F3}"); + } + else if (model == SymptomCheckerService.DetectionModel.NaiveBayes) + { + sb.AppendLine($" • {(t?.T("Explain_Prob") ?? "Estimated probability")}: {match.Score:F3}"); + } + + List? locTreat = null; + List? locMeds = null; + string? locAdvice = null; + var lang = _translationService?.CurrentLanguage?.ToLowerInvariant(); + if (lang == "fr") + { + locTreat = condition.Treatments_Fr ?? condition.Treatments; + locMeds = condition.Medications_Fr ?? condition.Medications; + locAdvice = condition.CareAdvice_Fr ?? condition.CareAdvice; + } + else if (lang == "ar") + { + locTreat = condition.Treatments_Ar ?? condition.Treatments; + locMeds = condition.Medications_Ar ?? condition.Medications; + locAdvice = condition.CareAdvice_Ar ?? condition.CareAdvice; + } + else + { + locTreat = condition.Treatments; + locMeds = condition.Medications; + locAdvice = condition.CareAdvice; + } + + if (locTreat != null && locTreat.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(t?.TDetails("Treatments") ?? "Possible treatments (educational):"); + foreach (var tr in locTreat) + { + sb.AppendLine($" • {tr}"); + } + } + if (locMeds != null && locMeds.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(t?.TDetails("Medications") ?? "Over‑the‑counter examples (educational):"); + foreach (var med in locMeds) + { + sb.AppendLine($" • {med}"); + } + } + if (!string.IsNullOrWhiteSpace(locAdvice)) + { + sb.AppendLine(); + sb.AppendLine(t?.TDetails("CareAdvice") ?? "Self‑care advice (educational):"); + sb.AppendLine($" • {locAdvice}"); + } + } + + private void CopySelectedDetailsToClipboard() + { + if (_service == null) return; + int idx = _resultsList.SelectedIndex; + if (idx < 0 || idx >= _lastResults.Count) return; + var match = _lastResults[idx]; + if (!_service.TryGetCondition(match.Name, out var condition) || condition == null) return; + var t = _translationService; + var sb = new System.Text.StringBuilder(); + BuildDetailsText(sb, t, match, condition); + try { Clipboard.SetText(sb.ToString()); } catch { } + } + + private void ShowDetailsDialog(string title, string text) + { + bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + using var dlg = new Form + { + Text = title, + StartPosition = FormStartPosition.CenterParent, + Width = 700, + Height = 550 + }; + ApplyRtl(dlg, rtl); + if (rtl) + { + text = TransformBulletsForRtl(text); + } + var tb = new TextBox + { + Multiline = true, + ReadOnly = true, + Dock = DockStyle.Fill, + ScrollBars = ScrollBars.Vertical, + WordWrap = true, + BorderStyle = BorderStyle.FixedSingle, + Text = 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 }; + btnClose.Click += (s, e) => dlg.Close(); + var btnCopy = new Button { Text = _translationService?.T("Copy") ?? "Copy", AutoSize = true }; + btnCopy.Click += (s, e) => { try { Clipboard.SetText(text); } catch { } }; + var btnAskAi = new Button + { + Text = _translationService?.T("AiMedications") ?? "💊 Ask AI for Medications", + AutoSize = true, + FlatStyle = FlatStyle.Flat, + BackColor = Color.FromArgb(230, 255, 230), + Enabled = _ollamaService?.IsAvailable ?? false + }; + btnAskAi.Click += async (s, e) => + { + btnAskAi.Enabled = false; + btnAskAi.Text = "⏳ ..."; + try { await RunAiMedicationAdviceAsync(); } catch { } + btnAskAi.Text = _translationService?.T("AiMedications") ?? "💊 Ask AI for Medications"; + btnAskAi.Enabled = _ollamaService?.IsAvailable ?? false; + }; + btnPanel.Controls.Add(btnClose); + btnPanel.Controls.Add(btnCopy); + btnPanel.Controls.Add(btnAskAi); + dlg.Controls.Add(tb); + dlg.Controls.Add(btnPanel); + 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("•")) + { + 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) + { + MessageBox.Show(this, "Translation service not loaded.", "Translations", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + var missing = _translationService.MissingKeys?.OrderBy(x => x).ToList() ?? new List(); + if (missing.Count == 0) + { + MessageBox.Show(this, _translationService.T("NoMissingTranslations") ?? "No missing translations detected.", _translationService.T("MissingTranslationsTitle") ?? "Missing Translations", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + bool rtl = string.Equals(_translationService.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); + using var dlg = new Form + { + Text = _translationService.T("MissingTranslationsTitle") ?? "Missing Translations", + StartPosition = FormStartPosition.CenterParent, + Width = 700, + Height = 500 + }; + ApplyRtl(dlg, rtl); + var list = new ListBox { Dock = DockStyle.Fill }; if (rtl) try { list.RightToLeft = RightToLeft.Yes; } catch { } + list.Items.AddRange(missing.Cast().ToArray()); + 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 }; + btnClose.Click += (s, e) => dlg.Close(); + var btnCopy = new Button { Text = _translationService.T("Copy") ?? "Copy", AutoSize = true }; + btnCopy.Click += (s, e) => { try { Clipboard.SetText(string.Join(Environment.NewLine, missing)); } catch { } }; + var btnExport = new Button { Text = _translationService.T("ExportReport") ?? "Export", AutoSize = true }; + btnExport.Click += (s, e) => + { + try + { + var sfd = new SaveFileDialog { Filter = "Text (*.txt)|*.txt", FileName = "translation_report.txt" }; + if (sfd.ShowDialog(dlg) == DialogResult.OK) + { + File.WriteAllLines(sfd.FileName, missing); + } + } + catch (Exception ex) + { + MessageBox.Show(dlg, ex.Message, _translationService.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + }; + btnPanel.Controls.Add(btnClose); + btnPanel.Controls.Add(btnExport); + btnPanel.Controls.Add(btnCopy); + dlg.Controls.Add(list); + dlg.Controls.Add(btnPanel); + 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; + int idx = _resultsList.SelectedIndex; + if (idx < 0 || idx >= _lastResults.Count) return; + var match = _lastResults[idx]; + if (!_service.TryGetCondition(match.Name, out var condition) || condition == null) return; + var t = _translationService; + var sb = new System.Text.StringBuilder(); + BuildDetailsText(sb, t, match, condition); + string text = sb.ToString(); + using var pd = new System.Drawing.Printing.PrintDocument(); + int charFrom = 0; + pd.PrintPage += (s, e) => + { + var font = new Font(FontFamily.GenericSansSerif, 10); + 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; + }; + try { using var dlg = new PrintPreviewDialog { Document = pd, Width = 800, Height = 600 }; dlg.ShowDialog(this); } + catch { try { pd.Print(); } 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); + } + } + } +} diff --git a/UI/MainForm.Export.cs b/UI/MainForm.Export.cs new file mode 100644 index 0000000..24eb29e --- /dev/null +++ b/UI/MainForm.Export.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.UI +{ + // Export functionality (CSV, Markdown, HTML) + public partial class MainForm + { + private bool _exportSelectedOnly = false; + + 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) + { + 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; + + 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 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 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; + } + + 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); + sb.AppendLine(string.Join(",", new[] + { + EscapeCsv(condDisp), + EscapeCsv(scoreStr), + EscapeCsv(m.MatchCount.ToString(System.Globalization.CultureInfo.InvariantCulture)), + EscapeCsv(catDisp) + })); + } + + 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 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(); + sb.AppendLine("# " + (t?.T("Title") ?? "Symptom Checker (Educational)")); + if (_checkedSymptoms.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("**" + (t?.T("SymptomsLabel") ?? "Symptoms:") + "** " + string.Join(", ", _checkedSymptoms.Select(s => _translationService?.Symptom(s) ?? s))); + } + sb.AppendLine(); + 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} | "); + } + + 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 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); + } + } + } +} diff --git a/UI/MainForm.ImageAnalysis.cs b/UI/MainForm.ImageAnalysis.cs new file mode 100644 index 0000000..2108ccf --- /dev/null +++ b/UI/MainForm.ImageAnalysis.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using SymptomCheckerApp.Models; +using SymptomCheckerApp.Services; + +namespace SymptomCheckerApp.UI +{ + /// + /// Image analysis feature: upload a medical image and use a multimodal AI model + /// (e.g. LLaVA) to deduce symptoms and possible conditions. + /// + public partial class MainForm + { + // Image analysis UI controls + private readonly GroupBox _grpImageAnalysis = new GroupBox(); + private readonly Button _imgUploadButton = new Button(); + private readonly Button _imgAnalyzeButton = new Button(); + private readonly Button _imgClearButton = new Button(); + private readonly Button _imgApplySymptomsButton = new Button(); + private readonly PictureBox _imgPreview = new PictureBox(); + private readonly RichTextBox _imgResultBox = new RichTextBox(); + private readonly ProgressBar _imgProgress = new ProgressBar(); + private readonly Label _imgTimingLabel = new Label(); + private readonly Label _imgInfoLabel = new Label(); + + private string? _currentImageBase64; + private ImageAnalysisResult? _lastImageAnalysisResult; + private CancellationTokenSource? _imgAnalysisCts; + + /// Build and wire the Image Analysis panel. Called from InitializeLayout. + private void InitializeImageAnalysisPanel(Control parent) + { + _grpImageAnalysis.Text = "📷 Image Analysis (Vision AI)"; + _grpImageAnalysis.Dock = DockStyle.Fill; + _grpImageAnalysis.Padding = new Padding(6); + + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 4 + }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30)); // image preview column + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 70)); // results column + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // toolbar + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // progress + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // content + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // timing + + // Toolbar row (spans both columns) + var toolbar = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + WrapContents = true, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(2) + }; + + _imgUploadButton.Text = "📁 Upload Image"; + _imgUploadButton.AutoSize = true; + _imgUploadButton.FlatStyle = FlatStyle.Flat; + _imgUploadButton.BackColor = Color.FromArgb(230, 240, 255); + _imgUploadButton.AccessibleName = "Upload a medical image for analysis"; + _imgUploadButton.Click += (s, e) => UploadImage(); + + _imgAnalyzeButton.Text = "🔍 Analyze Image"; + _imgAnalyzeButton.AutoSize = true; + _imgAnalyzeButton.FlatStyle = FlatStyle.Flat; + _imgAnalyzeButton.BackColor = Color.FromArgb(255, 240, 230); + _imgAnalyzeButton.AccessibleName = "Analyze the uploaded image with AI"; + _imgAnalyzeButton.Enabled = false; + _imgAnalyzeButton.Click += async (s, e) => await RunImageAnalysisAsync(); + + _imgApplySymptomsButton.Text = "✅ Apply Symptoms"; + _imgApplySymptomsButton.AutoSize = true; + _imgApplySymptomsButton.FlatStyle = FlatStyle.Flat; + _imgApplySymptomsButton.BackColor = Color.FromArgb(230, 255, 230); + _imgApplySymptomsButton.AccessibleName = "Select deduced symptoms in the symptom list"; + _imgApplySymptomsButton.Enabled = false; + _imgApplySymptomsButton.Click += (s, e) => ApplyDeducedSymptoms(); + + _imgClearButton.Text = "🗑 Clear"; + _imgClearButton.AutoSize = true; + _imgClearButton.FlatStyle = FlatStyle.Flat; + _imgClearButton.AccessibleName = "Clear uploaded image"; + _imgClearButton.Enabled = false; + _imgClearButton.Click += (s, e) => ClearImageAnalysis(); + + _imgInfoLabel.Text = "Supported: skin, throat, eye, mouth, nail — use a vision model (llava, bakllava, etc.)"; + _imgInfoLabel.AutoSize = true; + _imgInfoLabel.ForeColor = Color.DimGray; + _imgInfoLabel.Padding = new Padding(6, 6, 0, 0); + _imgInfoLabel.Font = new Font(Font.FontFamily, 8f); + + toolbar.Controls.Add(_imgUploadButton); + toolbar.Controls.Add(_imgAnalyzeButton); + toolbar.Controls.Add(_imgApplySymptomsButton); + toolbar.Controls.Add(_imgClearButton); + toolbar.Controls.Add(_imgInfoLabel); + + layout.Controls.Add(toolbar, 0, 0); + layout.SetColumnSpan(toolbar, 2); + + // Progress bar (spans both columns) + _imgProgress.Dock = DockStyle.Top; + _imgProgress.Style = ProgressBarStyle.Marquee; + _imgProgress.MarqueeAnimationSpeed = 30; + _imgProgress.Height = 4; + _imgProgress.Visible = false; + layout.Controls.Add(_imgProgress, 0, 1); + layout.SetColumnSpan(_imgProgress, 2); + + // Image preview (left column) + _imgPreview.Dock = DockStyle.Fill; + _imgPreview.SizeMode = PictureBoxSizeMode.Zoom; + _imgPreview.BorderStyle = BorderStyle.FixedSingle; + _imgPreview.BackColor = Color.FromArgb(245, 245, 245); + _imgPreview.AccessibleName = "Image preview"; + // Placeholder text via Paint event + _imgPreview.Paint += (s, e) => + { + if (_imgPreview.Image == null) + { + var text = "Drop or upload\nan image here"; + var font = new Font("Segoe UI", 10f, FontStyle.Italic); + var brush = new SolidBrush(Color.FromArgb(150, 150, 150)); + var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + e.Graphics.DrawString(text, font, brush, _imgPreview.ClientRectangle, sf); + font.Dispose(); + brush.Dispose(); + } + }; + // Enable drag and drop + _imgPreview.AllowDrop = true; + _imgPreview.DragEnter += (s, e) => + { + if (e.Data != null && e.Data.GetDataPresent(DataFormats.FileDrop)) + e.Effect = DragDropEffects.Copy; + }; + _imgPreview.DragDrop += (s, e) => + { + if (e.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0) + LoadImageFile(files[0]); + }; + layout.Controls.Add(_imgPreview, 0, 2); + + // Results box (right column) + _imgResultBox.Dock = DockStyle.Fill; + _imgResultBox.ReadOnly = true; + _imgResultBox.BorderStyle = BorderStyle.FixedSingle; + _imgResultBox.BackColor = SystemColors.Window; + _imgResultBox.Font = new Font("Segoe UI", 9.5f); + _imgResultBox.AccessibleName = "Image analysis results"; + _imgResultBox.Text = "Upload a medical image (photo of skin condition, throat, eye, etc.) and click 'Analyze Image'.\n\n" + + "The AI vision model will:\n" + + " • Identify the body region\n" + + " • Describe visual observations\n" + + " • Deduce possible symptoms\n" + + " • Suggest possible conditions\n\n" + + "You can then apply the deduced symptoms to the symptom checker.\n\n" + + "⚠ Requires a multimodal model in Ollama (e.g. llava, bakllava, llava-llama3)."; + layout.Controls.Add(_imgResultBox, 1, 2); + + // Timing label (spans both columns) + _imgTimingLabel.Text = ""; + _imgTimingLabel.AutoSize = true; + _imgTimingLabel.Padding = new Padding(4); + _imgTimingLabel.ForeColor = Color.DimGray; + _imgTimingLabel.AccessibleName = "Image analysis timing"; + layout.Controls.Add(_imgTimingLabel, 0, 3); + layout.SetColumnSpan(_imgTimingLabel, 2); + + _grpImageAnalysis.Controls.Add(layout); + parent.Controls.Add(_grpImageAnalysis); + } + + /// Open a file dialog to pick an image. + private void UploadImage() + { + using var dlg = new OpenFileDialog + { + Title = "Select a medical image", + Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp)|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp|All files (*.*)|*.*", + RestoreDirectory = true + }; + + if (dlg.ShowDialog(this) == DialogResult.OK) + { + LoadImageFile(dlg.FileName); + } + } + + /// Load an image file, display preview, and prepare base64. + private void LoadImageFile(string filePath) + { + try + { + if (!File.Exists(filePath)) return; + + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var validExts = new HashSet { ".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp" }; + if (!validExts.Contains(ext)) + { + MessageBox.Show(this, "Unsupported image format. Please use JPG, PNG, BMP, GIF or WebP.", + "Invalid Format", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // Check file size (limit to 20MB) + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length > 20 * 1024 * 1024) + { + MessageBox.Show(this, "Image file is too large. Maximum size is 20 MB.", + "File Too Large", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // Load image for preview + var imageBytes = File.ReadAllBytes(filePath); + _currentImageBase64 = Convert.ToBase64String(imageBytes); + + using var ms = new MemoryStream(imageBytes); + var oldImage = _imgPreview.Image; + _imgPreview.Image = Image.FromStream(ms); + oldImage?.Dispose(); + + _imgAnalyzeButton.Enabled = true; + _imgClearButton.Enabled = true; + _imgApplySymptomsButton.Enabled = false; + _lastImageAnalysisResult = null; + + _imgResultBox.Clear(); + _imgResultBox.Text = $"Image loaded: {Path.GetFileName(filePath)}\n" + + $"Size: {fileInfo.Length / 1024} KB\n\n" + + "Click 'Analyze Image' to start AI analysis.\n" + + "Make sure a vision model (llava, bakllava) is selected in the AI panel above."; + _imgTimingLabel.Text = ""; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Error loading image: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + /// Clear the uploaded image and results. + private void ClearImageAnalysis() + { + var oldImage = _imgPreview.Image; + _imgPreview.Image = null; + oldImage?.Dispose(); + + _currentImageBase64 = null; + _lastImageAnalysisResult = null; + _imgAnalyzeButton.Enabled = false; + _imgClearButton.Enabled = false; + _imgApplySymptomsButton.Enabled = false; + _imgTimingLabel.Text = ""; + + _imgResultBox.Clear(); + _imgResultBox.Text = "Upload a medical image to analyze."; + _imgPreview.Invalidate(); // force repaint for placeholder text + } + + /// Run the AI image analysis using the Ollama vision model. + private async Task RunImageAnalysisAsync() + { + if (_ollamaService == null || string.IsNullOrEmpty(_currentImageBase64)) + { + _imgResultBox.Text = "Please upload an image and ensure Ollama is connected."; + return; + } + + // Ensure correct model is set from the main Ollama panel selector + var selectedModel = _ollamaModelSelector.SelectedItem?.ToString(); + if (!string.IsNullOrEmpty(selectedModel)) + { + _ollamaService.SetModel(selectedModel); + } + else + { + _imgResultBox.Text = "Please select a vision model (e.g. llava) in the AI panel above."; + return; + } + + _imgAnalysisCts?.Cancel(); + _imgAnalysisCts = new CancellationTokenSource(); + + SetImageAnalysisLoading(true); + + try + { + var lang = _translationService?.CurrentLanguage ?? "en"; + var result = await _ollamaService.AnalyzeImageAsync( + _currentImageBase64, lang, _imgAnalysisCts.Token); + + _lastImageAnalysisResult = result; + DisplayImageAnalysisResult(result); + + _imgApplySymptomsButton.Enabled = result.DeducedSymptoms.Count > 0; + } + catch (OperationCanceledException) + { + _imgResultBox.Text = "Image analysis cancelled."; + } + catch (Exception ex) + { + _imgResultBox.Text = $"Error: {ex.Message}"; + } + finally + { + SetImageAnalysisLoading(false); + } + } + + private void SetImageAnalysisLoading(bool loading) + { + _imgProgress.Visible = loading; + _imgAnalyzeButton.Enabled = !loading && !string.IsNullOrEmpty(_currentImageBase64); + _imgUploadButton.Enabled = !loading; + _imgClearButton.Enabled = !loading && _imgPreview.Image != null; + if (loading) + { + _imgResultBox.Clear(); + _imgResultBox.Text = "🔍 AI is analyzing the image... please wait...\n\n" + + "(This may take 30-90 seconds depending on the model and hardware)"; + _imgTimingLabel.Text = ""; + } + } + + /// Render the image analysis result with formatting. + private void DisplayImageAnalysisResult(ImageAnalysisResult result) + { + _imgResultBox.Clear(); + var rtb = _imgResultBox; + + void AppendHeader(string text) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 11f, FontStyle.Bold); + rtb.SelectionColor = Color.FromArgb(30, 80, 160); + rtb.AppendText(text + "\n"); + } + + void AppendBody(string text) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular); + rtb.SelectionColor = rtb.ForeColor; + rtb.AppendText(text + "\n"); + } + + void AppendBullet(string text, Color color) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular); + rtb.SelectionColor = color; + rtb.AppendText($" • {text}\n"); + } + + // Body Region + if (!string.IsNullOrWhiteSpace(result.BodyRegion)) + { + AppendHeader("🔬 Body Region"); + AppendBody($" {result.BodyRegion}"); + rtb.AppendText("\n"); + } + + // Observations + if (result.Observations.Count > 0) + { + AppendHeader("👁 Visual Observations"); + foreach (var obs in result.Observations) + AppendBullet(obs, Color.FromArgb(60, 60, 60)); + rtb.AppendText("\n"); + } + + // Deduced Symptoms + if (result.DeducedSymptoms.Count > 0) + { + AppendHeader("🩺 Deduced Symptoms"); + foreach (var symptom in result.DeducedSymptoms) + { + // Check if symptom matches one in our database + bool isKnown = _allSymptoms.Any(s => + s.Contains(symptom, StringComparison.OrdinalIgnoreCase) || + symptom.Contains(s, StringComparison.OrdinalIgnoreCase)); + var color = isKnown ? Color.FromArgb(0, 120, 60) : Color.FromArgb(180, 100, 0); + var suffix = isKnown ? " ✓ (in database)" : " (not in database)"; + AppendBullet(symptom + suffix, color); + } + rtb.AppendText("\n"); + + // Show apply button hint + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic); + rtb.SelectionColor = Color.FromArgb(0, 100, 150); + rtb.AppendText(" 💡 Click 'Apply Symptoms' to auto-select matching symptoms in the checker.\n\n"); + } + + // Possible Conditions + if (result.PossibleConditions.Count > 0) + { + AppendHeader("🏥 Possible Conditions"); + foreach (var cond in result.PossibleConditions) + AppendBullet(cond, Color.FromArgb(160, 60, 0)); + rtb.AppendText("\n"); + } + + // Severity + if (!string.IsNullOrWhiteSpace(result.Severity)) + { + AppendHeader("⚡ Severity"); + var sevColor = result.Severity.ToLowerInvariant() switch + { + var s when s.Contains("severe") => Color.FromArgb(200, 0, 0), + var s when s.Contains("moderate") => Color.FromArgb(200, 140, 0), + _ => Color.FromArgb(0, 128, 0) + }; + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 10f, FontStyle.Bold); + rtb.SelectionColor = sevColor; + rtb.AppendText($" {result.Severity}\n\n"); + } + + // Recommendation + if (!string.IsNullOrWhiteSpace(result.Recommendation)) + { + AppendHeader("💡 Recommendation"); + AppendBody($" {result.Recommendation}"); + rtb.AppendText("\n"); + } + + // Disclaimer + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic); + rtb.SelectionColor = Color.Gray; + string disclaimer = !string.IsNullOrWhiteSpace(result.Disclaimer) + ? result.Disclaimer + : "⚠️ This image analysis is for educational purposes only. Always consult a healthcare professional for proper diagnosis."; + rtb.AppendText($"\n{disclaimer}\n"); + + // Timing + _imgTimingLabel.Text = $"Image analysis: {result.ElapsedMs} ms | Model: {_ollamaService?.ModelName ?? "?"}"; + + rtb.SelectionStart = 0; + rtb.ScrollToCaret(); + } + + /// + /// Apply the deduced symptoms from image analysis to the symptom checker. + /// Uses fuzzy matching against known symptoms in the database. + /// + private void ApplyDeducedSymptoms() + { + if (_lastImageAnalysisResult == null || _lastImageAnalysisResult.DeducedSymptoms.Count == 0) + { + MessageBox.Show(this, "No symptoms deduced from image analysis yet.", + "No Symptoms", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + int matched = 0; + var matchedNames = new List(); + var unmatchedNames = new List(); + + foreach (var deducedSymptom in _lastImageAnalysisResult.DeducedSymptoms) + { + // Try exact match first, then partial/fuzzy match + string? bestMatch = null; + int bestScore = 0; + + foreach (var knownSymptom in _allSymptoms) + { + // Exact match + if (string.Equals(knownSymptom, deducedSymptom, StringComparison.OrdinalIgnoreCase)) + { + bestMatch = knownSymptom; + bestScore = 100; + break; + } + + // Check if known symptom contains the deduced symptom or vice versa + if (knownSymptom.Contains(deducedSymptom, StringComparison.OrdinalIgnoreCase) || + deducedSymptom.Contains(knownSymptom, StringComparison.OrdinalIgnoreCase)) + { + int score = 80; + if (score > bestScore) + { + bestScore = score; + bestMatch = knownSymptom; + } + } + + // Word-level match: check if any significant word from deduced symptom appears + var deducedWords = deducedSymptom.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var knownWords = knownSymptom.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var commonWords = deducedWords.Intersect(knownWords).Where(w => w.Length > 3).ToList(); + if (commonWords.Count > 0) + { + int score = 40 + (commonWords.Count * 15); + if (score > bestScore) + { + bestScore = score; + bestMatch = knownSymptom; + } + } + } + + // Also check synonyms if available + if (bestScore < 60 && _synonymService != null) + { + var aliasMap = _synonymService.BuildAliasToCanonical(_allSymptoms); + if (aliasMap.TryGetValue(deducedSymptom, out var canonical) && + _allSymptoms.Contains(canonical, StringComparer.OrdinalIgnoreCase)) + { + bestMatch = canonical; + bestScore = 90; + } + } + + if (bestMatch != null && bestScore >= 40) + { + // Select this symptom in the CheckedListBox + if (_checkedSymptoms.Add(bestMatch)) + { + matched++; + matchedNames.Add($"{deducedSymptom} → {bestMatch}"); + } + } + else + { + unmatchedNames.Add(deducedSymptom); + } + } + + // Refresh the symptom list display to reflect new selections + RefreshSymptomList(); + + // Update check button state + _checkButton.Enabled = _checkedSymptoms.Count > 0; + + // Show feedback + var msg = $"Applied {matched} symptom(s) from image analysis.\n\n"; + if (matchedNames.Count > 0) + msg += "Matched:\n" + string.Join("\n", matchedNames.Select(m => $" ✓ {m}")) + "\n\n"; + if (unmatchedNames.Count > 0) + msg += "Not matched (not in database):\n" + string.Join("\n", unmatchedNames.Select(u => $" ✗ {u}")); + + MessageBox.Show(this, msg, "Symptoms Applied", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + /// Apply translations to image analysis panel labels. + private void ApplyImageAnalysisTranslations() + { + var t = _translationService; + if (t == null) return; + + _grpImageAnalysis.Text = t.T("ImageAnalysisTitle") ?? "📷 Image Analysis (Vision AI)"; + _imgUploadButton.Text = t.T("ImageUpload") ?? "📁 Upload Image"; + _imgAnalyzeButton.Text = t.T("ImageAnalyze") ?? "🔍 Analyze Image"; + _imgApplySymptomsButton.Text = t.T("ImageApplySymptoms") ?? "✅ Apply Symptoms"; + _imgClearButton.Text = t.T("ImageClear") ?? "🗑 Clear"; + } + } +} diff --git a/UI/MainForm.Ollama.cs b/UI/MainForm.Ollama.cs new file mode 100644 index 0000000..6360aff --- /dev/null +++ b/UI/MainForm.Ollama.cs @@ -0,0 +1,550 @@ +using System; +using System.Drawing; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using SymptomCheckerApp.Models; +using SymptomCheckerApp.Services; + +namespace SymptomCheckerApp.UI +{ + // AI-powered diagnosis reinforcement and medication proposals via Ollama + public partial class MainForm + { + private OllamaService? _ollamaService; + private CancellationTokenSource? _ollamaCts; + private AiDiagnosisResult? _lastAiResult; + + // Ollama UI controls + private readonly GroupBox _grpAi = new GroupBox(); + private readonly Button _aiDiagnoseButton = new Button(); + private readonly Button _aiMedsButton = new Button(); + private readonly ComboBox _ollamaModelSelector = new ComboBox(); + private readonly Button _ollamaRefreshButton = new Button(); + private readonly Label _ollamaStatus = new Label(); + private readonly TextBox _aiUrlBox = new TextBox(); + private readonly RichTextBox _aiOutputBox = new RichTextBox(); + private readonly ProgressBar _aiProgress = new ProgressBar(); + private readonly Label _aiTimingLabel = new Label(); + private readonly CheckBox _autoAiCheck = new CheckBox(); + + /// Build and wire the AI / Ollama panel controls. Called from InitializeLayout. + private void InitializeOllamaPanel(Control parent) + { + _grpAi.Text = "🤖 AI Diagnostic Assistant (Ollama)"; + _grpAi.Dock = DockStyle.Fill; + _grpAi.Padding = new Padding(6); + + var aiLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 4 + }; + aiLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // toolbar + aiLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // progress + aiLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // output + aiLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // timing + + // Toolbar row + var aiToolbar = new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + WrapContents = true, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(2) + }; + + var lblUrl = new Label { Text = "URL:", AutoSize = true, Padding = new Padding(0, 6, 0, 0) }; + _aiUrlBox.Text = "http://localhost:11434"; + _aiUrlBox.Width = ScaleX(160); + _aiUrlBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _aiUrlBox.AccessibleName = "Ollama server URL"; + + var lblModel = new Label { Text = "Model:", AutoSize = true, Padding = new Padding(6, 6, 0, 0) }; + _ollamaModelSelector.DropDownStyle = ComboBoxStyle.DropDownList; + _ollamaModelSelector.Width = ScaleX(140); + _ollamaModelSelector.AccessibleName = "Ollama model selector"; + + _ollamaRefreshButton.Text = "⟳"; + _ollamaRefreshButton.Width = ScaleX(32); + _ollamaRefreshButton.FlatStyle = FlatStyle.Flat; + _ollamaRefreshButton.AccessibleName = "Refresh Ollama models"; + _ollamaRefreshButton.Click += async (s, e) => await RefreshOllamaModelsAsync(); + + _ollamaStatus.Text = "⬤ Disconnected"; + _ollamaStatus.ForeColor = Color.Gray; + _ollamaStatus.AutoSize = true; + _ollamaStatus.Padding = new Padding(6, 6, 0, 0); + _ollamaStatus.AccessibleName = "Ollama connection status"; + + _aiDiagnoseButton.Text = "🧠 AI Diagnosis"; + _aiDiagnoseButton.AutoSize = true; + _aiDiagnoseButton.FlatStyle = FlatStyle.Flat; + _aiDiagnoseButton.BackColor = Color.FromArgb(230, 240, 255); + _aiDiagnoseButton.AccessibleName = "Run AI-powered diagnostic analysis"; + _aiDiagnoseButton.Click += async (s, e) => await RunAiDiagnosisAsync(); + + _aiMedsButton.Text = "💊 AI Medications"; + _aiMedsButton.AutoSize = true; + _aiMedsButton.FlatStyle = FlatStyle.Flat; + _aiMedsButton.BackColor = Color.FromArgb(230, 255, 230); + _aiMedsButton.AccessibleName = "Get AI medication recommendations"; + _aiMedsButton.Click += async (s, e) => await RunAiMedicationAdviceAsync(); + + _autoAiCheck.Text = "Auto AI"; + _autoAiCheck.AutoSize = true; + _autoAiCheck.AccessibleName = "Automatically run AI after check"; + _autoAiCheck.CheckedChanged += (s, e) => + { + if (_settingsService != null) { _settingsService.Settings.AutoAi = _autoAiCheck.Checked; _settingsService.Save(); } + }; + + _aiUrlBox.Leave += (s, e) => + { + if (_settingsService != null) { _settingsService.Settings.OllamaUrl = _aiUrlBox.Text?.Trim(); _settingsService.Save(); } + }; + + _ollamaModelSelector.SelectedIndexChanged += (s, e) => + { + if (_settingsService != null && _ollamaModelSelector.SelectedItem != null) + { + _settingsService.Settings.OllamaModel = _ollamaModelSelector.SelectedItem.ToString(); + _settingsService.Save(); + } + }; + + aiToolbar.Controls.Add(lblUrl); + aiToolbar.Controls.Add(_aiUrlBox); + aiToolbar.Controls.Add(lblModel); + aiToolbar.Controls.Add(_ollamaModelSelector); + aiToolbar.Controls.Add(_ollamaRefreshButton); + aiToolbar.Controls.Add(_ollamaStatus); + aiToolbar.Controls.Add(_aiDiagnoseButton); + aiToolbar.Controls.Add(_aiMedsButton); + aiToolbar.Controls.Add(_autoAiCheck); + + // Progress bar + _aiProgress.Dock = DockStyle.Top; + _aiProgress.Style = ProgressBarStyle.Marquee; + _aiProgress.MarqueeAnimationSpeed = 30; + _aiProgress.Height = 4; + _aiProgress.Visible = false; + + // Output box + _aiOutputBox.Dock = DockStyle.Fill; + _aiOutputBox.ReadOnly = true; + _aiOutputBox.BorderStyle = BorderStyle.FixedSingle; + _aiOutputBox.BackColor = SystemColors.Window; + _aiOutputBox.Font = new Font("Segoe UI", 9.5f); + _aiOutputBox.AccessibleName = "AI diagnostic output"; + _aiOutputBox.Text = "AI analysis will appear here after you run a symptom check and click 'AI Diagnosis'.\n\nRequires Ollama running locally (https://ollama.com)."; + + // Timing + _aiTimingLabel.Text = ""; + _aiTimingLabel.AutoSize = true; + _aiTimingLabel.Padding = new Padding(4); + _aiTimingLabel.ForeColor = Color.DimGray; + _aiTimingLabel.AccessibleName = "AI response timing"; + + aiLayout.Controls.Add(aiToolbar, 0, 0); + aiLayout.Controls.Add(_aiProgress, 0, 1); + aiLayout.Controls.Add(_aiOutputBox, 0, 2); + aiLayout.Controls.Add(_aiTimingLabel, 0, 3); + + _grpAi.Controls.Add(aiLayout); + parent.Controls.Add(_grpAi); + } + + /// Initialize or re-initialize the OllamaService and check availability. + private async Task InitializeOllamaAsync() + { + var url = _aiUrlBox.Text?.Trim(); + if (string.IsNullOrEmpty(url)) url = "http://localhost:11434"; + + _ollamaService?.Dispose(); + _ollamaService = new OllamaService(url); + + var available = await _ollamaService.CheckAvailabilityAsync(); + UpdateOllamaStatusUI(available); + + if (available) + { + await RefreshOllamaModelsAsync(); + } + } + + private async Task RefreshOllamaModelsAsync() + { + try + { + var url = _aiUrlBox.Text?.Trim(); + if (string.IsNullOrEmpty(url)) url = "http://localhost:11434"; + + if (_ollamaService == null || _ollamaService.BaseUrl != url.TrimEnd('/')) + { + _ollamaService?.Dispose(); + _ollamaService = new OllamaService(url); + } + + var available = await _ollamaService.CheckAvailabilityAsync(); + UpdateOllamaStatusUI(available); + + if (!available) return; + + var models = await _ollamaService.ListModelsAsync(); + _ollamaModelSelector.Items.Clear(); + foreach (var m in models) + _ollamaModelSelector.Items.Add(m); + + if (_ollamaModelSelector.Items.Count > 0) + { + // Prefer models with "llama" or "mistral" in the name + int bestIdx = 0; + for (int i = 0; i < _ollamaModelSelector.Items.Count; i++) + { + var name = _ollamaModelSelector.Items[i]?.ToString() ?? ""; + if (name.Contains("llama", StringComparison.OrdinalIgnoreCase) || + name.Contains("mistral", StringComparison.OrdinalIgnoreCase)) + { + bestIdx = i; + break; + } + } + + // Restore from settings if available + if (_settingsService?.Settings.OllamaModel is string savedModel && !string.IsNullOrEmpty(savedModel)) + { + for (int i = 0; i < _ollamaModelSelector.Items.Count; i++) + { + if (string.Equals(_ollamaModelSelector.Items[i]?.ToString(), savedModel, StringComparison.OrdinalIgnoreCase)) + { + bestIdx = i; + break; + } + } + } + + _ollamaModelSelector.SelectedIndex = bestIdx; + } + } + catch (Exception ex) + { + UpdateOllamaStatusUI(false); + _aiOutputBox.Text = $"Error listing models: {ex.Message}"; + } + } + + private void UpdateOllamaStatusUI(bool available) + { + if (available) + { + _ollamaStatus.Text = "⬤ Connected"; + _ollamaStatus.ForeColor = Color.FromArgb(0, 128, 0); + _aiDiagnoseButton.Enabled = true; + _aiMedsButton.Enabled = true; + } + else + { + _ollamaStatus.Text = "⬤ Disconnected"; + _ollamaStatus.ForeColor = Color.FromArgb(180, 0, 0); + _aiDiagnoseButton.Enabled = false; + _aiMedsButton.Enabled = false; + } + } + + /// Run full AI diagnostic assessment with medication suggestions. + private async Task RunAiDiagnosisAsync() + { + if (_ollamaService == null || _lastResults == null || _lastResults.Count == 0) + { + _aiOutputBox.Text = _translationService?.T("AiNoResults") ?? + "Please run a symptom check first (select symptoms and click Check)."; + return; + } + + // Ensure correct model is set + var selectedModel = _ollamaModelSelector.SelectedItem?.ToString(); + if (!string.IsNullOrEmpty(selectedModel)) + { + _ollamaService.SetModel(selectedModel); + if (_settingsService != null) + { + _settingsService.Settings.OllamaModel = selectedModel; + _settingsService.Save(); + } + } + + _ollamaCts?.Cancel(); + _ollamaCts = new CancellationTokenSource(); + + SetAiLoading(true); + + try + { + var lang = _translationService?.CurrentLanguage ?? "en"; + double? age = _numAge.Value > 0 ? (double)_numAge.Value : null; + double? tempC = _numTempC.Value > 30 ? (double)_numTempC.Value : null; + int? hr = (int)_numHR.Value > 20 ? (int)_numHR.Value : null; + int? rr = (int)_numRR.Value > 4 ? (int)_numRR.Value : null; + int? spo2 = (int)_numSpO2.Value > 50 ? (int)_numSpO2.Value : null; + + var result = await _ollamaService.GetDiagnosisReinforcementAsync( + _checkedSymptoms.ToList(), + _lastResults, + lang, age, tempC, hr, rr, spo2, + _ollamaCts.Token); + + _lastAiResult = result; + DisplayAiResult(result); + } + catch (OperationCanceledException) + { + _aiOutputBox.Text = "AI analysis cancelled."; + } + catch (Exception ex) + { + _aiOutputBox.Text = $"Error: {ex.Message}"; + } + finally + { + SetAiLoading(false); + } + } + + /// Run AI medication recommendations for the selected condition. + private async Task RunAiMedicationAdviceAsync() + { + if (_ollamaService == null) return; + + // Get selected condition from results list + string? conditionName = null; + var matchedSymptoms = new System.Collections.Generic.List(); + + int idx = _resultsList.SelectedIndex; + if (idx >= 0 && idx < _resultIndexMap.Count) + { + int ri = _resultIndexMap[idx]; + if (ri >= 0 && ri < _lastResults.Count) + { + conditionName = _lastResults[ri].Name; + matchedSymptoms = _lastResults[ri].MatchedSymptoms; + } + } + + if (string.IsNullOrEmpty(conditionName) && _lastResults?.Count > 0) + { + conditionName = _lastResults[0].Name; + matchedSymptoms = _lastResults[0].MatchedSymptoms; + } + + if (string.IsNullOrEmpty(conditionName)) + { + _aiOutputBox.Text = _translationService?.T("AiSelectCondition") ?? + "Please run a symptom check and select a condition for medication advice."; + return; + } + + var selectedModel = _ollamaModelSelector.SelectedItem?.ToString(); + if (!string.IsNullOrEmpty(selectedModel)) + _ollamaService.SetModel(selectedModel); + + _ollamaCts?.Cancel(); + _ollamaCts = new CancellationTokenSource(); + + SetAiLoading(true); + + try + { + var lang = _translationService?.CurrentLanguage ?? "en"; + double? age = _numAge.Value > 0 ? (double)_numAge.Value : null; + + var result = await _ollamaService.GetMedicationAdviceAsync( + conditionName, matchedSymptoms, lang, age, _ollamaCts.Token); + + _lastAiResult = result; + DisplayAiResult(result); + } + catch (OperationCanceledException) + { + _aiOutputBox.Text = "Cancelled."; + } + catch (Exception ex) + { + _aiOutputBox.Text = $"Error: {ex.Message}"; + } + finally + { + SetAiLoading(false); + } + } + + private void SetAiLoading(bool loading) + { + _aiProgress.Visible = loading; + _aiDiagnoseButton.Enabled = !loading && (_ollamaService?.IsAvailable ?? false); + _aiMedsButton.Enabled = !loading && (_ollamaService?.IsAvailable ?? false); + if (loading) + { + _aiOutputBox.Text = _translationService?.T("AiThinking") ?? "🤖 AI is analyzing... please wait..."; + _aiTimingLabel.Text = ""; + } + } + + /// Render the AI result with color formatting in the RichTextBox. + private void DisplayAiResult(AiDiagnosisResult result) + { + _aiOutputBox.Clear(); + var rtb = _aiOutputBox; + + void AppendHeader(string text) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 11f, FontStyle.Bold); + rtb.SelectionColor = Color.FromArgb(30, 80, 160); + rtb.AppendText(text + "\n"); + } + + void AppendBody(string text) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular); + rtb.SelectionColor = rtb.ForeColor; + rtb.AppendText(text + "\n"); + } + + void AppendMedication(MedicationProposal med) + { + // Name in bold + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Bold); + bool isOtc = med.Category.Contains("OTC", StringComparison.OrdinalIgnoreCase); + rtb.SelectionColor = isOtc ? Color.FromArgb(0, 120, 60) : Color.FromArgb(160, 80, 0); + rtb.AppendText($" 💊 {med.Name}"); + + // Category badge + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8f, FontStyle.Italic); + rtb.SelectionColor = isOtc ? Color.FromArgb(0, 100, 50) : Color.FromArgb(180, 60, 0); + rtb.AppendText($" [{med.Category}]\n"); + + // Details + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9f, FontStyle.Regular); + rtb.SelectionColor = rtb.ForeColor; + if (!string.IsNullOrEmpty(med.Purpose)) + rtb.AppendText($" Purpose: {med.Purpose}\n"); + if (!string.IsNullOrEmpty(med.Dosage)) + rtb.AppendText($" Dosage: {med.Dosage}\n"); + + if (!string.IsNullOrEmpty(med.Warning)) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9f, FontStyle.Italic); + rtb.SelectionColor = Color.FromArgb(180, 0, 0); + rtb.AppendText($" ⚠ {med.Warning}\n"); + } + + rtb.AppendText("\n"); + } + + void AppendRedFlag(string flag) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Bold); + rtb.SelectionColor = Color.FromArgb(200, 0, 0); + rtb.AppendText($" 🚩 {flag}\n"); + } + + // Diagnostic Assessment + if (!string.IsNullOrWhiteSpace(result.DiagnosticAssessment)) + { + AppendHeader("🧠 Diagnostic Assessment"); + AppendBody(result.DiagnosticAssessment); + rtb.AppendText("\n"); + } + + // Confidence reinforcement + if (result.ConfidenceReinforcement.HasValue) + { + AppendHeader("📊 AI Confidence"); + double conf = result.ConfidenceReinforcement.Value; + string confBar = new string('█', (int)(conf * 20)) + new string('░', 20 - (int)(conf * 20)); + rtb.SelectionFont = new Font("Consolas", 10f, FontStyle.Regular); + rtb.SelectionColor = conf > 0.7 ? Color.FromArgb(0, 128, 0) : + conf > 0.4 ? Color.FromArgb(180, 140, 0) : + Color.FromArgb(180, 0, 0); + rtb.AppendText($" [{confBar}] {conf:P0}\n\n"); + } + + // Medications + if (result.Medications.Count > 0) + { + AppendHeader("💊 Medication Proposals"); + // Separate OTC and prescription + var otcMeds = result.Medications.Where(m => + m.Category.Contains("OTC", StringComparison.OrdinalIgnoreCase) || + m.Category.Contains("over", StringComparison.OrdinalIgnoreCase)).ToList(); + var rxMeds = result.Medications.Where(m => + !m.Category.Contains("OTC", StringComparison.OrdinalIgnoreCase) && + !m.Category.Contains("over", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (otcMeds.Count > 0) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Italic); + rtb.SelectionColor = Color.FromArgb(0, 100, 50); + rtb.AppendText(" Over-the-Counter:\n"); + foreach (var med in otcMeds) AppendMedication(med); + } + + if (rxMeds.Count > 0) + { + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Italic); + rtb.SelectionColor = Color.FromArgb(180, 80, 0); + rtb.AppendText(" Prescription (requires doctor):\n"); + foreach (var med in rxMeds) AppendMedication(med); + } + + rtb.AppendText("\n"); + } + + // Red Flags + if (result.RedFlags.Count > 0) + { + AppendHeader("🚩 Red Flags — Seek Immediate Care"); + foreach (var flag in result.RedFlags) AppendRedFlag(flag); + rtb.AppendText("\n"); + } + + // Self-Care + if (!string.IsNullOrWhiteSpace(result.SelfCareAdvice)) + { + AppendHeader("🏠 Self-Care Advice"); + AppendBody(result.SelfCareAdvice); + rtb.AppendText("\n"); + } + + // Disclaimer + rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic); + rtb.SelectionColor = Color.Gray; + string disclaimer = !string.IsNullOrWhiteSpace(result.Disclaimer) + ? result.Disclaimer + : "⚠️ This information is for educational purposes only. Always consult a healthcare professional."; + rtb.AppendText($"\n{disclaimer}\n"); + + // Timing + _aiTimingLabel.Text = $"AI response: {result.ElapsedMs} ms | Model: {_ollamaService?.ModelName ?? "?"}"; + + // Scroll to top + rtb.SelectionStart = 0; + rtb.ScrollToCaret(); + } + + /// Apply Ollama translations. + private void ApplyOllamaTranslations() + { + var t = _translationService; + if (t == null) return; + + _grpAi.Text = t.T("AiAssistantTitle") ?? "🤖 AI Diagnostic Assistant (Ollama)"; + _aiDiagnoseButton.Text = t.T("AiDiagnose") ?? "🧠 AI Diagnosis"; + _aiMedsButton.Text = t.T("AiMedications") ?? "💊 AI Medications"; + _autoAiCheck.Text = t.T("AutoAi") ?? "Auto AI"; + } + } +} diff --git a/UI/MainForm.Results.cs b/UI/MainForm.Results.cs new file mode 100644 index 0000000..308cdb9 --- /dev/null +++ b/UI/MainForm.Results.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using SymptomCheckerApp.Models; + +namespace SymptomCheckerApp.UI +{ + // Results rendering: rebuild, draw, measure + public partial class MainForm + { + 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; + } + + // If categories service is available, group results by primary category (max overlap) + var categories = _categoriesService?.GetAllCategories()?.ToList() ?? new List(); + if (categories.Count == 0) + { + // 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(); + } + + private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e) + { + e.DrawBackground(); + if (e.Index < 0 || e.Index >= _resultsList.Items.Count) + { + return; + } + + 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) + { + 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 + Color foreColor = isTop ? Color.DarkGreen : e.ForeColor; + + using (var backBrush = new SolidBrush(backColor)) + using (var foreBrush = new SolidBrush(foreColor)) + { + e.Graphics.FillRectangle(backBrush, e.Bounds); + var font = e.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 rect = e.Bounds; + rect.Inflate(-4, -2); + TextRenderer.DrawText(e.Graphics, text, font, rect, foreColor, flags); + } + + 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; + } + } +} diff --git a/UI/MainForm.Session.cs b/UI/MainForm.Session.cs new file mode 100644 index 0000000..3c8ae1b --- /dev/null +++ b/UI/MainForm.Session.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using System.Text.Json; + +namespace SymptomCheckerApp.UI +{ + // Session save/load and settings profile management + public partial class MainForm + { + private class SessionData + { + public List SelectedSymptoms { get; set; } = new(); + public string? Model { get; set; } + public int ThresholdPercent { get; set; } + public int MinMatch { get; set; } + public int TopK { get; set; } + public string? Language { get; set; } + } + + 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 SaveSession() + { + try + { + var sfd = new SaveFileDialog { Filter = "Session JSON (*.json)|*.json", FileName = "session.json" }; + if (sfd.ShowDialog(this) != DialogResult.OK) return; + var data = new SessionData + { + SelectedSymptoms = _checkedSymptoms.ToList(), + Model = _modelSelector.SelectedItem?.ToString(), + ThresholdPercent = (int)_threshold.Value, + MinMatch = (int)_minMatch.Value, + TopK = (int)_topK.Value, + Language = (_languageSelector.SelectedItem as LangItem)?.Code + }; + var json = JsonSerializer.Serialize(data, new 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 LoadSession() + { + try + { + var ofd = new OpenFileDialog { Filter = "Session JSON (*.json)|*.json" }; + if (ofd.ShowDialog(this) != DialogResult.OK) return; + var json = File.ReadAllText(ofd.FileName); + var data = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (data == null) return; + _checkedSymptoms = new HashSet(data.SelectedSymptoms ?? new List(), StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(data.Language)) + { + for (int i = 0; i < _languageSelector.Items.Count; i++) + { + if (_languageSelector.Items[i] is LangItem li && + data.Language is string lang && + string.Equals(li.Code, lang, StringComparison.OrdinalIgnoreCase)) + { _languageSelector.SelectedIndex = i; break; } + } + } + if (!string.IsNullOrEmpty(data.Model)) + { + for (int i = 0; i < _modelSelector.Items.Count; i++) + { + if (_modelSelector.Items[i]?.ToString()?.Equals(data.Model, StringComparison.OrdinalIgnoreCase) == true) + { _modelSelector.SelectedIndex = i; break; } + } + } + _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, data.ThresholdPercent)); + _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, data.MinMatch)); + _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, data.TopK)); + RefreshSymptomList(); + UpdateCheckButtonEnabled(); + } + 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(); + _darkModeToggle.Checked = _settingsService.Settings.DarkMode; + 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 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 = JsonSerializer.Serialize(profile, new 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 = JsonSerializer.Deserialize(json, new 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(); + _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; } + } + } + 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); + } + } + } +} diff --git a/UI/MainForm.Theme.cs b/UI/MainForm.Theme.cs new file mode 100644 index 0000000..132ad51 --- /dev/null +++ b/UI/MainForm.Theme.cs @@ -0,0 +1,101 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace SymptomCheckerApp.UI +{ + // Theme application logic + public partial class MainForm + { + private void ApplyTheme() + { + bool dark = _darkModeToggle.Checked; + Color back = dark ? Color.FromArgb(32, 32, 32) : SystemColors.Window; + Color fore = dark ? Color.Gainsboro : SystemColors.WindowText; + Color panel = dark ? Color.FromArgb(24, 24, 24) : SystemColors.Control; + + this.BackColor = panel; + foreach (Control c in this.Controls) + { + ApplyThemeToControl(c, back, fore, panel, dark); + } + _resultsList.Invalidate(); + } + + private void ApplyThemeToControl(Control c, Color back, Color fore, Color panel, bool dark) + { + switch (c) + { + case SplitContainer sc: + sc.BackColor = panel; + ApplyThemeToControl(sc.Panel1, back, fore, panel, dark); + ApplyThemeToControl(sc.Panel2, back, fore, panel, dark); + break; + case TableLayoutPanel tl: + tl.BackColor = panel; + foreach (Control child in tl.Controls) ApplyThemeToControl(child, back, fore, panel, dark); + break; + case FlowLayoutPanel fl: + fl.BackColor = panel; + foreach (Control child in fl.Controls) ApplyThemeToControl(child, back, fore, panel, dark); + break; + case CheckedListBox clb: + clb.BackColor = back; clb.ForeColor = fore; + break; + case ListBox lb: + lb.BackColor = back; lb.ForeColor = fore; + break; + case TextBox tb: + tb.BackColor = back; tb.ForeColor = fore; + break; + case ComboBox cb: + cb.BackColor = back; cb.ForeColor = fore; + break; + 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: + if (btn == _checkButton) + { + 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; + } + // Triage banner specific colors + if (c == _triageBanner) + { + _triageBanner.BackColor = dark ? Color.FromArgb(64, 48, 0) : Color.FromArgb(255, 245, 230); + _triageBanner.ForeColor = dark ? Color.Khaki : Color.FromArgb(120, 60, 0); + } + } + + private void ApplyRtl(Control root, bool rtl) + { + if (root is Form f) + { + try { f.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } + try { f.RightToLeftLayout = rtl; } catch { } + } + } + } +} diff --git a/UI/MainForm.cs b/UI/MainForm.cs index 22c2654..4ff2f72 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -11,7 +11,7 @@ namespace SymptomCheckerApp.UI { - public class MainForm : Form + public partial class MainForm : Form { private class ListItem { @@ -59,6 +59,9 @@ public GroupHeader(string canonicalCategory, string display) public override string ToString() => Display; } + // Debounce timer for filter text input + private readonly System.Windows.Forms.Timer _filterDebounce = new System.Windows.Forms.Timer { Interval = 250 }; + private readonly CheckedListBox _symptomList = new CheckedListBox(); private readonly Button _checkButton = new Button(); private readonly Button _exitButton = new Button(); @@ -76,6 +79,8 @@ public GroupHeader(string canonicalCategory, string display) private readonly CheckBox _showOnlyCategory = new CheckBox(); private readonly Button _selectVisibleButton = new Button(); private readonly Button _clearVisibleButton = new Button(); + private readonly Button _selectAllButton = new Button(); + private readonly Button _deselectAllButton = new Button(); private readonly Button _saveSessionButton = new Button(); private readonly Button _loadSessionButton = new Button(); private readonly Button _resetSettingsButton = new Button(); @@ -158,8 +163,12 @@ public GroupHeader(string canonicalCategory, string display) public MainForm() { Text = "Symptom Checker (Educational)"; - Width = 900; - Height = 600; + AutoScaleMode = AutoScaleMode.Dpi; + // Responsive: size relative to screen, with sensible minimum + var screen = Screen.PrimaryScreen?.WorkingArea ?? new Rectangle(0, 0, 1920, 1080); + Width = Math.Max(800, (int)(screen.Width * 0.7)); + Height = Math.Max(550, (int)(screen.Height * 0.75)); + MinimumSize = new Size(700, 480); StartPosition = FormStartPosition.CenterScreen; // Accessibility: keyboard shortcuts @@ -185,19 +194,48 @@ public MainForm() { try { - var sc = this.Controls.OfType().FirstOrDefault(c => c.Name == "_mainSplit"); - if (sc != null) + var sc = FindControl(this, "_mainSplit"); + if (sc != null && sc.Width > 0) { - 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); - } + // Responsive: ensure ~30/70 split on first show + int target = (int)(sc.Width * 0.30); + if (target >= sc.Panel1MinSize && (sc.Width - target - sc.SplitterWidth) >= sc.Panel2MinSize) + sc.SplitterDistance = target; + } + // Set vertical split proportionally + var vs = FindControl(this, "_mainVerticalSplit"); + if (vs != null && vs.Height > 0) + { + int targetV = Math.Max(vs.Panel1MinSize, (int)(vs.Height * 0.60)); + if ((vs.Height - targetV - vs.SplitterWidth) >= vs.Panel2MinSize) + vs.SplitterDistance = targetV; } } catch { } _filterBox.Focus(); }; + // Re-proportion splits on resize + this.Resize += (s, e) => + { + try + { + var sc = FindControl(this, "_mainSplit"); + if (sc != null && sc.Width > 0) + { + int target = (int)(sc.Width * 0.30); + if (target >= sc.Panel1MinSize && (sc.Width - target) >= sc.Panel2MinSize) + sc.SplitterDistance = target; + } + var vs = FindControl(this, "_mainVerticalSplit"); + if (vs != null && vs.Height > 0) + { + int target = (int)(vs.Height * 0.60); + if (target >= vs.Panel1MinSize && (vs.Height - target) >= vs.Panel2MinSize) + vs.SplitterDistance = target; + } + } + catch { } + }; this.FormClosing += (s, e) => { try { _settingsService?.Save(); } catch { } @@ -216,11 +254,26 @@ private void InitializeLayout() { Dock = DockStyle.Fill, Orientation = Orientation.Vertical, - SplitterDistance = 400, Name = "_mainSplit" }; - // Ensure the right panel has enough space by default - try { split.Panel2MinSize = 360; split.Panel1MinSize = 220; split.SplitterWidth = 6; } catch { } + // Responsive: set splitter as percentage of width + try + { + int p1Min = ScaleX(180); + int p2Min = ScaleX(280); + int splW = ScaleX(5); + // Ensure the container is wide enough before setting min sizes + int requiredWidth = p1Min + p2Min + splW + 1; + if (split.Width < requiredWidth) + split.Width = Math.Max(requiredWidth, this.ClientSize.Width); + split.Panel1MinSize = p1Min; + split.Panel2MinSize = p2Min; + split.SplitterWidth = splW; + int desired = Math.Max(p1Min, (int)(this.ClientSize.Width * 0.30)); + int maxDist = split.Width - p2Min - splW; + split.SplitterDistance = Math.Max(p1Min, Math.Min(desired, maxDist)); + } + catch { try { split.SplitterDistance = 300; } catch { } } // Collapse/expand button (overlay small) var collapseBtn = new Button { @@ -245,7 +298,12 @@ private void InitializeLayout() { split.Panel1Collapsed = false; // Reassign a reasonable default width - try { split.SplitterDistance = Math.Max(250, Width / 3); } catch { } + try + { + int t = Math.Max(split.Panel1MinSize, (int)(split.Width * 0.30)); + int m = split.Width - split.Panel2MinSize - split.SplitterWidth; + split.SplitterDistance = Math.Max(split.Panel1MinSize, Math.Min(t, m)); + } catch { } collapseBtn.Text = "≪"; collapsed = false; try { _settingsService!.Settings.LeftPanelCollapsed = false; _settingsService.Save(); } catch { } @@ -277,12 +335,20 @@ private void InitializeLayout() _lblFilter.Text = "Filter:"; _lblFilter.AutoSize = true; _lblFilter.Padding = new Padding(0, 6, 0, 0); - _filterBox.Width = 220; + _filterBox.Width = ScaleX(180); + _filterBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; _filterBox.TabIndex = 0; _filterBox.AccessibleName = "Filter symptoms"; _filterBox.AccessibleDescription = "Type text to filter the symptoms list"; _filterBox.TextChanged += (s, e) => { + // Debounce: restart the timer on each keystroke + _filterDebounce.Stop(); + _filterDebounce.Start(); + }; + _filterDebounce.Tick += (s, e) => + { + _filterDebounce.Stop(); RefreshSymptomList(); if (_settingsService != null) { @@ -301,11 +367,21 @@ private void InitializeLayout() _clearVisibleButton.TabIndex = 2; _clearVisibleButton.Click += (s, e) => ClearVisibleSymptoms(); + _selectAllButton.Text = "Select All"; + _selectAllButton.AutoSize = true; + _selectAllButton.AccessibleName = "Select all symptoms"; + _selectAllButton.Click += (s, e) => SelectAllSymptoms(); + + _deselectAllButton.Text = "Deselect All"; + _deselectAllButton.AutoSize = true; + _deselectAllButton.AccessibleName = "Deselect all symptoms"; + _deselectAllButton.Click += (s, e) => DeselectAllSymptoms(); + _lblCategory.Text = "Category:"; _lblCategory.AutoSize = true; _lblCategory.Padding = new Padding(10, 6, 0, 0); _categorySelector.DropDownStyle = ComboBoxStyle.DropDownList; - _categorySelector.Width = 180; + _categorySelector.Width = ScaleX(150); _categorySelector.AccessibleName = "Category filter"; _categorySelector.TabIndex = 3; _categorySelector.SelectedIndexChanged += (s, e) => @@ -347,6 +423,8 @@ private void InitializeLayout() filterBar.Controls.Add(_filterBox); filterBar.Controls.Add(_selectVisibleButton); filterBar.Controls.Add(_clearVisibleButton); + filterBar.Controls.Add(_selectAllButton); + filterBar.Controls.Add(_deselectAllButton); _saveSessionButton.Text = "Save"; _saveSessionButton.AutoSize = true; _saveSessionButton.Click += (s, e) => SaveSession(); @@ -396,8 +474,8 @@ private void InitializeLayout() 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 }; + var vitalsRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = true }; + var rulesRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = true }; _modelSelector.DropDownStyle = ComboBoxStyle.DropDownList; _modelSelector.Items.AddRange(new object[] { DetectionModel.Jaccard, DetectionModel.Cosine, DetectionModel.NaiveBayes }); @@ -421,7 +499,7 @@ private void InitializeLayout() _threshold.Maximum = 100; _threshold.DecimalPlaces = 0; _threshold.Value = 0; - _threshold.Width = 80; + _threshold.Width = ScaleX(65); _threshold.AccessibleName = "Score threshold percent"; _threshold.TabIndex = 11; _threshold.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.ThresholdPercent = (int)_threshold.Value; _settingsService.Save(); } }; @@ -433,7 +511,7 @@ private void InitializeLayout() _minMatch.Minimum = 0; // 0 means any match count _minMatch.Maximum = 10; _minMatch.Value = 1; - _minMatch.Width = 60; + _minMatch.Width = ScaleX(55); _minMatch.AccessibleName = "Minimum matching symptoms"; _minMatch.TabIndex = 12; _minMatch.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.MinMatch = (int)_minMatch.Value; _settingsService.Save(); } }; @@ -442,7 +520,7 @@ private void InitializeLayout() _topK.Minimum = 0; // 0 = unlimited _topK.Maximum = 1000; _topK.Value = 0; - _topK.Width = 70; + _topK.Width = ScaleX(60); _topK.AccessibleName = "Top K results"; _topK.TabIndex = 13; _topK.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.TopK = (int)_topK.Value; _settingsService.Save(); } }; @@ -450,7 +528,7 @@ private void InitializeLayout() // Category weighting UI (model tuning) _weightCatSelector.DropDownStyle = ComboBoxStyle.DropDownList; - _weightCatSelector.Width = 130; + _weightCatSelector.Width = ScaleX(110); _weightCatSelector.AccessibleName = "Category weight selector"; _weightCatSelector.TabIndex = 13; _weightCatSelector.SelectedIndexChanged += (s, e) => @@ -473,7 +551,7 @@ private void InitializeLayout() _weightValue.Minimum = 10; // 0.1x _weightValue.Maximum = 500; // 5x _weightValue.Value = 100; // 1x - _weightValue.Width = 70; + _weightValue.Width = ScaleX(60); _weightValue.Increment = 10; _weightValue.AccessibleName = "Category weight percent"; _weightValue.TabIndex = 14; @@ -506,7 +584,7 @@ private void InitializeLayout() _nbTempValue.DecimalPlaces = 2; _nbTempValue.Increment = 5; // 0.05 _nbTempValue.Value = 100; // 1.00 - _nbTempValue.Width = 70; + _nbTempValue.Width = ScaleX(60); _nbTempValue.AccessibleName = "Naive Bayes temperature"; _nbTempValue.TabIndex = 18; _nbTempEnable.CheckedChanged += (s, e) => @@ -538,7 +616,7 @@ private void InitializeLayout() _lblLanguage.Text = "Language:"; _lblLanguage.AutoSize = true; _lblLanguage.Padding = new Padding(10, 6, 0, 0); _languageSelector.DropDownStyle = ComboBoxStyle.DropDownList; - _languageSelector.Width = 120; + _languageSelector.Width = ScaleX(100); _languageSelector.AccessibleName = "Language selector"; _languageSelector.TabIndex = 19; _languageSelector.SelectedIndexChanged += (s, e) => OnLanguageChanged(); @@ -621,29 +699,29 @@ private void InitializeLayout() // 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.DecimalPlaces = 1; _numTempC.Increment = 0.1M; _numTempC.Minimum = 30; _numTempC.Maximum = 45; _numTempC.Width = ScaleX(60); _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.Minimum = 20; _numHR.Maximum = 240; _numHR.Width = ScaleX(60); _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.Minimum = 4; _numRR.Maximum = 80; _numRR.Width = ScaleX(60); _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.Minimum = 50; _numSBP.Maximum = 260; _numSBP.Width = ScaleX(60); _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.Minimum = 30; _numDBP.Maximum = 160; _numDBP.Width = ScaleX(60); _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.Minimum = 50; _numSpO2.Maximum = 100; _numSpO2.Width = ScaleX(60); _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.DecimalPlaces = 1; _numWeightKg.Increment = 0.5M; _numWeightKg.Minimum = 2; _numWeightKg.Maximum = 350; _numWeightKg.Width = ScaleX(65); _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 @@ -658,7 +736,7 @@ private void InitializeLayout() // After layout we can cap height dynamically filterHost.Resize += (s, e) => { - int maxH = 120; // max visible area for filter controls + int maxH = ScaleY(120); // max visible area for filter controls (DPI-scaled) if (filterBar.Height > maxH) { filterHost.AutoScrollMinSize = new Size(filterBar.Width, filterBar.Height + 4); @@ -721,7 +799,7 @@ private void InitializeLayout() // 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.Minimum = 0; _numAge.Maximum = 120; _numAge.Value = 25; _numAge.Width = ScaleX(60); _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; @@ -774,6 +852,49 @@ private void InitializeLayout() rightPanel.Controls.Add(_disclaimer, 0, 5); split.Panel2.Controls.Add(rightPanel); + // AI / Ollama panel below the main split (bottom section) + var mainLayout = new SplitContainer + { + Dock = DockStyle.Fill, + Orientation = Orientation.Horizontal, + Name = "_mainVerticalSplit" + }; + try + { + int p1MinV = ScaleY(200); + int p2MinV = ScaleY(140); + int splWV = ScaleY(5); + int requiredHeight = p1MinV + p2MinV + splWV + 1; + if (mainLayout.Height < requiredHeight) + mainLayout.Height = Math.Max(requiredHeight, this.ClientSize.Height); + mainLayout.Panel1MinSize = p1MinV; + mainLayout.Panel2MinSize = p2MinV; + mainLayout.SplitterWidth = splWV; + int desiredV = Math.Max(p1MinV, (int)(this.ClientSize.Height * 0.60)); + int maxDistV = mainLayout.Height - p2MinV - splWV; + mainLayout.SplitterDistance = Math.Max(p1MinV, Math.Min(desiredV, maxDistV)); + } + catch { try { mainLayout.SplitterDistance = 400; } catch { } } + // Add the existing horizontal split into the top panel + mainLayout.Panel1.Controls.Add(split); + // Bottom panel: TabControl with AI Diagnosis + Image Analysis tabs + var aiTabControl = new TabControl + { + Dock = DockStyle.Fill, + Name = "_aiTabControl" + }; + var tabAiDiag = new TabPage("🤖 AI Diagnosis") { Name = "_tabAiDiag" }; + var tabImageAnalysis = new TabPage("📷 Image Analysis") { Name = "_tabImageAnalysis" }; + var tabBloodAnalysis = new TabPage("🔬 Blood Microscope") { Name = "_tabBloodAnalysis" }; + InitializeOllamaPanel(tabAiDiag); + InitializeImageAnalysisPanel(tabImageAnalysis); + InitializeBloodAnalysisPanel(tabBloodAnalysis); + aiTabControl.TabPages.Add(tabAiDiag); + aiTabControl.TabPages.Add(tabImageAnalysis); + aiTabControl.TabPages.Add(tabBloodAnalysis); + mainLayout.Panel2.Controls.Add(aiTabControl); + Controls.Add(mainLayout); + // Settings path label placed at bottom-left of main form _lblSettingsPath.Dock = DockStyle.Bottom; _lblSettingsPath.AutoSize = true; @@ -783,8 +904,6 @@ private void InitializeLayout() _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()); @@ -819,14 +938,20 @@ private void MainForm_Load(object? sender, EventArgs e) 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 synonymsData = Path.Combine(baseDir, "data", "synonyms.json"); + var synonymsSchema = Path.Combine(baseDir, "schemas", "synonyms.schema.json"); var condErr = await SchemaValidator.ValidateAsync(conditionsData, conditionsSchema); var catErr = await SchemaValidator.ValidateAsync(categoriesData, categoriesSchema); var transErr = await SchemaValidator.ValidateAsync(translationsData, translationsSchema); + string? synErr = null; + if (File.Exists(synonymsData) && File.Exists(synonymsSchema)) + synErr = await SchemaValidator.ValidateAsync(synonymsData, synonymsSchema); 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 (!string.IsNullOrEmpty(synErr)) all.Add(synErr); if (all.Count > 0) { var msg = string.Join("\n\n", all); @@ -925,7 +1050,7 @@ private void MainForm_Load(object? sender, EventArgs e) // Restore left panel collapsed state try { - var sc = this.Controls.OfType().FirstOrDefault(c => c.Name == "_mainSplit"); + var sc = FindControl(this, "_mainSplit"); if (sc != null && _settingsService?.Settings.LeftPanelCollapsed == true) { sc.Panel1Collapsed = true; if (_collapseBtn != null) _collapseBtn.Text = "≫"; @@ -996,10 +1121,20 @@ private void MainForm_Load(object? sender, EventArgs e) // Restore filter and category visibility try { _filterBox.Text = _settingsService?.Settings.FilterText ?? string.Empty; } catch { } try { _showOnlyCategory.Checked = _settingsService?.Settings.ShowOnlyCategory ?? false; } catch { } + // Restore Ollama settings + try + { + if (!string.IsNullOrEmpty(_settingsService?.Settings.OllamaUrl)) + _aiUrlBox.Text = _settingsService.Settings.OllamaUrl; + _autoAiCheck.Checked = _settingsService?.Settings.AutoAi ?? false; + } + catch { } ApplyTheme(); RefreshSymptomList(); UpdateDecisionRules(); UpdatePercRule(); + // Initialize Ollama connection (non-blocking) + _ = InitializeOllamaAsync(); } catch (Exception ex) { @@ -1063,6 +1198,8 @@ private void ApplyTranslations() _lblFilter.Text = t.T("Filter"); _selectVisibleButton.Text = t.T("SelectVisible"); _clearVisibleButton.Text = t.T("ClearVisible"); + _selectAllButton.Text = t.T("SelectAll") ?? _selectAllButton.Text; + _deselectAllButton.Text = t.T("DeselectAll") ?? _deselectAllButton.Text; _lblCategory.Text = t.T("Category"); _selectCategoryButton.Text = t.T("SelectCategory"); _clearCategoryButton.Text = t.T("ClearCategory"); @@ -1156,6 +1293,12 @@ private void ApplyTranslations() // Rebuild triage banner for current language UpdateTriageBanner(); + // Apply Ollama panel translations + ApplyOllamaTranslations(); + // Apply Image Analysis panel translations + ApplyImageAnalysisTranslations(); + // Apply Blood Analysis panel translations + ApplyBloodAnalysisTranslations(); } else { @@ -1164,235 +1307,6 @@ 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); - // 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; - return; - } - var t = _translationService; - var header = t?.T("RedFlagsHeader") ?? "Possible red flags:"; - var messages = new List(); - foreach (var k in keys) - { - 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."; - 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; - } - - private void ApplyRtl(Control root, bool rtl) - { - // Apply RTL at the form level only; most controls inherit safely from the form - if (root is Form f) - { - try { f.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { } - try { f.RightToLeftLayout = rtl; } catch { } - } - } - - private void ApplyTheme() - { - bool dark = _darkModeToggle.Checked; - Color back = dark ? Color.FromArgb(32, 32, 32) : SystemColors.Window; - Color fore = dark ? Color.Gainsboro : SystemColors.WindowText; - Color panel = dark ? Color.FromArgb(24, 24, 24) : SystemColors.Control; - - this.BackColor = panel; - foreach (Control c in this.Controls) - { - ApplyThemeToControl(c, back, fore, panel, dark); - } - _resultsList.Invalidate(); - } - - private void ApplyThemeToControl(Control c, Color back, Color fore, Color panel, bool dark) - { - switch (c) - { - case SplitContainer sc: - sc.BackColor = panel; - ApplyThemeToControl(sc.Panel1, back, fore, panel, dark); - ApplyThemeToControl(sc.Panel2, back, fore, panel, dark); - break; - case TableLayoutPanel tl: - tl.BackColor = panel; - foreach (Control child in tl.Controls) ApplyThemeToControl(child, back, fore, panel, dark); - break; - case FlowLayoutPanel fl: - fl.BackColor = panel; - foreach (Control child in fl.Controls) ApplyThemeToControl(child, back, fore, panel, dark); - break; - case CheckedListBox clb: - clb.BackColor = back; clb.ForeColor = fore; - break; - case ListBox lb: - lb.BackColor = back; lb.ForeColor = fore; - break; - case TextBox tb: - tb.BackColor = back; tb.ForeColor = fore; - break; - case ComboBox cb: - cb.BackColor = back; cb.ForeColor = fore; - break; - 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: - 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; - } - // Triage banner specific colors - if (c == _triageBanner) - { - _triageBanner.BackColor = dark ? Color.FromArgb(64, 48, 0) : Color.FromArgb(255, 245, 230); - _triageBanner.ForeColor = dark ? Color.Khaki : Color.FromArgb(120, 60, 0); - } - } - private void CheckButton_Click(object? sender, EventArgs e) { if (_service == null) return; @@ -1426,854 +1340,11 @@ IEnumerable GetCats(string conditionName) 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; - } - // If categories service is available, group results by primary category (max overlap) - var categories = _categoriesService?.GetAllCategories()?.ToList() ?? new List(); - if (categories.Count == 0) + // Auto-run AI diagnosis if enabled and Ollama is available + if (_autoAiCheck.Checked && _ollamaService?.IsAvailable == true && matches.Count > 0) { - // 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(); - } - - private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e) - { - e.DrawBackground(); - if (e.Index < 0 || e.Index >= _resultsList.Items.Count) - { - return; - } - - 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) - { - 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 - Color foreColor = isTop ? Color.DarkGreen : e.ForeColor; - - using (var backBrush = new SolidBrush(backColor)) - using (var foreBrush = new SolidBrush(foreColor)) - { - e.Graphics.FillRectangle(backBrush, e.Bounds); - var font = e.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 rect = e.Bounds; - rect.Inflate(-4, -2); - TextRenderer.DrawText(e.Graphics, text, font, rect, foreColor, flags); - } - - 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) 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[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); - return; - } - - var t = _translationService; - var sb = new System.Text.StringBuilder(); - BuildDetailsText(sb, t, match, condition); - - ShowDetailsDialog(t?.T("DetailsTitle") ?? "Condition Details", sb.ToString()); - } - - private void BuildDetailsText(System.Text.StringBuilder sb, TranslationService? t, ConditionMatch match, SymptomCheckerApp.Models.Condition condition) - { - string name = t?.Condition(match.Name) ?? match.Name; - string scoreLabel = t?.T("Score") ?? "Score:"; - string matchedLabel = t?.T("MatchedSymptoms") ?? "Matched symptoms:"; - string symptomsLabel = t?.T("SymptomsLabel") ?? "Symptoms:"; - sb.AppendLine(name); - sb.AppendLine($"{scoreLabel} {match.Score:F3}"); - sb.AppendLine($"{matchedLabel} {match.MatchCount}"); - sb.AppendLine(); - sb.AppendLine(symptomsLabel); - foreach (var s in condition.Symptoms) - { - sb.AppendLine($" • {(t?.Symptom(s) ?? s)}"); - } - - // Explainability (simple) - var model = (DetectionModel)_modelSelector.SelectedItem!; - sb.AppendLine(); - sb.AppendLine(t?.T("ExplainabilityHeader") ?? "How this score was computed:"); - if (model == DetectionModel.Jaccard || model == DetectionModel.Cosine) - { - // Overlap based models - sb.AppendLine($" • {(t?.T("Explain_MatchedOverlap") ?? "Matched overlap")}: {match.MatchCount}"); - sb.AppendLine($" • {(t?.T("Explain_Similarity") ?? "Similarity")}: {match.Score:F3}"); - } - else if (model == DetectionModel.NaiveBayes) - { - sb.AppendLine($" • {(t?.T("Explain_Prob") ?? "Estimated probability")}: {match.Score:F3}"); - } - - // Optional treatment info (educational only). Prefer localized fields when available. - List? locTreat = null; - List? locMeds = null; - string? locAdvice = null; - var lang = _translationService?.CurrentLanguage?.ToLowerInvariant(); - if (lang == "fr") - { - locTreat = condition.Treatments_Fr ?? condition.Treatments; - locMeds = condition.Medications_Fr ?? condition.Medications; - locAdvice = condition.CareAdvice_Fr ?? condition.CareAdvice; - } - else if (lang == "ar") - { - locTreat = condition.Treatments_Ar ?? condition.Treatments; - locMeds = condition.Medications_Ar ?? condition.Medications; - locAdvice = condition.CareAdvice_Ar ?? condition.CareAdvice; - } - else - { - locTreat = condition.Treatments; - locMeds = condition.Medications; - locAdvice = condition.CareAdvice; - } - - if (locTreat != null && locTreat.Count > 0) - { - sb.AppendLine(); - sb.AppendLine(t?.TDetails("Treatments") ?? "Possible treatments (educational):"); - foreach (var tr in locTreat) - { - sb.AppendLine($" • {tr}"); - } - } - if (locMeds != null && locMeds.Count > 0) - { - sb.AppendLine(); - sb.AppendLine(t?.TDetails("Medications") ?? "Over‑the‑counter examples (educational):"); - foreach (var med in locMeds) - { - sb.AppendLine($" • {med}"); - } - } - if (!string.IsNullOrWhiteSpace(locAdvice)) - { - sb.AppendLine(); - sb.AppendLine(t?.TDetails("CareAdvice") ?? "Self‑care advice (educational):"); - sb.AppendLine($" • {locAdvice}"); - } - - } - - private void CopySelectedDetailsToClipboard() - { - if (_service == null) return; - int idx = _resultsList.SelectedIndex; - if (idx < 0 || idx >= _lastResults.Count) return; - var match = _lastResults[idx]; - if (!_service.TryGetCondition(match.Name, out var condition) || condition == null) return; - var t = _translationService; - var sb = new System.Text.StringBuilder(); - BuildDetailsText(sb, t, match, condition); - try { Clipboard.SetText(sb.ToString()); } catch { } - } - - private void ShowDetailsDialog(string title, string text) - { - bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); - using var dlg = new Form - { - Text = title, - StartPosition = FormStartPosition.CenterParent, - Width = 700, - Height = 550 - }; - ApplyRtl(dlg, rtl); - if (rtl) - { - text = TransformBulletsForRtl(text); - } - var tb = new TextBox - { - Multiline = true, - ReadOnly = true, - Dock = DockStyle.Fill, - ScrollBars = ScrollBars.Vertical, - WordWrap = true, - BorderStyle = BorderStyle.FixedSingle, - Text = 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 }; - btnClose.Click += (s, e) => dlg.Close(); - var btnCopy = new Button { Text = _translationService?.T("Copy") ?? "Copy", AutoSize = true }; - btnCopy.Click += (s, e) => { try { Clipboard.SetText(text); } catch { } }; - btnPanel.Controls.Add(btnClose); - btnPanel.Controls.Add(btnCopy); - dlg.Controls.Add(tb); - dlg.Controls.Add(btnPanel); - 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) - { - MessageBox.Show(this, "Translation service not loaded.", "Translations", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - var missing = _translationService.MissingKeys?.OrderBy(x => x).ToList() ?? new List(); - if (missing.Count == 0) - { - MessageBox.Show(this, _translationService.T("NoMissingTranslations") ?? "No missing translations detected.", _translationService.T("MissingTranslationsTitle") ?? "Missing Translations", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - bool rtl = string.Equals(_translationService.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase); - using var dlg = new Form - { - Text = _translationService.T("MissingTranslationsTitle") ?? "Missing Translations", - StartPosition = FormStartPosition.CenterParent, - Width = 700, - Height = 500 - }; - ApplyRtl(dlg, rtl); - var list = new ListBox { Dock = DockStyle.Fill }; if (rtl) try { list.RightToLeft = RightToLeft.Yes; } catch { } - list.Items.AddRange(missing.Cast().ToArray()); - 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 }; - btnClose.Click += (s, e) => dlg.Close(); - var btnCopy = new Button { Text = _translationService.T("Copy") ?? "Copy", AutoSize = true }; - btnCopy.Click += (s, e) => { try { Clipboard.SetText(string.Join(Environment.NewLine, missing)); } catch { } }; - var btnExport = new Button { Text = _translationService.T("ExportReport") ?? "Export", AutoSize = true }; - btnExport.Click += (s, e) => - { - try - { - var sfd = new SaveFileDialog { Filter = "Text (*.txt)|*.txt", FileName = "translation_report.txt" }; - if (sfd.ShowDialog(dlg) == DialogResult.OK) - { - File.WriteAllLines(sfd.FileName, missing); - } - } - catch (Exception ex) - { - MessageBox.Show(dlg, ex.Message, _translationService.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - }; - btnPanel.Controls.Add(btnClose); - btnPanel.Controls.Add(btnExport); - btnPanel.Controls.Add(btnCopy); - dlg.Controls.Add(list); - dlg.Controls.Add(btnPanel); - 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; - int idx = _resultsList.SelectedIndex; - if (idx < 0 || idx >= _lastResults.Count) return; - var match = _lastResults[idx]; - if (!_service.TryGetCondition(match.Name, out var condition) || condition == null) return; - var t = _translationService; - var sb = new System.Text.StringBuilder(); - BuildDetailsText(sb, t, match, condition); - string text = sb.ToString(); - using var pd = new System.Drawing.Printing.PrintDocument(); - int charFrom = 0; - pd.PrintPage += (s, e) => - { - var font = new Font(FontFamily.GenericSansSerif, 10); - 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; - }; - try { using var dlg = new PrintPreviewDialog { Document = pd, Width = 800, Height = 600 }; dlg.ShowDialog(this); } - 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); + _ = RunAiDiagnosisAsync(); } } @@ -2433,79 +1504,28 @@ private void UpdateCheckButtonEnabled() _checkButton.Enabled = _checkedSymptoms.Count > 0; } - private class SessionData - { - public List SelectedSymptoms { get; set; } = new(); - public string? Model { get; set; } - public int ThresholdPercent { get; set; } - public int MinMatch { get; set; } - public int TopK { get; set; } - public string? Language { get; set; } - } - - private void SaveSession() + private void SelectAllSymptoms() { - try + foreach (var s in _allSymptoms) { - var sfd = new SaveFileDialog { Filter = "Session JSON (*.json)|*.json", FileName = "session.json" }; - if (sfd.ShowDialog(this) != DialogResult.OK) return; - var data = new SessionData - { - SelectedSymptoms = _checkedSymptoms.ToList(), - Model = _modelSelector.SelectedItem?.ToString(), - ThresholdPercent = (int)_threshold.Value, - MinMatch = (int)_minMatch.Value, - TopK = (int)_topK.Value, - Language = (_languageSelector.SelectedItem as LangItem)?.Code - }; - var json = System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - System.IO.File.WriteAllText(sfd.FileName, json); + _checkedSymptoms.Add(s); } - catch (Exception ex) + // Update visible checkboxes + for (int i = 0; i < _symptomList.Items.Count; i++) { - MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + _symptomList.SetItemChecked(i, true); } + UpdateCheckButtonEnabled(); } - private void LoadSession() + private void DeselectAllSymptoms() { - try - { - var ofd = new OpenFileDialog { Filter = "Session JSON (*.json)|*.json" }; - if (ofd.ShowDialog(this) != DialogResult.OK) return; - var json = System.IO.File.ReadAllText(ofd.FileName); - var data = System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - if (data == null) return; - _checkedSymptoms = new HashSet(data.SelectedSymptoms ?? new List(), StringComparer.OrdinalIgnoreCase); - // Restore UI states - if (!string.IsNullOrEmpty(data.Language)) - { - for (int i = 0; i < _languageSelector.Items.Count; i++) - { - if (_languageSelector.Items[i] is LangItem li && - data.Language is string lang && - string.Equals(li.Code, lang, StringComparison.OrdinalIgnoreCase)) - { _languageSelector.SelectedIndex = i; break; } - } - } - if (!string.IsNullOrEmpty(data.Model)) - { - for (int i = 0; i < _modelSelector.Items.Count; i++) - { - if (_modelSelector.Items[i]?.ToString()?.Equals(data.Model, StringComparison.OrdinalIgnoreCase) == true) - { _modelSelector.SelectedIndex = i; break; } - } - } - _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, data.ThresholdPercent)); - _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, data.MinMatch)); - _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, data.TopK)); - RefreshSymptomList(); - UpdateCheckButtonEnabled(); - } - catch (Exception ex) + _checkedSymptoms.Clear(); + for (int i = 0; i < _symptomList.Items.Count; i++) { - MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + _symptomList.SetItemChecked(i, false); } + UpdateCheckButtonEnabled(); } private async System.Threading.Tasks.Task SyncFromWikidataAsync() @@ -2543,5 +1563,31 @@ private async System.Threading.Tasks.Task SyncFromWikidataAsync() MessageBox.Show(this, ($"{(_translationService?.T("SyncFailed") ?? "Sync failed")}: {ex.Message}"), _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } } + + /// Recursively finds a control by name and type within a parent's control tree. + private static T? FindControl(Control parent, string name) where T : Control + { + foreach (Control c in parent.Controls) + { + if (c is T match && c.Name == name) return match; + var found = FindControl(c, name); + if (found != null) return found; + } + return null; + } + + /// Scale a horizontal pixel value by the current DPI factor. + private int ScaleX(int px) + { + using var g = CreateGraphics(); + return (int)(px * g.DpiX / 96.0); + } + + /// Scale a vertical pixel value by the current DPI factor. + private int ScaleY(int px) + { + using var g = CreateGraphics(); + return (int)(px * g.DpiY / 96.0); + } } } diff --git a/data/translations.json b/data/translations.json index a5f4eb1..6f81efc 100644 --- a/data/translations.json +++ b/data/translations.json @@ -51,7 +51,14 @@ { "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": "لأغراض تعليمية فقط. ليست نصيحة طبية." } + { "key": "Disclaimer", "en": "Educational only. Not medical advice.", "fr": "À des fins éducatives uniquement. Pas un avis médical.", "ar": "لأغراض تعليمية فقط. ليست نصيحة طبية." }, + { "key": "AiAssistantTitle", "en": "🤖 AI Diagnostic Assistant (Ollama)", "fr": "🤖 Assistant IA de diagnostic (Ollama)", "ar": "🤖 مساعد تشخيص ذكي (Ollama)" }, + { "key": "AiDiagnose", "en": "🧠 AI Diagnosis", "fr": "🧠 Diagnostic IA", "ar": "🧠 تشخيص ذكي" }, + { "key": "AiMedications", "en": "💊 AI Medications", "fr": "💊 Médicaments IA", "ar": "💊 أدوية ذكية" }, + { "key": "AutoAi", "en": "Auto AI", "fr": "IA Auto", "ar": "ذكاء تلقائي" }, + { "key": "AiNoResults", "en": "Please run a symptom check first.", "fr": "Veuillez d'abord effectuer une analyse de symptômes.", "ar": "يرجى إجراء فحص الأعراض أولاً." }, + { "key": "AiSelectCondition", "en": "Please select a condition for medication advice.", "fr": "Veuillez sélectionner une affection pour obtenir des conseils médicamenteux.", "ar": "يرجى اختيار حالة للحصول على نصائح دوائية." }, + { "key": "AiThinking", "en": "🤖 AI is analyzing... please wait...", "fr": "🤖 L'IA analyse... veuillez patienter...", "ar": "🤖 الذكاء الاصطناعي يحلل... يرجى الانتظار..." } ], "messages": [ { "key": "NoMatches", "en": "No matching conditions found based on the current selection.", "fr": "Aucune affection correspondante trouvée selon la sélection actuelle.", "ar": "لم يتم العثور على حالات مطابقة بناءً على الاختيار الحالي." }, diff --git a/schemas/synonyms.schema.json b/schemas/synonyms.schema.json new file mode 100644 index 0000000..418a139 --- /dev/null +++ b/schemas/synonyms.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Symptom Synonyms", + "description": "Maps canonical symptom names to their aliases/synonyms for fuzzy matching.", + "type": "object", + "required": ["synonyms"], + "properties": { + "synonyms": { + "type": "array", + "description": "Array of synonym mapping entries.", + "items": { + "type": "object", + "required": ["canonical", "aliases"], + "properties": { + "canonical": { + "type": "string", + "minLength": 1, + "description": "The canonical (primary) symptom name used in conditions.json." + }, + "aliases": { + "type": "array", + "description": "Alternative names or medical terms that map to the canonical symptom.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/specs/architecture-spec.md b/specs/architecture-spec.md new file mode 100644 index 0000000..6413279 --- /dev/null +++ b/specs/architecture-spec.md @@ -0,0 +1,263 @@ +# Architecture Specification + +## 1. High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Program.cs │ +│ (Entry point, exception handlers, logger bootstrap) │ +└──────────────────────┬──────────────────────────────────┘ + │ creates + ▼ +┌─────────────────────────────────────────────────────────┐ +│ UI / MainForm.cs │ +│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ +│ │ Left │ │ Right │ │ Dialogs │ │ +│ │ Panel │ │ Panel │ │ (Details, │ │ +│ │ (Filter, │ │ (Model, │ │ Help, │ │ +│ │ Symptom │ │ Vitals, │ │ Missing │ │ +│ │ List) │ │ Rules, │ │ Trans.) │ │ +│ │ │ │ Results, │ │ │ │ +│ │ │ │ Triage) │ │ │ │ +│ └──────────┘ └──────────┘ └───────────┘ │ +└──────────────────────┬──────────────────────────────────┘ + │ delegates to + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Services Layer │ +│ │ +│ SymptomCheckerService CategoriesService │ +│ SynonymService TranslationService │ +│ SettingsService TriageService (static) │ +│ WikidataImporter SchemaValidator (static) │ +│ LoggerService │ +└──────────────────────┬──────────────────────────────────┘ + │ reads / writes + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Data Layer (Files) │ +│ │ +│ data/conditions.json data/categories.json │ +│ data/synonyms.json data/translations.json │ +│ data/settings.json schemas/*.schema.json │ +│ logs/log_*.txt │ +└─────────────────────────────────────────────────────────┘ +``` + +## 2. Service Responsibilities + +| Service | Responsibility | State | +|---|---|---| +| `SymptomCheckerService` | Loads conditions, builds vocabulary, computes matches (Jaccard/Cosine/NaiveBayes), merges external data, saves database | Stateful — holds `ConditionDatabase`, vocabulary list, condition-set cache | +| `CategoriesService` | Loads category definitions, builds category-to-symptom sets from keywords or explicit lists | Stateful — holds `SymptomCategoryDatabase` | +| `SynonymService` | Loads synonym mappings, resolves aliases to canonical symptom names, enables synonym-aware filtering | Stateful — holds `SynonymDatabase` | +| `TranslationService` | Loads translations, resolves UI/symptom/condition/category/message/details keys per language, tracks missing keys | Stateful — holds `TranslationDatabase`, current language, missing keys set | +| `SettingsService` | Loads/saves user preferences (language, theme, model, vitals, PERC flags, etc.) | Stateful — holds `AppSettings`, file path | +| `TriageService` | Evaluates red-flag patterns from symptoms and vitals; returns localization keys | Stateless (static) | +| `SchemaValidator` | Validates JSON data files against JSON Schema (NJsonSchema) | Stateless (static) | +| `WikidataImporter` | Fetches disease-symptom pairs via SPARQL, normalizes labels to Title Case | Stateful — holds `HttpClient` | +| `LoggerService` | Writes timestamped log entries, rotates files at 512 KB, prunes to 5 files | Stateful — holds file path, lock object | + +## 3. Data Flow + +### 3.1 Startup Sequence + +``` +Program.Main() + ├── Create LoggerService(logs/) + ├── Create MainForm + │ └── InitializeLayout() — build all controls + └── Application.Run(form) + └── MainForm_Load() + ├── [Async] SchemaValidator.ValidateAsync() for conditions, categories, translations + ├── SymptomCheckerService(data/conditions.json) + │ └── Deserialize → ConditionDatabase + │ └── RebuildVocabulary() + ├── SettingsService(data/settings.json) + ├── TranslationService(data/translations.json) + ├── CategoriesService(data/categories.json) + │ └── Build _categorySetsCache per category + ├── SynonymService(data/synonyms.json) + ├── Restore UI state from settings + ├── ApplyTranslations() + ├── ApplyTheme() + └── RefreshSymptomList() +``` + +### 3.2 Match Computation Flow + +``` +User clicks "Check" + └── CheckButton_Click() + ├── Collect _checkedSymptoms + ├── Read model, threshold, minMatch, topK from controls + ├── Read categoryWeights, nbTemperature from settings + ├── Define GetCats() delegate using _categorySetsCache + ├── Call _service.GetMatches(...) + │ ├── Build selectedSet (case-insensitive) + │ ├── Guard: empty selection → return [] + │ ├── Switch on model: + │ │ ├── Jaccard: |A∩B| / |A∪B| + │ │ ├── Cosine: dot(A,B) / (‖A‖·‖B‖) + │ │ └── NaiveBayes: log-likelihood → softmax → optional temperature + │ ├── Filter by threshold + │ ├── Filter by minMatchCount + │ ├── Apply category weighting (multiply score, renormalize for NB) + │ ├── Sort: score desc, matchCount desc, name asc + │ └── Apply topK limit + ├── Store _lastResults + ├── RebuildResultsListItems() — group by category, add headers + ├── UpdateTriageBanner() — call TriageService.EvaluateV2() + └── Display elapsed time +``` + +### 3.3 Wikidata Sync Flow + +``` +User clicks "Sync" + └── SyncFromWikidataAsync() + ├── WikidataImporter.FetchConditionsAsync(limit: 200) + │ ├── Construct SPARQL query for diseases with symptoms + │ ├── HTTP GET to query.wikidata.org/sparql + │ ├── Deserialize SPARQL JSON results + │ └── Normalize labels to Title Case, group by disease + ├── _service.MergeConditions(fetched) + │ ├── For each new condition: add with deduped symptoms + │ ├── For existing conditions: union symptoms + │ └── RebuildVocabulary() if changes > 0 + ├── _service.SaveDatabase() — write updated conditions.json + └── Refresh _allSymptoms and UI +``` + +## 4. Dependency Boundaries + +### 4.1 Current Dependencies + +``` +MainForm ──depends──▶ SymptomCheckerService + ──depends──▶ CategoriesService + ──depends──▶ SynonymService + ──depends──▶ TranslationService + ──depends──▶ SettingsService + ──depends──▶ TriageService (static) + ──depends──▶ WikidataImporter + ──depends──▶ SchemaValidator (static) + +SymptomCheckerService ──depends──▶ Models (Condition, ConditionDatabase, ConditionMatch) +CategoriesService ──depends──▶ Models (SymptomCategory, SymptomCategoryDatabase) +SynonymService ──depends──▶ Models (SymptomSynonyms, SynonymDatabase) +WikidataImporter ──depends──▶ Models (Condition) + ──depends──▶ System.Net.Http.HttpClient + +All services ──depends──▶ System.Text.Json +SchemaValidator ──depends──▶ NJsonSchema (NuGet) +``` + +### 4.2 Coupling Issues + +| Issue | Impact | Recommendation | +|---|---|---| +| `MainForm` directly instantiates all services | No dependency injection; difficult to test or swap implementations | Introduce constructor injection or a simple service locator | +| `SymptomCheckerService.GetMatches()` contains all three model algorithms in a switch | Adding a model requires editing the service | Extract `IMatchingModel` with `CalculateScore(selectedSet, conditionSet, vocabulary)` | +| `TriageService` is static with hardcoded rule set | Adding rules requires code changes | Consider rule definitions in JSON or a pipeline pattern | +| `MainForm` accesses `SettingsService.Settings` properties directly throughout | Tight coupling to settings shape | Use an event-based or reactive pattern for settings changes | +| `TranslationService` uses `FirstOrDefault` linear scans for every lookup | Functional but O(n) per call | Build `Dictionary` indexes at load time | + +## 5. Suggested Refactors + +### 5.1 MainForm Decomposition (Priority: High) + +Split the 2548-line `MainForm.cs` into: + +| Component | Responsibility | +|---|---| +| `MainForm.cs` | Orchestration, form setup, top-level event wiring | +| `MainForm.Layout.cs` (partial) | `InitializeLayout()` and control construction | +| `MainForm.Theme.cs` (partial) | `ApplyTheme()`, `ApplyThemeToControl()` | +| `MainForm.Export.cs` (partial) or `ExportService` | CSV, Markdown, HTML export logic | +| `VitalsPanel : UserControl` | Vitals input controls (Temp, HR, RR, BP, SpO₂, Weight) | +| `DecisionRulesPanel : UserControl` | Centor/McIsaac + PERC UI and computation | +| `ResultsRenderer` | Owner-draw logic for `ResultsList_DrawItem`, `MeasureItem` | + +### 5.2 Model Strategy Pattern (Priority: High) + +```csharp +public interface IMatchingModel +{ + string Name { get; } + List ComputeMatches( + HashSet selectedSymptoms, + IReadOnlyList conditions, + IReadOnlyList vocabulary, + double threshold); +} +``` + +Register implementations: `JaccardModel`, `CosineModel`, `NaiveBayesModel`. The service iterates registered models; `MainForm` populates the dropdown from available model names. + +### 5.3 Translation Lookup Optimization (Priority: Medium) + +Replace `List.FirstOrDefault()` lookups with `Dictionary` built once at construction. Current complexity per lookup: O(n). After: O(1). + +### 5.4 Data Provider Abstraction (Priority: Medium) + +```csharp +public interface IConditionDataProvider +{ + ConditionDatabase Load(); + void Save(ConditionDatabase db); +} +``` + +Implementations: `JsonFileConditionDataProvider`, `InMemoryConditionDataProvider` (for tests). + +## 6. Component Diagram + +``` +┌──────────────────────────────────────────────────┐ +│ Application │ +│ │ +│ ┌──────────┐ ┌────────────────────────┐ │ +│ │ Program │────▶│ UI Layer │ │ +│ │ .cs │ │ MainForm │ │ +│ │ │ │ (Dialogs) │ │ +│ └──────────┘ └───────────┬────────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Services Layer │ │ +│ │ │ │ +│ │ SymptomChecker* │ │ +│ │ Categories* │ │ +│ │ Synonym* │ │ +│ │ Translation* │ │ +│ │ Settings* │ │ +│ │ Triage (static) │ │ +│ │ WikidataImporter │ │ +│ │ SchemaValidator │ │ +│ │ Logger │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Models Layer │ │ +│ │ Condition │ │ +│ │ ConditionMatch │ │ +│ │ SymptomCategory │ │ +│ │ SynonymMap │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Data Layer (Files) │ │ +│ │ data/*.json │ │ +│ │ schemas/*.json │ │ +│ │ logs/*.txt │ │ +│ └────────────────────┘ │ +│ │ +│ External: │ +│ ┌───────────────────────┐ │ +│ │ Wikidata SPARQL API │ (optional, no key) │ +│ └───────────────────────┘ │ +│ ┌───────────────────────┐ │ +│ │ NJsonSchema (NuGet) │ │ +│ └───────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` diff --git a/specs/data-spec.md b/specs/data-spec.md new file mode 100644 index 0000000..e27d5e2 --- /dev/null +++ b/specs/data-spec.md @@ -0,0 +1,292 @@ +# Data Specification + +## 1. Data Files Overview + +| File | Purpose | Schema | Required | +|---|---|---|---| +| `data/conditions.json` | Disease-symptom mappings + educational treatment info | `schemas/conditions.schema.json` | Yes | +| `data/categories.json` | Symptom category groups (keyword or explicit list) | `schemas/categories.schema.json` | No (degrades gracefully) | +| `data/translations.json` | Localized UI strings, symptom/condition/category names | `schemas/translations.schema.json` | No (falls back to English keys) | +| `data/synonyms.json` | Alias-to-canonical symptom mappings | None (see §1.1) | No (degrades gracefully) | +| `data/settings.json` | Persisted user preferences | None (auto-generated) | No (reset to defaults) | + +### 1.1 Missing Schema: synonyms.json + +**Recommendation:** Create `schemas/synonyms.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/synonyms.schema.json", + "title": "SynonymDatabase", + "type": "object", + "required": ["synonyms"], + "properties": { + "synonyms": { + "type": "array", + "items": { + "type": "object", + "required": ["canonical", "aliases"], + "properties": { + "canonical": { "type": "string", "minLength": 1 }, + "aliases": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} +``` + +--- + +## 2. JSON Schema Definitions + +### 2.1 conditions.json + +``` +{ + "conditions": [ + { + "name": string (required, non-empty), + "symptoms": string[] (required, min 1 item, unique within condition), + "treatments": string[]?, + "treatments_fr": string[]?, + "treatments_ar": string[]?, + "medications": string[]?, + "medications_fr": string[]?, + "medications_ar": string[]?, + "careAdvice": string?, + "careAdvice_fr": string?, + "careAdvice_ar": string? + } + ] +} +``` + +**Constraints:** +- `name` must be non-empty after trimming +- `symptoms` must contain at least 1 unique, non-empty string +- Localized fields (`_fr`, `_ar`) are optional; absence triggers fallback to base field +- `additionalProperties: false` — no extra fields allowed + +**Current scale:** ~60+ conditions, ~200 unique symptoms. + +### 2.2 categories.json + +``` +{ + "categories": [ + { + "name": string (required, non-empty), + "keywords": string[]? (substring matchers, case-insensitive), + "symptoms": string[]? (explicit list; overrides keywords when non-empty) + } + ] +} +``` + +**Current categories:** Respiratory, Gastrointestinal, Neurological, Musculoskeletal, Dermatological, ENT/Eye, Cardiac/Vascular, Endocrine/Metabolic, Genitourinary, Mental Health, General/Systemic, Sexual Health / STIs, Laboratory Findings. + +**Matching logic:** +1. If `symptoms` array is non-empty → use exactly those symptom names +2. Else → for each symptom in vocabulary, check if any keyword is a case-insensitive substring + +### 2.3 translations.json + +``` +{ + "languages": string[] (required, min 1, unique; e.g. ["en","fr","ar"]), + "ui": [{ "key": string, "en": string, "fr": string?, "ar": string? }], + "symptoms": [{ "key": string, "fr": string?, "ar": string? }], + "conditions": [{ "key": string, "fr": string?, "ar": string? }], + "messages": [{ "key": string, "en": string, "fr": string?, "ar": string? }], + "categories": [{ "key": string, "fr": string?, "ar": string? }], + "ui_details": [{ "key": string, "en": string, "fr": string?, "ar": string? }] +} +``` + +**Notes:** +- `key` values must match canonical names used in code and other data files +- `en` is required for `ui` and `messages` sections; optional for `symptoms`/`conditions`/`categories` where the key itself serves as the English label +- Null or empty localized values trigger fallback + +### 2.4 synonyms.json + +``` +{ + "synonyms": [ + { + "canonical": string (the known symptom name in conditions.json), + "aliases": string[] (alternative names / medical terms) + } + ] +} +``` + +**Current scale:** ~47 synonym entries covering lab findings, common symptoms, and medical terminology. + +### 2.5 settings.json (auto-generated) + +``` +{ + "Language": string?, + "DarkMode": boolean, + "Model": string?, + "ThresholdPercent": int, + "MinMatch": int, + "TopK": int, + "ShowOnlyCategory": boolean, + "SelectedCategory": string?, + "FilterText": string?, + "TempC": double?, + "HeartRate": int?, + "RespRate": int?, + "SystolicBP": int?, + "DiastolicBP": int?, + "SpO2": int?, + "WeightKg": double?, + "AgeYears": int?, + "PercHemoptysis": boolean?, + "PercEstrogenUse": boolean?, + "PercPriorDvtPe": boolean?, + "PercUnilateralLegSwelling": boolean?, + "PercRecentSurgeryTrauma": boolean?, + "LastExportFolder": string?, + "CategoryWeights": { [category: string]: double }?, + "NaiveBayesTemperature": double?, + "LeftPanelCollapsed": boolean? +} +``` + +--- + +## 3. Versioning Strategy + +### 3.1 Current State + +No version field exists in any data file. This creates risk when migrating formats. + +### 3.2 Recommended Approach + +Add a `"version"` field to the root of each data file: + +```json +{ + "version": "1.0", + "conditions": [...] +} +``` + +Update schemas to include `version` as an optional string field. Application logic: +1. Read `version` — if absent, assume `"1.0"` (backward compatible) +2. If version is newer than expected, warn user and proceed with best-effort parsing +3. On save, always write current version +4. Migration functions can be chained: `Migrate_1_0_to_1_1()`, etc. + +### 3.3 Schema Evolution Rules + +| Change Type | Handling | +|---|---| +| New optional field added | Backward-compatible; old files parse fine (field is `null`) | +| Field renamed | Requires migration function; support old name temporarily | +| Field removed | Ignore on read; stop writing on save | +| Array item structure changed | Version bump; migration function converts old items | +| New required field | Must provide default in migration; update schema | + +--- + +## 4. Merge Rules for Wikidata Sync + +### 4.1 Merge Algorithm (`MergeConditions`) + +``` +For each incoming condition: + 1. Skip if name is empty/whitespace + 2. Lookup existing condition by name (case-insensitive) + 3. If not found: + a. Create new condition with trimmed name + b. Deduplicate symptoms (case-insensitive), sort alphabetically + c. Add to database + 4. If found: + a. Union existing symptoms with incoming symptoms + b. Deduplicate (case-insensitive), filter empty, sort + c. If symptom count increased → count as a change + 5. If any changes → RebuildVocabulary() + 6. Return change count +``` + +### 4.2 Merge Guarantees + +| Guarantee | Detail | +|---|---| +| Additive only | Existing conditions are never deleted; existing symptoms are never removed | +| Idempotent | Syncing the same data twice produces 0 changes on the second run | +| Case-insensitive matching | "flu" and "Flu" are treated as the same condition | +| Label normalization | Incoming symptom labels are Title-Cased (`ToTitleCase`) | +| No localization merge | Wikidata sync only provides English labels; localized fields are untouched | + +### 4.3 Conflict Resolution + +| Conflict | Resolution | +|---|---| +| Name collision with different casing | First-seen casing wins; symptoms are merged | +| Symptom exists in different casing | Case-insensitive dedup preserves first occurrence | +| Wikidata returns condition with 0 symptoms | Skipped (empty symptom list) | + +--- + +## 5. Localization Fallback Rules + +### 5.1 UI Labels (`T()` method) + +``` +1. Look up key in ui[] +2. If found: return field for current language (fr/ar), or en if empty +3. If not in ui[]: look up key in messages[] +4. If found: return field for current language, or en if empty +5. If not found: return the raw key string +``` + +### 5.2 Symptom / Condition / Category Names + +``` +1. Look up key in respective array (symptoms[]/conditions[]/categories[]) +2. If found and localized value is non-empty: return localized value +3. Else: return the canonical key (which is the English name) +``` + +### 5.3 Condition Detail Fields (Treatments, Medications, CareAdvice) + +``` +1. Read language from TranslationService.CurrentLanguage +2. If "fr": use Treatments_Fr ?? Treatments, Medications_Fr ?? Medications, CareAdvice_Fr ?? CareAdvice +3. If "ar": use Treatments_Ar ?? Treatments, Medications_Ar ?? Medications, CareAdvice_Ar ?? CareAdvice +4. Else: use base (English) fields +``` + +### 5.4 Missing Key Registration + +When a lookup succeeds but the localized value is empty, or when a key is not found at all, the key is added to `_missing` set as `"{section}:{key}:{language}"` (e.g., `"sym:Fever:ar"`). + +--- + +## 6. Validation Constraints Summary + +| Constraint | Enforcement Point | +|---|---| +| `conditions.json` must have at least one condition | Schema: `conditions` array required | +| Each condition must have a non-empty name | Schema: `name` minLength 1 | +| Each condition must have at least one symptom | Schema: `symptoms` minItems 1 | +| Symptoms within a condition must be unique | Schema: `uniqueItems: true` | +| Categories must have a non-empty name | Schema: `name` minLength 1 | +| Languages array must have at least one entry | Schema: `languages` minItems 1 | +| Language codes must be unique | Schema: `uniqueItems: true` | +| No additional JSON properties allowed | Schema: `additionalProperties: false` on all definitions | +| Settings file can be absent or corrupt | Runtime: deserialization failure → reset to defaults | +| Synonyms file can be absent | Runtime: `SynonymService` stays null; filter uses substring matching only | diff --git a/specs/decision-rules-spec.md b/specs/decision-rules-spec.md new file mode 100644 index 0000000..d89b5ae --- /dev/null +++ b/specs/decision-rules-spec.md @@ -0,0 +1,277 @@ +# Decision Rules Specification + +> **DISCLAIMER:** All decision rules in this application are simplified educational approximations. They do not replace clinical tools, validated scoring systems, or professional medical judgment. Real-world implementations of these scoring systems have additional nuances, context, and clinical prerequisites not captured here. + +--- + +## 1. Centor Score + +### 1.1 Purpose (Educational) + +The Centor score estimates the likelihood of Group A Streptococcal (GAS) pharyngitis in patients with sore throat. It was developed to guide the need for rapid testing or empiric antibiotic therapy. + +### 1.2 Components + +| # | Criterion | Points | How Inferred in App | +|---|---|---|---| +| 1 | Fever (temperature ≥ 38.0 °C) | +1 | `TempC ≥ 38.0` OR "Fever" symptom is checked | +| 2 | Tonsillar exudates or swelling | +1 | "Sore Throat" symptom is checked (proxy) OR "Tonsillar Exudates"/"Tonsillar Swelling" if present | +| 3 | Tender anterior cervical lymphadenopathy | +1 | "Swollen Lymph Nodes" symptom is checked | +| 4 | Absence of cough | +1 | "Cough" symptom is NOT checked | + +**Score range:** 0–4 + +### 1.3 Implementation + +```csharp +bool hasFever = (TempC >= 38.0) || checkedSymptoms.Contains("Fever"); +bool tonsils = checkedSymptoms.Contains("Sore Throat") + || checkedSymptoms.Contains("Tonsillar Exudates") + || checkedSymptoms.Contains("Tonsillar Swelling"); +bool nodes = checkedSymptoms.Contains("Swollen Lymph Nodes"); +bool noCough = !checkedSymptoms.Contains("Cough"); + +int centor = (hasFever ? 1 : 0) + (tonsils ? 1 : 0) + (nodes ? 1 : 0) + (noCough ? 1 : 0); +``` + +### 1.4 Educational Limitations + +- Tonsillar exudates are proxied by "Sore Throat" — a significant simplification +- Physical exam findings cannot be reliably inferred from checkbox selections +- The real Centor score requires in-person clinical assessment + +--- + +## 2. McIsaac Score (Modified Centor) + +### 2.1 Purpose (Educational) + +The McIsaac score adds an age adjustment to the Centor score to account for the differing prevalence of GAS pharyngitis across age groups. + +### 2.2 Age Adjustment + +| Age Group | Adjustment | +|---|---| +| < 15 years | +1 | +| 15–44 years | 0 | +| ≥ 45 years | −1 | + +### 2.3 Computation + +``` +McIsaac = Centor + AgeAdjustment +Clamped to range [0, 5] +``` + +### 2.4 Advice Bands (Educational) + +| McIsaac Score | Advice (Localized) | +|---|---| +| 0–1 | Low risk: likely viral. No antibiotics. Consider symptomatic care. | +| 2 | Intermediate risk: consider rapid strep test (RADT). | +| 3 | Higher risk: RADT and/or consider empiric antibiotics as per local guidance. | +| 4–5 | High risk: consider testing and/or empiric antibiotics per guidelines. | + +### 2.5 UI Behavior + +- Centor component checkboxes are **read-only** (disabled) — they reflect inferred state, not user input +- Centor and McIsaac scores are displayed as localized labels +- Advice text updates automatically when symptoms, vitals, or age change + +--- + +## 3. PERC Rule (Pulmonary Embolism Rule-out Criteria) + +### 3.1 Purpose (Educational) + +The PERC rule is designed to identify patients at very low risk for pulmonary embolism (PE) who do not require further D-dimer testing. All 8 criteria must be met for PERC-negative status. + +### 3.2 Criteria + +| # | Criterion | Input Source | Pass Condition | +|---|---|---|---| +| 1 | Age < 50 | `_numAge.Value` | `age < 50` | +| 2 | Heart rate < 100 | `_numHR.Value` | `HR < 100` | +| 3 | SpO₂ ≥ 95% | `_numSpO2.Value` | `SpO2 >= 95` | +| 4 | No hemoptysis | `_percHemoptysis.Checked` | `!checked` | +| 5 | No estrogen use | `_percEstrogen.Checked` | `!checked` | +| 6 | No prior DVT/PE | `_percPriorDvtPe.Checked` | `!checked` | +| 7 | No unilateral leg swelling | `_percUnilateralLeg.Checked` | `!checked` | +| 8 | No recent surgery/trauma | `_percRecentSurgery.Checked` | `!checked` | + +### 3.3 Result + +``` +PERC negative = ALL 8 criteria pass +``` + +| Result | Display Text (Localized) | +|---|---| +| PERC negative | "PERC negative — PE unlikely if pretest probability is low." | +| PERC positive | "PERC positive — cannot rule out PE; consider further testing if suspicion persists." | + +### 3.4 PERC Integration with Triage + +When PERC is positive **and** the user has "Chest Pain" or "Shortness of Breath" checked, the triage banner adds `RF_PERC_Positive` (severity priority 1). + +### 3.5 Educational Limitations + +- PERC is only valid when clinical pretest probability is already low (< 15%) +- The app does not assess pretest probability +- Real PERC requires clinical context not available from checkboxes alone + +--- + +## 4. Triage Red-Flag Rules + +### 4.1 Purpose (Educational) + +The triage system flags symptom combinations and vital-sign thresholds that could indicate urgent conditions in a real clinical setting. This is for educational awareness only. + +### 4.2 Symptom-Based Red Flags + +| Key | Trigger | Severity | +|---|---|---| +| `RF_ChestPain_SOB` | "Chest Pain" AND "Shortness of Breath" | 1 (highest) | +| `RF_Fainting_ChestPain` | "Fainting" AND "Chest Pain" | 1 | +| `RF_Confusion` | "Confusion" | 1 | +| `RF_Fever_NeckPain_Light` | "Fever" AND ("Neck Pain" OR "Sensitivity to Light") | 2 | +| `RF_SevereCough_SOB` | "Severe Cough" AND "Shortness of Breath" | 2 | +| `RF_BloodInStool` | "Blood in Stool" | 2 | +| `RF_BloodInUrine` | "Blood in Urine" | 2 | +| `RF_TesticularPain` | "Testicular Pain" | 3 | + +### 4.3 Vitals-Based Red Flags (Triage v2) + +| Key | Trigger | Severity | +|---|---|---| +| `RF_Hypoxia` | SpO₂ < 92% | 1 | +| `RF_Hypotension` | SBP < 90 mmHg | 1 | +| `RF_PERC_Positive` | PERC positive + ("Chest Pain" OR "Shortness of Breath") | 1 | +| `RF_SevereHypertension` | SBP ≥ 180 OR DBP ≥ 120 | 2 | +| `RF_Tachycardia` | HR ≥ 120 bpm | 2 | +| `RF_Tachypnea` | RR ≥ 30 /min | 2 | +| `RF_HighFever` | Temp ≥ 40.0 °C | 2 | + +### 4.4 Severity Priority System + +``` +Priority 1 = Most critical (red — immediate concern) +Priority 2 = High concern (orange — urgent review) +Priority 3 = Moderate concern (yellow — prompt attention) +Priority 99 = Unknown / unclassified (default) +``` + +Red flags are sorted by severity (ascending priority number), then alphabetically for stability. + +### 4.5 Triage Banner Format + +**LTR languages (EN, FR):** +``` +Possible red flags: + • [Flag 1 localized text] + • [Flag 2 localized text] +If these apply, consider seeking urgent medical attention. This tool is educational, not medical advice. +``` + +**RTL language (AR):** +``` +[Header]: +[Flag 1 localized text] • +[Flag 2 localized text] • +[Disclaimer] +``` + +### 4.6 Evaluation Flow + +``` +TriageService.EvaluateV2() + 1. Call Evaluate() for symptom-only flags + 2. Check each vital threshold → add keys + 3. Check PERC+chest/SOB context → add RF_PERC_Positive + 4. Deduplicate (HashSet) + 5. Sort by severity priority, then alphabetically + 6. Return list of message keys +``` + +--- + +## 5. Explicit Educational Disclaimers + +The following disclaimers must be displayed in conjunction with decision rules: + +| Context | Disclaimer | +|---|---| +| Main form footer | "⚠️ Educational only. Not medical advice." | +| Triage banner footer | "If these apply, consider seeking urgent medical attention. This tool is educational, not medical advice." | +| Details dialog | Treatments, medications, and care advice are labeled "educational" | +| Export files | Timestamp + "Educational only. Not medical advice." | +| PERC result | Explicitly states "PE unlikely if pretest probability is low" (negative) or "cannot rule out PE" (positive) | +| Centor/McIsaac | Advice bands use hedging language ("consider", "per guidelines") | + +--- + +## 6. Rule Extensibility Strategy + +### 6.1 Current Architecture + +Rules are hardcoded in `TriageService.cs` (static class) and `MainForm.cs` (Centor/McIsaac/PERC logic). + +### 6.2 Recommended Extension Approach + +**Option A: JSON-Driven Rules (Preferred for simple thresholds)** + +Define a `rules.json` file: + +```json +{ + "version": "1.0", + "symptomRules": [ + { + "key": "RF_ChestPain_SOB", + "requires": ["Chest Pain", "Shortness of Breath"], + "requireAll": true, + "severity": 1 + } + ], + "vitalRules": [ + { + "key": "RF_Hypoxia", + "vital": "SpO2", + "operator": "<", + "value": 92, + "severity": 1 + } + ] +} +``` + +A `RulesEngine` service loads and evaluates these rules without code changes. + +**Option B: Plugin-Based Rules (For complex scoring systems)** + +```csharp +public interface IDecisionRule +{ + string Id { get; } + string DisplayNameKey { get; } + DecisionRuleResult Evaluate(DecisionRuleContext context); +} + +public record DecisionRuleContext( + HashSet SelectedSymptoms, + VitalsSnapshot? Vitals, + int? AgeYears, + IDictionary? Flags +); +``` + +Each rule (Centor, PERC, custom) implements `IDecisionRule`. The UI discovers rules via registration and renders results dynamically. + +### 6.3 Adding a New Rule (Current Process) + +1. Add evaluation logic to `TriageService.EvaluateV2()` or a new method +2. Add severity entry to `SeverityPriority` dictionary +3. Add localized message key to `translations.json` (ui section) +4. Wire UI display in `UpdateTriageBanner()` or new panel +5. Add tests in `TriageServiceTests.cs` diff --git a/specs/functional-requirements.md b/specs/functional-requirements.md new file mode 100644 index 0000000..d62fb34 --- /dev/null +++ b/specs/functional-requirements.md @@ -0,0 +1,160 @@ +# Functional Requirements + +## 1. User Flows + +### 1.1 Primary Flow — Symptom Check + +``` +1. Application starts → loads conditions.json, categories.json, synonyms.json, translations.json, settings.json +2. User optionally selects a language (EN/FR/AR) and theme (light/dark) +3. User filters symptoms using the filter text box and/or category selector +4. User checks one or more symptoms from the CheckedListBox +5. User selects a detection model (Jaccard / Cosine / Naive Bayes) +6. User adjusts parameters: Threshold (%), Min Match, Top-K +7. User clicks "Check" +8. Application computes matches and displays scored results grouped by category +9. Top-scoring result is highlighted; triage banner shows if red flags are detected +10. User double-clicks a result to see condition details (symptoms, treatments, medications, care advice) +``` + +### 1.2 Wikidata Sync Flow + +``` +1. User clicks "Sync" +2. Application fires a SPARQL query to Wikidata (no API key) +3. Fetched conditions are merged into the local dataset (additive merge) +4. Vocabulary is rebuilt; UI refreshes +5. User is notified of merge count or failure +``` + +### 1.3 Export Flow + +``` +1. User right-clicks the results list +2. User selects Export (CSV / Markdown / HTML) +3. SaveFileDialog opens; user chooses path +4. Application writes localized export file with condition, score, matches, category columns +5. Export folder is remembered for next use +``` + +### 1.4 Session Management + +``` +1. User clicks "Save" → saves selected symptoms + model + parameters to a JSON file +2. User clicks "Load" → restores a previously saved session +3. User clicks "Reset Settings" → reverts to defaults +4. User can save/load settings profiles (separate from sessions) +``` + +--- + +## 2. Feature List + +### 2.1 Existing Features (Implemented) + +| ID | Feature | Status | +|---|---|---| +| F-01 | Checkbox-based symptom selection (no free text) | Implemented | +| F-02 | Three detection models: Jaccard, Cosine (binary), Naive Bayes | Implemented | +| F-03 | Configurable threshold (%), min match count, top-K | Implemented | +| F-04 | Category-based symptom filtering and grouping | Implemented | +| F-05 | Synonym-aware filtering (canonical + aliases) | Implemented | +| F-06 | Localization: EN, FR, AR with RTL support | Implemented | +| F-07 | Dark mode toggle with persisted preference | Implemented | +| F-08 | Vitals input: Temp (°C), HR, RR, BP (SBP/DBP), SpO₂, Weight | Implemented | +| F-09 | Centor/McIsaac score computation with age adjustment | Implemented | +| F-10 | PERC rule evaluation (8 criteria) | Implemented | +| F-11 | Triage v2 banner: symptoms + vitals + PERC context | Implemented | +| F-12 | Wikidata SPARQL sync (no API key, limit 200) | Implemented | +| F-13 | Condition details dialog (symptoms, treatments, medications, care advice) | Implemented | +| F-14 | Export results: CSV, Markdown, HTML | Implemented | +| F-15 | Session save/load (JSON) | Implemented | +| F-16 | Settings profiles save/load | Implemented | +| F-17 | Settings persistence (data/settings.json) | Implemented | +| F-18 | Schema validation on startup (NJsonSchema) | Implemented | +| F-19 | Missing translations report dialog + export | Implemented | +| F-20 | Category weighting (multiply score by category factor) | Implemented | +| F-21 | Naive Bayes temperature scaling | Implemented | +| F-22 | Owner-drawn results list with group headers | Implemented | +| F-23 | Copy/Print details from context menu | Implemented | +| F-24 | Collapsible left panel | Implemented | +| F-25 | Keyboard shortcuts (Alt+F → filter, Alt+C → check) | Implemented | +| F-26 | Logging service with rotation and pruning | Implemented | +| F-27 | Help/About dialog | Implemented | +| F-28 | Score explainability in details dialog | Implemented | +| F-29 | Localized treatment/medication/care-advice fields per condition | Implemented | + +### 2.2 Proposed Improvements + +| ID | Improvement | Rationale | +|---|---|---| +| P-01 | Extract UI construction into a dedicated layout builder or UserControls | MainForm.cs is 2548 lines; splitting improves maintainability | +| P-02 | Introduce an interface `IMatchingModel` to decouple algorithms | Current switch-case in `GetMatches` violates OCP; new models require editing service | +| P-03 | Add unit tests for Cosine model and edge cases | Only Jaccard and NaiveBayes have partial test coverage | +| P-04 | Validate vitals ranges with user feedback (e.g., out-of-range warning) | Currently silently capped by NumericUpDown min/max | +| P-05 | Debounce filter text changes | Each keystroke triggers full list rebuild; noticeable with large datasets | +| P-06 | Add a "Select All / Deselect All" global button | Users currently rely on category-level or visible-level toggles | +| P-07 | Display matched-symptom chips/tags inline in results | Improves explainability beyond the details dialog | +| P-08 | Add condition-detail hyperlinks to trusted educational sources (e.g., Wikipedia) | Enriches learning without introducing clinical tools | +| P-09 | Support additional languages via translation file extension | Architecture supports it; only EN/FR/AR have data | +| P-10 | Add schema version field to all JSON data files | Enables safe migration when formats change | +| P-11 | Introduce `IDataProvider` abstraction over file-system JSON access | Enables testing with in-memory data and future data sources | + +--- + +## 3. Validation Rules + +### 3.1 Input Validation + +| Input | Rule | Enforcement | +|---|---|---| +| Symptom selection | At least 1 symptom must be checked to enable "Check" | `_checkButton.Enabled` bound to `_checkedSymptoms.Count > 0` | +| Threshold | Integer 0–100 (interpreted as 0.00–1.00 score) | `NumericUpDown.Minimum=0, Maximum=100` | +| Min Match | Integer 0–10 | `NumericUpDown.Minimum=0, Maximum=10` | +| Top-K | Integer 0–1000 (0 = unlimited) | `NumericUpDown.Minimum=0, Maximum=1000` | +| Temperature (°C) | 30.0–45.0, step 0.1 | `NumericUpDown` bounds | +| Heart Rate (bpm) | 20–240 | `NumericUpDown` bounds | +| Respiratory Rate (/min) | 4–80 | `NumericUpDown` bounds | +| SBP (mmHg) | 50–260 | `NumericUpDown` bounds | +| DBP (mmHg) | 30–160 | `NumericUpDown` bounds | +| SpO₂ (%) | 50–100 | `NumericUpDown` bounds | +| Weight (kg) | 2.0–350.0 | `NumericUpDown` bounds | +| Age (years) | 0–120 | `NumericUpDown` bounds | +| NB Temperature | 0.10–5.00 (entered as 10–500, divided by 100) | `NumericUpDown` bounds | +| Category weight | 10–500 (0.1x–5.0x) | `NumericUpDown` bounds | + +### 3.2 Data Validation + +| Data File | Validation | +|---|---| +| `conditions.json` | Validated against `conditions.schema.json` — requires `conditions` array, each with non-empty `name` and `symptoms` (min 1, unique) | +| `categories.json` | Validated against `categories.schema.json` — requires `categories` array, each with non-empty `name` | +| `translations.json` | Validated against `translations.schema.json` — requires `languages` array (min 1, unique) | +| `synonyms.json` | No schema currently defined (improvement candidate) | +| `settings.json` | No schema — deserialization failure resets to defaults | + +### 3.3 Merge Validation (Wikidata Sync) + +- Condition names are trimmed. Empty/whitespace names are skipped. +- Symptom labels are normalized to Title Case. +- Duplicate symptoms within a condition are deduplicated (case-insensitive). +- Existing conditions are preserved; new symptoms are appended. +- Merge count of 0 results in a "No changes detected" message. + +--- + +## 4. Error Handling Behavior + +| Scenario | Behavior | +|---|---| +| `conditions.json` not found at startup | `FileNotFoundException` → MessageBox with error, app cannot proceed | +| `categories.json` / `synonyms.json` not found | Respective service is `null`; features degrade gracefully (no categories, no synonym matching) | +| `translations.json` not found | TranslationService loads empty DB; all keys fall back to canonical English strings | +| `settings.json` not found or corrupt | Settings reset to defaults; no user-visible error | +| Schema validation fails on startup | Non-blocking warning dialog listing validation errors | +| Wikidata sync network failure | Sync button re-enabled; MessageBox with error message; local data unchanged | +| Wikidata rate limit / timeout | Same as network failure | +| Unhandled UI thread exception | `Application.ThreadException` handler shows MessageBox and logs to file | +| Unhandled domain exception | `AppDomain.UnhandledException` handler shows MessageBox and logs to file | +| Export file write failure | MessageBox with exception message | +| Print failure | Falls back to direct `pd.Print()`; if both fail, silently swallowed | diff --git a/specs/future-extensions.md b/specs/future-extensions.md new file mode 100644 index 0000000..a156c7b --- /dev/null +++ b/specs/future-extensions.md @@ -0,0 +1,377 @@ +# Future Extensions Specification + +> **DISCLAIMER:** All extensions described here must preserve the educational-only nature of the application. No extension should cause the application to be classified as a medical device or clinical decision-support system. + +--- + +## 1. Extension Principles + +| Principle | Rationale | +|---|---| +| Backward compatibility | New features must not break existing session files, data files, or settings | +| Additive-only data changes | Never remove or rename existing fields; add new optional fields | +| Educational framing preserved | Every new feature must include appropriate disclaimers | +| Offline-first | Core functionality must work without network; online features are optional enrichment | +| Modular architecture | New capabilities added via interfaces and services, not monolithic code changes | + +--- + +## 2. Architecture Extensions + +### 2.1 Matching Model Plugin System + +**Priority:** High +**Rationale:** The current `switch` on `DetectionModel` in `SymptomCheckerService.GetMatches()` is a closed design. Adding a new model requires modifying the service. + +**Proposed Design:** + +```csharp +public interface IMatchingModel +{ + string Id { get; } // e.g., "jaccard", "cosine", "naive-bayes" + string DisplayNameKey { get; } // translation key for UI + List Evaluate( + HashSet selectedSymptoms, + List conditions, + Dictionary> conditionSets, + HashSet vocabulary, + MatchingOptions options + ); +} + +public record MatchingOptions( + double NaiveBayesTemperature = 1.0, + double MinimumScore = 0.0 +); +``` + +**Registration:** +```csharp +var models = new Dictionary +{ + ["jaccard"] = new JaccardModel(), + ["cosine"] = new CosineModel(), + ["naive-bayes"] = new NaiveBayesModel() +}; +``` + +**Benefits:** +- Open/closed principle — add models without modifying existing code +- Each model is independently testable +- UI model selector populated from registry + +**Candidate New Models:** +| Model | Description | +|---|---| +| TF-IDF | Weight symptoms by inverse frequency across conditions | +| Weighted Jaccard | Category weights applied to symptom overlap | +| Ensemble | Average/vote across multiple models | + +### 2.2 Decision Rule Plugin System + +**Priority:** High +**Rationale:** Centor/McIsaac and PERC logic is currently embedded in `MainForm.cs`. New scoring systems (e.g., Wells Score, CURB-65) require UI and logic changes. + +**Proposed Design:** + +```csharp +public interface IDecisionRule +{ + string Id { get; } + string DisplayNameKey { get; } + IReadOnlyList Criteria { get; } + DecisionRuleResult Evaluate(DecisionRuleContext context); +} + +public record RuleCriterion( + string Key, + string LabelKey, + CriterionType Type, // Boolean, Numeric, Enum + object? DefaultValue +); + +public record DecisionRuleResult( + int Score, + int MaxScore, + string AdviceKey, + RiskLevel Level +); + +public enum RiskLevel { Low, Intermediate, High, Critical } +``` + +**Benefits:** +- Rules can be added without UI code changes +- Criteria drive dynamic UI generation (checkboxes for Boolean, NumericUpDown for Numeric) +- Scoring logic is testable in isolation + +### 2.3 Data Provider Abstraction + +**Priority:** Medium +**Rationale:** Currently data is loaded from local JSON files only. Future providers could include SQLite, REST APIs, or bundled binary formats. + +```csharp +public interface IDataProvider +{ + Task> LoadConditionsAsync(); + Task> LoadCategoriesAsync(); + Task LoadSynonymsAsync(); + Task SaveConditionsAsync(List conditions); +} +``` + +Implementations: `JsonFileDataProvider` (current), `SqliteDataProvider` (future), `ReadOnlyBundleProvider` (embedded resources). + +--- + +## 3. Feature Extensions + +### 3.1 Symptom–Condition Relationship Graph + +**Priority:** Medium +**Description:** Interactive visualization showing how symptoms connect to conditions. + +**Implementation Notes:** +- Use a graph layout library (e.g., Microsoft Automatic Graph Layout — MSAGL) or custom GDI+ rendering +- Nodes: symptoms (circles) and conditions (rectangles) +- Edges: symptom→condition association +- Color coding: matched symptoms highlighted, red-flag symptoms marked +- Click a node to see details + +**Educational Value:** Helps users understand why certain conditions score higher. + +### 3.2 Symptom Frequency Analytics + +**Priority:** Low +**Description:** Track which symptoms are most/least selected across sessions for educational pattern analysis. + +**Implementation Notes:** +- Aggregate data stored locally in `analytics.json` +- No personally identifiable data — only symptom selection counts +- Bar chart or heatmap visualization +- Reset option to clear analytics data + +### 3.3 Condition Detail Cards + +**Priority:** Medium +**Description:** Richer educational information per condition beyond the current details dialog. + +**Proposed Content:** +- Prevalence estimate (from Wikidata or manual annotation) +- Related conditions (conditions sharing ≥ 3 symptoms) +- Differential diagnosis hints (educational) +- External reference links (Wikipedia, MedlinePlus) — opens in default browser + +### 3.4 Multi-Patient Comparison (Educational) + +**Priority:** Low +**Description:** Side-by-side comparison of two saved sessions to illustrate how different symptom profiles produce different results. + +**Use Case:** Classroom setting where instructor demonstrates how adding/removing symptoms changes match rankings. + +### 3.5 Guided Walkthrough Mode + +**Priority:** Medium +**Description:** Step-by-step tutorial mode for new users. + +**Steps:** +1. Select a category → expand accordion +2. Check some symptoms → see instant feedback +3. Enter vitals → observe triage banner +4. Run check → explore results +5. Open details → review educational content +6. Export → save results + +**Implementation:** Overlay tooltips pointing to each UI element in sequence. + +--- + +## 4. Localization Extensions + +### 4.1 Additional Languages + +**Priority:** Medium +**Current:** EN, FR, AR + +**Candidate Languages:** +| Language | Code | RTL? | Notes | +|---|---|---|---| +| Spanish | es | No | Large user base | +| German | de | No | Medical education context | +| Chinese (Simplified) | zh-CN | No | Requires CJK font consideration | +| Urdu | ur | Yes | Shares RTL infrastructure with AR | + +**Requirements per new language:** +1. Add translations to `translations.json` (ui, symptoms, conditions, messages, categories, uiDetails sections) +2. Add `Name_Xx` and `Treatment_Xx` fields to conditions (or use fallback to EN) +3. Test RTL layout if applicable +4. Validate font glyph coverage + +### 4.2 Translation Workflow Tooling + +**Priority:** Low +**Description:** CLI tool or script to: +- Extract all translation keys and their EN values +- Generate a template file for translators +- Validate completeness of a new language against EN baseline +- Report missing keys (leveraging existing `_missing` set in `TranslationService`) + +--- + +## 5. Data Extensions + +### 5.1 Condition Metadata Enrichment + +Add optional fields to `conditions.json`: + +```json +{ + "Name": "Common Cold", + "ICD10": "J00", + "Prevalence": "very_common", + "OnsetSpeed": "gradual", + "TypicalDuration": "7-10 days", + "AgeGroups": ["child", "adult", "elderly"], + "Sources": ["https://en.wikipedia.org/wiki/Common_cold"] +} +``` + +All fields optional for backward compatibility. + +### 5.2 Symptom Metadata + +Add optional fields to category symptom entries: + +```json +{ + "Name": "Fever", + "Severity": "moderate", + "BodySystem": "systemic", + "CommonWith": ["infection", "inflammation"], + "WikidataId": "Q38933" +} +``` + +### 5.3 Evidence Annotations + +Each condition–symptom association could carry a weight or evidence level: + +```json +{ + "Name": "Pneumonia", + "SymptomWeights": { + "Cough": 0.9, + "Fever": 0.85, + "Headache": 0.3 + } +} +``` + +This enables weighted matching models without changing the data structure fundamentally. + +--- + +## 6. Technical Debt Reduction + +### 6.1 MainForm Decomposition + +**Priority:** Critical +**Current:** 2548 lines in a single file + +**Recommended Split:** + +| Component | Responsibility | Est. Lines | +|---|---|---| +| `MainForm.cs` | Shell, initialization, top-level event wiring | ~200 | +| `MainForm.Layout.cs` | `InitializeLayout()`, control creation | ~400 | +| `MainForm.Theme.cs` | `ApplyTheme()`, color definitions | ~150 | +| `MainForm.Symptoms.cs` | Category accordion, search, checkbox logic | ~300 | +| `MainForm.Results.cs` | Owner-draw ListBox, result rendering | ~250 | +| `MainForm.DecisionRules.cs` | Centor, McIsaac, PERC UI logic | ~200 | +| `MainForm.Triage.cs` | Triage banner rendering | ~100 | +| `MainForm.Export.cs` | CSV, MD, HTML export methods | ~200 | +| `MainForm.Session.cs` | Save/load session, settings profiles | ~150 | +| `MainForm.Vitals.cs` | Vitals panel, value-change handlers | ~150 | + +Use `partial class MainForm` to keep all code in the same class while splitting across files. + +### 6.2 TranslationService Optimization + +**Priority:** Medium +**Current:** `FirstOrDefault()` linear scans on every lookup (O(n)) + +**Fix:** Build `Dictionary` at load time, keyed by translation key. Reduces lookups to O(1). + +### 6.3 Settings Validation + +**Priority:** Low +**Current:** `SettingsService` loads JSON without schema validation + +**Fix:** Add `settings.schema.json` and validate at load; apply defaults for missing or out-of-range values. + +### 6.4 Test Coverage Expansion + +**Priority:** Medium +**Current tests:** `SymptomCheckerServiceTests`, `TriageServiceTests`, `TranslationServiceTests`, `CategoriesServiceTests`, `CategoryWeightingTests`, `NaiveBayesTemperatureTests` + +**Missing coverage:** +- `SynonymService` unit tests +- `WikidataImporter` tests (mock HTTP) +- `SchemaValidator` edge cases +- `LoggerService` rotation tests +- Export format validation tests +- RTL layout assertions (if feasible in headless test) + +--- + +## 7. Deployment Extensions + +### 7.1 Single-File Publish + +```xml +true +true +true +``` + +Produces a single `.exe` with embedded runtime — no .NET install required on target machine. + +### 7.2 MSIX Packaging + +Package as MSIX for Windows Store or enterprise sideloading: +- Auto-update support +- Clean install/uninstall +- Sandboxed file access (data files bundled as AppData) + +### 7.3 CI/CD Pipeline + +```yaml +# Proposed GitHub Actions workflow +name: Build & Test +on: [push, pull_request] +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { dotnet-version: '8.0.x' } + - run: dotnet build --configuration Release + - run: dotnet test --configuration Release --logger trx + - uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/*.trx' +``` + +--- + +## 8. Roadmap Summary + +| Phase | Items | Priority | +|---|---|---| +| **Phase 1 — Stabilize** | MainForm decomposition, TranslationService optimization, test coverage expansion, synonyms schema | Critical / High | +| **Phase 2 — Modularize** | IMatchingModel interface, IDecisionRule interface, IDataProvider abstraction | High | +| **Phase 3 — Enrich** | Condition detail cards, symptom–condition graph, additional languages | Medium | +| **Phase 4 — Distribute** | Single-file publish, MSIX packaging, CI/CD pipeline | Medium | +| **Phase 5 — Explore** | Frequency analytics, guided walkthrough, multi-session comparison, evidence annotations | Low | diff --git a/specs/non-functional-requirements.md b/specs/non-functional-requirements.md new file mode 100644 index 0000000..4f10995 --- /dev/null +++ b/specs/non-functional-requirements.md @@ -0,0 +1,152 @@ +# Non-Functional Requirements + +## 1. Performance + +| Metric | Target | Notes | +|---|---|---| +| Startup time (cold) | < 2 s | Load JSON files, validate schemas (async), build vocabulary, render UI | +| Symptom filter response | < 100 ms perceived | On keystroke; consider debouncing (current: immediate rebuild) | +| Match computation (Check) | < 200 ms for 60–100 conditions | Measured via `Stopwatch`; displayed in `_lblPerf` | +| Vocabulary rebuild | < 50 ms | Called after sync merge; linear scan of all condition symptoms | +| Export file generation | < 500 ms | In-memory string builder, single file write | +| Memory footprint | < 100 MB RSS | Small dataset (~60 conditions, ~200 symptoms); scales linearly | + +### 1.1 Current Performance Observations + +- `RefreshSymptomList()` rebuilds the full `CheckedListBox` on every filter keystroke. With large datasets (post-Wikidata sync), this may cause perceivable lag. **Recommendation:** introduce a 150–250 ms debounce timer. +- `GetMatches()` iterates all conditions × full vocabulary for Naive Bayes. With V > 500 symptoms, consider caching log-probability tables or pruning irrelevant conditions early. +- `_categorySetsCache` is well-designed and avoids redundant set-building. + +--- + +## 2. Accessibility + +### 2.1 Keyboard Navigation + +| Requirement | Status | +|---|---| +| All interactive controls reachable via Tab | Implemented (TabIndex assigned 0–64) | +| Alt+F focuses filter box | Implemented | +| Alt+C triggers Check button | Implemented | +| Enter on focused button activates it | Default WinForms behavior | +| Escape closes dialogs | Not explicitly implemented; add `CancelButton` binding | + +### 2.2 Screen Reader Support + +| Requirement | Status | +|---|---| +| `AccessibleName` set on all primary controls | Implemented (filter, symptoms list, results, model selector, vitals, PERC checkboxes) | +| `AccessibleDescription` set where helpful | Partially implemented (filter, symptoms list, results, disclaimer, triage banner) | +| GroupBox text announces Centor/McIsaac and PERC sections | Implemented | +| Focus set to filter box on form shown | Implemented | + +### 2.3 RTL Layout (Arabic) + +| Requirement | Status | +|---|---| +| `RightToLeft = Yes` and `RightToLeftLayout = true` applied at Form level | Implemented | +| Individual controls explicitly set for RTL (lists, checkboxes, groups) | Implemented | +| Bullet points reversed for RTL (appended to end) | Implemented in banner and details | +| Result items rendered with `TextFormatFlags.RightToLeft | Right` | Implemented in owner-draw | +| Context menu mirrors for RTL | Implemented | + +### 2.4 Contrast & Readability + +| Requirement | Status | +|---|---| +| Light mode uses system colors (high contrast compatible) | Implemented via `SystemColors` | +| Dark mode uses sufficient contrast (Gainsboro on #202020) | Implemented; ratio ≈ 11:1 | +| Top result highlighted with distinct background (light green / dark green text) | Implemented | +| Triage banner uses warm accent (soft orange / khaki in dark) | Implemented | +| Check button uses accent green color for prominence | Implemented | + +**Improvement:** Test with Windows High Contrast themes; current dark mode bypasses system HC settings. + +--- + +## 3. Localization Rules + +### 3.1 Supported Languages + +| Code | Language | Direction | Status | +|---|---|---|---| +| `en` | English | LTR | Complete | +| `fr` | Français | LTR | Partially complete (missing keys tracked) | +| `ar` | العربية | RTL | Partially complete (missing keys tracked) | + +### 3.2 Fallback Chain + +``` +Requested language → available translation → English fallback → raw key +``` + +- UI labels: `translations.json > ui[]` → match by `key`, select `fr`/`ar` field → fallback to `en` → fallback to literal key +- Symptoms: `translations.json > symptoms[]` → match by `key` → fallback to canonical name +- Conditions: `translations.json > conditions[]` → match by `key` → fallback to canonical name +- Categories: `translations.json > categories[]` → same pattern +- Details labels: `translations.json > ui_details[]` → same pattern +- Messages: `translations.json > messages[]` → same pattern + +### 3.3 Missing Key Tracking + +- `TranslationService._missing` collects keys not found or with empty localized values. +- On form close, a report is written to `data/translation_report.txt`. +- Users can view and export via the "Missing Translations" button. + +### 3.4 Condition-Level Localization + +Treatment, medication, and care-advice fields have `_Fr` / `_Ar` suffixed variants directly on the `Condition` model. The UI selects the localized variant with fallback to the base (English) field. + +**Improvement:** Consider a more generic per-locale map (e.g., `Dictionary`) to avoid N×L suffix fields as more languages are added. + +--- + +## 4. Offline Behavior + +| Scenario | Behavior | +|---|---| +| Normal operation (no network) | Fully functional — all data loaded from local `data/` folder | +| Wikidata sync without network | Sync fails gracefully with error MessageBox; local data unchanged | +| Export | Writes to local filesystem only | +| Settings persistence | Local file I/O only | +| Schema validation | Local schema files; no external fetch | + +The application must never require a network connection for core functionality. Network is used exclusively for the optional Wikidata sync. + +--- + +## 5. Maintainability & Extensibility + +### 5.1 Current State Assessment + +| Area | Assessment | +|---|---| +| Service layer | Well-separated: each service has a single file and clear responsibility | +| Models | Clean, minimal POCOs in `Models/` | +| UI layer | **MainForm.cs at 2548 lines is the primary maintainability risk** — mixes layout, event handling, business logic delegation, theming, and export | +| Data files | Clean JSON with schemas; categories and synonyms are user-editable | +| Test coverage | Tests exist for CategoriesService, SymptomCheckerService, TranslationService, TriageService, NaiveBayes, CategoryWeighting | +| Logging | Simple file-based logger with rotation | + +### 5.2 Refactoring Recommendations + +| Priority | Recommendation | +|---|---| +| High | Split `MainForm.cs` into partial classes or extract: `LayoutBuilder`, `ThemeManager`, `ExportService`, `DecisionRulesPanel` (UserControl), `VitalsPanel` (UserControl) | +| High | Introduce `IMatchingModel` interface with `Jaccard`, `Cosine`, `NaiveBayes` implementations — enables adding models without modifying `SymptomCheckerService` | +| Medium | Introduce `IDataProvider` interface over JSON file access — enables in-memory testing and potential future data sources | +| Medium | Move inline lambda event handlers in `InitializeLayout()` to named methods for testability and readability | +| Medium | Add a synonyms JSON schema (`synonyms.schema.json`) for startup validation | +| Low | Replace `try { } catch { }` swallowed exceptions with structured logging via `LoggerService` | +| Low | Extract export logic (CSV/MD/HTML) into a standalone `ExportService` class | + +### 5.3 Extensibility Points + +| Extension | Mechanism | +|---|---| +| New detection model | Implement `IMatchingModel` (proposed) and register in model selector | +| New language | Add language code to `translations.json > languages[]`, add translations for all categories | +| New symptom category | Add entry to `categories.json` with keywords and/or explicit symptoms | +| New condition | Add to `conditions.json` or sync from Wikidata; vocabulary auto-rebuilds | +| New decision rule | Add method to `TriageService` or new service; wire into triage banner | +| New export format | Add method to `ExportService` (proposed) and context menu item | diff --git a/specs/security-and-compliance.md b/specs/security-and-compliance.md new file mode 100644 index 0000000..d6013fc --- /dev/null +++ b/specs/security-and-compliance.md @@ -0,0 +1,239 @@ +# Security and Compliance Specification + +> **DISCLAIMER:** This application is an educational desktop tool. It is not a medical device, does not process Protected Health Information (PHI), and must never be used for clinical decision-making. + +--- + +## 1. Classification + +### 1.1 Regulatory Status + +| Dimension | Status | +|---|---| +| Medical device | **No** — educational tool only | +| FDA / CE classification | Not applicable | +| HIPAA applicability | Not applicable (no PHI processed) | +| GDPR applicability | Minimal — no personal data collected or transmitted | +| SOC 2 applicability | Not applicable (desktop-only, no cloud services) | + +### 1.2 Justification for Non-Regulated Status + +The application: +- Does **not** accept free-text patient descriptions +- Does **not** store patient identifiers (name, DOB, address, insurance, etc.) +- Does **not** render diagnoses — only educational condition matches +- Uses hedging language throughout ("consider", "may suggest", "educational only") +- Displays prominent disclaimers on every screen and export +- Does **not** transmit any data to external servers (except optional Wikidata SPARQL queries for disease data enrichment) + +--- + +## 2. Data Privacy + +### 2.1 No-PHI Guarantee + +| Data Type | Collected? | Stored? | Transmitted? | +|---|---|---|---| +| Patient name | No | No | No | +| Date of birth | No | No | No | +| Address / contact info | No | No | No | +| Insurance / billing | No | No | No | +| Medical record number | No | No | No | +| Symptom selections | In-memory only | Session file (user-initiated, local) | No | +| Vitals values | In-memory only | Session file (user-initiated, local) | No | +| Age (numeric only) | In-memory only | Session/settings (local) | No | +| PERC flags | In-memory only | Session file (local) | No | +| Results / matches | In-memory only | Export file (user-initiated, local) | No | + +### 2.2 Session Files + +- **Format:** JSON +- **Location:** User-chosen path via `SaveFileDialog` +- **Content:** Symptom selections, vitals, PERC flags, language, model, results — no identifying data +- **Encryption:** None (plain text) — acceptable because no PHI is present +- **Retention:** User-managed; application does not auto-save sessions + +### 2.3 Settings File + +- **Location:** `settings.json` in application directory +- **Content:** UI preferences (language, theme, model, layout), vitals defaults, category weights, NB temperature +- **Sensitivity:** None — contains no patient or user-identifying data + +### 2.4 Log Files + +- **Location:** `logs/` subdirectory of application directory +- **Content:** Timestamps, operation names, error messages, file paths +- **No PHI logged:** Symptom names may appear in debug logs but are medical terms, not patient data +- **Rotation:** 512 KB max per file, 5 files max — oldest auto-deleted +- **Access:** Local filesystem only; no remote log shipping + +--- + +## 3. Network Security + +### 3.1 Network Boundary + +| Connection | Direction | Destination | Data Sent | Optional? | +|---|---|---|---|---| +| Wikidata SPARQL | Outbound HTTPS | `query.wikidata.org` | SPARQL query (disease/symptom request) | Yes — user-initiated only | + +### 3.2 No Inbound Connections + +The application: +- Does not open any listening ports +- Does not register any URL handlers or protocol handlers +- Does not expose any API endpoints + +### 3.3 Wikidata Request Details + +``` +POST https://query.wikidata.org/sparql +Content-Type: application/x-www-form-urlencoded +Accept: application/sparql-results+json +User-Agent: SymptomCheckerEducational/1.0 + +Body: query=SELECT ... WHERE { ... } LIMIT 200 +``` + +- No API keys or authentication tokens required +- No patient data included in any request +- Query returns only disease names and associated symptom names from the public Wikidata knowledge graph +- Rate limit: Wikidata imposes its own rate limits; the app makes at most one request per user-initiated sync +- Timeout: `HttpClient` with configurable timeout (default: 30s) + +### 3.4 Offline Operation + +The application is fully functional offline. Wikidata sync is optional enrichment; failure to connect results in a status message and no data loss. + +--- + +## 4. Data Integrity + +### 4.1 Schema Validation + +At startup, the following data files are validated against their JSON schemas: + +| Data File | Schema File | Validation Library | +|---|---|---| +| `conditions.json` | `conditions.schema.json` | NJsonSchema | +| `categories.json` | `categories.schema.json` | NJsonSchema | +| `translations.json` | `translations.schema.json` | NJsonSchema | + +**On validation failure:** +- Warning logged +- MessageBox displayed to user +- Application continues with valid data files; invalid files are skipped +- No data is silently corrupted + +### 4.2 Wikidata Merge Integrity + +- Merge is **additive only** — existing conditions are never deleted or modified +- New conditions are added with Title Case normalization +- Duplicate detection is by condition name (case-insensitive) +- Merged file is saved atomically (write-then-rename pattern recommended; current implementation writes directly) + +### 4.3 No Code Injection Vectors + +| Vector | Mitigation | +|---|---| +| SQL injection | No database — JSON file storage only | +| XSS | No web content rendered; HTML export is pre-formatted with escaped values | +| Command injection | No shell commands executed; no `Process.Start()` with user input | +| Deserialization attacks | `System.Text.Json` with strongly-typed POCOs — no polymorphic deserialization | +| Path traversal | File dialogs constrain paths; no user-supplied path strings parsed | + +--- + +## 5. Application Security + +### 5.1 Dependencies + +| Dependency | Version | Risk Assessment | +|---|---|---| +| .NET 8 SDK | 8.x | Maintained by Microsoft; regular security patches | +| NJsonSchema | 11.0.0 | Widely used; no known critical CVEs at time of spec | +| System.Text.Json | Built-in | Part of .NET runtime; patched with runtime updates | + +### 5.2 Dependency Update Policy + +- Monitor NuGet advisories for NJsonSchema +- Update .NET SDK with each LTS patch release +- No transitive dependency on Newtonsoft.Json (eliminated attack surface) + +### 5.3 Build Security + +| Measure | Status | +|---|---| +| Signed assemblies | Not currently implemented (not required for educational tool) | +| Deterministic builds | Enabled via `true` (default in .NET 8) | +| Source Link | Configured (`SymptomChecker.sourcelink.json` present) | + +--- + +## 6. Compliance Requirements + +### 6.1 Educational Disclaimer Obligations + +Every user-facing surface must display or reference the educational disclaimer: + +| Surface | Requirement | +|---|---| +| Main form | Persistent footer: "⚠️ Educational only. Not medical advice." | +| Results panel | Disclaimer visible without scrolling when results are displayed | +| Triage banner | Disclaimer appended to every triage warning | +| Details dialog | "Educational" label on treatments and care advice | +| Export files (CSV, MD, HTML) | Disclaimer in header and/or footer | +| About / Help | Full disclaimer text with scope and limitations | + +### 6.2 Language Requirements + +All disclaimers must be: +- Available in all supported languages (EN, FR, AR) +- Displayed in the currently selected language +- Never truncated or hidden behind scroll + +### 6.3 No-Diagnosis Guarantee + +The application must never: +- Use the word "diagnosis" or "diagnose" in any user-facing text (use "match", "suggestion", "educational result") +- Present results with > 100% confidence +- Recommend specific medications or dosages without "educational only" qualifier +- Suggest urgency without the triage disclaimer + +### 6.4 Audit Trail + +| Audit Need | Current Support | +|---|---| +| Who accessed the app | Not tracked (single-user desktop app, no auth) | +| What data was viewed | Not tracked (no PHI to audit) | +| Data modifications | Log file records Wikidata sync additions and settings changes | +| Export history | Log file records export file paths and timestamps | + +--- + +## 7. Threat Model Summary + +| Threat | Likelihood | Impact | Mitigation | +|---|---|---|---| +| User mistakes results for medical advice | Medium | High | Pervasive disclaimers; hedging language; no "diagnosis" terminology | +| Corrupted data files | Low | Medium | Schema validation at startup; additive-only merges | +| Malicious data file replacement | Low | Low | App validates schema; no code execution from data | +| Network interception (Wikidata) | Very Low | Very Low | HTTPS only; no sensitive data transmitted | +| Dependency vulnerability | Low | Medium | Single NuGet dependency; regular updates recommended | +| Local file access by other users | Low | Very Low | No PHI stored; standard OS file permissions apply | + +--- + +## 8. Recommendations + +### 8.1 Short-Term + +1. Add a `synonyms.schema.json` for completeness of validation coverage +2. Ensure HTML exports escape all condition/symptom names to prevent any injection if opened in browser +3. Add file integrity check (hash) for data files at startup (optional hardening) + +### 8.2 Long-Term + +1. If the app ever collects identifiable data (e.g., export with patient context), implement encryption at rest +2. If network features expand beyond Wikidata, implement certificate pinning +3. Consider code signing for distribution builds to prevent tampering diff --git a/specs/spec-overview.md b/specs/spec-overview.md new file mode 100644 index 0000000..cd2298b --- /dev/null +++ b/specs/spec-overview.md @@ -0,0 +1,58 @@ +# Spec Overview + +## 1. Purpose + +Symptom Checker (Educational) is a Windows Forms desktop application that allows users to select symptoms from a predefined checkbox list and receive scored suggestions of possible medical conditions from a local JSON dataset. It is designed exclusively for **educational and learning purposes** — to help students, developers, and curious learners understand how symptom-to-condition matching algorithms work. + +## 2. Educational Scope + +| In Scope | Explicitly Out of Scope | +|---|---| +| Demonstrate set-overlap, cosine, and Bayesian matching | Clinical diagnosis or triage decisions | +| Illustrate decision-rule scoring (Centor/McIsaac, PERC) | Any form of treatment recommendation that could be acted upon | +| Teach red-flag pattern recognition concepts | Replacement for professional medical consultation | +| Show internationalization patterns (EN/FR/AR + RTL) | Regulatory compliance as a medical device | +| Expose data-sync workflows (Wikidata SPARQL) | Free-text NLP or symptom extraction | + +The application displays educational disclaimers at all times and must never be marketed, positioned, or relied upon as a medical diagnostic tool. + +## 3. Target Users + +| Persona | Description | +|---|---| +| CS / Health-Informatics Students | Learning about matching algorithms, Bayesian classifiers, and decision-support systems | +| Software Developers | Studying WinForms architecture, i18n with RTL, JSON data workflows, or desktop app patterns | +| Educators / Instructors | Using the app as a teaching aid in classroom or lab settings | +| Self-learners | Exploring how symptom checkers conceptually work without clinical reliance | + +Users are **not** expected to be clinicians, and the application must never encourage clinical use. + +## 4. Non-Goals (Explicit Exclusions) + +| ID | Non-Goal | +|---|---| +| NG-1 | The application is **not** a medical device and must not be classified, certified, or marketed as one. | +| NG-2 | No free-text symptom input will be introduced; all input is checkbox-based from a curated list. | +| NG-3 | No cloud services, API keys, user accounts, or telemetry will be required. Wikidata sync is optional and key-free. | +| NG-4 | No patient data (PHI/PII) is collected, stored, or transmitted. | +| NG-5 | The application does not aim for clinical accuracy, sensitivity, or specificity guarantees. | +| NG-6 | No real-time monitoring, alerts, or integration with EHR/EMR systems. | +| NG-7 | The application will not evolve into a multi-user or server-based system within the current scope. | + +## 5. Platform & Runtime + +| Attribute | Value | +|---|---| +| Framework | .NET 8 SDK (Windows) | +| UI Toolkit | Windows Forms | +| Target OS | Windows 10+ (x64) | +| Offline-first | Yes — all core features work without network access | +| External dependency | NJsonSchema (schema validation only) | + +## 6. Disclaimer Requirement + +Every user-facing surface — main window, details dialogs, export files, triage banners — must include or reference the disclaimer: + +> **This tool is for educational purposes only and is not a substitute for professional medical advice, diagnosis, or treatment.** + +This statement must appear in all supported languages. diff --git a/specs/ux-ui-spec.md b/specs/ux-ui-spec.md new file mode 100644 index 0000000..b428ceb --- /dev/null +++ b/specs/ux-ui-spec.md @@ -0,0 +1,368 @@ +# UX / UI Specification + +> **DISCLAIMER:** This application is an educational tool only. All UI elements must reinforce this context. No user should mistake this application for a clinical decision-support system. + +--- + +## 1. Application Window + +### 1.1 Shell Structure + +| Property | Value | +|---|---| +| Title | "Symptom Checker (Educational)" | +| Minimum size | 900 × 600 px | +| Start position | CenterScreen | +| Resizable | Yes — all panels scale via proportional layout | +| Font | Segoe UI, 10pt base | +| Icon | Custom icon or default WinForms icon | + +### 1.2 Top-Level Layout + +``` +┌─────────────────────────────────────────────────────────┐ +│ [Top Bar] Language · Theme · Settings · Sync · Session │ +├────────────────────────┬────────────────────────────────┤ +│ │ │ +│ Left Panel │ Right Panel │ +│ • Search / Filter │ • Results List │ +│ • Category Accordion │ • Details Dialog (modal) │ +│ • Vitals Panel │ • Export Buttons │ +│ • PERC Panel │ • Triage Banner │ +│ • Decision Rules │ • Decision Rule Display │ +│ • Check Button │ │ +│ │ │ +├────────────────────────┴────────────────────────────────┤ +│ [Status Bar] Disclaimer · Condition count · Version │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.3 SplitContainer Configuration + +| Property | Value | +|---|---| +| Orientation | Vertical (left/right) | +| SplitterDistance | 50% of form width | +| Panel1MinSize | 350 px | +| Panel2MinSize | 350 px | +| IsSplitterFixed | No (user-adjustable) | + +--- + +## 2. Left Panel — Input Controls + +### 2.1 Search / Filter Bar + +- **Control:** `TextBox` with placeholder text (localized: "Search symptoms…") +- **Behavior:** Filters the visible symptom list in real-time using `SynonymService.MatchSymptomsByQuery()` — matches canonical names and aliases +- **Clear button:** "×" button clears text and restores full list +- **Access key:** Not applicable (always focused first via tab order) + +### 2.2 Symptom List (Category Accordion) + +Layout uses nested `FlowLayoutPanel` containers: + +``` +▼ Category A (checkbox header — select/deselect all) + ☐ Symptom 1 + ☐ Symptom 2 + ☑ Symptom 3 +▶ Category B (collapsed) +``` + +| Behavior | Detail | +|---|---| +| Collapse/expand | Click category header label toggles child visibility | +| Category checkbox | Tri-state: unchecked (none selected), checked (all selected), indeterminate (some selected) | +| Individual checkbox | Standard CheckBox with localized symptom name | +| Scroll | Parent FlowLayoutPanel has `AutoScroll = true` | +| Tooltip | Canonical English name shown on hover when language ≠ EN | +| Category re-ordering | Categories sorted by localized name at load time | + +### 2.3 Vitals Input Panel + +Collapsible panel with `NumericUpDown` controls: + +| Vital | Control | Range | Increment | Decimal Places | Default | +|---|---|---|---|---|---| +| Age | NumericUpDown | 1–120 | 1 | 0 | 30 | +| Temperature °C | NumericUpDown | 30.0–45.0 | 0.1 | 1 | 37.0 | +| SpO₂ % | NumericUpDown | 50–100 | 1 | 0 | 98 | +| Heart Rate bpm | NumericUpDown | 20–250 | 1 | 0 | 72 | +| Resp Rate /min | NumericUpDown | 4–60 | 1 | 0 | 16 | +| Systolic BP mmHg | NumericUpDown | 50–300 | 1 | 0 | 120 | +| Diastolic BP mmHg | NumericUpDown | 20–200 | 1 | 0 | 80 | + +All vitals trigger re-evaluation of triage + decision rules on `ValueChanged`. + +### 2.4 PERC Panel + +Collapsible panel with 4 boolean checkboxes (age, HR, SpO₂ are inferred from vitals): + +| Checkbox | Default | +|---|---| +| Hemoptysis | Unchecked | +| Estrogen use | Unchecked | +| Prior DVT / PE | Unchecked | +| Unilateral leg swelling | Unchecked | +| Recent surgery / trauma | Unchecked | + +PERC result label shows "PERC negative" or "PERC positive" (localized), updates on any change. + +### 2.5 Decision Rules Display + +- **Centor Score:** 4 read-only checkboxes (Fever, Tonsils, Nodes, No Cough) + score label +- **McIsaac Score:** Label showing adjusted score and advice band text (localized) +- Panel background color varies by score range (informational shading only) + +### 2.6 Check Button + +| Property | Value | +|---|---| +| Text | Localized "Check Symptoms" | +| Dock | Bottom of left panel | +| Enabled | Always (returns empty results if no symptoms checked) | +| Action | Calls `SymptomCheckerService.GetMatches()` and populates right panel | + +--- + +## 3. Right Panel — Results Display + +### 3.1 Results ListBox (Owner-Draw) + +| Property | Value | +|---|---| +| Control | `ListBox` with `DrawMode.OwnerDrawVariable` | +| Item types | `GroupHeader` (non-selectable separator) and `ListItem` (selectable condition) | +| Sorting | Groups sorted by match score descending; items sorted within group | +| Double-click | Opens Details dialog for selected `ListItem` | + +**GroupHeader rendering:** +- Bold font, category-colored background stripe +- Displays group name + item count + +**ListItem rendering:** +- Regular font, alternating row background (light mode) or uniform dark background (dark mode) +- Columns: Condition name (localized) | Match score (%) | Model indicator icon +- Hover: Highlight background + +### 3.2 Details Dialog + +Modal dialog shown on double-click of a result item: + +``` +┌──────────────────────────────────────┐ +│ Condition Name (Localized) │ +├──────────────────────────────────────┤ +│ Match Score: 85% (Jaccard) │ +│ │ +│ Matched Symptoms: │ +│ • Fever ✓ │ +│ • Cough ✓ │ +│ • Headache ✗ │ +│ │ +│ Description: │ +│ [Localized description text] │ +│ │ +│ Treatments (Educational): │ +│ • Rest │ +│ • Fluids │ +│ │ +│ ⚠️ Educational only, not medical │ +│ advice. │ +├──────────────────────────────────────┤ +│ [ Close ] │ +└──────────────────────────────────────┘ +``` + +### 3.3 Triage Banner + +| State | Behavior | +|---|---| +| No red flags | Banner is hidden (height = 0) | +| Red flags detected | Banner appears at top of right panel with colored background | + +Banner colors by highest severity present: + +| Severity | Background | Foreground | +|---|---|---| +| 1 (critical) | `#D32F2F` (red) | White | +| 2 (high) | `#F57C00` (orange) | White | +| 3 (moderate) | `#FBC02D` (amber) | Black | + +### 3.4 Export Buttons + +Row of buttons below results list: + +| Button | Action | Format | +|---|---|---| +| Export CSV | Save results to `.csv` file | Comma-separated, UTF-8 BOM | +| Export Markdown | Save results to `.md` file | Markdown table with header | +| Export HTML | Save results to `.html` file | Styled HTML with inline CSS | + +All exports include: +- Timestamp +- Language code +- Model name +- Educational disclaimer +- Session vitals (if entered) + +--- + +## 4. Top Bar Controls + +### 4.1 Language Selector + +| Property | Value | +|---|---| +| Control | `ComboBox` | +| Items | English (en), Français (fr), العربية (ar) | +| Default | en | +| On change | Reloads all UI strings, symptom/condition names, category names; preserves checked symptom state | + +### 4.2 Theme Toggle + +| Property | Value | +|---|---| +| Control | `Button` or `CheckBox` styled as toggle | +| States | Light mode (default), Dark mode | +| On change | Recursively applies color scheme to all controls | + +### 4.3 Model Selector + +| Property | Value | +|---|---| +| Control | `ComboBox` | +| Items | Jaccard, Cosine, Naive Bayes | +| Default | Jaccard | +| On change | Re-runs matching if results are currently displayed | + +### 4.4 Session Controls + +| Button | Action | +|---|---| +| Save Session | Serializes current state (symptoms, vitals, PERC flags, language, model, results) to JSON file | +| Load Session | Restores saved state from JSON file; validates structure before applying | +| Reset | Clears all inputs to defaults; clears results | + +### 4.5 Wikidata Sync Button + +| Property | Value | +|---|---| +| Label | "Sync from Wikidata" (localized) | +| Action | Runs `WikidataImporter.ImportFromWikidataAsync()` | +| During sync | Button disabled, progress status shown | +| On success | Toast/status message with count of new conditions added | +| On failure | Error message in status bar; no data lost (additive merge only) | + +--- + +## 5. Theming + +### 5.1 Color Palette + +| Token | Light Mode | Dark Mode | +|---|---|---| +| Background | `#FFFFFF` | `#1E1E1E` | +| Surface | `#F5F5F5` | `#2D2D2D` | +| Text primary | `#212121` | `#E0E0E0` | +| Text secondary | `#757575` | `#9E9E9E` | +| Accent | `#1976D2` | `#64B5F6` | +| Error/Warning | `#D32F2F` | `#EF5350` | +| Border | `#E0E0E0` | `#424242` | +| Selection | `#BBDEFB` | `#1565C0` | + +### 5.2 Theme Application + +``` +ApplyTheme(Control root, bool isDark) + foreach control in root.Controls (recursive): + set BackColor, ForeColor based on control type + special handling for: ListBox, ComboBox, NumericUpDown, TextBox, Button, Label, CheckBox, GroupBox +``` + +Theme changes apply immediately without restart. + +--- + +## 6. RTL Support + +### 6.1 RTL Activation + +When language = `ar`: +- `Form.RightToLeft = RightToLeft.Yes` +- `Form.RightToLeftLayout = true` +- All controls inherit RTL layout + +### 6.2 RTL-Specific Adjustments + +| Element | LTR (en, fr) | RTL (ar) | +|---|---|---| +| Text alignment | Left | Right | +| Split panel order | Input left, Results right | Input right, Results left | +| Bullet lists | ` • text` | `text •` | +| Status bar | Left-aligned | Right-aligned | +| Scroll bars | Right side | Left side | +| Triage banner bullets | ` • Flag` | `Flag •` | + +### 6.3 Font Consideration + +Arabic text uses the same Segoe UI font family which has Arabic glyph support on Windows 10+. + +--- + +## 7. Accessibility + +### 7.1 Keyboard Navigation + +| Key | Action | +|---|---| +| Tab / Shift+Tab | Navigate between controls in tab order | +| Space | Toggle checkbox | +| Enter | Activate focused button / open details | +| Escape | Close modal dialog | +| Arrow keys | Navigate within lists | + +### 7.2 Screen Reader Compatibility + +- All controls have meaningful `AccessibleName` and `AccessibleDescription` properties +- Group headers in ListBox announce group name and item count +- Triage banner announces severity level and flag descriptions +- Status bar updates announce via `AccessibleRole` + +### 7.3 Color Contrast + +- All text meets WCAG 2.1 AA contrast ratio (4.5:1 for normal text, 3:1 for large text) +- Severity colors are paired with text labels — color is never the sole indicator +- Dark mode maintains equivalent or better contrast ratios + +### 7.4 Focus Indicators + +- All focusable controls show visible focus rectangles +- Custom-drawn ListBox items render focus rectangle via `DrawFocusRectangle()` + +--- + +## 8. Error and Warning Presentation + +### 8.1 Schema Validation Errors (Startup) + +Displayed as a `MessageBox` with warning icon. Lists each invalid file and the validation error. Application continues with valid files; invalid files are skipped with logged warnings. + +### 8.2 Runtime Errors + +| Error Type | Presentation | +|---|---| +| File save failure | MessageBox (Error icon) with file path | +| Session load failure | MessageBox (Warning icon) with parse error | +| Wikidata sync failure | Status bar message (no modal) | +| Export failure | MessageBox (Error icon) | + +### 8.3 Informational Feedback + +| Event | Feedback | +|---|---| +| Successful export | Status bar message: "Exported to [path]" (auto-clears after 5s) | +| Successful sync | Status bar message: "Added N conditions from Wikidata" | +| No results | Results list shows single item: "No matching conditions found" | +| Zero symptoms checked | Results cleared; no error shown | diff --git a/tests/SymptomChecker.Tests/CategoryWeightingTests.cs b/tests/SymptomChecker.Tests/CategoryWeightingTests.cs index 9ec4797..63f31a6 100644 --- a/tests/SymptomChecker.Tests/CategoryWeightingTests.cs +++ b/tests/SymptomChecker.Tests/CategoryWeightingTests.cs @@ -36,11 +36,11 @@ 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 baseRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.Jaccard, threshold:0, getConditionCategories: GetCats); var baseCommonCold = baseRes.First(r => r.Name == "Common Cold").Score; - // Apply respiratory weight 2x + // Apply respiratory weight 2x (use Jaccard – NaiveBayes renormalizes, cancelling uniform boosts) var weights = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Respiratory", 2.0 } }; - var weightedRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.NaiveBayes, threshold:0, categoryWeights: weights, getConditionCategories: GetCats); + var weightedRes = svc.GetMatches(sel, SymptomCheckerService.DetectionModel.Jaccard, 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/MatchingModelTests.cs b/tests/SymptomChecker.Tests/MatchingModelTests.cs new file mode 100644 index 0000000..10274c7 --- /dev/null +++ b/tests/SymptomChecker.Tests/MatchingModelTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SymptomCheckerApp.Models; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class MatchingModelTests + { + // Shared test data + private static readonly List _conditions = new() + { + new Condition { Name = "Common Cold", Symptoms = new List { "Cough", "Runny Nose", "Sore Throat" } }, + new Condition { Name = "Pneumonia", Symptoms = new List { "Fever", "Shortness of Breath", "Cough" } }, + new Condition { Name = "PE", Symptoms = new List { "Chest Pain", "Shortness of Breath" } } + }; + + private static readonly List _vocabulary; + private static readonly Dictionary> _conditionSets; + + static MatchingModelTests() + { + var allSymptoms = new HashSet(StringComparer.OrdinalIgnoreCase); + _conditionSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var c in _conditions) + { + var set = new HashSet(c.Symptoms, StringComparer.OrdinalIgnoreCase); + _conditionSets[c.Name] = set; + foreach (var s in c.Symptoms) allSymptoms.Add(s); + } + _vocabulary = allSymptoms.OrderBy(s => s).ToList(); + } + + // --- Jaccard Tests --- + + [Fact] + public void Jaccard_PerfectMatch_ReturnsScore1() + { + var model = new JaccardModel(); + var selected = new HashSet(new[] { "Cough", "Runny Nose", "Sore Throat" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + var cold = results.First(r => r.Name == "Common Cold"); + Assert.Equal(1.0, cold.Score, 3); + } + + [Fact] + public void Jaccard_PartialMatch_ScoreLessThan1() + { + var model = new JaccardModel(); + var selected = new HashSet(new[] { "Cough" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + var cold = results.First(r => r.Name == "Common Cold"); + // Jaccard: |{Cough}∩{Cough,Runny Nose,Sore Throat}| / |union| = 1/3 + Assert.True(cold.Score > 0 && cold.Score < 1.0); + Assert.Equal(1.0 / 3.0, cold.Score, 3); + } + + [Fact] + public void Jaccard_NoMatch_AllScoresZero() + { + var model = new JaccardModel(); + var selected = new HashSet(new[] { "Headache" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + Assert.All(results, r => Assert.Equal(0.0, r.Score)); + } + + [Fact] + public void Jaccard_ThresholdFilters() + { + var model = new JaccardModel(); + var selected = new HashSet(new[] { "Cough" }, StringComparer.OrdinalIgnoreCase); + // Score for Common Cold = 1/3 ≈ 0.333; threshold 0.5 should exclude it + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.5, null); + Assert.DoesNotContain(results, r => r.Name == "Common Cold"); + } + + // --- Cosine Tests --- + + [Fact] + public void Cosine_PerfectMatch_ReturnsScore1() + { + var model = new CosineModel(); + var selected = new HashSet(new[] { "Chest Pain", "Shortness of Breath" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + var pe = results.First(r => r.Name == "PE"); + Assert.Equal(1.0, pe.Score, 3); + } + + [Fact] + public void Cosine_PartialOverlap_ScoreCorrect() + { + var model = new CosineModel(); + var selected = new HashSet(new[] { "Cough", "Fever" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + // Pneumonia has Fever+Cough overlap = 2; |sel|=2, |cond|=3 + // Cosine = 2 / (sqrt(2) * sqrt(3)) ≈ 0.816 + var pneu = results.First(r => r.Name == "Pneumonia"); + Assert.InRange(pneu.Score, 0.8, 0.9); + } + + [Fact] + public void Cosine_NoOverlap_AllScoresZero() + { + var model = new CosineModel(); + var selected = new HashSet(new[] { "Headache" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + Assert.All(results, r => Assert.Equal(0.0, r.Score)); + } + + // --- NaiveBayes Tests --- + + [Fact] + public void NaiveBayes_ReturnsNormalizedProbabilities() + { + var model = new NaiveBayesModel(); + var selected = new HashSet(new[] { "Cough", "Runny Nose" }, StringComparer.OrdinalIgnoreCase); + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + // All results should have score between 0 and 1 + foreach (var r in results) + { + Assert.InRange(r.Score, 0.0, 1.0); + } + // Common Cold should rank highest with those symptoms + var top = results.OrderByDescending(r => r.Score).First(); + Assert.Equal("Common Cold", top.Name); + } + + [Fact] + public void NaiveBayes_TemperatureScaling_ChangesDistribution() + { + var model = new NaiveBayesModel(); + var selected = new HashSet(new[] { "Cough", "Fever" }, StringComparer.OrdinalIgnoreCase); + + var resultsNoTemp = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + var resultsHighTemp = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, + new MatchingOptions { NaiveBayesTemperature = 2.0 }); + + // With higher temperature, scores should be more evenly distributed (flatter) + var maxNoTemp = resultsNoTemp.Max(r => r.Score); + var minNoTemp = resultsNoTemp.Min(r => r.Score); + var maxHighTemp = resultsHighTemp.Max(r => r.Score); + var minHighTemp = resultsHighTemp.Min(r => r.Score); + + // Gap should be smaller with higher temperature + double gapNoTemp = maxNoTemp - minNoTemp; + double gapHighTemp = maxHighTemp - minHighTemp; + Assert.True(gapHighTemp <= gapNoTemp + 0.001, "Higher temperature should flatten the score distribution"); + } + + [Fact] + public void NaiveBayes_LowTemperature_SharpensDistribution() + { + var model = new NaiveBayesModel(); + var selected = new HashSet(new[] { "Cough", "Fever" }, StringComparer.OrdinalIgnoreCase); + + var resultsNoTemp = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + var resultsLowTemp = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, + new MatchingOptions { NaiveBayesTemperature = 0.5 }); + + // With lower temperature, the top score should be higher (sharper) + var maxLowTemp = resultsLowTemp.Max(r => r.Score); + var maxNoTemp = resultsNoTemp.Max(r => r.Score); + Assert.True(maxLowTemp >= maxNoTemp - 0.001, "Lower temperature should sharpen the distribution"); + } + + // --- Interface contract tests --- + + [Fact] + public void AllModels_ReturnMatchedSymptoms() + { + IMatchingModel[] models = { new JaccardModel(), new CosineModel(), new NaiveBayesModel() }; + var selected = new HashSet(new[] { "Cough", "Fever" }, StringComparer.OrdinalIgnoreCase); + + foreach (var model in models) + { + var results = model.ComputeMatches(selected, _conditions, _conditionSets, _vocabulary, 0.0, null); + var pneu = results.FirstOrDefault(r => r.Name == "Pneumonia"); + Assert.NotNull(pneu); + Assert.Contains("Cough", pneu!.MatchedSymptoms); + Assert.Contains("Fever", pneu.MatchedSymptoms); + } + } + + [Fact] + public void AllModels_HaveCorrectName() + { + Assert.Equal("Jaccard", new JaccardModel().Name); + Assert.Equal("Cosine", new CosineModel().Name); + Assert.Equal("NaiveBayes", new NaiveBayesModel().Name); + } + } +} diff --git a/tests/SymptomChecker.Tests/SynonymServiceTests.cs b/tests/SymptomChecker.Tests/SynonymServiceTests.cs new file mode 100644 index 0000000..71d9624 --- /dev/null +++ b/tests/SymptomChecker.Tests/SynonymServiceTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SymptomCheckerApp.Services; +using Xunit; + +namespace SymptomChecker.Tests +{ + public class SynonymServiceTests + { + private static string DataPath(string file) => + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestData", file); + + private readonly SynonymService _service; + private readonly List _vocabulary = new() + { + "Cough", "Runny Nose", "Fever", "Shortness of Breath", + "Sore Throat", "Chest Pain", "Headache" + }; + + public SynonymServiceTests() + { + _service = new SynonymService(DataPath("synonyms.min.json")); + } + + [Fact] + public void BuildAliasToCanonical_MapsAliasesToCanonical() + { + var map = _service.BuildAliasToCanonical(_vocabulary); + + Assert.Equal("Cough", map["Tussis"]); + Assert.Equal("Runny Nose", map["Rhinorrhea"]); + Assert.Equal("Fever", map["Pyrexia"]); + Assert.Equal("Fever", map["High Temperature"]); + } + + [Fact] + public void BuildAliasToCanonical_CanonicalMapsToItself() + { + var map = _service.BuildAliasToCanonical(_vocabulary); + + Assert.Equal("Cough", map["Cough"]); + Assert.Equal("Fever", map["Fever"]); + } + + [Fact] + public void BuildAliasToCanonical_CaseInsensitive() + { + var map = _service.BuildAliasToCanonical(_vocabulary); + + Assert.Equal("Cough", map["tussis"]); + Assert.Equal("Cough", map["TUSSIS"]); + } + + [Fact] + public void MatchSymptomsByQuery_AliasTerm_ReturnsCanonical() + { + var results = _service.MatchSymptomsByQuery("Tussis", _vocabulary).ToList(); + + Assert.Contains("Cough", results); + } + + [Fact] + public void MatchSymptomsByQuery_CanonicalTerm_ReturnsItself() + { + var results = _service.MatchSymptomsByQuery("Cough", _vocabulary).ToList(); + + Assert.Contains("Cough", results); + } + + [Fact] + public void MatchSymptomsByQuery_PartialAlias_MatchesViaSubstring() + { + // "Rhinor" is a partial match for "Rhinorrhea" alias + var results = _service.MatchSymptomsByQuery("Rhinor", _vocabulary).ToList(); + + Assert.Contains("Runny Nose", results); + } + + [Fact] + public void MatchSymptomsByQuery_EmptyQuery_ReturnsAll() + { + var results = _service.MatchSymptomsByQuery("", _vocabulary).ToList(); + + Assert.Equal(_vocabulary.Count, results.Count); + } + + [Fact] + public void MatchSymptomsByQuery_NoMatch_FallsBackToSubstring() + { + // "Head" doesn't match any synonym but is a substring of "Headache" + var results = _service.MatchSymptomsByQuery("Head", _vocabulary).ToList(); + + Assert.Contains("Headache", results); + } + + [Fact] + public void MatchSymptomsByQuery_CaseInsensitive() + { + var results = _service.MatchSymptomsByQuery("dyspnea", _vocabulary).ToList(); + + Assert.Contains("Shortness of Breath", results); + } + } +} diff --git a/tests/SymptomChecker.Tests/TestData/synonyms.min.json b/tests/SymptomChecker.Tests/TestData/synonyms.min.json new file mode 100644 index 0000000..6105148 --- /dev/null +++ b/tests/SymptomChecker.Tests/TestData/synonyms.min.json @@ -0,0 +1,8 @@ +{ + "synonyms": [ + { "canonical": "Cough", "aliases": ["Tussis"] }, + { "canonical": "Runny Nose", "aliases": ["Rhinorrhea", "Nasal Discharge"] }, + { "canonical": "Fever", "aliases": ["Pyrexia", "High Temperature"] }, + { "canonical": "Shortness of Breath", "aliases": ["Dyspnea", "Breathlessness"] } + ] +}