diff --git a/.env.example b/.env.example index 15bb94a..5097808 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/GermanApp/Application/Models/MistralRequest.cs b/GermanApp/Application/Models/MistralRequest.cs index f4ebb4b..bf524d7 100644 --- a/GermanApp/Application/Models/MistralRequest.cs +++ b/GermanApp/Application/Models/MistralRequest.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace GermanApp.Application.Models; /// @@ -108,72 +110,38 @@ public record MistralChatRequest /// /// ID of the model to use. /// - public string Model { get; init; } = "mistral-medium"; + [JsonPropertyName("model")] + public string Model { get; init; } = "mistral-small-latest"; /// /// A list of messages comprising the conversation so far. /// + [JsonPropertyName("messages")] public IReadOnlyList Messages { get; init; } = new List(); /// /// The maximum number of tokens to generate in the completion. /// - public int? MaxTokens { get; init; } = 512; + [JsonPropertyName("max_tokens")] + public int? MaxTokens { get; init; } /// /// What sampling temperature to use. /// - public double? Temperature { get; init; } = 0.7; - - /// - /// Nucleus sampling. - /// - public double? TopP { get; init; } = 1.0; - - /// - /// How many chat completions to generate for each input message. - /// - public int? N { get; init; } = 1; - - /// - /// Whether to stream partial responses. - /// - public bool? Stream { get; init; } = false; - - /// - /// Up to 4 sequences where the API will stop generating further tokens. - /// - public IReadOnlyList? Stop { get; init; } - - /// - /// Number between -2.0 and 2.0. Positive values penalize new tokens based on their frequency. - /// - public double? FrequencyPenalty { get; init; } - - /// - /// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear. - /// - public double? PresencePenalty { get; init; } - - /// - /// User identifier for tracking usage. - /// - public string? User { get; init; } + [JsonPropertyName("temperature")] + public double? Temperature { get; init; } /// /// Factory method to create a chat request. /// - public static MistralChatRequest CreateChat(IReadOnlyList messages, string model = "mistral-medium", int maxTokens = 512) + public static MistralChatRequest CreateChat(IReadOnlyList 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 /// /// The role of the message author. /// + [JsonPropertyName("role")] public string Role { get; init; } = string.Empty; /// /// The content of the message. /// + [JsonPropertyName("content")] public string Content { get; init; } = string.Empty; /// diff --git a/GermanApp/Application/Services/MistralService.cs b/GermanApp/Application/Services/MistralService.cs index 79bafa9..ccf838d 100644 --- a/GermanApp/Application/Services/MistralService.cs +++ b/GermanApp/Application/Services/MistralService.cs @@ -24,6 +24,7 @@ public class MistralService : IMistralService /// /// Generates text from a prompt using Mistral API. + /// Uses chat completion endpoint (completions endpoint is deprecated). /// public virtual async Task 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 { - 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; } /// diff --git a/GermanApp/Application/Services/StoryGenerationService.cs b/GermanApp/Application/Services/StoryGenerationService.cs index d20f065..5063baa 100644 --- a/GermanApp/Application/Services/StoryGenerationService.cs +++ b/GermanApp/Application/Services/StoryGenerationService.cs @@ -52,6 +52,8 @@ public class StoryGenerationService int levelId, string theme, IReadOnlyList 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 /// List of vocabulary words /// Number of segments to generate /// The prompt string - private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList vocabulary, int segmentCount) + private string BuildStoryPrompt(int levelId, string theme, IReadOnlyList 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 diff --git a/GermanApp/Infrastructure/Configuration/MistralConfig.cs b/GermanApp/Infrastructure/Configuration/MistralConfig.cs index d27ce74..c767a82 100644 --- a/GermanApp/Infrastructure/Configuration/MistralConfig.cs +++ b/GermanApp/Infrastructure/Configuration/MistralConfig.cs @@ -19,9 +19,9 @@ public class MistralConfig /// /// The default model to use for completions. - /// Default: mistral-medium + /// Default: mistral-small-latest /// - public string DefaultModel { get; set; } = "mistral-medium"; + public string DefaultModel { get; set; } = "mistral-small-latest"; /// /// Timeout in seconds for HTTP requests. diff --git a/GermanApp/Infrastructure/Services/MistralConnector.cs b/GermanApp/Infrastructure/Services/MistralConnector.cs index a09f517..513fc15 100644 --- a/GermanApp/Infrastructure/Services/MistralConnector.cs +++ b/GermanApp/Infrastructure/Services/MistralConnector.cs @@ -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(httpResponse); @@ -224,21 +226,67 @@ public class MistralConnector : IMistralConnector if (!httpResponse.IsSuccessStatusCode) { var errorContent = await httpResponse.Content.ReadAsStringAsync(); - var errorResponse = JsonSerializer.Deserialize(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(content, _jsonOptions) - ?? throw new AiServiceException("Invalid response from Mistral API", - AiErrorCode.InvalidResponse); + try + { + var result = JsonSerializer.Deserialize(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 request) diff --git a/GermanApp/Presentation/Controllers/StoryController.cs b/GermanApp/Presentation/Controllers/StoryController.cs index c0b673d..48c8955 100644 --- a/GermanApp/Presentation/Controllers/StoryController.cs +++ b/GermanApp/Presentation/Controllers/StoryController.cs @@ -243,6 +243,8 @@ public class StoryController : ControllerBase levelId, request.Theme, lessons, + request.SegmentCount, + request.CustomPrompt, cancellationToken); return Ok(response); diff --git a/docker-compose.yml b/docker-compose.yml index 8632890..92463a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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