using System.Text.Json; using System.Text.Json.Nodes; using Deal.Grpc.Ai; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Observability; using Deal.SharedKernel.Tenants; namespace Deal.Infrastructure.Integrations; /// /// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service /// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму /// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml). /// /// /// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/ /// LocalMlClient.Predict). Три учёта: /// /// Бюджет периодаITenantLimitStore.AddUsageAsync: инкремент UsedTokens в public.tenant_limits /// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9. /// Только для платных AI-вызовов (ML бюджет не расходует). /// Lifetime-счётчик — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат /// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет). /// История событийTokenUsageEventService.AppendAsync (public.token_usage_events): провайдер, /// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3). /// /// Списание в tenant_limits выполняется только при Total>0 (нулевой usage ответа моделью не заводит строку /// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore /// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext. /// public sealed class TokenUsageRecorder { // Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total). private const string PromptField = "prompt"; private const string CompletionField = "completion"; private const string TotalField = "total"; // Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4). private const int CharsPerToken = 4; private readonly ISettingsStore _store; private readonly ITenantLimitStore _tenantLimits; private readonly ITenantContext _tenantContext; private readonly TokenUsageEventService _events; /// /// Создаёт recorder расхода токенов. /// /// KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик). /// Хранилище лимитов бюджета (public.tenant_limits, списание периода). /// Контекст текущего тенанта (AsyncLocal; tenantId списания/события). /// Сервис истории расхода (public.token_usage_events, этап 10). public TokenUsageRecorder( ISettingsStore store, ITenantLimitStore tenantLimits, ITenantContext tenantContext, TokenUsageEventService events) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(tenantLimits); ArgumentNullException.ThrowIfNull(tenantContext); ArgumentNullException.ThrowIfNull(events); _store = store; _tenantLimits = tenantLimits; _tenantContext = tenantContext; _events = events; } /// /// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму /// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов). /// /// Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op). /// Id активного провайдера (deepseek/openai/anthropic/…; событие истории). /// Модель провайдера (событие истории). /// Токен отмены. public async Task AddAsync(Usage? usage, string provider, string model, CancellationToken ct) { if (usage is null) { return; } if (usage.Total > 0) { await _tenantLimits.AddUsageAsync(RequireTenantId(), usage.Total, ct); } await AddToLifetimeAsync(usage, ct); // Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие // token_usage_events (без tenantId в метках). DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); await RecordEventAsync( provider, model, TokenUsageEventKinds.Ai, promptTokens: usage.Prompt, completionTokens: usage.Completion, totalTokens: usage.Total, ct); } /// /// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста /// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает. /// /// Входной текст предсказания (оценка токенов запроса; null — 0). /// Провайдер/источник события (для локальной ML-модели — "local"). /// Модель/вид локального ML-вызова (событие истории). /// Токен отмены. /// Оценка токенов (для тестов/наблюдаемости). public async Task AddEstimatedAsync(string? text, string provider, string model, CancellationToken ct) { long promptTokens = EstimateTokens(text); // Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка, // что и событие token_usage_events, kind=ml). DealMetrics.RecordMlUsage(promptTokens); await RecordEventAsync( provider, model, TokenUsageEventKinds.Ml, promptTokens: promptTokens, completionTokens: 0, totalTokens: promptTokens, ct); return promptTokens; } /// /// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5). /// /// Текст (null/пустой — 0). /// Оценка токенов (неотрицательная). public static long EstimateTokens(string? text) => string.IsNullOrEmpty(text) ? 0 : text.Length / CharsPerToken; // Пишет событие истории расхода токенов (public.token_usage_events, tenant-id текущего scope). // provider: Провайдер/источник. // model: Модель. // kind: Вид вызова ai|ml. // promptTokens: Токены запроса. // completionTokens: Токены ответа. // totalTokens: Всего токенов. // ct: Токен отмены. private async Task RecordEventAsync( string provider, string model, string kind, long promptTokens, long completionTokens, long totalTokens, CancellationToken ct) { // Секретов в DetailJson нет: событие хранит только провайдера/модель/вид/токены. await _events.AppendAsync( new TokenUsageEventDto( TenantId: RequireTenantId(), At: default, Provider: provider, Model: model, Kind: kind, PromptTokens: promptTokens, CompletionTokens: completionTokens, TotalTokens: totalTokens, DetailJson: null), ct); } // Текущий тенант scope как Guid строки public.tenants (без него списание не имеет смысла). // Возвращает: Идентификатор тенанта (Guid). // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. private Guid RequireTenantId() { TenantId? tenantId = _tenantContext.TenantId; if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) { throw new InvalidOperationException( "TokenUsageRecorder запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."); } return id; } // Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6). // usage: Оценка токенов ответа. // ct: Токен отмены. private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct) { JsonObject? current = await ReadAsync(ct); long prompt = ReadBound(current, PromptField) + usage.Prompt; long completion = ReadBound(current, CompletionField) + usage.Completion; long total = ReadBound(current, TotalField) + usage.Total; var updated = new JsonObject { [PromptField] = ClampToUint(prompt), [CompletionField] = ClampToUint(completion), [TotalField] = ClampToUint(total), }; await _store.SetAsync(SettingsKeys.AiTokenUsage, updated.ToJsonString(), ct); } // Текущее значение aiTokenUsage (JSON-объект) или null — строки нет. // ct: Токен отмены. // Возвращает: Объект значения или null. private async Task ReadAsync(CancellationToken ct) { SettingValue? row = await _store.GetAsync(SettingsKeys.AiTokenUsage, ct); if (row is null) { return null; } try { return JsonNode.Parse(row.ValueJson) as JsonObject; } catch (JsonException) { // Повреждённая строка — нули (мягкая семантика, как в SettingsService). return null; } } // Число поля значения: отсутствие/не-число → 0 (без clamp: сумма ограничивается при записи). // value: Объект значения aiTokenUsage (может быть null). // field: Имя поля (prompt/completion/total). // Возвращает: Значение поля или 0. private static long ReadBound(JsonObject? value, string field) { if (value is null || !value.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue scalar) { return 0; } return scalar.TryGetValue(out long number) && number > 0 ? number : 0; } // Ограничивает сумму диапазоном uint32 (proto Usage — uint; переполнение не ожидается). // value: Накопленная сумма. // Возвращает: Значение в диапазоне uint32. private static JsonNode ClampToUint(long value) => JsonValue.Create(Math.Clamp(value, 0, uint.MaxValue))!; }