< Summary

Information
Class: Services.TransactionCommentAi.TransactionCommentAiEnqueuer
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/TransactionCommentAi/TransactionCommentAiService.cs
Line coverage
52%
Covered lines: 13
Uncovered lines: 12
Coverable lines: 25
Total lines: 172
Line coverage: 52%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
TryEnqueue(...)87.5%24836.84%

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
 19    public TransactionCommentAiService(
 20        OpenRouterDataManager openRouter,
 21        ITransactionDataServices transactionDataServices,
 22        TransactionCache transactionCache,
 23        IOptionsMonitor<TransactionCommentAiOptions> options,
 24        ILogger<TransactionCommentAiService> logger)
 25    {
 26        _openRouter = openRouter;
 27        _transactionDataServices = transactionDataServices;
 28        _transactionCache = transactionCache;
 29        _options = options;
 30        _logger = logger;
 31    }
 32
 33    public async Task GenerateAndSaveAsync(int transactionId, string contextJson, CancellationToken ct = default)
 34    {
 35        var options = _options.CurrentValue;
 36        if (!options.Enabled)
 37            return;
 38
 39        TransactionCommentAiContext? context;
 40        try
 41        {
 42            context = JsonSerializer.Deserialize<TransactionCommentAiContext>(contextJson);
 43        }
 44        catch (Exception ex)
 45        {
 46            _logger.LogWarning(ex, "TransactionCommentAi: invalid context JSON for transaction {TransactionId}", transac
 47            return;
 48        }
 49
 50        if (context == null || context.SkipGeneration)
 51            return;
 52
 53        context.TransactionId = transactionId;
 54
 55        if (!_openRouter.IsConfigured)
 56        {
 57            _logger.LogInformation(
 58                "TransactionCommentAi: OpenRouter не настроен (нет ApiKey), используем детерминированный fallback для тр
 59                transactionId);
 60            var fallback = TransactionCommentAiFallbackFormatter.Format(context);
 61            fallback = Truncate(fallback, options.MaxCommentLength);
 62            await _transactionDataServices.UpdateTransactionComment(transactionId, fallback);
 63            await _transactionCache.UpdateCacheIncrementally(transactionId);
 64            return;
 65        }
 66
 67        string finalComment;
 68        try
 69        {
 70            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 71            timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(10, options.TimeoutSeconds)));
 72
 73            var dataContext = TransactionCommentAiPromptBuilder.BuildDataContext(
 74                context,
 75                Math.Max(1, options.MaxOrdersInPrompt));
 76
 77            finalComment = await _openRouter.AskWithSystemPromptAsync(
 78                TransactionCommentAiPromptBuilder.BuildSystemPrompt(context) + "\n\n=== Контекст транзакции ===\n" + dat
 79                TransactionCommentAiPromptBuilder.UserInstruction,
 80                modelOverride: options.Model,
 81                ct: timeoutCts.Token);
 82
 83            finalComment = SanitizeLlmOutput(finalComment);
 84        }
 85        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 86        {
 87            throw;
 88        }
 89        catch (OperationCanceledException)
 90        {
 91            _logger.LogInformation("TransactionCommentAi: LLM request timed out for transaction {TransactionId}, using f
 92            finalComment = TransactionCommentAiFallbackFormatter.Format(context);
 93        }
 94        catch (Exception ex)
 95        {
 96            _logger.LogWarning(ex, "TransactionCommentAi: LLM failed for transaction {TransactionId}, using fallback", t
 97            finalComment = TransactionCommentAiFallbackFormatter.Format(context);
 98        }
 99
 100        finalComment = Truncate(finalComment, options.MaxCommentLength);
 101
 102        if (string.IsNullOrWhiteSpace(finalComment))
 103            finalComment = TransactionCommentAiFallbackFormatter.Format(context);
 104
 105        await _transactionDataServices.UpdateTransactionComment(transactionId, finalComment);
 106        await _transactionCache.UpdateCacheIncrementally(transactionId);
 107    }
 108
 109    private static string SanitizeLlmOutput(string text)
 110    {
 111        if (string.IsNullOrWhiteSpace(text))
 112            return string.Empty;
 113
 114        return text
 115            .Replace("```", string.Empty, StringComparison.Ordinal)
 116            .Trim();
 117    }
 118
 119    private static string Truncate(string text, int maxLength)
 120    {
 121        if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
 122            return text;
 123
 124        const string suffix = "… (обрезано)";
 125        var cut = Math.Max(0, maxLength - suffix.Length);
 126        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
 4135    public TransactionCommentAiEnqueuer(
 4136        IOptionsMonitor<TransactionCommentAiOptions> options,
 4137        ILogger<TransactionCommentAiEnqueuer> logger)
 138    {
 4139        _options = options;
 4140        _logger = logger;
 4141    }
 142
 143    public void TryEnqueue(TransactionCommentAiContext context)
 144    {
 4145        var options = _options.CurrentValue;
 4146        if (!options.Enabled || context.SkipGeneration)
 2147            return;
 148
 2149        if (!string.IsNullOrWhiteSpace(context.UserComment))
 1150            return;
 151
 1152        if (context.TransactionId <= 0)
 1153            return;
 154
 155        try
 156        {
 0157            var json = JsonSerializer.Serialize(context);
 0158            HangfireJobClientExtensions.EnqueueMain<TransactionCommentAiJob>(
 0159                job => job.GenerateAsync(context.TransactionId, json));
 160
 0161            _logger.LogDebug(
 0162                "TransactionCommentAi: enqueued for transaction {TransactionId}, type {Type}",
 0163                context.TransactionId,
 0164                context.TransactionType);
 0165        }
 0166        catch (Exception ex)
 167        {
 168            // Комментарий — фоновое обогащение; ошибка Hangfire не может отменять уже проведённую транзакцию.
 0169            _logger.LogWarning(ex, "TransactionCommentAi: не удалось поставить в очередь комментарий для транзакции {Tra
 0170        }
 0171    }
 172}