using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using GermanApp.Application.DTOs; using GermanApp.Domain.Entities; using GermanApp.Domain.Interfaces; using Microsoft.Extensions.Logging; namespace GermanApp.Application.Services; /// /// Application service for generating stories using AI. /// This is part of the Application layer and uses MistralService for text generation. /// public class StoryGenerationService { private readonly IMistralService _mistralService; private readonly IStoryRepository _storyRepository; private readonly ITtsService _ttsService; private readonly ILogger _logger; /// /// Creates a new StoryGenerationService. /// /// Mistral service for text generation /// Repository for story segments /// TTS service for audio generation /// Logger for service operations public StoryGenerationService( IMistralService mistralService, IStoryRepository storyRepository, ITtsService ttsService, ILogger logger) { _mistralService = mistralService; _storyRepository = storyRepository; _ttsService = ttsService; _logger = logger; } /// /// Generates a complete story for a level and divides it into segments. /// /// The level ID /// The theme for the story /// List of lessons in the level with their vocabulary /// Cancellation token /// Response DTO with the full story and segments public virtual async Task GenerateStoryAsync( int levelId, string theme, IReadOnlyList lessons, CancellationToken cancellationToken = default) { _logger.LogInformation( "Generating story for level {LevelId} with theme '{Theme}'", levelId, theme); // Extract vocabulary from all lessons var allVocabulary = ExtractVocabularyFromLessons(lessons); if (!allVocabulary.Any()) { _logger.LogWarning("No vocabulary found for level {LevelId}", levelId); throw new InvalidOperationException("Cannot generate story: No vocabulary available"); } // Build the prompt for Mistral var prompt = BuildStoryPrompt(levelId, theme, allVocabulary, lessons.Count); _logger.LogDebug("Story generation prompt: {Prompt}", prompt); // Generate the full story var fullStory = await _mistralService.GenerateStoryAsync( GetLevelCode(levelId), theme, allVocabulary, 500, // Approximate word count cancellationToken); if (string.IsNullOrWhiteSpace(fullStory)) { _logger.LogError("Failed to generate story: Empty response from AI service"); throw new InvalidOperationException("Failed to generate story: Empty response"); } _logger.LogInformation("Generated story text (length: {Length} chars)", fullStory.Length); // Split the story into segments (one per lesson) var segments = SplitStoryIntoSegments(fullStory, lessons.Count); if (segments.Count != lessons.Count) { _logger.LogWarning( "Story split into {SegmentCount} segments but expected {LessonCount}", segments.Count, lessons.Count); // Adjust: if we have fewer segments, duplicate the last one // If we have more, combine some segments = AdjustSegmentCount(segments, lessons.Count); } // Create story segment entities var createdSegments = new List(); for (int i = 0; i < segments.Count; i++) { var segment = StorySegment.Create( levelId, lessons[i].Id, segments[i], i + 1, // Order starts at 1 $"{theme} - Part {i + 1}", theme, 2); // Estimated reading minutes createdSegments.Add(segment); } // Save all segments foreach (var segment in createdSegments) { await _storyRepository.AddAsync(segment, cancellationToken); } _logger.LogInformation("Created {Count} story segments for level {LevelId}", createdSegments.Count, levelId); // Convert to DTOs var segmentDtos = createdSegments.Select(StorySegmentDto.FromEntity).ToList(); return new StoryGenerationResponseDto( levelId, theme, segments.Count, fullStory, segmentDtos); } /// /// Generates a story segment for a specific lesson. /// /// The level ID /// The lesson ID /// The theme for the story /// Vocabulary words to include /// The order of this segment /// Cancellation token /// The created story segment DTO public virtual async Task GenerateSegmentAsync( int levelId, int lessonId, string theme, IReadOnlyList vocabulary, int order, CancellationToken cancellationToken = default) { _logger.LogInformation( "Generating story segment for level {LevelId}, lesson {LessonId}, order {Order}", levelId, lessonId, order); if (!vocabulary.Any()) { _logger.LogWarning("No vocabulary provided for segment generation"); throw new InvalidOperationException("Cannot generate segment: No vocabulary available"); } // Build prompt for a single segment var prompt = BuildSegmentPrompt(GetLevelCode(levelId), theme, vocabulary); _logger.LogDebug("Segment generation prompt: {Prompt}", prompt); // Generate the segment var content = await _mistralService.GenerateStoryAsync( GetLevelCode(levelId), theme, vocabulary, 100, // Approximate word count for a segment cancellationToken); if (string.IsNullOrWhiteSpace(content)) { _logger.LogError("Failed to generate story segment: Empty response from AI service"); throw new InvalidOperationException("Failed to generate segment: Empty response"); } // Create and save the segment var segment = StorySegment.Create( levelId, lessonId, content, order, $"{theme} - Part {order}", theme, 2); var created = await _storyRepository.AddAsync(segment, cancellationToken); _logger.LogInformation("Created story segment with ID: {Id}", created.Id); return StorySegmentDto.FromEntity(created); } /// /// Generates audio for a story segment. /// /// The segment ID /// Cancellation token /// The updated segment DTO with audio URL public virtual async Task GenerateAudioAsync( int segmentId, CancellationToken cancellationToken = default) { _logger.LogInformation("Generating audio for story segment: {SegmentId}", segmentId); var segment = await _storyRepository.GetByIdAsync(segmentId, cancellationToken); if (segment == null) { _logger.LogWarning("Segment not found: {SegmentId}", segmentId); return null; } if (!string.IsNullOrEmpty(segment.AudioUrl)) { _logger.LogInformation("Segment already has audio: {SegmentId}", segmentId); return StorySegmentDto.FromEntity(segment); } // Generate audio file path var audioPath = $"/audio/story/level{segment.LevelId}-segment{segment.Order}.wav"; try { // Generate audio using the TTS service directly await _ttsService.GenerateAudioToFileAsync( segment.Content, audioPath, null, "de", cancellationToken); // Update segment with audio URL segment.UpdateAudioUrl(audioPath); await _storyRepository.UpdateAsync(segment, cancellationToken); _logger.LogInformation("Generated audio for segment {SegmentId}: {AudioPath}", segmentId, audioPath); return StorySegmentDto.FromEntity(segment); } catch (Exception ex) { _logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segmentId); throw; } } /// /// Generates audio for all segments that don't have audio yet. /// /// Optional level ID to limit generation to /// Cancellation token /// List of segments that had audio generated public virtual async Task> GenerateAudioForAllSegmentsAsync( int? levelId = null, CancellationToken cancellationToken = default) { _logger.LogInformation("Generating audio for all segments needing audio"); var segments = await _storyRepository.GetSegmentsNeedingAudioAsync(cancellationToken); if (levelId.HasValue) { segments = segments.Where(s => s.LevelId == levelId.Value).ToList(); } _logger.LogInformation("Found {Count} segments needing audio", segments.Count); var results = new List(); foreach (var segment in segments) { try { var result = await GenerateAudioAsync(segment.Id, cancellationToken); if (result != null) { results.Add(result); } } catch (Exception ex) { _logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segment.Id); } } _logger.LogInformation("Generated audio for {Count} segments", results.Count); return results; } /// /// Extracts vocabulary words from lessons. /// This is a placeholder - in a real implementation, vocabulary would be stored in the lesson. /// /// List of lessons /// List of vocabulary words private IReadOnlyList ExtractVocabularyFromLessons(IReadOnlyList lessons) { // For now, extract from lesson titles and topics // In a real implementation, lessons would have a VocabularyWords collection var vocabulary = new List(); foreach (var lesson in lessons) { // Extract words from title (simple word splitting) var titleWords = lesson.Title.Split(new[] { ' ', ',', '.', '!', '?' }, StringSplitOptions.RemoveEmptyEntries); vocabulary.AddRange(titleWords); // Extract words from topic var topicWords = lesson.Topic.Split(new[] { ' ', ',', '.', '!', '?' }, StringSplitOptions.RemoveEmptyEntries); vocabulary.AddRange(topicWords); } // Remove duplicates and filter return vocabulary .Where(w => !string.IsNullOrWhiteSpace(w)) .Where(w => w.Length > 2) // Skip very short words .Distinct() .OrderBy(w => w) .ToList(); } /// /// Builds a prompt for full story generation. /// /// The level ID /// The story theme /// List of vocabulary words /// Number of segments to generate /// The prompt string private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList vocabulary, int segmentCount) { var levelCode = GetLevelCode(levelId); var vocabularyString = string.Join(", ", vocabulary.Take(20)); if (vocabulary.Count > 20) { vocabularyString += ", ..."; } var requirements = GetStoryRequirements(levelCode); return $@"Write a {segmentCount}-part continuous story for a German {levelCode} learner. The story theme is: {theme}. Include these German words and phrases: {vocabularyString}. REQUIREMENTS: {requirements}"; } private string GetStoryRequirements(string levelCode) => levelCode switch { "A1" => "- Use ONLY A1-level vocabulary and grammar\n" + "- Use simple present tense (ich bin, ich habe, ich gehe)\n" + "- Basic sentence structure: Subject-Verb-Object\n" + "- Vocabulary: max 500 words, sentence length: max 10 words\n" + "- Each part: 2-3 short sentences\n" + "- Do NOT use: subjunctive, genitive, complex prepositions", "A2" => "- Use ONLY A2-level vocabulary and grammar\n" + "- Use present, past (Perfekt), future tense\n" + "- Can use subordinate clauses with weil, dass, wenn\n" + "- Vocabulary: 500-1000 words, sentence length: max 15 words\n" + "- Each part: 3-4 sentences\n" + "- Do NOT use: Konjunktiv II, Passiv, complex noun compounds", "B1" => "- Use ONLY B1-level vocabulary and grammar\n" + "- Use all tenses: present, past (Praeteritum/Perfekt), future\n" + "- Use modal verbs: koennen, muessen, duerfen, sollen, wollen, moegen\n" + "- Sentence structure: complex sentences with subordinate clauses\n" + "- Vocabulary: 1000-2000 words, sentence length: max 20 words\n" + "- Each part: 4-5 sentences", "B2" => "- Use ONLY B2-level vocabulary and grammar\n" + "- Use nuanced tenses: Present, Praeteritum, Perfekt, Plusquamperfekt, Future I/II\n" + "- Use all cases: Nominativ, Akkusativ, Dativ, Genitiv\n" + "- Use Konjunktiv I (indirect speech) and Konjunktiv II\n" + "- Use Passiv where appropriate\n" + "- Vocabulary: 2000-3000 words, sentence length: max 25 words\n" + "- Each part: 5-6 sentences", "C1" => "- Use C1-level vocabulary and grammar with precision\n" + "- Use all tenses and moods: Indikativ, Konjunktiv I/II, Passiv in all forms\n" + "- Sentence structure: highly complex with nested relative clauses\n" + "- Use sophisticated vocabulary including idiomatic expressions\n" + "- Vocabulary: 3000-5000 words, sentence length: can exceed 25 words\n" + "- Each part: 6-8 sentences", _ => "- Use only " + levelCode + " level vocabulary and grammar" }; /// /// Builds a prompt for a single story segment. /// /// The level code (A1, A2, etc.) /// The story theme /// List of vocabulary words /// The prompt string private string BuildSegmentPrompt(string levelCode, string theme, IReadOnlyList vocabulary) { var vocabularyString = string.Join(", ", vocabulary.Take(15)); if (vocabulary.Count > 15) { vocabularyString += ", ..."; } var sentenceCount = levelCode switch { "A1" => "3-5", "A2" => "4-6", "B1" => "5-7", "B2" => "6-8", "C1" => "7-10", _ => "5-8" }; var requirements = GetSegmentRequirements(levelCode); return $@"Write a short story segment ({sentenceCount} sentences) for a German {levelCode} learner. The theme is: {theme}. Include these German words: {vocabularyString}. REQUIREMENTS: {requirements}"; } private string GetSegmentRequirements(string levelCode) => levelCode switch { "A1" => "- Use ONLY A1-level vocabulary and grammar\n" + "- Use simple present tense only (ich bin, ich habe, ich gehe)\n" + "- Basic sentence structure: Subject-Verb-Object\n" + "- Sentence length: max 8 words\n" + "- Do NOT use: subjunctive, genitive, complex prepositions", "A2" => "- Use ONLY A2-level vocabulary and grammar\n" + "- Use present, past (Perfekt), future tense\n" + "- Can use simple subordinate clauses with weil, dass, wenn\n" + "- Sentence length: max 12 words", "B1" => "- Use ONLY B1-level vocabulary and grammar\n" + "- Use present, past (Praeteritum/Perfekt), future tense\n" + "- Use modal verbs: koennen, muessen, duerfen, sollen, wollen\n" + "- Sentence structure: complex sentences with subordinate clauses\n" + "- Sentence length: max 20 words", "B2" => "- Use ONLY B2-level vocabulary and grammar\n" + "- Use nuanced tenses including Plusquamperfekt and Konjunktiv II\n" + "- Use all cases: Nominativ, Akkusativ, Dativ, Genitiv\n" + "- Use Passiv where appropriate\n" + "- Sentence length: max 25 words", "C1" => "- Use C1-level vocabulary and grammar with precision\n" + "- Use all tenses, moods, and cases correctly\n" + "- Use sophisticated vocabulary including idiomatic expressions\n" + "- Sentence structure: complex nested sentences with multiple clauses", _ => "- Use only " + levelCode + " level vocabulary and grammar" }; /// /// Splits a full story text into segments. /// /// The full story text /// Number of segments to create /// List of story segments private List SplitStoryIntoSegments(string story, int segmentCount) { var segments = new List(); // Split by double newlines (paragraphs) var paragraphs = story.Split(new[] { "\n\n", "\n\r\n", "\r\n\r\n" }, StringSplitOptions.RemoveEmptyEntries); if (paragraphs.Length >= segmentCount) { // Take the first segmentCount paragraphs for (int i = 0; i < segmentCount; i++) { segments.Add(paragraphs[i].Trim()); } } else { // Need to split paragraphs further // Calculate how many paragraphs per segment we need var paragraphsPerSegment = Math.Max(1, paragraphs.Length / segmentCount); for (int i = 0; i < segmentCount; i++) { var start = i * paragraphsPerSegment; var end = Math.Min((i + 1) * paragraphsPerSegment, paragraphs.Length); var segmentText = string.Join(" ", paragraphs[start..end]).Trim(); segments.Add(segmentText); } } return segments; } /// /// Adjusts the segment count to match the expected number. /// /// Current list of segments /// Target number of segments /// Adjusted list of segments private List AdjustSegmentCount(List segments, int targetCount) { if (segments.Count == targetCount) return segments; if (segments.Count < targetCount) { // Duplicate the last segment to fill while (segments.Count < targetCount) { segments.Add(segments[^1]); } } else { // Combine segments to reduce count var result = new List(); var combineCount = (int)Math.Ceiling((double)segments.Count / targetCount); for (int i = 0; i < segments.Count; i += combineCount) { var end = Math.Min(i + combineCount, segments.Count); var combined = string.Join(" ", segments[i..end]).Trim(); result.Add(combined); } segments = result; } return segments; } /// /// Gets the level code (A1, A2, etc.) from the level ID. /// This is a temporary implementation - in a real app, we would look this up. /// /// The level ID /// The level code private string GetLevelCode(int levelId) { // This is a placeholder - the actual mapping should come from the database return levelId switch { 1 => "A1", 2 => "A2", 3 => "B1", 4 => "B2", 5 => "C1", _ => "A1" }; } }