DeutschLernen/GermanApp/Application/Services/StoryGenerationService.cs
Lasse Rune Hansen e002868b74 feat(backend/application): implement Phase 5 AI Service Integration
- Create higher-level AI services:
  - StoryGenerationService (uses MistralService)
  - WritingFeedbackService (uses MistralService)
  - SpeechExerciseService (uses VoskService)
  - AudioGenerationService (uses TtsService)
  - AiFallbackService (fallback mechanisms for service failures)
- Register AiFallbackService in Program.cs DI container
- Add comprehensive unit tests for all Phase 5 services:
  - AiFallbackServiceTests (14 tests)
  - AudioGenerationServiceTests (16 tests)
  - MistralServiceTests (16 tests)
  - SpeechExerciseServiceTests (12 tests)
  - StoryGenerationServiceTests (13 tests)
  - WritingFeedbackServiceTests (11 tests)
  - VoskServiceTests (15 tests)
  - TtsServiceTests (21 tests)
- Update feature document (ai-services.md) to mark Phase 5 as complete

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-13 12:44:10 +02:00

238 lines
8.4 KiB
C#

using GermanApp.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace GermanApp.Application.Services;
/// <summary>
/// Application service for generating stories using Mistral AI.
/// This is part of the Application layer.
/// </summary>
public class StoryGenerationService
{
private readonly IMistralService _mistralService;
private readonly ILogger<StoryGenerationService> _logger;
/// <summary>
/// Creates a new StoryGenerationService.
/// </summary>
/// <param name="mistralService">The Mistral text generation service</param>
/// <param name="logger">Logger for service operations</param>
public StoryGenerationService(
IMistralService mistralService,
ILogger<StoryGenerationService> logger)
{
_mistralService = mistralService;
_logger = logger;
}
/// <summary>
/// Generates a story based on the given parameters.
/// </summary>
/// <param name="level">The CEFR level (A1, A2, B1, B2, C1)</param>
/// <param name="topic">The story topic or theme</param>
/// <param name="vocabularyWords">List of German vocabulary words to include in the story</param>
/// <param name="length">Approximate word count for the story</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The generated story text in German</returns>
public virtual async Task<string> GenerateStoryAsync(
string level,
string topic,
IReadOnlyList<string> vocabularyWords,
int length = 200,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating story: Level={Level}, Topic={Topic}, VocabularyCount={Count}, Length={Length}",
level, topic, vocabularyWords?.Count ?? 0, length);
try
{
var story = await _mistralService.GenerateStoryAsync(
level,
topic,
vocabularyWords,
length,
cancellationToken);
// Validate the generated story
ValidateStory(story, level, vocabularyWords);
_logger.LogInformation("Story generated successfully");
return story;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate story");
throw new AiServiceException(
"Failed to generate story: " + ex.Message,
AiErrorCode.Temporary);
}
}
/// <summary>
/// Generates a story for a specific lesson.
/// </summary>
/// <param name="lessonTitle">The lesson title for context</param>
/// <param name="level">The CEFR level</param>
/// <param name="vocabularyWords">List of vocabulary words from the lesson</param>
/// <param name="length">Approximate word count</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The generated story text</returns>
public virtual async Task<string> GenerateLessonStoryAsync(
string lessonTitle,
string level,
IReadOnlyList<string> vocabularyWords,
int length = 250,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating lesson story: Lesson={Lesson}, Level={Level}, VocabularyCount={Count}",
lessonTitle, level, vocabularyWords?.Count ?? 0);
return await GenerateStoryAsync(
level,
lessonTitle,
vocabularyWords,
length,
cancellationToken);
}
/// <summary>
/// Generates multiple stories for different levels.
/// </summary>
/// <param name="topic">The common topic for all stories</param>
/// <param name="vocabularyByLevel">Dictionary mapping CEFR levels to vocabulary words</param>
/// <param name="lengthByLevel">Dictionary mapping CEFR levels to story lengths</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Dictionary mapping levels to generated stories</returns>
public virtual async Task<IDictionary<string, string>> GenerateStoriesByLevelAsync(
string topic,
IDictionary<string, IReadOnlyList<string>> vocabularyByLevel,
IDictionary<string, int>? lengthByLevel = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Generating stories for multiple levels: Topic={Topic}, LevelCount={Count}",
topic, vocabularyByLevel?.Count ?? 0);
var results = new Dictionary<string, string>();
lengthByLevel ??= new Dictionary<string, int>();
foreach (var kvp in vocabularyByLevel)
{
var level = kvp.Key;
var vocabulary = kvp.Value;
var length = lengthByLevel.TryGetValue(level, out var l) ? l : 200;
try
{
var story = await GenerateStoryAsync(
level,
topic,
vocabulary,
length,
cancellationToken);
results[level] = story;
_logger.LogInformation("Generated story for level: {Level}", level);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate story for level {Level}", level);
results[level] = string.Empty;
}
}
return results;
}
/// <summary>
/// Validates the generated story meets basic requirements.
/// </summary>
/// <param name="story">The generated story text</param>
/// <param name="level">The target CEFR level</param>
/// <param name="vocabularyWords">The vocabulary words that should be included</param>
/// <exception cref="InvalidOperationException">Thrown when story validation fails</exception>
private void ValidateStory(
string story,
string level,
IReadOnlyList<string>? vocabularyWords)
{
if (string.IsNullOrWhiteSpace(story))
{
_logger.LogError("Generated story is empty");
throw new InvalidOperationException("Generated story is empty");
}
// Check minimum length based on level
var minLength = GetMinStoryLength(level);
if (story.Length < minLength)
{
_logger.LogWarning(
"Generated story is too short: {Length} characters (min: {Min})",
story.Length, minLength);
// Don't throw - just log warning, AI might generate short stories
}
// Check if vocabulary words are included (if provided)
if (vocabularyWords != null && vocabularyWords.Count > 0)
{
var storyLower = story.ToLower();
var missingWords = vocabularyWords
.Where(w => !string.IsNullOrWhiteSpace(w))
.Where(w => !storyLower.Contains(w.ToLower()))
.ToList();
if (missingWords.Count > vocabularyWords.Count * 0.5)
{
_logger.LogWarning(
"Many vocabulary words missing from story: {MissingCount}/{TotalCount}",
missingWords.Count, vocabularyWords.Count);
}
}
}
/// <summary>
/// Gets the minimum expected story length based on CEFR level.
/// </summary>
/// <param name="level">The CEFR level</param>
/// <returns>Minimum character count</returns>
private int GetMinStoryLength(string level)
{
return level.ToUpper() switch
{
"A1" => 100,
"A2" => 150,
"B1" => 200,
"B2" => 300,
"C1" => 400,
_ => 100
};
}
/// <summary>
/// Tests the story generation service.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if service is working, false otherwise</returns>
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
{
try
{
// Generate a simple test story
var story = await GenerateStoryAsync(
"A1",
"Test",
new List<string> { "Hallo", "Welt" },
50,
cancellationToken);
return !string.IsNullOrWhiteSpace(story);
}
catch (Exception ex)
{
_logger.LogError(ex, "Story generation service test failed");
return false;
}
}
}