using System.Text; using MarketAlly.AIPlugin; using MarketAlly.AIPlugin.Conversation; using MarketAlly.GitCommitEditor.Models; using MarketAlly.GitCommitEditor.Options; using MarketAlly.GitCommitEditor.Plugins; using MarketAlly.GitCommitEditor.Services; namespace MarketAlly.GitCommitEditor.Rewriters; /// /// Commit message rewriter using MarketAlly.AIPlugin's AIConversation API. /// Supports multiple AI providers (Claude, OpenAI, Gemini, Mistral, Qwen). /// public sealed class AICommitRewriter : ICommitMessageRewriter, IDisposable { private const string OperationType = "CommitSuggestion"; private readonly AiOptions _options; private readonly AIConversation _conversation; private readonly ICostTrackingService? _costTracker; public AICommitRewriter(AiOptions options, ICostTrackingService? costTracker = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _costTracker = costTracker; var provider = ParseProvider(options.Provider); _conversation = AIConversationBuilder.Create() .UseProvider(provider, options.ApiKey) .UseModel(options.Model) .WithSystemPrompt(BuildSystemPrompt()) .WithTemperature(0.3) // Lower temperature for more consistent output .WithMaxTokens(options.MaxTokens) .WithToolExecutionLimit(3) .RegisterPlugin() .Build(); } private static AIProvider ParseProvider(string? providerName) { return providerName?.ToLowerInvariant() switch { "claude" or "anthropic" => AIProvider.Claude, "openai" or "gpt" => AIProvider.OpenAI, "gemini" or "google" => AIProvider.Gemini, "mistral" => AIProvider.Mistral, "qwen" or "alibaba" => AIProvider.Qwen, _ => AIProvider.Claude // Default to Claude }; } private static string BuildSystemPrompt() { return """ You are a git commit message expert. Your task is to improve commit messages following conventional commit format. RULES: 1. Description: max 60 chars, imperative mood ("add" not "added"), no period, lowercase start 2. Scope is optional - use it for the area of codebase (e.g., "api", "ui", "auth") 3. Be specific about WHAT changed based on the files and diff 4. Add a body ONLY if the change is complex and needs explanation 5. Body should explain WHY, not repeat WHAT When you receive commit information, call ReturnCommitMessage with the improved message. """; } public async Task SuggestImprovedMessageAsync(CommitAnalysis analysis, CancellationToken ct = default) { var prompt = BuildPrompt(analysis); var response = await _conversation.SendForStructuredOutputAsync( prompt, "ReturnCommitMessage", ct); // Extract cost information from response var inputTokens = response.InputTokens; var outputTokens = response.OutputTokens; var cost = response.EstimatedCost ?? 0m; // Record cost if tracking service is available _costTracker?.RecordOperation(OperationType, inputTokens, outputTokens, cost); if (response.StructuredOutput is CommitMessageResult result) { // Combine subject and body var suggestion = string.IsNullOrWhiteSpace(result.Body) ? result.Subject.Trim() : $"{result.Subject.Trim()}\n\n{result.Body.Trim()}"; // Check if the suggestion is actually different from original if (string.Equals(suggestion, analysis.OriginalMessage, StringComparison.Ordinal)) { return SuggestionResult.FailedWithOriginal( analysis.OriginalMessage, $"AI returned same message as original. Raw: {response.FinalMessage}", inputTokens, outputTokens, cost); } return SuggestionResult.Succeeded(suggestion, inputTokens, outputTokens, cost); } // AI failed to return structured output return SuggestionResult.FailedWithOriginal( analysis.OriginalMessage, $"No structured output. FinalMessage: {response.FinalMessage ?? "(null)"}", inputTokens, outputTokens, cost); } public async Task> SuggestBatchAsync( IEnumerable analyses, IProgress? progress = null, CancellationToken ct = default) { var results = new List<(CommitAnalysis, SuggestionResult)>(); var analysisList = analyses.ToList(); var processed = 0; foreach (var analysis in analysisList) { if (ct.IsCancellationRequested) break; try { var result = await SuggestImprovedMessageAsync(analysis, ct); results.Add((analysis, result)); } catch (Exception ex) { results.Add((analysis, SuggestionResult.Failed(ex.Message))); } processed++; progress?.Report(processed); // Rate limiting delay between requests if (processed < analysisList.Count && _options.RateLimitDelayMs > 0) { await Task.Delay(_options.RateLimitDelayMs, ct); } } return results; } private string BuildPrompt(CommitAnalysis analysis) { var sb = new StringBuilder(); sb.AppendLine("Please improve this commit message:"); sb.AppendLine(); sb.AppendLine($"ORIGINAL MESSAGE: {analysis.OriginalMessage}"); sb.AppendLine(); sb.AppendLine("FILES CHANGED:"); var files = analysis.FilesChanged.Take(20).ToList(); foreach (var file in files) { sb.AppendLine($" - {file}"); } if (analysis.FilesChanged.Count > 20) { sb.AppendLine($" ... and {analysis.FilesChanged.Count - 20} more files"); } sb.AppendLine(); sb.AppendLine($"STATS: +{analysis.LinesAdded} -{analysis.LinesDeleted} lines"); if (!string.IsNullOrEmpty(analysis.DiffSummary) && _options.IncludeDiffContext) { sb.AppendLine(); sb.AppendLine("DIFF SUMMARY:"); var diffLines = analysis.DiffSummary.Split('\n').Take(_options.MaxDiffLines); sb.AppendLine(string.Join('\n', diffLines)); } if (analysis.Quality.Issues.Count > 0) { sb.AppendLine(); sb.AppendLine("ISSUES WITH CURRENT MESSAGE:"); foreach (var issue in analysis.Quality.Issues) { sb.AppendLine($" - [{issue.Severity}] {issue.Message}"); } } sb.AppendLine(); sb.AppendLine("Call ReturnCommitMessage with the improved message."); return sb.ToString(); } public void Dispose() { // AIConversation handles its own disposal } }