- 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>
238 lines
8.4 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|