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))!;
}