Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
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))!;
|
||||
}
|
||||
Reference in New Issue
Block a user