Implement story integration backend services:
- Create StoryService (Application/Services/StoryService.cs) with full CRUD operations
- Create StoryGenerationService (Application/Services/StoryGenerationService.cs) for AI-powered story generation
- Uses IMistralService for text generation
- Uses ITtsService for audio generation
- Splits stories into segments per lesson
- Create StoryUnlockService (Application/Services/StoryUnlockService.cs) for progress management
- Handles lesson completion → story segment unlocking
- Create StoryController (Presentation/Controllers/StoryController.cs) with 12 endpoints:
- GET /api/story/levels - list levels with stories
- GET /api/story/levels/{levelId}/segments - get segments for level
- GET /api/story/segments/{id} - get specific segment
- POST /api/story/segments - create segment (Admin)
- PUT /api/story/segments/{id} - update segment (Admin)
- DELETE /api/story/segments/{id} - delete segment (Admin)
- POST /api/story/levels/{levelId}/generate - generate story with AI (Admin)
- POST /api/story/segments/{segmentId}/audio - generate audio (Admin)
- GET /api/story/levels/{levelId}/progress - get user progress
- POST /api/story/segments/{segmentId}/complete - mark as completed
- GET /api/story/levels/{levelId}/next - get next segment
- GET /api/story/segments/{segmentId}/unlocked - check if unlocked
- GET /api/story/segments/{segmentId}/audio - get audio URL
- GET /api/story/levels/{levelId}/lessons-with-stories - get lessons with story status
- Register all services in Program.cs DI container
- Update feature document to reflect Phase 2 completion
Next: Phase 3 - AI Integration (unit tests for services)
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
483 lines
17 KiB
C#
483 lines
17 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 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;
|
|
}
|
|
}
|
|
|
|
/// <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)); // 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.";
|
|
}
|
|
|
|
/// <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 += ", ...";
|
|
}
|
|
|
|
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.";
|
|
}
|
|
|
|
/// <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"
|
|
};
|
|
}
|
|
}
|