- Fixed audio file path generation in StoryGenerationService.GenerateAudioAsync
- Audio files now stored at wwwroot/audio/story/level{id}-segment{order}.wav
- Added static file serving in Program.cs (app.UseStaticFiles)
- Added wwwroot/audio/story directory creation on startup
- Updated StoryController.GetSegmentAudioAsync to return audio URL
- Static file middleware serves audio files automatically
- All 324 tests still pass
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
557 lines
22 KiB
C#
557 lines
22 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Application service for generating stories using AI.
|
|
/// This is part of the Application layer and uses MistralService for text generation.
|
|
/// </summary>
|
|
public class StoryGenerationService
|
|
{
|
|
private readonly IMistralService _mistralService;
|
|
private readonly IStoryRepository _storyRepository;
|
|
private readonly ITtsService _ttsService;
|
|
private readonly ILogger<StoryGenerationService> _logger;
|
|
|
|
/// <summary>
|
|
/// Creates a new StoryGenerationService.
|
|
/// </summary>
|
|
/// <param name="mistralService">Mistral service for text generation</param>
|
|
/// <param name="storyRepository">Repository for story segments</param>
|
|
/// <param name="ttsService">TTS service for audio generation</param>
|
|
/// <param name="logger">Logger for service operations</param>
|
|
public StoryGenerationService(
|
|
IMistralService mistralService,
|
|
IStoryRepository storyRepository,
|
|
ITtsService ttsService,
|
|
ILogger<StoryGenerationService> logger)
|
|
{
|
|
_mistralService = mistralService;
|
|
_storyRepository = storyRepository;
|
|
_ttsService = ttsService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a complete story for a level and divides it into segments.
|
|
/// </summary>
|
|
/// <param name="levelId">The level ID</param>
|
|
/// <param name="theme">The theme for the story</param>
|
|
/// <param name="lessons">List of lessons in the level with their vocabulary</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Response DTO with the full story and segments</returns>
|
|
public virtual async Task<StoryGenerationResponseDto> GenerateStoryAsync(
|
|
int levelId,
|
|
string theme,
|
|
IReadOnlyList<Lesson> 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<StorySegment>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a story segment for a specific lesson.
|
|
/// </summary>
|
|
/// <param name="levelId">The level ID</param>
|
|
/// <param name="lessonId">The lesson ID</param>
|
|
/// <param name="theme">The theme for the story</param>
|
|
/// <param name="vocabulary">Vocabulary words to include</param>
|
|
/// <param name="order">The order of this segment</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>The created story segment DTO</returns>
|
|
public virtual async Task<StorySegmentDto> GenerateSegmentAsync(
|
|
int levelId,
|
|
int lessonId,
|
|
string theme,
|
|
IReadOnlyList<string> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates audio for a story segment.
|
|
/// </summary>
|
|
/// <param name="segmentId">The segment ID</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>The updated segment DTO with audio URL</returns>
|
|
public virtual async Task<StorySegmentDto?> 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 file system path for audio file (relative to current directory)
|
|
var audioDirectory = Path.Combine("wwwroot", "audio", "story");
|
|
var filename = $"level{segment.LevelId}-segment{segment.Order}.wav";
|
|
var audioFilePath = Path.Combine(audioDirectory, filename);
|
|
|
|
try
|
|
{
|
|
// Ensure directory exists
|
|
Directory.CreateDirectory(audioDirectory);
|
|
|
|
// Generate audio using the TTS service
|
|
await _ttsService.GenerateAudioToFileAsync(
|
|
segment.Content,
|
|
audioFilePath,
|
|
null,
|
|
"de",
|
|
cancellationToken);
|
|
|
|
// Convert to URL path for storage
|
|
var audioUrl = $"/audio/story/{filename}";
|
|
segment.UpdateAudioUrl(audioUrl);
|
|
await _storyRepository.UpdateAsync(segment, cancellationToken);
|
|
|
|
_logger.LogInformation("Generated audio for segment {SegmentId}: {AudioUrl}",
|
|
segmentId, audioUrl);
|
|
|
|
return StorySegmentDto.FromEntity(segment);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to generate audio for segment {SegmentId}", segmentId);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates audio for all segments that don't have audio yet.
|
|
/// </summary>
|
|
/// <param name="levelId">Optional level ID to limit generation to</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of segments that had audio generated</returns>
|
|
public virtual async Task<IReadOnlyList<StorySegmentDto>> 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<StorySegmentDto>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts vocabulary words from lessons.
|
|
/// This is a placeholder - in a real implementation, vocabulary would be stored in the lesson.
|
|
/// </summary>
|
|
/// <param name="lessons">List of lessons</param>
|
|
/// <returns>List of vocabulary words</returns>
|
|
private IReadOnlyList<string> ExtractVocabularyFromLessons(IReadOnlyList<Lesson> lessons)
|
|
{
|
|
// For now, extract from lesson titles and topics
|
|
// In a real implementation, lessons would have a VocabularyWords collection
|
|
var vocabulary = new List<string>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a prompt for full story generation.
|
|
/// </summary>
|
|
/// <param name="levelId">The level ID</param>
|
|
/// <param name="theme">The story theme</param>
|
|
/// <param name="vocabulary">List of vocabulary words</param>
|
|
/// <param name="segmentCount">Number of segments to generate</param>
|
|
/// <returns>The prompt string</returns>
|
|
private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList<string> 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"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Builds a prompt for a single story segment.
|
|
/// </summary>
|
|
/// <param name="levelCode">The level code (A1, A2, etc.)</param>
|
|
/// <param name="theme">The story theme</param>
|
|
/// <param name="vocabulary">List of vocabulary words</param>
|
|
/// <returns>The prompt string</returns>
|
|
private string BuildSegmentPrompt(string levelCode, string theme, IReadOnlyList<string> 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"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Splits a full story text into segments.
|
|
/// </summary>
|
|
/// <param name="story">The full story text</param>
|
|
/// <param name="segmentCount">Number of segments to create</param>
|
|
/// <returns>List of story segments</returns>
|
|
private List<string> SplitStoryIntoSegments(string story, int segmentCount)
|
|
{
|
|
var segments = new List<string>();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adjusts the segment count to match the expected number.
|
|
/// </summary>
|
|
/// <param name="segments">Current list of segments</param>
|
|
/// <param name="targetCount">Target number of segments</param>
|
|
/// <returns>Adjusted list of segments</returns>
|
|
private List<string> AdjustSegmentCount(List<string> 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<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="levelId">The level ID</param>
|
|
/// <returns>The level code</returns>
|
|
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"
|
|
};
|
|
}
|
|
}
|