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)); // Limit to first 20 words
if (vocabulary.Count > 20)
{
vocabularyString += ", ...";
}
return $@"Write a {segmentCount}-part continuous story for a German {levelCode} learner.
The story theme is: {theme}.
Include these German words and phrases: {vocabularyString}.
Use only {levelCode} level vocabulary and grammar.
Write each part as a separate paragraph.
Make the story engaging and appropriate for language learners.";
}
///
/// 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 += ", ...";
}
return $@"Write a short story segment (5-8 sentences) for a German {levelCode} learner.
The theme is: {theme}.
Include these German words: {vocabularyString}.
Use only {levelCode} level vocabulary and grammar.
Make it engaging and appropriate for language learners.";
}
///
/// 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"
};
}
}