DeutschLernen/GermanApp/Application/Services/StoryGenerationService.cs
Lasse Rune Hansen aa3db295ce fix(backend): Fix Mistral story generation with proper chat completion format
- Update MistralChatRequest to remove unused properties (User, Stop, FrequencyPenalty, PresencePenalty) causing 422 errors
- Add explicit JsonPropertyName attributes to ensure correct JSON serialization
- Update MistralService to send minimal requests matching Mistral API expectations
- Pass SegmentCount and CustomPrompt through StoryGenerationService to build proper prompts
- Add request logging in MistralConnector to debug API calls
- Update default model to mistral-small-latest
- Enhance error handling to log raw JSON responses

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-18 15:21:04 +02:00

566 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,
int segmentCount = 5,
string? customPrompt = null,
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, segmentCount, customPrompt);
_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, string? customPrompt = null)
{
var levelCode = GetLevelCode(levelId);
var vocabularyString = string.Join(", ", vocabulary.Take(20));
if (vocabulary.Count > 20)
{
vocabularyString += ", ...";
}
var requirements = GetStoryRequirements(levelCode);
var prompt = $@"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}";
if (!string.IsNullOrWhiteSpace(customPrompt))
{
prompt += $"\n\nCUSTOM PROMPT:\n{customPrompt}";
}
return prompt;
}
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"
};
}
}