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>
This commit is contained in:
@@ -76,6 +76,29 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Baut eine nutzergerichtete Fehlermeldung für eine fehlgeschlagene Backend-Antwort, die
|
||||
/// weder 401 noch 402 war (die haben eigene, feste Meldungen). Liest den vom Backend
|
||||
/// gesendeten Klartext-Grund (<c>ai_backend_fail</c> liefert immer <c>{"error": "..."}</c>)
|
||||
/// mit, statt nur eine pauschale Meldung zu zeigen — ohne das war z.B. beim Fehlschlagen der
|
||||
/// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der
|
||||
/// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen".
|
||||
/// </summary>
|
||||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||
{
|
||||
string? backendReason = null;
|
||||
try
|
||||
{
|
||||
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
|
||||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||||
}
|
||||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||
|
||||
return new AiBackendException(backendReason is null
|
||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
|
||||
}
|
||||
|
||||
public async Task<string> LoginAsync(string username, string password)
|
||||
{
|
||||
HttpResponseMessage resp;
|
||||
@@ -327,7 +350,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -368,7 +391,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -409,7 +432,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -422,6 +445,46 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt sicherheitsrelevante Daten zu einer Chemikalie ab (Nutzerwunsch neben 4.2): eigener
|
||||
/// Endpunkt <c>ai-backend/substance.php</c>, serverseitig dateibasiert gecacht (Name/CAS,
|
||||
/// Ablauffrist je nach Einstufung) — wiederholte Anfragen für denselben Stoff verursachen
|
||||
/// nach dem ersten Mal keine weiteren Kosten mehr. Kein Unit-/Guppen-Kontext nötig, nur der
|
||||
/// eingetippte Stoffname.
|
||||
/// </summary>
|
||||
public async Task<AiSubstanceResearchResponse> RequestSubstanceResearchAsync(string substanceName, string token)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "substance.php")
|
||||
{
|
||||
Content = JsonContent.Create(new { name = substanceName }, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiSubstanceResearchResponse>(JsonOptions);
|
||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
|
||||
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
|
||||
@@ -478,4 +541,5 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
private class BackendErrorResult { public string? Error { get; set; } }
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ public sealed record HazardAssessmentPrintData(
|
||||
bool IsAiAssisted);
|
||||
|
||||
public sealed record HazardSubstancePrintRow(
|
||||
string Name, string Amount, string Pictograms, string HStatements, string PStatements);
|
||||
string Name, string Cas, string Amount, string Pictograms, string SignalWord,
|
||||
string HStatements, string PStatements, string ActivityRestriction);
|
||||
|
||||
/// <summary>
|
||||
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
|
||||
@@ -380,7 +381,7 @@ public sealed class PdfExportService
|
||||
if (!string.IsNullOrWhiteSpace(data.Procedure))
|
||||
column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure));
|
||||
if (data.Substances.Count > 0)
|
||||
column.Item().Element(c => SubstanceTable(c, data.Substances));
|
||||
column.Item().Element(c => SubstanceBlocks(c, data.Substances));
|
||||
if (data.Hazards.Count > 0)
|
||||
column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards));
|
||||
if (data.ProtectiveMeasures.Count > 0)
|
||||
@@ -419,37 +420,43 @@ public sealed class PdfExportService
|
||||
column.Item().Text($"• {item}");
|
||||
});
|
||||
|
||||
private static void SubstanceTable(IContainer container, IReadOnlyList<HazardSubstancePrintRow> rows) =>
|
||||
/// Eigener Block je Gefahrstoff statt einer engen Tabelle — bei sieben Datenpunkten pro Stoff
|
||||
/// (inkl. CAS, Signalwort, Altersbeschränkung) wäre eine Tabelle auf A4-Hochformat zu schmal
|
||||
/// für lesbare H-/P-Satz-Texte.
|
||||
private static void SubstanceBlocks(IContainer container, IReadOnlyList<HazardSubstancePrintRow> rows) =>
|
||||
container.Column(column =>
|
||||
{
|
||||
column.Spacing(6);
|
||||
column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold();
|
||||
column.Item().Table(table =>
|
||||
foreach (var row in rows)
|
||||
column.Item().Element(c => SubstanceBlock(c, row));
|
||||
});
|
||||
|
||||
private static void SubstanceBlock(IContainer container, HazardSubstancePrintRow row) =>
|
||||
container.Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(6).Column(block =>
|
||||
{
|
||||
block.Spacing(2);
|
||||
block.Item().Row(header =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
header.RelativeItem().Text(text =>
|
||||
{
|
||||
columns.RelativeColumn(2f);
|
||||
columns.RelativeColumn(1f);
|
||||
columns.RelativeColumn(1.3f);
|
||||
columns.RelativeColumn(2f);
|
||||
columns.RelativeColumn(2f);
|
||||
text.Span(row.Name).SemiBold();
|
||||
if (!string.IsNullOrWhiteSpace(row.Cas)) text.Span($" · CAS {row.Cas}").FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(row.Amount)) text.Span($" · {row.Amount}").FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(HeaderCell).Text("Stoff").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("Menge").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("GHS").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("H-Sätze").SemiBold();
|
||||
header.Cell().Element(HeaderCell).Text("P-Sätze").SemiBold();
|
||||
});
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Cell().Element(BodyCell).Text(row.Name);
|
||||
table.Cell().Element(BodyCell).Text(row.Amount);
|
||||
table.Cell().Element(BodyCell).Text(row.Pictograms);
|
||||
table.Cell().Element(BodyCell).Text(row.HStatements);
|
||||
table.Cell().Element(BodyCell).Text(row.PStatements);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(row.SignalWord))
|
||||
header.ConstantItem(70).AlignRight().Text(row.SignalWord).SemiBold()
|
||||
.FontColor(Colors.Red.Darken2);
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(row.Pictograms))
|
||||
block.Item().Text($"GHS: {row.Pictograms}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(row.HStatements))
|
||||
block.Item().Text($"H-Sätze: {row.HStatements}");
|
||||
if (!string.IsNullOrWhiteSpace(row.PStatements))
|
||||
block.Item().Text($"P-Sätze: {row.PStatements}");
|
||||
if (!string.IsNullOrWhiteSpace(row.ActivityRestriction))
|
||||
block.Item().Text($"Tätigkeitsbeschränkung: {row.ActivityRestriction}").SemiBold()
|
||||
.FontColor(Colors.Orange.Darken3);
|
||||
});
|
||||
|
||||
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>
|
||||
|
||||
@@ -80,32 +80,81 @@ public partial class GhsPictogramOption(GhsPictogram value, string label) : Obse
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -217,10 +266,25 @@ public partial class HazardAssessmentWizardViewModel : ObservableObject
|
||||
|
||||
private HazardSubstanceEditItem NewSubstanceItem(HazardSubstance? source = null)
|
||||
{
|
||||
var item = new HazardSubstanceEditItem(source) { OnRemove = RemoveSubstance };
|
||||
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());
|
||||
|
||||
|
||||
@@ -81,14 +81,32 @@
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="0,10">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,140,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="Stoffname"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Amount}" PlaceholderText="Menge, z.B. ca. 2 g"
|
||||
Margin="0,0,8,0"/>
|
||||
<Button Grid.Column="1" Content="🔍 Recherchieren" Command="{Binding ResearchCommand}"
|
||||
IsVisible="{Binding CanResearch}" IsEnabled="{Binding !IsResearching}"
|
||||
Margin="0,0,8,0"
|
||||
ToolTip.Tip="Sucht Piktogramme, Signalwort, H-/P-Sätze und Tätigkeitsbeschränkung in der RiSU-Stoffliste (Sicheres Experimentieren im Unterricht)."/>
|
||||
<Button Grid.Column="2" Content="✕" Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="Stoff entfernen"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding ResearchError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding ResearchError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Grid ColumnDefinitions="*,12,140,12,140">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="CAS-Nummer" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding Cas}" PlaceholderText="z.B. 7440-23-5"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock Text="Menge" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding Amount}" PlaceholderText="z.B. ca. 2 g"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="2">
|
||||
<TextBlock Text="Signalwort" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding SignalWord}" PlaceholderText="Gefahr/Achtung"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding PictogramOptions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="10" LineSpacing="4"/></ItemsPanelTemplate>
|
||||
@@ -109,6 +127,11 @@
|
||||
<TextBox Text="{Binding PStatements}" PlaceholderText="z.B. P280, P305+P351+P338"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="Tätigkeitsbeschränkung" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding ActivityRestriction}"
|
||||
PlaceholderText="z.B. Für Schüler ab der 5. Klasse freigegeben"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -126,8 +126,8 @@ public partial class LessonDialog : Window
|
||||
assessment.Title, assessment.GroupLabel, assessment.Date?.ToString("dd.MM.yyyy") ?? "",
|
||||
ExperimentKindDisplay.Label(assessment.Kind), assessment.Procedure,
|
||||
assessment.Substances.Select(s => new HazardSubstancePrintRow(
|
||||
s.Name, s.Amount, string.Join(", ", s.GhsPictograms.Select(GhsPictogramDisplay.Code)),
|
||||
s.HStatements, s.PStatements)).ToList(),
|
||||
s.Name, s.Cas, s.Amount, string.Join(", ", s.GhsPictograms.Select(GhsPictogramDisplay.Code)),
|
||||
s.SignalWord, s.HStatements, s.PStatements, s.ActivityRestriction)).ToList(),
|
||||
assessment.Hazards, assessment.ProtectiveMeasures, assessment.FirstAid, assessment.Disposal,
|
||||
assessment.Notes, assessment.IsAiAssisted);
|
||||
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildHazardAssessmentPdf(data);
|
||||
|
||||
Reference in New Issue
Block a user