refactor: share the AI draft pipeline between endpoint and upcoming automation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 19:04:17 +02:00
1 parent bd6e272f57
commit 034b2ef790
2 files changed
+72 -41

No files matched your search

@@ -16,8 +16,6 @@ namespace FamilyPlanner.Api.Features.Learning;
/// </summary>
internal static class LearningAiDraftEndpoints
{
private const int MaxReasonsInMessage = 3;
public static void Map(RouteGroupBuilder group) =>
group.MapPost("/sets/ai-draft", CreateDraftAsync)
.Validate<CreateAiDraftRequest>()
@@ -39,54 +37,22 @@ internal static class LearningAiDraftEndpoints
return invalid;
}
AiResult result;
DraftGeneration generation;
try
{
result = await ai.RunAsync(user.UserId() ?? Guid.Empty, AiPurpose.LearningGeneration, LearningAiPrompt.Build(request), ct);
generation = await LearningAiDraftPipeline.GenerateAsync(ai, user.UserId() ?? Guid.Empty, request, ct);
}
catch (AiException ex)
{
return TypedResults.Problem(statusCode: ex.HttpStatus, title: "KI-Entwurf nicht möglich", detail: ex.Message);
}
var parsed = LearningAiParser.Parse(result.Response.Json, request);
if (parsed.Items.Count == 0)
if (generation.Set is not { } set)
{
var detail = "Die KI hat keine brauchbaren Aufgaben geliefert. Bitte versuche es noch einmal oder formuliere den Wunsch anders.";
if (parsed.DiscardReasons.Count > 0)
{
detail += " " + string.Join(' ', parsed.DiscardReasons.Take(MaxReasonsInMessage));
}
return TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "KI-Entwurf nicht möglich", detail: detail);
return TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "KI-Entwurf nicht möglich", detail: generation.FailureDetail);
}
var exam = request.Mode == LearningMode.Exam;
var fields = new CreateLearningSetRequest(
parsed.Title,
request.Subject,
request.GradeLevel,
request.TargetUserId,
Mode: request.Mode,
MaxAttempts: exam ? 1 : 0, // wie die Voreinstellung im Editor: Prüfung einmalig und ohne Lösungen, Übung unbegrenzt
ShowSolutionsAfterSubmit: !exam,
TotalPoints: 10);
var set = new LearningSet
{
Source = LearningSource.Ai,
AiPrompt = LearningAiPrompt.StoredPrompt(request),
AiProvider = Truncate(result.ProviderName, LearningSet.AiProviderMaxLength),
AiModel = Truncate(result.Model, LearningSet.AiProviderMaxLength),
};
LearningManageEndpoints.ApplySet(set, fields);
var order = 0;
foreach (var input in parsed.Items)
{
var item = LearningValidation.ToItem(input);
item.SortOrder = order++;
set.Items.Add(item);
}
var parsed = generation.Parsed;
db.LearningSets.Add(set);
await db.SaveChangesAsync(ct);
@@ -96,6 +62,4 @@ internal static class LearningAiDraftEndpoints
var dto = new LearningAiDraftDto(detailDto, request.Count, parsed.Discarded, parsed.Verified, parsed.NotVerified, parsed.Notes);
return TypedResults.Created($"/api/learning/sets/{set.Id}", dto);
}
private static string Truncate(string text, int max) => text.Length <= max ? text : text[..max];
}
@@ -0,0 +1,67 @@
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>Ergebnis der KI-Erzeugung: das noch ungespeicherte Blatt (Entwurf) oder der Grund, warum keines entstand.</summary>
internal sealed record DraftGeneration(LearningSet? Set, LearningAiParseResult Parsed, string FailureDetail);
/// <summary>
/// Die gemeinsame Erzeugung von KI-Entwürfen (Phase 10b): Prompt bauen, <see cref="AiService"/> aufrufen, jede Aufgabe prüfen und ein ungespeichertes
/// <see cref="LearningSet"/> mit <c>Source = Ai</c> aufbauen. Genutzt vom Endpunkt <c>ai-draft</c> und von der Automatik (Phase 11c, <see cref="LearningScheduleRunner"/>).
/// Der Aufrufer speichert – bis dahin darf der Kontext nichts vormerken, weil <see cref="AiService"/> sein Nutzungsprotokoll über denselben Kontext schreibt.
/// </summary>
internal static class LearningAiDraftPipeline
{
private const int MaxReasonsInMessage = 3;
/// <exception cref="AiException">Konfiguration, Limit, Erreichbarkeit oder unbrauchbare Antwort der KI.</exception>
public static async Task<DraftGeneration> GenerateAsync(AiService ai, Guid userId, CreateAiDraftRequest request, CancellationToken ct)
{
var result = await ai.RunAsync(userId, AiPurpose.LearningGeneration, LearningAiPrompt.Build(request), ct);
var parsed = LearningAiParser.Parse(result.Response.Json, request);
if (parsed.Items.Count == 0)
{
var detail = "Die KI hat keine brauchbaren Aufgaben geliefert. Bitte versuche es noch einmal oder formuliere den Wunsch anders.";
if (parsed.DiscardReasons.Count > 0)
{
detail += " " + string.Join(' ', parsed.DiscardReasons.Take(MaxReasonsInMessage));
}
return new DraftGeneration(null, parsed, detail);
}
var exam = request.Mode == LearningMode.Exam;
var fields = new CreateLearningSetRequest(
parsed.Title,
request.Subject,
request.GradeLevel,
request.TargetUserId,
Mode: request.Mode,
MaxAttempts: exam ? 1 : 0, // wie die Voreinstellung im Editor: Prüfung einmalig und ohne Lösungen, Übung unbegrenzt
ShowSolutionsAfterSubmit: !exam,
TotalPoints: 10);
var set = new LearningSet
{
Source = LearningSource.Ai,
AiPrompt = LearningAiPrompt.StoredPrompt(request),
AiProvider = Truncate(result.ProviderName, LearningSet.AiProviderMaxLength),
AiModel = Truncate(result.Model, LearningSet.AiProviderMaxLength),
};
LearningManageEndpoints.ApplySet(set, fields);
var order = 0;
foreach (var input in parsed.Items)
{
var item = LearningValidation.ToItem(input);
item.SortOrder = order++;
set.Items.Add(item);
}
return new DraftGeneration(set, parsed, string.Empty);
}
private static string Truncate(string text, int max) => text.Length <= max ? text : text[..max];
}