Application проектов Discovery, Kanban, Pipeline, Settings, Tenants разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services; namespace приведён к путям, using потребителей мигрированы и дедуплицированы (169 файлов), cref/FQN обновлены.
255 lines
13 KiB
C#
255 lines
13 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service
|
||
/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму
|
||
/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/
|
||
/// LocalMlClient.Predict). Три учёта:
|
||
/// <list type="number">
|
||
/// <item><b>Бюджет периода</b> — <c>ITenantLimitStore.AddUsageAsync</c>: инкремент UsedTokens в public.tenant_limits
|
||
/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9.
|
||
/// Только для платных AI-вызовов (ML бюджет не расходует).</item>
|
||
/// <item><b>Lifetime-счётчик</b> — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат
|
||
/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет).</item>
|
||
/// <item><b>История событий</b> — <c>TokenUsageEventService.AppendAsync</c> (public.token_usage_events): провайдер,
|
||
/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3).</item>
|
||
/// </list>
|
||
/// Списание в tenant_limits выполняется только при Total>0 (нулевой usage ответа моделью не заводит строку
|
||
/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore
|
||
/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext.
|
||
/// </remarks>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Создаёт recorder расхода токенов.
|
||
/// </summary>
|
||
/// <param name="store">KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик).</param>
|
||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits, списание периода).</param>
|
||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId списания/события).</param>
|
||
/// <param name="events">Сервис истории расхода (public.token_usage_events, этап 10).</param>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму
|
||
/// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов).
|
||
/// </summary>
|
||
/// <param name="usage">Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op).</param>
|
||
/// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param>
|
||
/// <param name="model">Модель провайдера (событие истории).</param>
|
||
/// <param name="ct">Токен отмены.</param>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста
|
||
/// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает.
|
||
/// </summary>
|
||
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
|
||
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
|
||
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
|
||
/// <param name="ct">Токен отмены.</param>
|
||
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
|
||
public async Task<long> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5).
|
||
/// </summary>
|
||
/// <param name="text">Текст (null/пустой — 0).</param>
|
||
/// <returns>Оценка токенов (неотрицательная).</returns>
|
||
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<JsonObject?> 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<long>(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))!;
|
||
}
|