Files
2026-10-22 11:40:19 +02:00

87 lines
5.4 KiB
C#

using System.Globalization;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
/// <summary>Verbrauch einer Aktion im Zeitraum, z. B. „Text korrigieren“ oder eine Planung der LehrerApp (alle Apps am selben Backend).</summary>
/// <param name="Operation">Funktion der Arbeitsblatt-API; null bei Aktionen anderer Apps.</param>
/// <param name="ChargedEur">Nur, wenn für jeden Aufruf der Euro-Gegenwert dokumentiert ist; sonst null (der Dienst schätzt nichts).</param>
public sealed record AiUsageGroup(string ActionId, string Label, string? Operation, int Calls, decimal ChargedUsd, decimal? ChargedEur);
/// <summary>Ein berechneter Aufruf. <see cref="At"/> ist die Uhrzeit der Datenbank des Dienstes, ohne Zeitzone.</summary>
public sealed record AiUsageEntry(DateTime At, string ActionId, string Label, string? Operation, string Status, decimal ChargedUsd, decimal? ChargedEur);
/// <param name="SpellcheckGranted">Ob die Rechtschreibprüfung über das Backend freigegeben ist; null, wenn der Dienst es nicht meldet.</param>
public sealed record AiUsageReport(int Days, DateTime From, DateTime To, int Calls, decimal ChargedUsd, decimal? ChargedEur,
IReadOnlyList<AiUsageGroup> ByAction, IReadOnlyList<AiUsageEntry> Entries, bool EntriesTruncated, bool? SpellcheckGranted)
{
/// <summary>
/// Liest die Antwort von <c>api/v1/usage.php</c>. Unbekannte zusätzliche Felder werden übergangen (der Dienst darf wachsen), fehlende oder
/// unplausible Pflichtfelder sind ein Fehler.
/// </summary>
public static AiUsageReport Parse(string json)
{
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
var root = document.RootElement;
Need(root.GetProperty("apiVersion").GetString() == "1" && root.GetProperty("schema").GetString() == "lehrerbackend.usage.v1");
var period = root.GetProperty("period");
var days = period.GetProperty("days").GetInt32();
var totals = root.GetProperty("totals");
var groups = new List<AiUsageGroup>();
foreach (var item in root.GetProperty("byAction").EnumerateArray())
{
Need(groups.Count < 256);
groups.Add(new(Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), item.GetProperty("calls").GetInt32(),
RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur")));
}
var entries = new List<AiUsageEntry>();
foreach (var item in root.GetProperty("entries").EnumerateArray())
{
Need(entries.Count < 1000);
entries.Add(new(Date(item.GetProperty("at").GetString()), Text(item, "actionId", 64), Text(item, "label", 200), Operation(item), Text(item, "status", 32),
RequiredMoney(item, "chargedUsd"), Money(item, "chargedEur")));
}
bool? spellcheck = root.TryGetProperty("features", out var features) && features.TryGetProperty("spellcheck", out var flag) && flag.ValueKind is JsonValueKind.True or JsonValueKind.False
? flag.GetBoolean() : null;
var calls = totals.GetProperty("calls").GetInt32();
Need(days is >= 1 and <= 366 && calls >= 0 && groups.All(g => g.Calls >= 0 && g.ChargedUsd >= 0 && g.ChargedEur is null or >= 0) && entries.All(e => e.ChargedUsd >= 0));
return new(days, Date(period.GetProperty("from").GetString()), Date(period.GetProperty("to").GetString()), calls, RequiredMoney(totals, "chargedUsd"), Money(totals, "chargedEur"),
groups, entries, root.GetProperty("entriesTruncated").GetBoolean(), spellcheck);
}
catch (Exception e) when (e is JsonException or InvalidOperationException or KeyNotFoundException or FormatException or ArgumentException or OverflowException)
{ throw new AiBackendException("Die Verbrauchsdaten des KI-Dienstes konnten nicht verarbeitet werden."); }
}
private static void Need(bool condition) { if (!condition) throw new FormatException(); }
private static string Text(JsonElement item, string name, int max)
{
var value = item.GetProperty(name).GetString();
Need(!string.IsNullOrWhiteSpace(value) && value.Length <= max);
return value!;
}
private static string? Operation(JsonElement item) =>
item.TryGetProperty("operation", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static decimal RequiredMoney(JsonElement item, string name) =>
Money(item, name) ?? throw new FormatException();
private static decimal? Money(JsonElement item, string name)
{
if (!item.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null;
// Dezimaltext mit höchstens sechs Nachkommastellen wie im ganzen Vertrag, nie eine Fließkommazahl
var text = value.GetString();
Need(text is not null && System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d{1,9}(\.\d{1,6})?$"));
return decimal.Parse(text!, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
}
private static DateTime Date(string? text)
{
Need(DateTime.TryParseExact(text, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _));
return DateTime.ParseExact(text!, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
}
}