482 lines
16 KiB
C#
482 lines
16 KiB
C#
using DramaLing.Api.Models.DTOs;
|
|
using DramaLing.Api.Models.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.Json;
|
|
using System.Text;
|
|
using System.Diagnostics;
|
|
|
|
namespace DramaLing.Api.Services.AI;
|
|
|
|
/// <summary>
|
|
/// Google Gemini AI 提供商實作
|
|
/// </summary>
|
|
public class GeminiAIProvider : IAIProvider
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<GeminiAIProvider> _logger;
|
|
private readonly GeminiOptions _options;
|
|
private AIProviderStats _stats;
|
|
|
|
public GeminiAIProvider(HttpClient httpClient, IOptions<GeminiOptions> options, ILogger<GeminiAIProvider> logger)
|
|
{
|
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
|
|
|
_stats = new AIProviderStats();
|
|
|
|
_httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds);
|
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "DramaLing/1.0");
|
|
|
|
_logger.LogInformation("GeminiAIProvider initialized with model: {Model}, timeout: {Timeout}s",
|
|
_options.Model, _options.TimeoutSeconds);
|
|
}
|
|
|
|
#region IAIProvider 屬性
|
|
|
|
public string ProviderName => "Google Gemini";
|
|
|
|
public bool IsAvailable => !string.IsNullOrEmpty(_options.ApiKey);
|
|
|
|
public decimal CostPerRequest => 0.001m; // 大概每次請求成本
|
|
|
|
public int MaxInputLength => _options.MaxOutputTokens / 4; // 粗略估計
|
|
|
|
public int AverageResponseTimeMs => _stats.AverageResponseTimeMs;
|
|
|
|
#endregion
|
|
|
|
#region 核心功能
|
|
|
|
public async Task<SentenceAnalysisData> AnalyzeSentenceAsync(string inputText, AnalysisOptions options)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
_stats.TotalRequests++;
|
|
|
|
try
|
|
{
|
|
_logger.LogInformation("Starting sentence analysis for text: {Text}",
|
|
inputText.Substring(0, Math.Min(50, inputText.Length)));
|
|
|
|
var prompt = BuildAnalysisPrompt(inputText);
|
|
var aiResponse = await CallGeminiAPIAsync(prompt);
|
|
|
|
if (string.IsNullOrWhiteSpace(aiResponse))
|
|
{
|
|
throw new InvalidOperationException("Gemini API returned empty response");
|
|
}
|
|
|
|
var analysisData = ParseAIResponse(inputText, aiResponse);
|
|
|
|
stopwatch.Stop();
|
|
RecordSuccessfulRequest(stopwatch.ElapsedMilliseconds);
|
|
|
|
_logger.LogInformation("Sentence analysis completed in {ElapsedMs}ms", stopwatch.ElapsedMilliseconds);
|
|
|
|
return analysisData;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
RecordFailedRequest(stopwatch.ElapsedMilliseconds);
|
|
|
|
_logger.LogError(ex, "Error analyzing sentence: {Text}", inputText);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<AIProviderHealthStatus> CheckHealthAsync()
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
var testPrompt = "Test health check prompt";
|
|
var response = await CallGeminiAPIAsync(testPrompt);
|
|
|
|
stopwatch.Stop();
|
|
|
|
return new AIProviderHealthStatus
|
|
{
|
|
IsHealthy = !string.IsNullOrEmpty(response),
|
|
CheckedAt = DateTime.UtcNow,
|
|
ResponseTimeMs = (int)stopwatch.ElapsedMilliseconds
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
|
|
return new AIProviderHealthStatus
|
|
{
|
|
IsHealthy = false,
|
|
ErrorMessage = ex.Message,
|
|
CheckedAt = DateTime.UtcNow,
|
|
ResponseTimeMs = (int)stopwatch.ElapsedMilliseconds
|
|
};
|
|
}
|
|
}
|
|
|
|
public Task<AIProviderStats> GetStatsAsync()
|
|
{
|
|
return Task.FromResult(_stats);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 私有方法
|
|
|
|
private string BuildAnalysisPrompt(string inputText)
|
|
{
|
|
return $@"You are an English learning assistant. Analyze this sentence and return ONLY a valid JSON response.
|
|
|
|
**Input Sentence**: ""{inputText}""
|
|
|
|
**Required JSON Structure:**
|
|
{{
|
|
""sentenceTranslation"": ""Traditional Chinese translation of the entire sentence"",
|
|
""hasGrammarErrors"": true/false,
|
|
""grammarCorrections"": [
|
|
{{
|
|
""original"": ""incorrect text"",
|
|
""corrected"": ""correct text"",
|
|
""type"": ""error type (tense/subject-verb/preposition/word-order)"",
|
|
""explanation"": ""brief explanation in Traditional Chinese""
|
|
}}
|
|
],
|
|
""vocabularyAnalysis"": {{
|
|
""word1"": {{
|
|
""word"": ""the word"",
|
|
""translation"": ""Traditional Chinese translation"",
|
|
""definition"": ""English definition"",
|
|
""partOfSpeech"": ""noun/verb/adjective/etc"",
|
|
""pronunciation"": ""/phonetic/"",
|
|
""difficultyLevel"": ""A1/A2/B1/B2/C1/C2"",
|
|
""frequency"": ""high/medium/low"",
|
|
""synonyms"": [""synonym1"", ""synonym2""],
|
|
""example"": ""example sentence"",
|
|
""exampleTranslation"": ""Traditional Chinese example translation""
|
|
}}
|
|
}},
|
|
""idioms"": [
|
|
{{
|
|
""idiom"": ""idiomatic expression"",
|
|
""translation"": ""Traditional Chinese meaning"",
|
|
""definition"": ""English explanation"",
|
|
""pronunciation"": ""/phonetic notation/"",
|
|
""difficultyLevel"": ""A1/A2/B1/B2/C1/C2"",
|
|
""frequency"": ""high/medium/low"",
|
|
""synonyms"": [""synonym1"", ""synonym2""],
|
|
""example"": ""usage example"",
|
|
""exampleTranslation"": ""Traditional Chinese example""
|
|
}}
|
|
]
|
|
}}
|
|
|
|
**Analysis Guidelines:**
|
|
1. **Grammar Check**: Detect tense errors, subject-verb agreement, preposition usage, word order
|
|
2. **Vocabulary Analysis**: Include ALL significant words (exclude articles: a, an, the)
|
|
3. **CEFR Levels**: Assign accurate A1-C2 levels for each word
|
|
4. **Idioms**: Identify any idiomatic expressions or phrasal verbs
|
|
5. **Translations**: Use Traditional Chinese (Taiwan standard)
|
|
|
|
**IMPORTANT**: Return ONLY the JSON object, no additional text or explanation.";
|
|
}
|
|
|
|
private async Task<string> CallGeminiAPIAsync(string prompt)
|
|
{
|
|
try
|
|
{
|
|
var requestBody = new
|
|
{
|
|
contents = new[]
|
|
{
|
|
new
|
|
{
|
|
parts = new[]
|
|
{
|
|
new { text = prompt }
|
|
}
|
|
}
|
|
},
|
|
generationConfig = new
|
|
{
|
|
temperature = _options.Temperature,
|
|
topK = 40,
|
|
topP = 0.95,
|
|
maxOutputTokens = _options.MaxOutputTokens
|
|
}
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(requestBody);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await _httpClient.PostAsync(
|
|
$"{_options.BaseUrl}/v1beta/models/{_options.Model}:generateContent?key={_options.ApiKey}",
|
|
content);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var responseJson = await response.Content.ReadAsStringAsync();
|
|
_logger.LogDebug("Raw Gemini API response: {Response}",
|
|
responseJson.Substring(0, Math.Min(500, responseJson.Length)));
|
|
|
|
return ExtractTextFromResponse(responseJson);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Gemini API call failed");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private string ExtractTextFromResponse(string responseJson)
|
|
{
|
|
using var document = JsonDocument.Parse(responseJson);
|
|
var root = document.RootElement;
|
|
|
|
if (root.TryGetProperty("candidates", out var candidatesElement) &&
|
|
candidatesElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var firstCandidate = candidatesElement.EnumerateArray().FirstOrDefault();
|
|
if (firstCandidate.ValueKind != JsonValueKind.Undefined &&
|
|
firstCandidate.TryGetProperty("content", out var contentElement) &&
|
|
contentElement.TryGetProperty("parts", out var partsElement) &&
|
|
partsElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var firstPart = partsElement.EnumerateArray().FirstOrDefault();
|
|
if (firstPart.TryGetProperty("text", out var textElement))
|
|
{
|
|
return textElement.GetString() ?? string.Empty;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 檢查是否有安全過濾
|
|
if (root.TryGetProperty("promptFeedback", out _))
|
|
{
|
|
_logger.LogWarning("Gemini content filtered due to safety policies");
|
|
return "The content analysis is temporarily unavailable due to safety filtering.";
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
private SentenceAnalysisData ParseAIResponse(string inputText, string aiResponse)
|
|
{
|
|
try
|
|
{
|
|
var cleanJson = CleanAIResponse(aiResponse);
|
|
var aiAnalysis = JsonSerializer.Deserialize<AiAnalysisResponse>(cleanJson, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (aiAnalysis == null)
|
|
{
|
|
throw new InvalidOperationException("Failed to parse AI response JSON");
|
|
}
|
|
|
|
return new SentenceAnalysisData
|
|
{
|
|
OriginalText = inputText,
|
|
SentenceMeaning = aiAnalysis.SentenceTranslation ?? "",
|
|
VocabularyAnalysis = ConvertVocabularyAnalysis(aiAnalysis.VocabularyAnalysis ?? new()),
|
|
Idioms = ConvertIdioms(aiAnalysis.Idioms ?? new()),
|
|
GrammarCorrection = ConvertGrammarCorrection(aiAnalysis),
|
|
Metadata = new AnalysisMetadata
|
|
{
|
|
ProcessingDate = DateTime.UtcNow,
|
|
AnalysisModel = _options.Model,
|
|
AnalysisVersion = "2.0"
|
|
}
|
|
};
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to parse AI response as JSON: {Response}", aiResponse);
|
|
return CreateFallbackAnalysis(inputText, aiResponse);
|
|
}
|
|
}
|
|
|
|
private string CleanAIResponse(string aiResponse)
|
|
{
|
|
var cleanJson = aiResponse.Trim();
|
|
if (cleanJson.StartsWith("```json"))
|
|
{
|
|
cleanJson = cleanJson.Substring(7);
|
|
}
|
|
if (cleanJson.EndsWith("```"))
|
|
{
|
|
cleanJson = cleanJson.Substring(0, cleanJson.Length - 3);
|
|
}
|
|
return cleanJson.Trim();
|
|
}
|
|
|
|
private Dictionary<string, VocabularyAnalysisDto> ConvertVocabularyAnalysis(Dictionary<string, AiVocabularyAnalysis> aiVocab)
|
|
{
|
|
var result = new Dictionary<string, VocabularyAnalysisDto>();
|
|
|
|
foreach (var kvp in aiVocab)
|
|
{
|
|
var aiWord = kvp.Value;
|
|
result[kvp.Key] = new VocabularyAnalysisDto
|
|
{
|
|
Word = aiWord.Word ?? kvp.Key,
|
|
Translation = aiWord.Translation ?? "",
|
|
Definition = aiWord.Definition ?? "",
|
|
PartOfSpeech = aiWord.PartOfSpeech ?? "unknown",
|
|
Pronunciation = aiWord.Pronunciation ?? $"/{kvp.Key}/",
|
|
DifficultyLevel = aiWord.DifficultyLevel ?? "A2",
|
|
Frequency = aiWord.Frequency ?? "medium",
|
|
Synonyms = aiWord.Synonyms ?? new List<string>(),
|
|
Example = aiWord.Example,
|
|
ExampleTranslation = aiWord.ExampleTranslation,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private List<IdiomDto> ConvertIdioms(List<AiIdiom> aiIdioms)
|
|
{
|
|
var result = new List<IdiomDto>();
|
|
|
|
foreach (var aiIdiom in aiIdioms)
|
|
{
|
|
result.Add(new IdiomDto
|
|
{
|
|
Idiom = aiIdiom.Idiom ?? "",
|
|
Translation = aiIdiom.Translation ?? "",
|
|
Definition = aiIdiom.Definition ?? "",
|
|
Pronunciation = aiIdiom.Pronunciation ?? "",
|
|
DifficultyLevel = aiIdiom.DifficultyLevel ?? "B2",
|
|
Frequency = aiIdiom.Frequency ?? "medium",
|
|
Synonyms = aiIdiom.Synonyms ?? new List<string>(),
|
|
Example = aiIdiom.Example,
|
|
ExampleTranslation = aiIdiom.ExampleTranslation
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private GrammarCorrectionDto? ConvertGrammarCorrection(AiAnalysisResponse aiAnalysis)
|
|
{
|
|
if (!aiAnalysis.HasGrammarErrors || aiAnalysis.GrammarCorrections == null || !aiAnalysis.GrammarCorrections.Any())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var corrections = aiAnalysis.GrammarCorrections.Select(gc => new GrammarErrorDto
|
|
{
|
|
Error = gc.Original ?? "",
|
|
Correction = gc.Corrected ?? "",
|
|
Type = gc.Type ?? "grammar",
|
|
Explanation = gc.Explanation ?? "",
|
|
Severity = "medium",
|
|
Position = new ErrorPosition { Start = 0, End = 0 }
|
|
}).ToList();
|
|
|
|
return new GrammarCorrectionDto
|
|
{
|
|
HasErrors = true,
|
|
CorrectedText = string.Join(" ", corrections.Select(c => c.Correction)),
|
|
Corrections = corrections
|
|
};
|
|
}
|
|
|
|
private SentenceAnalysisData CreateFallbackAnalysis(string inputText, string aiResponse)
|
|
{
|
|
_logger.LogWarning("Using fallback analysis due to JSON parsing failure");
|
|
|
|
return new SentenceAnalysisData
|
|
{
|
|
OriginalText = inputText,
|
|
SentenceMeaning = aiResponse,
|
|
VocabularyAnalysis = new Dictionary<string, VocabularyAnalysisDto>(),
|
|
Metadata = new AnalysisMetadata
|
|
{
|
|
ProcessingDate = DateTime.UtcNow,
|
|
AnalysisModel = $"{_options.Model}-fallback",
|
|
AnalysisVersion = "2.0"
|
|
},
|
|
};
|
|
}
|
|
|
|
private void RecordSuccessfulRequest(long elapsedMs)
|
|
{
|
|
_stats.SuccessfulRequests++;
|
|
_stats.LastUsedAt = DateTime.UtcNow;
|
|
_stats.TotalCost += CostPerRequest;
|
|
UpdateAverageResponseTime((int)elapsedMs);
|
|
}
|
|
|
|
private void RecordFailedRequest(long elapsedMs)
|
|
{
|
|
_stats.FailedRequests++;
|
|
UpdateAverageResponseTime((int)elapsedMs);
|
|
}
|
|
|
|
private void UpdateAverageResponseTime(int responseTimeMs)
|
|
{
|
|
if (_stats.AverageResponseTimeMs == 0)
|
|
{
|
|
_stats.AverageResponseTimeMs = responseTimeMs;
|
|
}
|
|
else
|
|
{
|
|
_stats.AverageResponseTimeMs = (_stats.AverageResponseTimeMs + responseTimeMs) / 2;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
#region AI Response Models (重用現有的模型)
|
|
|
|
internal class AiAnalysisResponse
|
|
{
|
|
public string? SentenceTranslation { get; set; }
|
|
public bool HasGrammarErrors { get; set; }
|
|
public List<AiGrammarCorrection>? GrammarCorrections { get; set; }
|
|
public Dictionary<string, AiVocabularyAnalysis>? VocabularyAnalysis { get; set; }
|
|
public List<AiIdiom>? Idioms { get; set; }
|
|
}
|
|
|
|
internal class AiGrammarCorrection
|
|
{
|
|
public string? Original { get; set; }
|
|
public string? Corrected { get; set; }
|
|
public string? Type { get; set; }
|
|
public string? Explanation { get; set; }
|
|
}
|
|
|
|
internal class AiVocabularyAnalysis
|
|
{
|
|
public string? Word { get; set; }
|
|
public string? Translation { get; set; }
|
|
public string? Definition { get; set; }
|
|
public string? PartOfSpeech { get; set; }
|
|
public string? Pronunciation { get; set; }
|
|
public string? DifficultyLevel { get; set; }
|
|
public string? Frequency { get; set; }
|
|
public List<string>? Synonyms { get; set; }
|
|
public string? Example { get; set; }
|
|
public string? ExampleTranslation { get; set; }
|
|
}
|
|
|
|
internal class AiIdiom
|
|
{
|
|
public string? Idiom { get; set; }
|
|
public string? Translation { get; set; }
|
|
public string? Definition { get; set; }
|
|
public string? Pronunciation { get; set; }
|
|
public string? DifficultyLevel { get; set; }
|
|
public string? Frequency { get; set; }
|
|
public List<string>? Synonyms { get; set; }
|
|
public string? Example { get; set; }
|
|
public string? ExampleTranslation { get; set; }
|
|
}
|
|
|
|
#endregion |