Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/HazardAssessmentWizardViewModel.cs
T
adminandClaude Sonnet 5 7b883555bf feat: Chemikalien-Recherche über lokale RiSU-Stoffliste (kein KI-Aufruf mehr)
Ersetzt den ursprünglichen KI-Websuche-Ansatz (GESTIS scheiterte sowohl über
Websuche als auch über direkten API-Zugriff an fehlenden schulspezifischen
Tätigkeitsbeschränkungen) durch einen reinen lokalen Datenbank-Lookup gegen die
offizielle RiSU-Stoffliste (1764 Stoffe inkl. Tätigkeitsbeschränkungs-Codes,
Legende zur Abfragezeit übersetzt statt in die Datenbank gebacken). Keine
KI-Kosten, keine Drittanbieter-Abhängigkeit mehr für diese Funktion.

HazardSubstance.AgeRestriction zu ActivityRestriction umbenannt, da die
Tätigkeitsbeschränkung mehr abdeckt als reine Altersgrenzen. Details und
verworfene Zwischenstände in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:43:56 +02:00

441 lines
18 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Anzeige: Experimentart, GHS-Piktogramme (Nutzerwunsch neben 4.2 "Anhänge je Stunde") ────────
public static class ExperimentKindDisplay
{
public static string[] Options { get; } = ["Lehrerversuch", "Schülerversuch", "Demonstrationsversuch"];
public static string Label(ExperimentKind kind) => kind switch
{
ExperimentKind.Schuelerversuch => "Schülerversuch",
ExperimentKind.Demonstrationsversuch => "Demonstrationsversuch",
_ => "Lehrerversuch",
};
public static ExperimentKind FromLabel(string? label) => label switch
{
"Schülerversuch" => ExperimentKind.Schuelerversuch,
"Demonstrationsversuch" => ExperimentKind.Demonstrationsversuch,
_ => ExperimentKind.Lehrerversuch,
};
/// Für die KI-Antwort, die den unmarkierten Enum-Namen ("Schuelerversuch") statt der
/// deutschen Anzeige-Bezeichnung liefert (siehe ai-backend/gbu.php "Antwortformat").
public static ExperimentKind FromWireValue(string? value) =>
Enum.TryParse<ExperimentKind>(value, ignoreCase: true, out var kind) ? kind : ExperimentKind.Lehrerversuch;
}
public static class GhsPictogramDisplay
{
private static readonly (GhsPictogram Value, string Code, string Label)[] All =
[
(GhsPictogram.Explosive, "GHS01", "Explosionsgefährlich"),
(GhsPictogram.Flammable, "GHS02", "Entzündbar"),
(GhsPictogram.Oxidizing, "GHS03", "Brandfördernd"),
(GhsPictogram.CompressedGas, "GHS04", "Gase unter Druck"),
(GhsPictogram.Corrosive, "GHS05", "Ätzend"),
(GhsPictogram.Toxic, "GHS06", "Giftig"),
(GhsPictogram.Harmful, "GHS07", "Gesundheitsschädlich/Reizend"),
(GhsPictogram.HealthHazard, "GHS08", "Gesundheitsgefährdend"),
(GhsPictogram.Environmental, "GHS09", "Umweltgefährdend"),
];
public static string Code(GhsPictogram value) => All.First(o => o.Value == value).Code;
public static string Label(GhsPictogram value) => All.First(o => o.Value == value).Label;
public static GhsPictogram? FromCode(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
foreach (var option in All)
if (string.Equals(option.Code, code.Trim(), StringComparison.OrdinalIgnoreCase))
return option.Value;
return null;
}
public static List<GhsPictogramOption> BuildOptions(IEnumerable<GhsPictogram>? selected = null)
{
var selectedSet = (selected ?? []).ToHashSet();
return All.Select(o => new GhsPictogramOption(o.Value, $"{o.Code} · {o.Label}") { IsSelected = selectedSet.Contains(o.Value) }).ToList();
}
}
public partial class GhsPictogramOption(GhsPictogram value, string label) : ObservableObject
{
public GhsPictogram Value { get; } = value;
public string Label { get; } = label;
[ObservableProperty] private bool _isSelected;
}
// ── Zeile im Gefahrstoff-Editor (Wizard-Schritt 2) ───────────────────────────────────────────────
public partial class HazardSubstanceEditItem : ObservableObject
{
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _cas = "";
[ObservableProperty] private string _amount = "";
[ObservableProperty] private string _signalWord = "";
[ObservableProperty] private string _hStatements = "";
[ObservableProperty] private string _pStatements = "";
[ObservableProperty] private string _activityRestriction = "";
[ObservableProperty] private bool _isResearching;
[ObservableProperty] private string _researchError = "";
public ObservableCollection<GhsPictogramOption> PictogramOptions { get; }
public Action<HazardSubstanceEditItem>? OnRemove { get; set; }
/// Vom Wizard gesetzt (nur wenn eine KI-Backend-Anmeldung vorliegt, siehe CanUseAi — der
/// Lookup selbst ruft aber keine KI auf, sondern nur die lokale RiSU-Stoffliste auf dem
/// Server, siehe ai-backend/substance.php): führt die eigentliche Chemikalien-Recherche aus.
/// Wird vor der Anzeige in der UI (Objektinitialisierer in NewSubstanceItem) endgültig
/// gesetzt, daher genügt eine einmalig berechnete Eigenschaft ohne Änderungsbenachrichtigung.
public Func<string, Task<AiSubstanceResearchResponse>>? OnResearch { get; set; }
public bool CanResearch => OnResearch is not null;
public HazardSubstanceEditItem(HazardSubstance? source = null)
{
PictogramOptions = new ObservableCollection<GhsPictogramOption>(
GhsPictogramDisplay.BuildOptions(source?.GhsPictograms));
if (source is null) return;
Name = source.Name;
Cas = source.Cas;
Amount = source.Amount;
SignalWord = source.SignalWord;
HStatements = source.HStatements;
PStatements = source.PStatements;
ActivityRestriction = source.ActivityRestriction;
}
[RelayCommand]
private void Remove() => OnRemove?.Invoke(this);
[RelayCommand]
private async Task Research()
{
if (OnResearch is null) return;
if (string.IsNullOrWhiteSpace(Name)) { ResearchError = "Stoffname eingeben."; return; }
ResearchError = ""; IsResearching = true;
try
{
var result = await OnResearch(Name.Trim());
Name = result.Name;
Cas = result.Cas;
SignalWord = result.SignalWord;
HStatements = result.HStatements;
PStatements = result.PStatements;
ActivityRestriction = result.ActivityRestriction;
var pictograms = result.GhsPictograms
.Select(GhsPictogramDisplay.FromCode).Where(p => p.HasValue).Select(p => p!.Value).ToHashSet();
foreach (var option in PictogramOptions) option.IsSelected = pictograms.Contains(option.Value);
}
catch (AiBackendException ex)
{
ResearchError = ex.Message;
}
finally
{
IsResearching = false;
}
}
public HazardSubstance ToModel() => new()
{
Name = Name.Trim(),
Cas = Cas.Trim(),
Amount = Amount.Trim(),
SignalWord = SignalWord.Trim(),
HStatements = HStatements.Trim(),
PStatements = PStatements.Trim(),
ActivityRestriction = ActivityRestriction.Trim(),
GhsPictograms = PictogramOptions.Where(o => o.IsSelected).Select(o => o.Value).ToList(),
};
}
// ── Wizard: Gefährdungsbeurteilung anlegen/bearbeiten ────────────────────────────────────────────
/// <summary>
/// Mehrschrittiger Assistent für eine Gefährdungsbeurteilung zu einem Experiment (Nutzerwunsch
/// neben 4.2 "Anhänge je Stunde"). Persistiert NICHT selbst — der Aufrufer (LessonDialog)
/// serialisiert <see cref="Result"/> zu JSON und hängt es über die bestehende
/// Anhang-Infrastruktur an die Lesson an (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2).
/// KI-Unterstützung ist optional: ohne <paramref name="ai"/>/<paramref name="unit"/>/
/// <paramref name="lesson"/> bleibt <see cref="CanUseAi"/> false und der Assistent funktioniert
/// rein manuell.
/// </summary>
public partial class HazardAssessmentWizardViewModel : ObservableObject
{
private static readonly string[] StepTitlesArray =
["Basisdaten", "Gefahrstoffe", "Gefährdungen & Schutzmaßnahmen", "Erste Hilfe & Entsorgung", "Zusammenfassung"];
private readonly AiPlanningService? _ai;
private readonly AiSettingsService? _aiSettings;
private readonly Unit? _unit;
private readonly Lesson? _lesson;
[ObservableProperty] private int _stepIndex;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _groupLabel = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _kindName = ExperimentKindDisplay.Options[0];
[ObservableProperty] private string _procedure = "";
public ObservableCollection<HazardSubstanceEditItem> Substances { get; } = [];
public bool HasSubstances => Substances.Count > 0;
[ObservableProperty] private string _newHazard = "";
public ObservableCollection<string> Hazards { get; } = [];
[ObservableProperty] private string _newProtectiveMeasure = "";
public ObservableCollection<string> ProtectiveMeasures { get; } = [];
[ObservableProperty] private string _firstAid = "";
[ObservableProperty] private string _disposal = "";
[ObservableProperty] private string _notes = "";
[ObservableProperty] private bool _isAiAssisted;
[ObservableProperty] private bool _isAiBusy;
[ObservableProperty] private string _aiError = "";
public string AiButtonLabel => IsAiBusy ? "KI fragt..." : "🤖 KI-Entwurf erstellen";
public bool CanUseAi => _ai is not null && _aiSettings is { Enabled: true, IsLoggedIn: true }
&& _unit is not null && _lesson is not null;
public string[] StepTitles => StepTitlesArray;
public string StepTitle => StepTitles[StepIndex];
public string StepProgressLabel => $"Schritt {StepIndex + 1} von {StepTitles.Length}";
public int StepCount => StepTitles.Length;
public bool IsFirstStep => StepIndex == 0;
public bool IsLastStep => StepIndex == StepTitles.Length - 1;
public bool IsBasicsStep => StepIndex == 0;
public bool IsSubstancesStep => StepIndex == 1;
public bool IsHazardsStep => StepIndex == 2;
public bool IsFirstAidStep => StepIndex == 3;
public bool IsSummaryStep => StepIndex == 4;
public string[] KindOptions => ExperimentKindDisplay.Options;
public string DialogTitle => _editing ? "Gefährdungsbeurteilung bearbeiten" : "Gefährdungsbeurteilung erstellen";
private readonly bool _editing;
public HazardAssessment? Result { get; private set; }
public HazardAssessmentWizardViewModel(HazardAssessment? editing, string defaultGroupLabel,
AiPlanningService? ai = null, AiSettingsService? aiSettings = null, Unit? unit = null, Lesson? lesson = null)
{
_ai = ai; _aiSettings = aiSettings; _unit = unit; _lesson = lesson;
Substances.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasSubstances));
GroupLabel = defaultGroupLabel;
_editing = editing is not null;
if (editing is null) return;
Title = editing.Title;
GroupLabel = editing.GroupLabel;
DateText = editing.Date?.ToString("dd.MM.yyyy") ?? "";
KindName = ExperimentKindDisplay.Label(editing.Kind);
Procedure = editing.Procedure;
foreach (var s in editing.Substances) Substances.Add(NewSubstanceItem(s));
foreach (var h in editing.Hazards) Hazards.Add(h);
foreach (var m in editing.ProtectiveMeasures) ProtectiveMeasures.Add(m);
FirstAid = editing.FirstAid;
Disposal = editing.Disposal;
Notes = editing.Notes;
IsAiAssisted = editing.IsAiAssisted;
}
partial void OnIsAiBusyChanged(bool value) => OnPropertyChanged(nameof(AiButtonLabel));
partial void OnStepIndexChanged(int value)
{
OnPropertyChanged(nameof(StepTitle));
OnPropertyChanged(nameof(StepProgressLabel));
OnPropertyChanged(nameof(IsFirstStep));
OnPropertyChanged(nameof(IsLastStep));
OnPropertyChanged(nameof(IsBasicsStep));
OnPropertyChanged(nameof(IsSubstancesStep));
OnPropertyChanged(nameof(IsHazardsStep));
OnPropertyChanged(nameof(IsFirstAidStep));
OnPropertyChanged(nameof(IsSummaryStep));
}
private HazardSubstanceEditItem NewSubstanceItem(HazardSubstance? source = null)
{
var item = new HazardSubstanceEditItem(source)
{
OnRemove = RemoveSubstance,
OnResearch = CanUseAi ? ResearchSubstanceAsync : null,
};
return item;
}
/// Führt die eigentliche Chemikalien-Recherche aus — ein reiner Datenbank-Lookup gegen die
/// lokale RiSU-Stoffliste auf dem Server (ai-backend/stoffliste.php), keine KI beteiligt und
/// damit auch kein IsAiAssisted-Hinweis nötig. Die Anmeldung läuft trotzdem über dieselbe
/// KI-Backend-Sitzung wie die echten KI-Funktionen (gemeinsamer Host/Token).
private async Task<AiSubstanceResearchResponse> ResearchSubstanceAsync(string substanceName)
{
var token = _aiSettings!.GetToken()
?? throw new AiBackendException("Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.");
return await _ai!.RequestSubstanceResearchAsync(substanceName, token);
}
[RelayCommand]
private void AddSubstance() => Substances.Add(NewSubstanceItem());
private void RemoveSubstance(HazardSubstanceEditItem item) => Substances.Remove(item);
[RelayCommand]
private void AddHazard()
{
if (string.IsNullOrWhiteSpace(NewHazard)) return;
Hazards.Add(NewHazard.Trim());
NewHazard = "";
}
[RelayCommand]
private void RemoveHazard(string? hazard) { if (hazard is not null) Hazards.Remove(hazard); }
[RelayCommand]
private void AddProtectiveMeasure()
{
if (string.IsNullOrWhiteSpace(NewProtectiveMeasure)) return;
ProtectiveMeasures.Add(NewProtectiveMeasure.Trim());
NewProtectiveMeasure = "";
}
[RelayCommand]
private void RemoveProtectiveMeasure(string? measure) { if (measure is not null) ProtectiveMeasures.Remove(measure); }
[RelayCommand]
private void Next()
{
if (IsFirstStep && string.IsNullOrWhiteSpace(Title))
{
TitleError = "Titel erforderlich.";
return;
}
TitleError = "";
if (!IsLastStep) StepIndex++;
}
[RelayCommand]
private void Back() { if (!IsFirstStep) StepIndex--; }
/// <summary>
/// Fragt einen KI-Entwurf zu genau dieser Lesson ab (Thema + Verlaufsplan als Kontext) und
/// übernimmt ihn in die Wizard-Felder — überschreibt dabei bewusst den aktuellen Stand, damit
/// nach einem erneuten Klick klar ist, was tatsächlich von der KI kommt. Die Lehrkraft prüft
/// und passt danach in den einzelnen Schritten an; der Rechtssicherheits-Hinweis wird über
/// <see cref="IsAiAssisted"/> in den PDF-Export übernommen.
/// </summary>
[RelayCommand]
private async Task RequestAiDraft()
{
if (!CanUseAi) return;
var token = _aiSettings!.GetToken();
if (token is null)
{
AiError = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
return;
}
AiError = ""; IsAiBusy = true;
try
{
var aiLesson = new AiLesson
{
Id = _lesson!.Id,
Date = _lesson.Date,
LessonNumber = _lesson.LessonNumber,
Topic = _lesson.Topic,
StartTime = _lesson.StartTime,
Phases = _lesson.Phases.Select(p => new AiPhaseStep
{
Name = p.Name, DurationMinutes = p.DurationMinutes,
Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand,
}).ToList(),
};
var response = await _ai!.RequestHazardAssessmentDraftAsync(_unit!, aiLesson, token);
ApplyAiResponse(response);
}
catch (AiBackendException ex)
{
AiError = ex.Message;
}
finally
{
IsAiBusy = false;
}
}
private void ApplyAiResponse(AiHazardAssessmentResponse response)
{
if (string.IsNullOrWhiteSpace(Title)) Title = _lesson?.Topic ?? "";
KindName = ExperimentKindDisplay.Label(ExperimentKindDisplay.FromWireValue(response.ExperimentKind));
Procedure = response.Procedure;
Substances.Clear();
foreach (var s in response.Substances)
Substances.Add(NewSubstanceItem(new HazardSubstance
{
Name = s.Name,
Amount = s.Amount,
HStatements = s.HStatements,
PStatements = s.PStatements,
GhsPictograms = s.GhsPictograms
.Select(GhsPictogramDisplay.FromCode)
.Where(p => p.HasValue)
.Select(p => p!.Value)
.ToList(),
}));
Hazards.Clear();
foreach (var h in response.Hazards) Hazards.Add(h);
ProtectiveMeasures.Clear();
foreach (var m in response.ProtectiveMeasures) ProtectiveMeasures.Add(m);
FirstAid = response.FirstAid;
Disposal = response.Disposal;
IsAiAssisted = true;
}
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(Title))
{
TitleError = "Titel erforderlich.";
StepIndex = 0;
return;
}
TitleError = "";
DateOnly? date = null;
if (!string.IsNullOrWhiteSpace(DateText) &&
DateOnly.TryParseExact(DateText, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
date = parsed;
Result = new HazardAssessment
{
Title = Title.Trim(),
GroupLabel = GroupLabel.Trim(),
Date = date,
Kind = ExperimentKindDisplay.FromLabel(KindName),
Procedure = Procedure.Trim(),
Substances = Substances.Select(s => s.ToModel()).ToList(),
Hazards = Hazards.ToList(),
ProtectiveMeasures = ProtectiveMeasures.ToList(),
FirstAid = FirstAid.Trim(),
Disposal = Disposal.Trim(),
Notes = Notes.Trim(),
IsAiAssisted = IsAiAssisted,
};
}
}