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>
This commit is contained in:
parent
989ffb032c
commit
aa3db295ce
8 changed files with 113 additions and 76 deletions
|
|
@ -16,7 +16,7 @@ JWT_EXPIREHOURS=24
|
|||
# ============================================
|
||||
MISTRAL_APIKEY=YOUR_MISTRAL_API_KEY_HERE
|
||||
MISTRAL_BASEURL=https://api.mistral.ai/v1/
|
||||
MISTRAL_DEFAULTMODEL=mistral-medium
|
||||
MISTRAL_DEFAULTMODEL=mistral-small-latest
|
||||
MISTRAL_TIMEOUTSECONDS=30
|
||||
MISTRAL_MAXRETRIES=3
|
||||
MISTRAL_RATELIMITPERMINUTE=10
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GermanApp.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -108,72 +110,38 @@ public record MistralChatRequest
|
|||
/// <summary>
|
||||
/// ID of the model to use.
|
||||
/// </summary>
|
||||
public string Model { get; init; } = "mistral-medium";
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; init; } = "mistral-small-latest";
|
||||
|
||||
/// <summary>
|
||||
/// A list of messages comprising the conversation so far.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messages")]
|
||||
public IReadOnlyList<MistralMessage> Messages { get; init; } = new List<MistralMessage>();
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of tokens to generate in the completion.
|
||||
/// </summary>
|
||||
public int? MaxTokens { get; init; } = 512;
|
||||
[JsonPropertyName("max_tokens")]
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What sampling temperature to use.
|
||||
/// </summary>
|
||||
public double? Temperature { get; init; } = 0.7;
|
||||
|
||||
/// <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; }
|
||||
[JsonPropertyName("temperature")]
|
||||
public double? Temperature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a chat request.
|
||||
/// </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
|
||||
{
|
||||
Model = model,
|
||||
Messages = messages,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = 0.7,
|
||||
TopP = 1.0,
|
||||
N = 1,
|
||||
Stream = false
|
||||
Temperature = 0.7
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -186,11 +154,13 @@ public record MistralMessage
|
|||
/// <summary>
|
||||
/// The role of the message author.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The content of the message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public class MistralService : IMistralService
|
|||
|
||||
/// <summary>
|
||||
/// Generates text from a prompt using Mistral API.
|
||||
/// Uses chat completion endpoint (completions endpoint is deprecated).
|
||||
/// </summary>
|
||||
public virtual async Task<string> GenerateTextAsync(
|
||||
string prompt,
|
||||
|
|
@ -33,22 +34,29 @@ public class MistralService : IMistralService
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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,
|
||||
Prompt = prompt,
|
||||
Temperature = temperature,
|
||||
MaxTokens = maxTokens.Value
|
||||
new MistralMessage { Role = "user", Content = prompt }
|
||||
};
|
||||
|
||||
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)
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ public class StoryGenerationService
|
|||
int levelId,
|
||||
string theme,
|
||||
IReadOnlyList<Lesson> lessons,
|
||||
int segmentCount = 5,
|
||||
string? customPrompt = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
|
|
@ -68,7 +70,7 @@ public class StoryGenerationService
|
|||
}
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
@ -347,7 +349,7 @@ public class StoryGenerationService
|
|||
/// <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)
|
||||
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));
|
||||
|
|
@ -358,12 +360,19 @@ public class StoryGenerationService
|
|||
}
|
||||
|
||||
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}.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ public class MistralConfig
|
|||
|
||||
/// <summary>
|
||||
/// The default model to use for completions.
|
||||
/// Default: mistral-medium
|
||||
/// Default: mistral-small-latest
|
||||
/// </summary>
|
||||
public string DefaultModel { get; set; } = "mistral-medium";
|
||||
public string DefaultModel { get; set; } = "mistral-small-latest";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for HTTP requests.
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ public class MistralConnector : IMistralConnector
|
|||
_cache = cache;
|
||||
|
||||
// Initialize JSON options (always needed)
|
||||
// Mistral API uses snake_case for property names (max_tokens, not maxTokens)
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
// Skip validation and initialization during EF migrations
|
||||
|
|
@ -160,6 +161,7 @@ public class MistralConnector : IMistralConnector
|
|||
var response = await ExecuteWithRetryAsync(async () =>
|
||||
{
|
||||
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 httpResponse = await _httpClient.PostAsync(endpoint, content, cancellationToken);
|
||||
return await HandleResponseAsync<TResponse>(httpResponse);
|
||||
|
|
@ -224,22 +226,68 @@ public class MistralConnector : IMistralConnector
|
|||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
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);
|
||||
var message = errorResponse?.Message ?? $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}";
|
||||
AiErrorCode errorCode = MapStatusCodeToErrorCode(httpResponse.StatusCode);
|
||||
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}",
|
||||
(int)httpResponse.StatusCode, message);
|
||||
|
||||
httpResponse.EnsureSuccessStatusCode(); // This will throw
|
||||
httpResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var content = await httpResponse.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<T>(content, _jsonOptions)
|
||||
?? throw new AiServiceException("Invalid response from Mistral API",
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ public class StoryController : ControllerBase
|
|||
levelId,
|
||||
request.Theme,
|
||||
lessons,
|
||||
request.SegmentCount,
|
||||
request.CustomPrompt,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(response);
|
||||
|
|
|
|||
|
|
@ -34,17 +34,17 @@ services:
|
|||
Jwt__Issuer: ${JWT_ISSUER:-DeutschLernen}
|
||||
Jwt__Audience: ${JWT_AUDIENCE:-DeutschLernen}
|
||||
Jwt__ExpireHours: ${JWT_EXPIREHOURS:-24}
|
||||
# Mistral API Configuration (from appsettings.Development.json)
|
||||
Mistral__ApiKey: Odq70O3NUQ1H8hMhGJAFAz5OJfukX6lT
|
||||
Mistral__BaseUrl: https://api.mistral.ai/v1/
|
||||
Mistral__DefaultModel: mistral-medium
|
||||
Mistral__TimeoutSeconds: 30
|
||||
Mistral__MaxRetries: 3
|
||||
Mistral__RateLimitPerMinute: 10
|
||||
Mistral__EnableCaching: true
|
||||
Mistral__CacheTTLMinutes: 60
|
||||
Mistral__CircuitBreakerFailureThreshold: 5
|
||||
Mistral__CircuitBreakerResetMinutes: 1
|
||||
# Mistral API Configuration (from Woodpecker secrets or environment)
|
||||
Mistral__ApiKey: ${MISTRAL_APIKEY:-}
|
||||
Mistral__BaseUrl: ${MISTRAL_BASEURL:-https://api.mistral.ai/v1/}
|
||||
Mistral__DefaultModel: ${MISTRAL_DEFAULTMODEL:-mistral-small-latest}
|
||||
Mistral__TimeoutSeconds: ${MISTRAL_TIMEOUTSECONDS:-30}
|
||||
Mistral__MaxRetries: ${MISTRAL_MAXRETRIES:-3}
|
||||
Mistral__RateLimitPerMinute: ${MISTRAL_RATELIMITPERMINUTE:-10}
|
||||
Mistral__EnableCaching: ${MISTRAL_ENABLECACHING:-true}
|
||||
Mistral__CacheTTLMinutes: ${MISTRAL_CACHETTLMINUTES:-60}
|
||||
Mistral__CircuitBreakerFailureThreshold: ${MISTRAL_CIRCUITBREAKERFAILURETHRESHOLD:-5}
|
||||
Mistral__CircuitBreakerResetMinutes: ${MISTRAL_CIRCUITBREAKERRESETMINUTES:-1}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue