< Summary

Information
Class: Services.TransactionCommentAi.TransactionCommentAiService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/TransactionCommentAi/TransactionCommentAiService.cs
Line coverage
85%
Covered lines: 59
Uncovered lines: 10
Coverable lines: 69
Total lines: 172
Line coverage: 85.5%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GenerateAndSaveAsync()100%111079.16%
SanitizeLlmOutput(...)100%22100%
Truncate(...)100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/TransactionCommentAi/TransactionCommentAiService.cs

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using Services.ApiServices.OpenRouterApi;
 5using Services.CacheServices.Cache.OzonOrdersCache;
 6using Services.DataServices;
 7using Services.HangfireService;
 8
 9namespace Services.TransactionCommentAi;
 10
 11public sealed class TransactionCommentAiService
 12{
 13    private readonly OpenRouterDataManager _openRouter;
 14    private readonly ITransactionDataServices _transactionDataServices;
 15    private readonly TransactionCache _transactionCache;
 16    private readonly IOptionsMonitor<TransactionCommentAiOptions> _options;
 17    private readonly ILogger<TransactionCommentAiService> _logger;
 18
 819    public TransactionCommentAiService(
 820        OpenRouterDataManager openRouter,
 821        ITransactionDataServices transactionDataServices,
 822        TransactionCache transactionCache,
 823        IOptionsMonitor<TransactionCommentAiOptions> options,
 824        ILogger<TransactionCommentAiService> logger)
 25    {
 826        _openRouter = openRouter;
 827        _transactionDataServices = transactionDataServices;
 828        _transactionCache = transactionCache;
 829        _options = options;
 830        _logger = logger;
 831    }
 32
 33    public async Task GenerateAndSaveAsync(int transactionId, string contextJson, CancellationToken ct = default)
 34    {
 835        var options = _options.CurrentValue;
 836        if (!options.Enabled)
 137            return;
 38
 39        TransactionCommentAiContext? context;
 40        try
 41        {
 742            context = JsonSerializer.Deserialize<TransactionCommentAiContext>(contextJson);
 643        }
 144        catch (Exception ex)
 45        {
 146            _logger.LogWarning(ex, "TransactionCommentAi: invalid context JSON for transaction {TransactionId}", transac
 147            return;
 48        }
 49
 650        if (context == null || context.SkipGeneration)
 151            return;
 52
 553        context.TransactionId = transactionId;
 54
 555        if (!_openRouter.IsConfigured)
 56        {
 157            _logger.LogInformation(
 158                "TransactionCommentAi: OpenRouter не настроен (нет ApiKey), используем детерминированный fallback для тр
 159                transactionId);
 160            var fallback = TransactionCommentAiFallbackFormatter.Format(context);
 161            fallback = Truncate(fallback, options.MaxCommentLength);
 162            await _transactionDataServices.UpdateTransactionComment(transactionId, fallback);
 163            await _transactionCache.UpdateCacheIncrementally(transactionId);
 164            return;
 65        }
 66
 67        string finalComment;
 68        try
 69        {
 470            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 471            timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(10, options.TimeoutSeconds)));
 72
 473            var dataContext = TransactionCommentAiPromptBuilder.BuildDataContext(
 474                context,
 475                Math.Max(1, options.MaxOrdersInPrompt));
 76
 477            finalComment = await _openRouter.AskWithSystemPromptAsync(
 478                TransactionCommentAiPromptBuilder.BuildSystemPrompt(context) + "\n\n=== Контекст транзакции ===\n" + dat
 479                TransactionCommentAiPromptBuilder.UserInstruction,
 480                modelOverride: options.Model,
 481                ct: timeoutCts.Token);
 82
 483            finalComment = SanitizeLlmOutput(finalComment);
 484        }
 085        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 86        {
 087            throw;
 88        }
 089        catch (OperationCanceledException)
 90        {
 091            _logger.LogInformation("TransactionCommentAi: LLM request timed out for transaction {TransactionId}, using f
 092            finalComment = TransactionCommentAiFallbackFormatter.Format(context);
 093        }
 094        catch (Exception ex)
 95        {
 096            _logger.LogWarning(ex, "TransactionCommentAi: LLM failed for transaction {TransactionId}, using fallback", t
 097            finalComment = TransactionCommentAiFallbackFormatter.Format(context);
 098        }
 99
 4100        finalComment = Truncate(finalComment, options.MaxCommentLength);
 101
 4102        if (string.IsNullOrWhiteSpace(finalComment))
 2103            finalComment = TransactionCommentAiFallbackFormatter.Format(context);
 104
 4105        await _transactionDataServices.UpdateTransactionComment(transactionId, finalComment);
 4106        await _transactionCache.UpdateCacheIncrementally(transactionId);
 8107    }
 108
 109    private static string SanitizeLlmOutput(string text)
 110    {
 4111        if (string.IsNullOrWhiteSpace(text))
 2112            return string.Empty;
 113
 2114        return text
 2115            .Replace("```", string.Empty, StringComparison.Ordinal)
 2116            .Trim();
 117    }
 118
 119    private static string Truncate(string text, int maxLength)
 120    {
 5121        if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
 4122            return text;
 123
 124        const string suffix = "… (обрезано)";
 1125        var cut = Math.Max(0, maxLength - suffix.Length);
 1126        return text[..cut].TrimEnd() + suffix;
 127    }
 128}
 129
 130public sealed class TransactionCommentAiEnqueuer : ITransactionCommentAiEnqueuer
 131{
 132    private readonly IOptionsMonitor<TransactionCommentAiOptions> _options;
 133    private readonly ILogger<TransactionCommentAiEnqueuer> _logger;
 134
 135    public TransactionCommentAiEnqueuer(
 136        IOptionsMonitor<TransactionCommentAiOptions> options,
 137        ILogger<TransactionCommentAiEnqueuer> logger)
 138    {
 139        _options = options;
 140        _logger = logger;
 141    }
 142
 143    public void TryEnqueue(TransactionCommentAiContext context)
 144    {
 145        var options = _options.CurrentValue;
 146        if (!options.Enabled || context.SkipGeneration)
 147            return;
 148
 149        if (!string.IsNullOrWhiteSpace(context.UserComment))
 150            return;
 151
 152        if (context.TransactionId <= 0)
 153            return;
 154
 155        try
 156        {
 157            var json = JsonSerializer.Serialize(context);
 158            HangfireJobClientExtensions.EnqueueMain<TransactionCommentAiJob>(
 159                job => job.GenerateAsync(context.TransactionId, json));
 160
 161            _logger.LogDebug(
 162                "TransactionCommentAi: enqueued for transaction {TransactionId}, type {Type}",
 163                context.TransactionId,
 164                context.TransactionType);
 165        }
 166        catch (Exception ex)
 167        {
 168            // Комментарий — фоновое обогащение; ошибка Hangfire не может отменять уже проведённую транзакцию.
 169            _logger.LogWarning(ex, "TransactionCommentAi: не удалось поставить в очередь комментарий для транзакции {Tra
 170        }
 171    }
 172}