Merge feature/fix-mistral-story-generation into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Lasse Rune Hansen 2026-06-18 16:08:42 +02:00
commit 0733314aab
8 changed files with 113 additions and 76 deletions

View file

@ -16,7 +16,7 @@ JWT_EXPIREHOURS=24
# ============================================ # ============================================
MISTRAL_APIKEY=YOUR_MISTRAL_API_KEY_HERE MISTRAL_APIKEY=YOUR_MISTRAL_API_KEY_HERE
MISTRAL_BASEURL=https://api.mistral.ai/v1/ MISTRAL_BASEURL=https://api.mistral.ai/v1/
MISTRAL_DEFAULTMODEL=mistral-medium MISTRAL_DEFAULTMODEL=mistral-small-latest
MISTRAL_TIMEOUTSECONDS=30 MISTRAL_TIMEOUTSECONDS=30
MISTRAL_MAXRETRIES=3 MISTRAL_MAXRETRIES=3
MISTRAL_RATELIMITPERMINUTE=10 MISTRAL_RATELIMITPERMINUTE=10

View file

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace GermanApp.Application.Models; namespace GermanApp.Application.Models;
/// <summary> /// <summary>
@ -108,72 +110,38 @@ public record MistralChatRequest
/// <summary> /// <summary>
/// ID of the model to use. /// ID of the model to use.
/// </summary> /// </summary>
public string Model { get; init; } = "mistral-medium"; [JsonPropertyName("model")]
public string Model { get; init; } = "mistral-small-latest";
/// <summary> /// <summary>
/// A list of messages comprising the conversation so far. /// A list of messages comprising the conversation so far.
/// </summary> /// </summary>
[JsonPropertyName("messages")]
public IReadOnlyList<MistralMessage> Messages { get; init; } = new List<MistralMessage>(); public IReadOnlyList<MistralMessage> Messages { get; init; } = new List<MistralMessage>();
/// <summary> /// <summary>
/// The maximum number of tokens to generate in the completion. /// The maximum number of tokens to generate in the completion.
/// </summary> /// </summary>
public int? MaxTokens { get; init; } = 512; [JsonPropertyName("max_tokens")]
public int? MaxTokens { get; init; }
/// <summary> /// <summary>
/// What sampling temperature to use. /// What sampling temperature to use.
/// </summary> /// </summary>
public double? Temperature { get; init; } = 0.7; [JsonPropertyName("temperature")]
public double? Temperature { get; init; }
/// <summary>
/// Nucleus sampling.
/// </summary>
public double? TopP { get; init; } = 1.0;
/// <summary>
/// How many chat completions to generate for each input message.
/// </summary>
public int? N { get; init; } = 1;
/// <summary>
/// Whether to stream partial responses.
/// </summary>
public bool? Stream { get; init; } = false;
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
public IReadOnlyList<string>? Stop { get; init; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency.
/// </summary>
public double? FrequencyPenalty { get; init; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear.
/// </summary>
public double? PresencePenalty { get; init; }
/// <summary>
/// User identifier for tracking usage.
/// </summary>
public string? User { get; init; }
/// <summary> /// <summary>
/// Factory method to create a chat request. /// Factory method to create a chat request.
/// </summary> /// </summary>
public static MistralChatRequest CreateChat(IReadOnlyList<MistralMessage> messages, string model = "mistral-medium", int maxTokens = 512) public static MistralChatRequest CreateChat(IReadOnlyList<MistralMessage> messages, string model = "mistral-small-latest", int maxTokens = 512)
{ {
return new MistralChatRequest return new MistralChatRequest
{ {
Model = model, Model = model,
Messages = messages, Messages = messages,
MaxTokens = maxTokens, MaxTokens = maxTokens,
Temperature = 0.7, Temperature = 0.7
TopP = 1.0,
N = 1,
Stream = false
}; };
} }
} }
@ -186,11 +154,13 @@ public record MistralMessage
/// <summary> /// <summary>
/// The role of the message author. /// The role of the message author.
/// </summary> /// </summary>
[JsonPropertyName("role")]
public string Role { get; init; } = string.Empty; public string Role { get; init; } = string.Empty;
/// <summary> /// <summary>
/// The content of the message. /// The content of the message.
/// </summary> /// </summary>
[JsonPropertyName("content")]
public string Content { get; init; } = string.Empty; public string Content { get; init; } = string.Empty;
/// <summary> /// <summary>

View file

@ -24,6 +24,7 @@ public class MistralService : IMistralService
/// <summary> /// <summary>
/// Generates text from a prompt using Mistral API. /// Generates text from a prompt using Mistral API.
/// Uses chat completion endpoint (completions endpoint is deprecated).
/// </summary> /// </summary>
public virtual async Task<string> GenerateTextAsync( public virtual async Task<string> GenerateTextAsync(
string prompt, string prompt,
@ -33,22 +34,29 @@ public class MistralService : IMistralService
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
model ??= _config.DefaultModel; model ??= _config.DefaultModel;
maxTokens ??= 500;
var request = new MistralRequest // Use chat completion endpoint instead of completions (which is deprecated)
// Send minimal request matching the working curl: only model + messages
var messages = new List<MistralMessage>
{ {
Model = model, new MistralMessage { Role = "user", Content = prompt }
Prompt = prompt,
Temperature = temperature,
MaxTokens = maxTokens.Value
}; };
var response = await _connector.CompleteAsync(request, cancellationToken); // Build request - only include max_tokens and temperature if explicitly provided
var request = new MistralChatRequest
{
Model = model,
Messages = messages,
MaxTokens = maxTokens,
Temperature = temperature == 0.7f ? null : temperature
};
var response = await _connector.ChatAsync(request, cancellationToken);
if (response.Choices == null || response.Choices.Count == 0) if (response.Choices == null || response.Choices.Count == 0)
throw new InvalidOperationException("No choices returned from Mistral API"); throw new InvalidOperationException("No choices returned from Mistral API");
return response.Choices[0].Text ?? string.Empty; return response.Choices[0].Message?.Content ?? string.Empty;
} }
/// <summary> /// <summary>

View file

@ -52,6 +52,8 @@ public class StoryGenerationService
int levelId, int levelId,
string theme, string theme,
IReadOnlyList<Lesson> lessons, IReadOnlyList<Lesson> lessons,
int segmentCount = 5,
string? customPrompt = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_logger.LogInformation( _logger.LogInformation(
@ -68,7 +70,7 @@ public class StoryGenerationService
} }
// Build the prompt for Mistral // Build the prompt for Mistral
var prompt = BuildStoryPrompt(levelId, theme, allVocabulary, lessons.Count); var prompt = BuildStoryPrompt(levelId, theme, allVocabulary, segmentCount, customPrompt);
_logger.LogDebug("Story generation prompt: {Prompt}", prompt); _logger.LogDebug("Story generation prompt: {Prompt}", prompt);
@ -347,7 +349,7 @@ public class StoryGenerationService
/// <param name="vocabulary">List of vocabulary words</param> /// <param name="vocabulary">List of vocabulary words</param>
/// <param name="segmentCount">Number of segments to generate</param> /// <param name="segmentCount">Number of segments to generate</param>
/// <returns>The prompt string</returns> /// <returns>The prompt string</returns>
private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList<string> vocabulary, int segmentCount) private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList<string> vocabulary, int segmentCount, string? customPrompt = null)
{ {
var levelCode = GetLevelCode(levelId); var levelCode = GetLevelCode(levelId);
var vocabularyString = string.Join(", ", vocabulary.Take(20)); var vocabularyString = string.Join(", ", vocabulary.Take(20));
@ -358,12 +360,19 @@ public class StoryGenerationService
} }
var requirements = GetStoryRequirements(levelCode); var requirements = GetStoryRequirements(levelCode);
return $@"Write a {segmentCount}-part continuous story for a German {levelCode} learner. var prompt = $@"Write a {segmentCount}-part continuous story for a German {levelCode} learner.
The story theme is: {theme}. The story theme is: {theme}.
Include these German words and phrases: {vocabularyString}. Include these German words and phrases: {vocabularyString}.
REQUIREMENTS: REQUIREMENTS:
{requirements}"; {requirements}";
if (!string.IsNullOrWhiteSpace(customPrompt))
{
prompt += $"\n\nCUSTOM PROMPT:\n{customPrompt}";
}
return prompt;
} }
private string GetStoryRequirements(string levelCode) => levelCode switch private string GetStoryRequirements(string levelCode) => levelCode switch

View file

@ -19,9 +19,9 @@ public class MistralConfig
/// <summary> /// <summary>
/// The default model to use for completions. /// The default model to use for completions.
/// Default: mistral-medium /// Default: mistral-small-latest
/// </summary> /// </summary>
public string DefaultModel { get; set; } = "mistral-medium"; public string DefaultModel { get; set; } = "mistral-small-latest";
/// <summary> /// <summary>
/// Timeout in seconds for HTTP requests. /// Timeout in seconds for HTTP requests.

View file

@ -32,10 +32,11 @@ public class MistralConnector : IMistralConnector
_cache = cache; _cache = cache;
// Initialize JSON options (always needed) // Initialize JSON options (always needed)
// Mistral API uses snake_case for property names (max_tokens, not maxTokens)
_jsonOptions = new JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}; };
// Skip validation and initialization during EF migrations // Skip validation and initialization during EF migrations
@ -160,6 +161,7 @@ public class MistralConnector : IMistralConnector
var response = await ExecuteWithRetryAsync(async () => var response = await ExecuteWithRetryAsync(async () =>
{ {
var json = JsonSerializer.Serialize(request, _jsonOptions); var json = JsonSerializer.Serialize(request, _jsonOptions);
_logger.LogDebug("Mistral API request to {Endpoint}: {Request}", endpoint, json);
var content = new StringContent(json, Encoding.UTF8, "application/json"); var content = new StringContent(json, Encoding.UTF8, "application/json");
var httpResponse = await _httpClient.PostAsync(endpoint, content, cancellationToken); var httpResponse = await _httpClient.PostAsync(endpoint, content, cancellationToken);
return await HandleResponseAsync<TResponse>(httpResponse); return await HandleResponseAsync<TResponse>(httpResponse);
@ -224,22 +226,68 @@ public class MistralConnector : IMistralConnector
if (!httpResponse.IsSuccessStatusCode) if (!httpResponse.IsSuccessStatusCode)
{ {
var errorContent = await httpResponse.Content.ReadAsStringAsync(); var errorContent = await httpResponse.Content.ReadAsStringAsync();
var errorResponse = JsonSerializer.Deserialize<MistralErrorResponse>(errorContent, _jsonOptions); _logger.LogError("Mistral API error response (status {StatusCode}): {Content}",
(int)httpResponse.StatusCode, errorContent);
var errorCode = errorResponse?.ErrorCode ?? MapStatusCodeToErrorCode(httpResponse.StatusCode); AiErrorCode errorCode = MapStatusCodeToErrorCode(httpResponse.StatusCode);
var message = errorResponse?.Message ?? $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}"; string message = $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}";
// Try to extract error message from JSON without requiring exact DTO match
try
{
using var doc = JsonDocument.Parse(errorContent);
if (doc.RootElement.TryGetProperty("message", out var messageElement))
{
message = messageElement.ValueKind switch
{
JsonValueKind.String => messageElement.GetString() ?? message,
JsonValueKind.Object => messageElement.EnumerateObject().FirstOrDefault().Value.GetString() ?? message,
JsonValueKind.Array => messageElement.EnumerateArray().FirstOrDefault().GetString() ?? message,
_ => message
};
}
if (doc.RootElement.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String)
{
errorCode = typeElement.GetString() switch
{
"rate_limit_exceeded" or "rate_limit" => AiErrorCode.RateLimited,
"invalid_request_error" => AiErrorCode.InvalidRequest,
"authentication_error" or "invalid_api_key" => AiErrorCode.AuthenticationError,
"server_error" or "internal_server_error" => AiErrorCode.Temporary,
"timeout" => AiErrorCode.Timeout,
_ => errorCode
};
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not parse error response JSON, using status code mapping");
}
_logger.LogError("Mistral API error: {StatusCode} - {Message}", _logger.LogError("Mistral API error: {StatusCode} - {Message}",
(int)httpResponse.StatusCode, message); (int)httpResponse.StatusCode, message);
httpResponse.EnsureSuccessStatusCode(); // This will throw httpResponse.EnsureSuccessStatusCode();
} }
var content = await httpResponse.Content.ReadAsStringAsync(); var content = await httpResponse.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, _jsonOptions) try
?? throw new AiServiceException("Invalid response from Mistral API", {
var result = JsonSerializer.Deserialize<T>(content, _jsonOptions);
if (result == null)
{
_logger.LogError("Mistral API returned null when deserializing to {Type}. Content: {Content}", typeof(T).Name, content);
throw new AiServiceException("Invalid response from Mistral API",
AiErrorCode.InvalidResponse); AiErrorCode.InvalidResponse);
} }
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize Mistral response to {Type}. Raw JSON: {Content}", typeof(T).Name, content);
throw;
}
}
private string GenerateCacheKey<T>(T request) private string GenerateCacheKey<T>(T request)
{ {

View file

@ -243,6 +243,8 @@ public class StoryController : ControllerBase
levelId, levelId,
request.Theme, request.Theme,
lessons, lessons,
request.SegmentCount,
request.CustomPrompt,
cancellationToken); cancellationToken);
return Ok(response); return Ok(response);

View file

@ -34,17 +34,17 @@ services:
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen} Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}
Jwt__Audience: ${JWT_AUDIENCE:-DeutschLernen} Jwt__Audience: ${JWT_AUDIENCE:-DeutschLernen}
Jwt__ExpireHours: ${JWT_EXPIREHOURS:-24} Jwt__ExpireHours: ${JWT_EXPIREHOURS:-24}
# Mistral API Configuration (from appsettings.Development.json) # Mistral API Configuration (from Woodpecker secrets or environment)
Mistral__ApiKey: Odq70O3NUQ1H8hMhGJAFAz5OJfukX6lT Mistral__ApiKey: ${MISTRAL_APIKEY:-}
Mistral__BaseUrl: https://api.mistral.ai/v1/ Mistral__BaseUrl: ${MISTRAL_BASEURL:-https://api.mistral.ai/v1/}
Mistral__DefaultModel: mistral-medium Mistral__DefaultModel: ${MISTRAL_DEFAULTMODEL:-mistral-small-latest}
Mistral__TimeoutSeconds: 30 Mistral__TimeoutSeconds: ${MISTRAL_TIMEOUTSECONDS:-30}
Mistral__MaxRetries: 3 Mistral__MaxRetries: ${MISTRAL_MAXRETRIES:-3}
Mistral__RateLimitPerMinute: 10 Mistral__RateLimitPerMinute: ${MISTRAL_RATELIMITPERMINUTE:-10}
Mistral__EnableCaching: true Mistral__EnableCaching: ${MISTRAL_ENABLECACHING:-true}
Mistral__CacheTTLMinutes: 60 Mistral__CacheTTLMinutes: ${MISTRAL_CACHETTLMINUTES:-60}
Mistral__CircuitBreakerFailureThreshold: 5 Mistral__CircuitBreakerFailureThreshold: ${MISTRAL_CIRCUITBREAKERFAILURETHRESHOLD:-5}
Mistral__CircuitBreakerResetMinutes: 1 Mistral__CircuitBreakerResetMinutes: ${MISTRAL_CIRCUITBREAKERRESETMINUTES:-1}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy