- 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>
394 lines
13 KiB
C#
394 lines
13 KiB
C#
using GermanApp.Domain.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace GermanApp.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Service for handling AI service failures and providing fallback responses.
|
|
/// This is part of the Application layer.
|
|
/// </summary>
|
|
public class AiFallbackService
|
|
{
|
|
private readonly IMistralService? _mistralService;
|
|
private readonly IVoskService? _voskService;
|
|
private readonly ITtsService? _ttsService;
|
|
private readonly ILogger<AiFallbackService> _logger;
|
|
|
|
/// <summary>
|
|
/// Creates a new AiFallbackService.
|
|
/// </summary>
|
|
/// <param name="mistralService">The Mistral text generation service (optional)</param>
|
|
/// <param name="voskService">The Vosk speech recognition service (optional)</param>
|
|
/// <param name="ttsService">The Coqui TTS service (optional)</param>
|
|
/// <param name="logger">Logger for service operations</param>
|
|
public AiFallbackService(
|
|
IMistralService? mistralService,
|
|
IVoskService? voskService,
|
|
ITtsService? ttsService,
|
|
ILogger<AiFallbackService> logger)
|
|
{
|
|
_mistralService = mistralService;
|
|
_voskService = voskService;
|
|
_ttsService = ttsService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to generate a story, with fallback if the primary service fails.
|
|
/// </summary>
|
|
/// <param name="level">The CEFR level</param>
|
|
/// <param name="topic">The story topic</param>
|
|
/// <param name="vocabularyWords">List of vocabulary words</param>
|
|
/// <param name="length">Approximate word count</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Generated story text, or fallback if AI service fails</returns>
|
|
public virtual async Task<string> GenerateStoryWithFallbackAsync(
|
|
string level,
|
|
string topic,
|
|
IReadOnlyList<string> vocabularyWords,
|
|
int length = 200,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (_mistralService != null)
|
|
{
|
|
return await _mistralService.GenerateStoryAsync(
|
|
level,
|
|
topic,
|
|
vocabularyWords,
|
|
length,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Mistral story generation failed, using fallback");
|
|
}
|
|
|
|
// Fallback: Generate a simple story based on parameters
|
|
return GenerateFallbackStory(level, topic, vocabularyWords, length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to provide writing feedback, with fallback if the primary service fails.
|
|
/// </summary>
|
|
/// <param name="userText">The user's text</param>
|
|
/// <param name="level">The CEFR level</param>
|
|
/// <param name="customPrompt">Optional custom prompt</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Feedback text, or fallback if AI service fails</returns>
|
|
public virtual async Task<string> ProvideFeedbackWithFallbackAsync(
|
|
string userText,
|
|
string level,
|
|
string? customPrompt = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (_mistralService != null)
|
|
{
|
|
return await _mistralService.GenerateWritingFeedbackAsync(
|
|
userText,
|
|
level,
|
|
customPrompt,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Mistral feedback generation failed, using fallback");
|
|
}
|
|
|
|
// Fallback: Provide simple feedback based on text length
|
|
return GenerateFallbackFeedback(userText, level);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to recognize speech, with fallback if the primary service fails.
|
|
/// </summary>
|
|
/// <param name="audioBytes">The audio data</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Recognized text, or empty string if service fails</returns>
|
|
public virtual async Task<string> RecognizeSpeechWithFallbackAsync(
|
|
byte[] audioBytes,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (_voskService != null)
|
|
{
|
|
return await _voskService.RecognizeSpeechAsync(
|
|
audioBytes,
|
|
16000,
|
|
null,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Vosk speech recognition failed, using fallback");
|
|
}
|
|
|
|
// Fallback: Return empty string or transcription placeholder
|
|
return "[Speech transcription unavailable - please try again]";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to generate audio, with fallback if the primary service fails.
|
|
/// </summary>
|
|
/// <param name="text">The text to convert to speech</param>
|
|
/// <param name="language">The language code</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Audio data, or empty array if service fails</returns>
|
|
public virtual async Task<byte[]> GenerateAudioWithFallbackAsync(
|
|
string text,
|
|
string language = "de",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (_ttsService != null)
|
|
{
|
|
return await _ttsService.GenerateAudioAsync(
|
|
text,
|
|
null,
|
|
language,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "TTS audio generation failed, using fallback");
|
|
}
|
|
|
|
// Fallback: Return empty audio array
|
|
return new byte[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if AI services are healthy.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Dictionary mapping service names to health status</returns>
|
|
public virtual async Task<IDictionary<string, bool>> CheckServiceHealthAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var health = new Dictionary<string, bool>();
|
|
|
|
// Check Mistral service
|
|
if (_mistralService != null)
|
|
{
|
|
try
|
|
{
|
|
var isHealthy = await _mistralService.TestConnectionAsync(cancellationToken);
|
|
health["Mistral"] = isHealthy;
|
|
}
|
|
catch
|
|
{
|
|
health["Mistral"] = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
health["Mistral"] = false;
|
|
}
|
|
|
|
// Check Vosk service
|
|
if (_voskService != null)
|
|
{
|
|
try
|
|
{
|
|
var isHealthy = await _voskService.TestModelAsync(cancellationToken);
|
|
health["Vosk"] = isHealthy;
|
|
}
|
|
catch
|
|
{
|
|
health["Vosk"] = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
health["Vosk"] = false;
|
|
}
|
|
|
|
// Check Coqui TTS service
|
|
if (_ttsService != null)
|
|
{
|
|
try
|
|
{
|
|
var isHealthy = await _ttsService.TestModelAsync(cancellationToken);
|
|
health["CoquiTTS"] = isHealthy;
|
|
}
|
|
catch
|
|
{
|
|
health["CoquiTTS"] = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
health["CoquiTTS"] = false;
|
|
}
|
|
|
|
return health;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a human-readable status message for AI services.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Status message describing which services are available</returns>
|
|
public virtual async Task<string> GetServiceStatusMessageAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var health = await CheckServiceHealthAsync(cancellationToken);
|
|
var available = health.Where(kvp => kvp.Value).Select(kvp => kvp.Key).ToList();
|
|
var unavailable = health.Where(kvp => !kvp.Value).Select(kvp => kvp.Key).ToList();
|
|
|
|
var message = "AI Service Status: ";
|
|
|
|
if (available.Count > 0)
|
|
{
|
|
message += "Available: ";
|
|
message += string.Join(", ", available);
|
|
}
|
|
|
|
if (unavailable.Count > 0)
|
|
{
|
|
if (available.Count > 0)
|
|
message += "; ";
|
|
message += "Unavailable: ";
|
|
message += string.Join(", ", unavailable);
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a fallback story when AI service is unavailable.
|
|
/// </summary>
|
|
private string GenerateFallbackStory(
|
|
string level,
|
|
string topic,
|
|
IReadOnlyList<string> vocabularyWords,
|
|
int length)
|
|
{
|
|
_logger.LogInformation("Generating fallback story for: Level={Level}, Topic={Topic}", level, topic);
|
|
|
|
// Create a simple story based on the topic and vocabulary
|
|
var story = $"Ein {GetLevelDescription(level)} Story über {topic}.";
|
|
|
|
if (vocabularyWords != null && vocabularyWords.Count > 0)
|
|
{
|
|
story += " Es enthält die Wörter: " + string.Join(", ", vocabularyWords.Take(5));
|
|
}
|
|
|
|
// Add some generic story content based on topic
|
|
story += " " + GetGenericStoryContent(topic, length);
|
|
|
|
return story;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates fallback feedback when AI service is unavailable.
|
|
/// </summary>
|
|
private string GenerateFallbackFeedback(string userText, string level)
|
|
{
|
|
_logger.LogInformation("Generating fallback feedback for: Level={Level}", level);
|
|
|
|
var feedback = $"Feedback for {level} level: ";
|
|
|
|
// Simple length-based feedback
|
|
if (string.IsNullOrWhiteSpace(userText))
|
|
{
|
|
feedback += "Please write some text to receive feedback.";
|
|
}
|
|
else if (userText.Length < 20)
|
|
{
|
|
feedback += "Your text is quite short. Try writing a few more sentences.";
|
|
}
|
|
else if (userText.Length < 100)
|
|
{
|
|
feedback += "Good start! Your text is clear. Keep practicing to improve.";
|
|
}
|
|
else
|
|
{
|
|
feedback += "Great job! Your text is well-developed. Continue practicing to maintain your skills.";
|
|
}
|
|
|
|
return feedback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a description for the CEFR level.
|
|
/// </summary>
|
|
private string GetLevelDescription(string level)
|
|
{
|
|
return level.ToUpper() switch
|
|
{
|
|
"A1" => "einfacher",
|
|
"A2" => "leichter",
|
|
"B1" => "mittelschwerer",
|
|
"B2" => "fortgeschrittener",
|
|
"C1" => "komplexer",
|
|
_ => "einfacher"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets generic story content based on topic.
|
|
/// </summary>
|
|
private string GetGenericStoryContent(string topic, int length)
|
|
{
|
|
// Simple topic-based content
|
|
var topics = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["food"] = "Es geht um Essen und Trinken.",
|
|
["travel"] = "Es geht um Reisen und Abenteuer.",
|
|
["family"] = "Es geht um Familie und Beziehungen.",
|
|
["work"] = "Es geht um Arbeit und Beruf.",
|
|
["school"] = "Es geht um Schule und Lernen.",
|
|
["animals"] = "Es geht um Tiere und Natur.",
|
|
["sports"] = "Es geht um Sport und Bewegung.",
|
|
["holiday"] = "Es geht um Ferien und Feiertage.",
|
|
["weather"] = "Es geht um das Wetter.",
|
|
["shopping"] = "Es geht um Einkaufen."
|
|
};
|
|
|
|
if (topics.TryGetValue(topic, out var content))
|
|
{
|
|
return content;
|
|
}
|
|
|
|
return "Es geht um ein interessantes Thema.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests the fallback service.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>True if service is working</returns>
|
|
public virtual async Task<bool> TestServiceAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
// Test fallback story generation
|
|
var story = GenerateFallbackStory("A1", "Test", new List<string> { "Test" }, 100);
|
|
if (string.IsNullOrWhiteSpace(story))
|
|
return false;
|
|
|
|
// Test fallback feedback generation
|
|
var feedback = GenerateFallbackFeedback("Test", "A1");
|
|
if (string.IsNullOrWhiteSpace(feedback))
|
|
return false;
|
|
|
|
// Test health check
|
|
var health = await CheckServiceHealthAsync(cancellationToken);
|
|
return health.Count > 0;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|