Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -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&gt;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))!;
}