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

Первый коммит: модульный монолит ядра (.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,7 @@
using Xunit;
// Интеграционные тесты ml-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные
// env-переменные (DEAL_SERVICE_TOKEN/DEAL_ML_DATA_DIR) на время сценария (MlTestHost и
// MlServiceHostTests). Параллельный прогон классов дал бы гонки на env — тесты сериализованы
// (тот же шаблон, что AssemblyInfo тестов telegram-service).
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Deal.Ml.Tests — интеграционные тесты каркаса ml-service (план Task 3, Acceptance):
хост (Kestrel HTTP/2) поднимается в процессе теста на эфемерном порту через MlServiceHost.Create
(та же сборка, что и Program.cs), проверяются gRPC-health → SERVING и ServiceTokenInterceptor
(нет/неверный токен → UNAUTHENTICATED; пустой env DEAL_SERVICE_TOKEN → fail-closed). Стек тестов —
как в src/core/tests/Deal.Tests.Unit и Deal.Telegram.Tests (Task 2).
-->
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<!-- Клиент gRPC в тестах (транспорт HTTP/2 + типы grpc.health.v1.Health). -->
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
<PackageReference Include="Grpc.HealthCheck" Version="2.83.0" />
</ItemGroup>
<ItemGroup>
<!-- Прямые чтения SQLite-файлов моделей в тестах (проверка прунинга eval_log). -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<!-- WebApplication/Kestrel-типы приходят из общего фреймворка ASP.NET Core (хосты тестов). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Deal.Ml\Deal.Ml.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
using Deal.Ml.Model;
namespace Deal.Ml.Tests;
// Тексты и батчи обучения для тестов модели/RPC (1:1 сценарии приёмки плана Task 5:
// «нужен middle python…» → колонка + тип, «резюме…» → spam после обучения). Словари классов
// намеренно не пересекаются: колонка/тип разработки (Dev), разовая сделка (Order) и спам —
// разные термины, чтобы предсказания были детерминированы порогами, а не шумом пересечений.
internal static class LearningData
{
/// <summary>
/// Id колонки канбана «разработка/найм» (форма b_… как в ядре).
/// </summary>
public const string ColumnDev = "b_col_dev";
/// <summary>
/// Id колонки канбана «разовая сделка/заказ».
/// </summary>
public const string ColumnOrder = "b_col_order";
/// <summary>
/// Внутренний класс типа «найм».
/// </summary>
public const string TypeHire = "t:hire";
/// <summary>
/// Внутренний класс типа «разовая сделка».
/// </summary>
public const string TypeOrder = "t:order";
/// <summary>
/// Сообщение-заявка на разработчика (→ колонка b_col_dev + тип hire).
/// </summary>
public const string DevMessage =
"нужен middle python разработчик в команду, удаленная работа, стек django postgres, оффер конкурентный";
/// <summary>
/// Сообщение-заказ сайта/лендинга (→ колонка b_col_order + тип order).
/// </summary>
public const string OrderMessage =
"закажу сайт визитку и лендинг для малого бизнеса, недорого, дизайнер и верстка";
/// <summary>
/// Спам-резюме/рассылка (→ колонка spam; словарь не пересекается с dev/order).
/// </summary>
public const string SpamMessage =
"резюме ищу подработку, разошлю отклик по вакансиям, рассылка кадровым агентствам";
/// <summary>
/// Пример обучения с заданным весом сигнала (пользователь/ИИ/правила).
/// </summary>
/// <param name="label">Метка класса.</param>
/// <param name="text">Текст примера.</param>
/// <param name="delta">Вес сигнала.</param>
public static LearnItem Signal(string label, string text, double delta) => new(text, label, delta);
/// <summary>
/// Пример обучения с дельтой 1.0 (действие пользователя).
/// </summary>
/// <param name="label">Метка класса.</param>
/// <param name="text">Текст примера.</param>
public static LearnItem User(string label, string text) => new(text, label, 1.0);
/// <summary>
/// Канонический батч, доводящий модель до готовности и уверенных предсказаний (28 примеров):
/// колонки 8/6/6 (dev/order/spam), типы t:hire/t:order по 4, после «включения» — 4 реальных
/// действия (delta=1) для журнала самооценки. Баланс 1:1 со сценарием приёмки Task 5.
/// </summary>
public static List<LearnItem> CanonicalTrainItems()
{
var items = new List<LearnItem>();
AddRepeated(items, User(ColumnDev, DevMessage), 6);
AddRepeated(items, User(ColumnOrder, OrderMessage), 6);
AddRepeated(items, User(LearningData.SpamLabel, SpamMessage), 4);
AddRepeated(items, User(TypeHire, DevMessage), 4);
AddRepeated(items, User(TypeOrder, OrderMessage), 4);
AddRepeated(items, User(ColumnDev, DevMessage), 2);
AddRepeated(items, User(LearningData.SpamLabel, SpamMessage), 2);
return items;
}
/// <summary>
/// Метка spam (внутренняя константа модели; зеркало для читаемости батчей).
/// </summary>
public static string SpamLabel => ModelConstants.SpamLabel;
// Добавляет count копий примера в список.
// target: Список-приёмник.
// item: Пример.
// count: Число копий.
private static void AddRepeated(List<LearnItem> target, LearnItem item, int count)
{
for (int i = 0; i < count; i++)
{
target.Add(item);
}
}
}
+283
View File
@@ -0,0 +1,283 @@
using Deal.Grpc.Ml;
using Grpc.Core;
using Grpc.Net.Client;
namespace Deal.Ml.Tests;
/// <summary>
/// In-proc gRPC-тесты MlService (план Task 6, Acceptance): Predict/Status/Reset/TrainBatch через
/// реальный хост (Kestrel HTTP/2, интерцептор service-token) с tenant-id из metadata; модель
/// тенанта создаётся лениво (Predict неготовой модели — «не уверен», не ошибка). Без сети.
/// </summary>
public sealed class MlRpcTests
{
/// <summary>
/// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен» 1:1.
/// </summary>
[Fact]
public async Task FreshTenant_StatusAndPredictReturnNotReadyShape()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
CallOptions options = Options(MlTestHost.DefaultTenantId);
StatusReply status = await client.StatusAsync(new StatusRequest(), options);
Assert.False(status.Ready);
Assert.Empty(status.Classes);
Assert.Equal(0, status.Learned);
Assert.Equal(0, status.Eval.Count);
Assert.Equal(0.0, status.Eval.Accuracy);
PredictReply predict = await client.PredictAsync(
new PredictRequest { Text = LearningData.DevMessage },
options);
Assert.False(predict.Take);
Assert.False(predict.HasLabel);
Assert.False(predict.Ready);
Assert.Empty(predict.Scores);
Assert.Equal(0, predict.Hits);
Assert.False(predict.HasMargin);
Assert.Empty(predict.Terms);
Assert.Null(predict.Type);
});
}
/// <summary>
/// TrainBatch батчем из 3 → learned=3; модель остаётся неготовой (ниже MIN_TOTAL).
/// </summary>
[Fact]
public async Task TrainBatch_ThreeItems_LearnsThree()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
CallOptions options = Options(MlTestHost.DefaultTenantId);
var request = new TrainBatchRequest();
request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 });
request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 });
request.Items.Add(new TrainExample { Text = LearningData.SpamMessage, Label = LearningData.SpamLabel, Delta = 1.0 });
TrainBatchReply reply = await client.TrainBatchAsync(request, options);
Assert.Equal(3, reply.Learned);
StatusReply status = await client.StatusAsync(new StatusRequest(), options);
Assert.False(status.Ready);
Assert.Equal(3, status.Learned);
});
}
/// <summary>
/// Полный цикл: канонический батч → Status ready/learned → Predict колонки + типа.
/// </summary>
[Fact]
public async Task TrainBatch_FullCanonical_ThenStatusAndPredict()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
CallOptions options = Options(MlTestHost.DefaultTenantId);
TrainBatchReply batch = await client.TrainBatchAsync(
new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } },
options);
Assert.Equal(28, batch.Learned);
StatusReply status = await client.StatusAsync(new StatusRequest(), options);
Assert.True(status.Ready);
Assert.Equal(28, status.Learned);
Assert.Equal(6.0, status.Classes[LearningData.SpamLabel]);
PredictReply dev = await client.PredictAsync(
new PredictRequest { Text = LearningData.DevMessage },
options);
Assert.True(dev.Take);
Assert.Equal(LearningData.ColumnDev, dev.Label);
Assert.True(dev.HasMargin);
Assert.NotNull(dev.Type);
Assert.Equal("hire", dev.Type.Label);
PredictReply spam = await client.PredictAsync(
new PredictRequest { Text = LearningData.SpamMessage },
options);
Assert.True(spam.Take);
Assert.Equal(LearningData.SpamLabel, spam.Label);
Assert.Null(spam.Type);
});
}
/// <summary>
/// Reset обнуляет модель тенанта: Status снова пуст, Predict «не уверен».
/// </summary>
[Fact]
public async Task Reset_AfterTraining_EmptiesModel()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
CallOptions options = Options(MlTestHost.DefaultTenantId);
await client.TrainBatchAsync(
new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } },
options);
ResetReply reset = await client.ResetAsync(new ResetRequest(), options);
Assert.True(reset.Ok);
Assert.False(reset.HasError);
StatusReply status = await client.StatusAsync(new StatusRequest(), options);
Assert.False(status.Ready);
Assert.Empty(status.Classes);
Assert.Equal(0, status.Learned);
PredictReply predict = await client.PredictAsync(
new PredictRequest { Text = LearningData.DevMessage },
options);
Assert.False(predict.Take);
Assert.False(predict.Ready);
});
}
/// <summary>
/// Изоляция тенантов: обучение одного не влияет на модель другого (пул per-tenant).
/// </summary>
[Fact]
public async Task Tenants_AreIsolated()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
await client.TrainBatchAsync(
new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } },
Options("tenant-a"));
StatusReply trained = await client.StatusAsync(new StatusRequest(), Options("tenant-a"));
Assert.True(trained.Ready);
Assert.Equal(28, trained.Learned);
StatusReply untouched = await client.StatusAsync(new StatusRequest(), Options("tenant-b"));
Assert.False(untouched.Ready);
Assert.Equal(0, untouched.Learned);
PredictReply predictB = await client.PredictAsync(
new PredictRequest { Text = LearningData.DevMessage },
Options("tenant-b"));
Assert.False(predictB.Take);
Assert.False(predictB.Ready);
});
}
/// <summary>
/// Отсутствующий tenant-id в metadata → UNAUTHENTICATED (Ruling 1; шаблон T5).
/// </summary>
[Fact]
public async Task Status_WithoutTenantId_IsUnauthenticated()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
Metadata metadata = MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId: null);
AsyncUnaryCall<StatusReply> call = client.StatusAsync(new StatusRequest(), MlTestHost.CallOptions(metadata));
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
});
}
/// <summary>
/// Tenant-id с недопустимыми символами пути → INVALID_ARGUMENT (защита каталога).
/// </summary>
[Fact]
public async Task Status_WithPathTraversalTenantId_IsInvalidArgument()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
AsyncUnaryCall<StatusReply> call = client.StatusAsync(
new StatusRequest(),
MlTestHost.CallOptions(MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId: "../escape")));
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
});
}
/// <summary>
/// Пустые text/label в батче пропускаются: learned = применённые, ответ не падает.
/// </summary>
[Fact]
public async Task TrainBatch_EmptyItems_SkippedQuietly()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
var request = new TrainBatchRequest();
request.Items.Add(new TrainExample { Text = string.Empty, Label = LearningData.ColumnDev, Delta = 1.0 });
request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = " ", Delta = 1.0 });
request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 });
TrainBatchReply reply = await client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId));
Assert.Equal(1, reply.Learned);
});
}
/// <summary>
/// Серверный лимит батча (ml.proto: ≤100 примеров): 101 → INVALID_ARGUMENT до обучения.
/// </summary>
[Fact]
public async Task TrainBatch_OverBatchLimit_IsInvalidArgument()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
var request = new TrainBatchRequest();
for (int i = 0; i < 101; i++)
{
request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 });
}
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
});
}
/// <summary>
/// Серверный лимит длины текста примера (source_msg ≤4000): превышение → INVALID_ARGUMENT.
/// </summary>
[Fact]
public async Task TrainBatch_TooLongExampleText_IsInvalidArgument()
{
await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel =>
{
var client = new MlService.MlServiceClient(channel);
var request = new TrainBatchRequest();
request.Items.Add(new TrainExample { Text = new string('а', 4001), Label = LearningData.ColumnDev, Delta = 1.0 });
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
});
}
// CallOptions с metadata (service-token + tenant-id) и deadline.
// tenantId: Id тенанта.
private static CallOptions Options(string tenantId)
=> MlTestHost.CallOptions(MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId));
// Маппит пример обучения в wire-форму TrainExample (text/label/delta).
// item: Пример.
private static TrainExample ToExample(Deal.Ml.Model.LearnItem item)
=> new() { Text = item.Text, Label = item.Label, Delta = item.Delta };
}
@@ -0,0 +1,241 @@
using Deal.Grpc.Ml;
using Deal.Ml;
using Microsoft.AspNetCore.Builder;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using System.Net;
using System.Net.Sockets;
using Xunit;
namespace Deal.Ml.Tests;
/// <summary>
/// Интеграционные тесты хоста ml-service (каркас план Task 3 + Status задач 56).
///
/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через MlServiceHost.Create —
/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку
/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor
/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к
/// реализованному Status (свежая модель → ready=false); при незаданном DEAL_SERVICE_TOKEN — fail-closed.
///
/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN), Status создаёт модель тенанта —
/// каталог моделей (DEAL_ML_DATA_DIR) на время сценария направляется во временную папку; тесты
/// выставляют env и восстанавливают исходные значения. Все тесты класса живут в одном процессе/классе,
/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно).
/// </summary>
public sealed class MlServiceHostTests
{
// Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor).
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
// Env-ключ каталога моделей (зеркало MlOptions; Status создаёт модель тенанта).
private const string DataDirEnvKey = "DEAL_ML_DATA_DIR";
// Id тенанта сценариев теста (Status реализован задачами 5–6).
private const string TestTenantId = "tenant-test";
// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey).
private const string TenantIdMetadataKey = "tenant-id";
// Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey).
private const string ServiceTokenMetadataKey = "service-token";
// Токен сценариев теста.
private const string ValidToken = "task3-test-token";
// Deadline RPC-вызовов теста (сек).
private const int RpcDeadlineSeconds = 10;
/// <summary>
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
/// </summary>
[Fact]
public async Task HealthCheck_ReturnsServing()
{
await RunHostScenarioAsync(
ValidToken,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse response = await health.CheckAsync(
new HealthCheckRequest(),
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
});
}
/// <summary>
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
/// </summary>
[Fact]
public async Task Status_WithoutToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: null,
expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
/// </summary>
[Fact]
public async Task Status_WithWrongToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: "wrong-token",
expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Верный токен проходит интерцептор к методу Status (задачи 5–6): кодогенерация и маппинг
/// сервиса работают, свежая модель тенанта отвечает готовым ответом (ready=false), а не UNIMPLEMENTED.
/// </summary>
[Fact]
public async Task Status_WithValidToken_ReturnsEmptyStatus()
{
await RunHostScenarioAsync(
ValidToken,
async channel =>
{
var client = new MlService.MlServiceClient(channel);
Metadata metadata = new();
metadata.Add(ServiceTokenMetadataKey, ValidToken);
metadata.Add(TenantIdMetadataKey, TestTenantId);
StatusReply response = await client.StatusAsync(
new StatusRequest(),
new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)));
Assert.False(response.Ready);
Assert.Empty(response.Classes);
Assert.Equal(0, response.Learned);
});
}
/// <summary>
/// Fail-closed (замечание ревью Task 2): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда,
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
/// </summary>
[Fact]
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
{
await RunHostScenarioAsync(
serviceToken: null,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse healthResponse = await health.CheckAsync(
new HealthCheckRequest(),
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
// Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену).
await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated);
await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated);
});
}
// Прогоняет Status с заданным заголовком «service-token» (null — без заголовка) и проверяет
// ожидаемый код статуса RPC-исключения.
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
// expected: Ожидаемый StatusCode ответа.
private static async Task AssertDealRpcRejectedAsync(string? serviceToken, string? tokenHeader, StatusCode expected)
{
await RunHostScenarioAsync(
serviceToken,
channel => AssertRejectedAsync(channel, tokenHeader, expected));
}
// Вызывает Status и проверяет, что сервер ответил ожидаемым кодом статуса.
// channel: Канал к хосту ml-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode.
private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader, StatusCode expected)
{
var client = new MlService.MlServiceClient(channel);
Metadata metadata = new();
if (tokenHeader is not null)
{
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
}
var callOptions = new CallOptions(
metadata,
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
AsyncUnaryCall<StatusReply> call = client.StatusAsync(new StatusRequest(), callOptions);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
Assert.Equal(expected, exception.StatusCode);
}
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
// гасит хост/канал и восстанавливает исходный env.
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
// scenario: Сценарий с каналом к поднятому хосту.
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
{
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey);
// Status реализован (задачи 5–6): модель тенанта создаёт SQLite-файл — каталог теста
// направляем во временную папку, чтобы файлы не попали в data/ml репозитория.
string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-host-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataDir);
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir);
WebApplication? app = null;
GrpcChannel? channel = null;
try
{
int port = FreeTcpPort();
app = MlServiceHost.Create(port);
await app.StartAsync();
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
await scenario(channel);
}
finally
{
if (channel is not null)
{
channel.Dispose();
}
if (app is not null)
{
await app.StopAsync();
await app.DisposeAsync();
}
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir);
try
{
Directory.Delete(dataDir, recursive: true);
}
catch (IOException)
{
// Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим.
}
}
}
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
private static int FreeTcpPort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}
+143
View File
@@ -0,0 +1,143 @@
using Deal.Grpc.Hosting;
using Deal.Ml;
using Deal.Ml.Model;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Sockets;
namespace Deal.Ml.Tests;
// Общий харнесс RPC-тестов ml-service (план Task 6): поднимает хост (MlServiceHost.Create) в
// процессе теста на эфемерном порту и направляет каталог моделей (DEAL_ML_DATA_DIR) во временную
// папку — файлы data/ml/&lt;tenant&gt;.sqlite тестов не попадают в репозиторий. Исходные значения
// env восстанавливаются; каталог удаляется после сценария (шаблон TelegramTestHost).
internal static class MlTestHost
{
/// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor).
/// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Env-ключ каталога моделей (зеркало MlOptions.DataDirEnvVarName).
/// </summary>
public const string DataDirEnvKey = MlOptions.DataDirEnvVarName;
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor).
/// </summary>
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
/// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey).
/// </summary>
public const string TenantIdMetadataKey = MlServiceImpl.TenantIdMetadataKey;
/// <summary>
/// Токен сценариев теста.
/// </summary>
public const string DefaultToken = "deal-ml-test-token";
/// <summary>
/// Id тенанта сценариев по умолчанию.
/// </summary>
public const string DefaultTenantId = "tenant-test";
/// <summary>
/// Deadline RPC-вызовов теста (сек).
/// </summary>
public const int RpcDeadlineSeconds = 15;
/// <summary>
/// Прогоняет сценарий против поднятого хоста с заданным env-токеном и тестовым каталогом моделей.
/// </summary>
/// <param name="serviceToken">Значение env DEAL_SERVICE_TOKEN (null — убрать переменную).</param>
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
public static async Task RunAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
{
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey);
string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataDir);
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir);
WebApplication? app = null;
GrpcChannel? channel = null;
try
{
int port = FreeTcpPort();
app = MlServiceHost.Create(port);
await app.StartAsync();
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
await scenario(channel);
}
finally
{
if (channel is not null)
{
channel.Dispose();
}
if (app is not null)
{
await app.StopAsync();
await app.DisposeAsync();
}
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir);
try
{
Directory.Delete(dataDir, recursive: true);
}
catch (IOException)
{
// Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим.
}
}
}
/// <summary>
/// Строит metadata вызова: service-token (+ tenant-id, если задан).
/// </summary>
/// <param name="serviceToken">Значение заголовка service-token.</param>
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
public static Metadata CallMetadata(string? serviceToken, string? tenantId = null)
{
var metadata = new Metadata();
if (serviceToken is not null)
{
metadata.Add(ServiceTokenMetadataKey, serviceToken);
}
if (tenantId is not null)
{
metadata.Add(TenantIdMetadataKey, tenantId);
}
return metadata;
}
/// <summary>
/// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L6273).
/// </summary>
/// <param name="metadata">Metadata вызова.</param>
public static CallOptions CallOptions(Metadata metadata)
=> new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
private static int FreeTcpPort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}
@@ -0,0 +1,77 @@
using Deal.Ml.Model;
namespace Deal.Ml.Tests;
/// <summary>
/// Тесты токенизации (план Task 5; 1:1 mlservice/model.py tokenize L7887): удаление ссылок
/// (включая markdown), lowercase, отбрасывание слов короче 3, «хвостовые» ~prefix-термины для
/// слов длиной ≥ 6, разрешённый алфавит [a-zа-яё0-9@+.#].
/// </summary>
public sealed class MlTokenizerTests
{
/// <summary>
/// Базовое: lowercase, дефис разделяет термины, длинные слова дают ~prefix.
/// </summary>
[Fact]
public void Tokenize_LowercasesAndAddsPrefixes()
{
string[] tokens = MlTokenizer.Tokenize("Нужен Python-разработчик");
Assert.Equal(new[] { "нужен", "python", "~pyth", "разработчик", "~разр" }, tokens);
}
/// <summary>
/// Ссылки (http/www/markdown) не дают терминов и не ломают соседние слова.
/// </summary>
[Fact]
public void Tokenize_RemovesLinksAndMarkdownLinks()
{
string text = "Ссылка https://example.com/page?utm_source=x и www.example.org [текст](https://x.ru/a) конец";
string[] tokens = MlTokenizer.Tokenize(text);
Assert.Equal(new[] { "ссылка", "~ссыл", "конец" }, tokens);
}
/// <summary>
/// Слова короче 3 символов отбрасываются (пустой результат для «а б вг»).
/// </summary>
[Fact]
public void Tokenize_DropsShortWords()
{
Assert.Empty(MlTokenizer.Tokenize("а б вг"));
}
/// <summary>
/// Кириллица/ё обрабатываются как в python: lower + префикс первых 4 символов.
/// </summary>
[Fact]
public void Tokenize_CyrillicWithYo()
{
string[] tokens = MlTokenizer.Tokenize("Программист Ёжик");
Assert.Equal(new[] { "программист", "~прог", "ёжик" }, tokens);
}
/// <summary>
/// Разрешённый алфавит включает @ + . # — email остаётся одним термином (1:1 python).
/// </summary>
[Fact]
public void Tokenize_KeepsEmailAsSingleTerm()
{
string[] tokens = MlTokenizer.Tokenize("привет@mail.ru");
Assert.Equal(new[] { "привет@mail.ru", "~прив" }, tokens);
}
/// <summary>
/// Null/пустой текст — пустой набор терминов.
/// </summary>
[Fact]
public void Tokenize_NullOrEmpty_ReturnsEmpty()
{
Assert.Empty(MlTokenizer.Tokenize(null));
Assert.Empty(MlTokenizer.Tokenize(string.Empty));
Assert.Empty(MlTokenizer.Tokenize(" "));
}
}
@@ -0,0 +1,159 @@
using Deal.Ml.Model;
using Microsoft.Data.Sqlite;
namespace Deal.Ml.Tests;
/// <summary>
/// Тесты персистентности модели (план Task 5): веса переживают перезапуск пула (второй инстанс
/// на тот же SQLite-файл), журнал самооценки прунится до EVAL_KEEP=200, статус отдаёт окно
/// EVAL_WINDOW=50 последних решений.
/// </summary>
public sealed class ModelPersistenceTests
{
/// <summary>
/// Перезапуск пула сохраняет веса: обучение на первом пуле → закрытие → второй пул на тот же
/// каталог отдаёт те же классы/learned/eval и те же предсказания.
/// </summary>
[Fact]
public void SecondPoolOnSameFile_ReloadsWeightsAndEval()
{
string dataDir = NewDataDir();
try
{
MlStatusResult firstStatus;
MlPredictResult firstPredict;
using (var firstPool = new ModelPool(MlOptions.Create(dataDir)))
{
TenantModel model = firstPool.GetOrCreate("tenant-persist");
List<LearnItem> items = LearningData.CanonicalTrainItems();
model.LearnBatch(items.Take(24).ToList());
model.LearnBatch(items.Skip(24).ToList());
firstStatus = model.Status();
firstPredict = model.Predict(LearningData.DevMessage);
}
using var secondPool = new ModelPool(MlOptions.Create(dataDir));
TenantModel reloaded = secondPool.GetOrCreate("tenant-persist");
MlStatusResult secondStatus = reloaded.Status();
MlPredictResult secondPredict = reloaded.Predict(LearningData.DevMessage);
Assert.True(secondStatus.Ready);
Assert.Equal(firstStatus.Learned, secondStatus.Learned);
Assert.Equal(firstStatus.Classes, secondStatus.Classes);
Assert.Equal(firstStatus.Eval, secondStatus.Eval);
Assert.True(secondPredict.Take);
Assert.Equal(firstPredict.Label, secondPredict.Label);
Assert.Equal(firstPredict.Scores, secondPredict.Scores);
}
finally
{
TryDelete(dataDir);
}
}
/// <summary>
/// Окно самооценки: журнал хранит не больше EVAL_KEEP (200) строк (проверка в файле), статус
/// считает последние EVAL_WINDOW (50) решений. Сценарий — 250 дополнительных «реальных»
/// действий поверх канонического батча.
/// </summary>
[Fact]
public void EvalLog_PrunesToKeepAndWindowIsFifty()
{
string dataDir = NewDataDir();
try
{
TenantModel model;
using (var pool = new ModelPool(MlOptions.Create(dataDir)))
{
model = pool.GetOrCreate("tenant-eval-window");
List<LearnItem> items = LearningData.CanonicalTrainItems();
model.LearnBatch(items.Take(24).ToList());
model.LearnBatch(items.Skip(24).ToList());
// 250 реальных действий (delta=1, метка совпадает с предсказанием) — самооценка пишется.
model.LearnBatch(
Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList());
MlStatusResult status = model.Status();
Assert.Equal(50, status.Eval.Count); // окно — последние EVAL_WINDOW
Assert.Equal(50, status.Eval.Correct);
Assert.Equal(1.0, status.Eval.Accuracy);
Assert.Equal(200, CountEvalRows(model.DatabasePath)); // журнал прунится до EVAL_KEEP
}
}
finally
{
TryDelete(dataDir);
}
}
/// <summary>
/// Прунинг переживает перезапуск: reload держит последние EVAL_KEEP и окно 50.
/// </summary>
[Fact]
public void EvalLog_PrunePersistedAcrossPoolRestart()
{
string dataDir = NewDataDir();
try
{
using (var pool = new ModelPool(MlOptions.Create(dataDir)))
{
TenantModel model = pool.GetOrCreate("tenant-eval-restart");
List<LearnItem> items = LearningData.CanonicalTrainItems();
model.LearnBatch(items.Take(24).ToList());
model.LearnBatch(items.Skip(24).ToList());
model.LearnBatch(
Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList());
}
using var secondPool = new ModelPool(MlOptions.Create(dataDir));
TenantModel reloaded = secondPool.GetOrCreate("tenant-eval-restart");
MlStatusResult status = reloaded.Status();
Assert.Equal(50, status.Eval.Count);
Assert.Equal(1.0, status.Eval.Accuracy);
Assert.Equal(200, CountEvalRows(reloaded.DatabasePath));
}
finally
{
TryDelete(dataDir);
}
}
// Читает число строк eval_log прямо из SQLite-файла модели.
// dbPath: Путь к файлу модели.
private static long CountEvalRows(string dbPath)
{
using var connection = new SqliteConnection($"Data Source={dbPath}");
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT count(*) FROM eval_log";
return (long)command.ExecuteScalar()!;
}
// Новая временная папка данных моделей.
private static string NewDataDir()
{
string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-persist-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataDir);
return dataDir;
}
// Удаляет временную папку (мусор в temp допустим при сбое).
// dataDir: Папка.
private static void TryDelete(string dataDir)
{
try
{
Directory.Delete(dataDir, recursive: true);
}
catch (IOException)
{
// Занятый каталог — тестовый мусор в temp допустим.
}
}
}
@@ -0,0 +1,85 @@
using Deal.Ml.Model;
namespace Deal.Ml.Tests;
/// <summary>
/// Тесты пула моделей (план Task 5, Ruling 4): ленивое создание по тенанту (один инстанс на
/// тенанта), файл data/ml/&lt;tenantId&gt;.sqlite, защита tenant-id от выхода из каталога.
/// </summary>
public sealed class ModelPoolTests
{
/// <summary>
/// Один тенант → один инстанс модели; файл создаётся только по первому обращению.
/// </summary>
[Fact]
public void GetOrCreate_ReturnsSameModelPerTenant()
{
RunWithPool(pool =>
{
TenantModel first = pool.GetOrCreate("tenant-a");
TenantModel second = pool.GetOrCreate("tenant-a");
TenantModel other = pool.GetOrCreate("tenant-b");
Assert.Same(first, second);
Assert.NotSame(first, other);
Assert.False(File.Exists(first.DatabasePath)); // лениво: файла ещё нет
});
}
/// <summary>
/// Имя файла модели — &lt;tenantId&gt;.sqlite в каталоге данных (Ruling 4).
/// </summary>
[Fact]
public void GetOrCreate_DbPathIsTenantFileUnderDataDir()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("t1");
model.Status(); // первое обращение создаёт файл
string expected = Path.Combine(pool.DataDirectory, "t1.sqlite");
Assert.Equal(expected, model.DatabasePath);
Assert.True(File.Exists(expected));
});
}
/// <summary>
/// Некорректный tenant-id (путь/«..») не может вывести файл за каталог данных.
/// </summary>
[Fact]
public void GetOrCreate_InvalidTenantId_Throws()
{
RunWithPool(pool =>
{
Assert.Throws<ArgumentException>(() => pool.GetOrCreate(string.Empty));
Assert.Throws<ArgumentException>(() => pool.GetOrCreate(" "));
Assert.Throws<ArgumentException>(() => pool.GetOrCreate("../outside"));
Assert.Throws<ArgumentException>(() => pool.GetOrCreate("a\\b"));
Assert.Throws<ArgumentException>(() => pool.GetOrCreate(".."));
});
}
// Прогоняет сценарий над свежим пулом во временной папке (очистка после).
// scenario: Сценарий с пулом.
private static void RunWithPool(Action<ModelPool> scenario)
{
string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-pool-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataDir);
try
{
using var pool = new ModelPool(MlOptions.Create(dataDir));
scenario(pool);
}
finally
{
try
{
Directory.Delete(dataDir, recursive: true);
}
catch (IOException)
{
// Занятый каталог — тестовый мусор в temp допустим.
}
}
}
}
@@ -0,0 +1,378 @@
using Deal.Ml.Model;
namespace Deal.Ml.Tests;
/// <summary>
/// Тесты движка модели (план Task 5): обучение → предсказание спама/колонки/типа на русских
/// примерах, ready-пороги (20/6/4/2), адаптивный margin, delta&lt;0 «разучивание», дробные веса
/// ИИ-сигналов, журнал самооценки. Модель — поверх реального SQLite-файла во временной папке.
/// </summary>
public sealed class TenantModelLearningTests
{
/// <summary>
/// Пустая модель: predict — фиксированный «не уверен» 1:1 (take:false, ready:false).
/// </summary>
[Fact]
public void EmptyModel_PredictReturnsNotReadyShape()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-empty");
MlPredictResult predict = model.Predict(LearningData.DevMessage);
Assert.False(predict.Take);
Assert.Null(predict.Label);
Assert.False(predict.Ready);
Assert.Empty(predict.Scores);
Assert.Equal(0, predict.Hits);
Assert.Null(predict.Margin);
Assert.Empty(predict.Terms);
Assert.Null(predict.Type);
MlStatusResult status = model.Status();
Assert.False(status.Ready);
Assert.Empty(status.Classes);
Assert.Equal(0, status.Learned);
Assert.Equal(0, status.Eval.Count);
Assert.Equal(0, status.Eval.Correct);
Assert.Equal(0.0, status.Eval.Accuracy);
});
}
/// <summary>
/// Пока суммарно примеров &lt; MIN_TOTAL (20), модель не готова и ничего не решает
/// (ready=false, «не уверен») — не может ошибочно удалить заявку как спам.
/// </summary>
[Fact]
public void BelowMinTotal_ModelNotReadyAndDoesNotDecide()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-below-threshold");
Train(model, BelowThresholdItems());
MlStatusResult status = model.Status();
Assert.False(status.Ready);
Assert.Equal(16, status.Learned);
MlPredictResult predict = model.Predict(LearningData.SpamMessage);
Assert.False(predict.Take);
Assert.Null(predict.Label);
Assert.False(predict.Ready);
});
}
/// <summary>
/// Порог ready (суммарно ≥ 20, spam ≥ 4, не-спам ≥ 6): после добавления t:hire модель
/// «включается» и начинает уверенно решать (приёмка Task 5: «нужен middle python…» → колонка).
/// </summary>
[Fact]
public void ReachingReadyThreshold_StartsDeciding()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-ready-threshold");
Train(model, BelowThresholdItems());
Assert.False(model.Status().Ready);
Train(model, TypeHireItems());
MlStatusResult status = model.Status();
Assert.True(status.Ready);
MlPredictResult predict = model.Predict(LearningData.DevMessage);
Assert.True(predict.Take);
Assert.Equal(LearningData.ColumnDev, predict.Label);
Assert.Null(predict.Type); // только один t:-класс — тип не определяется
});
}
/// <summary>
/// Полный сценарий приёмки Task 5: обучение колонок dev/order + spam + типы t:hire/t:order →
/// предсказания: Dev → колонка b_col_dev + тип hire, Order → b_col_order + тип order,
/// Spam → spam без типа. Параллельно проверяются статус (learned/classes) и журнал самооценки.
/// </summary>
[Fact]
public void CanonicalTraining_LearnsColumnsSpamAndTypes()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-canonical");
List<LearnItem> items = LearningData.CanonicalTrainItems();
// Батчами, как флашер ядра (Ruling 6): сначала доводим до готовности (24 примера),
// затем «реальные действия» (4) — они и дают строки самооценки.
int learnedFirst = model.LearnBatch(items.Take(24).ToList());
int learnedSecond = model.LearnBatch(items.Skip(24).ToList());
Assert.Equal(24, learnedFirst);
Assert.Equal(4, learnedSecond);
MlStatusResult status = model.Status();
Assert.True(status.Ready);
Assert.Equal(28, status.Learned);
Assert.Equal(8.0, status.Classes[LearningData.ColumnDev]);
Assert.Equal(6.0, status.Classes[LearningData.ColumnOrder]);
Assert.Equal(6.0, status.Classes[LearningData.SpamLabel]);
Assert.Equal(4.0, status.Classes[LearningData.TypeHire]);
Assert.Equal(4.0, status.Classes[LearningData.TypeOrder]);
Assert.Equal(4, status.Eval.Count); // 2 dev + 2 spam «реальных» действия после ready
Assert.Equal(4, status.Eval.Correct);
Assert.Equal(1.0, status.Eval.Accuracy);
MlPredictResult dev = model.Predict(LearningData.DevMessage);
Assert.True(dev.Take);
Assert.Equal(LearningData.ColumnDev, dev.Label);
Assert.Equal(0.9, dev.Margin);
Assert.Equal(21, dev.Hits); // паритет с python (mlservice/model.py, контрольный прогон)
Assert.Equal(37.333, dev.Scores[LearningData.ColumnDev]);
Assert.Equal(33.6, dev.Scores[LearningData.TypeHire]);
Assert.NotNull(dev.Type);
Assert.True(dev.Type.Take);
Assert.Equal("hire", dev.Type.Label);
Assert.Equal(LearningData.TypeHire, dev.Type.Value);
Assert.NotEmpty(dev.Terms);
MlPredictResult order = model.Predict(LearningData.OrderMessage);
Assert.True(order.Take);
Assert.Equal(LearningData.ColumnOrder, order.Label);
Assert.Equal(18, order.Hits);
Assert.Equal(30.857, order.Scores[LearningData.ColumnOrder]);
Assert.Equal(28.8, order.Scores[LearningData.TypeOrder]);
Assert.NotNull(order.Type);
Assert.Equal("order", order.Type.Label);
MlPredictResult spam = model.Predict(LearningData.SpamMessage);
Assert.True(spam.Take);
Assert.Equal(LearningData.SpamLabel, spam.Label);
Assert.Equal(17, spam.Hits);
Assert.Equal(29.143, spam.Scores[LearningData.SpamLabel]);
Assert.Null(spam.Type);
// Минимальный отклик одного термина (паритет с python): weight=6 → score 1.714.
MlPredictResult oneTerm = model.Predict("ищу");
Assert.True(oneTerm.Ready);
Assert.False(oneTerm.Take);
Assert.Equal(1, oneTerm.Hits);
Assert.Equal(1.714, oneTerm.Scores[LearningData.SpamLabel]);
MlPredictResult twoTerms = model.Predict("ищу подработку");
Assert.True(twoTerms.Take);
Assert.Equal(LearningData.SpamLabel, twoTerms.Label);
Assert.Equal(5.143, twoTerms.Scores[LearningData.SpamLabel]);
});
}
/// <summary>
/// MIN_HITS=2: одно совпадение у победителя ещё не «взятие» (take=false, hits=1), двух — уже да.
/// </summary>
[Fact]
public void MinHitsThreshold_NeedsTwoMatchedTerms()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-min-hits");
Train(model, BelowThresholdItems());
Train(model, TypeHireItems()); // суммарно 20 → ready
MlPredictResult single = model.Predict("ищу");
Assert.True(single.Ready);
Assert.False(single.Take);
Assert.Null(single.Label);
Assert.Equal(1, single.Hits);
Assert.NotNull(single.Margin);
MlPredictResult two = model.Predict("ищу подработку");
Assert.True(two.Take);
Assert.Equal(LearningData.SpamLabel, two.Label);
});
}
/// <summary>
/// Незнакомый текст готовой модели: «не уверен», но ready=true и margin пуст.
/// </summary>
[Fact]
public void UnknownText_NotTakenButReady()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-unknown");
Train(model, LearningData.CanonicalTrainItems());
MlPredictResult predict = model.Predict("совершенно посторонний текст без общих терминов");
Assert.True(predict.Ready);
Assert.False(predict.Take);
Assert.Null(predict.Label);
Assert.Empty(predict.Scores);
Assert.Null(predict.Margin);
Assert.Null(predict.Type);
});
}
/// <summary>
/// Адаптивный margin: 0.9 на старте, 0.7 после ≥ 60 суммарных примеров (L42–55).
/// </summary>
[Fact]
public void AdaptiveMargin_DropsAfter60Examples()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-margin");
Train(model, LearningData.CanonicalTrainItems());
Assert.Equal(0.9, model.Predict(LearningData.DevMessage).Margin);
Train(model, Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 40).ToList());
double? margin = model.Predict(LearningData.DevMessage).Margin;
Assert.Equal(0.7, margin);
});
}
/// <summary>
/// delta &lt; 0 «разучивает»: вес класса и его термины уменьшаются до удаления.
/// </summary>
[Fact]
public void NegativeDelta_UnlearnsUntilClassRemoved()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-unlearn");
Train(model, Enumerable.Repeat(LearningData.User(LearningData.SpamLabel, LearningData.SpamMessage), 3).ToList());
Assert.Equal(3.0, model.Status().Classes[LearningData.SpamLabel]);
Assert.Equal(3, model.Status().Learned);
Train(model, Enumerable.Repeat(LearningData.Signal(LearningData.SpamLabel, LearningData.SpamMessage, -1.0), 1).ToList());
Assert.Equal(2.0, model.Status().Classes[LearningData.SpamLabel]);
Train(model, Enumerable.Repeat(LearningData.Signal(LearningData.SpamLabel, LearningData.SpamMessage, -1.0), 2).ToList());
MlStatusResult status = model.Status();
Assert.False(status.Classes.ContainsKey(LearningData.SpamLabel));
Assert.Equal(0, status.Learned);
Assert.False(model.Predict(LearningData.SpamMessage).Take);
});
}
/// <summary>
/// Дробные веса ИИ-сигналов (delta 0.4): класс копится, но не кратен 1 (гипотезы ИИ).
/// </summary>
[Fact]
public void FractionalDelta_TrainsAiHypotheses()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-ai-signal");
Train(model, [LearningData.Signal(LearningData.TypeHire, LearningData.DevMessage, 0.4)]);
MlStatusResult status = model.Status();
Assert.False(status.Ready);
Assert.Equal(0.4, status.Classes[LearningData.TypeHire]);
Assert.Equal(0, status.Learned); // int(0.4) — как python int(sum)
});
}
/// <summary>
/// Пустые/пробельные text или label пропускаются тихо (1:1 _upsert_one L112114).
/// </summary>
[Fact]
public void LearnBatch_SkipsEmptyItems()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-empty-items");
var items = new List<LearnItem>
{
LearningData.User(LearningData.ColumnDev, string.Empty),
LearningData.User(" ", LearningData.DevMessage),
LearningData.User(LearningData.ColumnOrder, LearningData.OrderMessage),
};
int learned = model.LearnBatch(items);
Assert.Equal(1, learned);
MlStatusResult status = model.Status();
Assert.Equal(1, status.Learned);
Assert.True(status.Classes.ContainsKey(LearningData.ColumnOrder));
Assert.False(status.Classes.ContainsKey(LearningData.ColumnDev));
});
}
/// <summary>
/// Reset очищает модель и пересоздаёт файл при следующем обучении (план Task 6).
/// </summary>
[Fact]
public void Reset_ClearsWeightsAndFileRecreatedOnNextLearn()
{
RunWithPool(pool =>
{
TenantModel model = pool.GetOrCreate("tenant-reset");
Train(model, LearningData.CanonicalTrainItems());
string dbPath = model.DatabasePath;
Assert.True(File.Exists(dbPath));
model.Reset();
Assert.False(File.Exists(dbPath));
Assert.False(model.Status().Ready);
Assert.Empty(model.Status().Classes);
Assert.Equal(0, model.Status().Learned);
Assert.False(model.Predict(LearningData.DevMessage).Take);
Train(model, Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 3).ToList());
Assert.True(File.Exists(dbPath));
Assert.Equal(3, model.Status().Learned);
});
}
// Батч: 6 колонок dev, 6 колонок order, 4 спама (суммарно 16 — ниже MIN_TOTAL).
private static List<LearnItem> BelowThresholdItems()
{
var items = new List<LearnItem>();
items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 6));
items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.ColumnOrder, LearningData.OrderMessage), 6));
items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.SpamLabel, LearningData.SpamMessage), 4));
return items;
}
// 4 примера типа t:hire (доводят суммарный счёт до MIN_TOTAL).
private static List<LearnItem> TypeHireItems()
=> Enumerable.Repeat(LearningData.User(LearningData.TypeHire, LearningData.DevMessage), 4).ToList();
// Обучает модель батчем (один вызов LearnBatch).
// model: Модель.
// items: Примеры.
private static void Train(TenantModel model, List<LearnItem> items)
=> model.LearnBatch(items);
// Прогоняет сценарий над свежим пулом во временной папке (очистка после).
// scenario: Сценарий с пулом.
private static void RunWithPool(Action<ModelPool> scenario)
{
string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-model-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataDir);
try
{
using var pool = new ModelPool(MlOptions.Create(dataDir));
scenario(pool);
}
finally
{
try
{
Directory.Delete(dataDir, recursive: true);
}
catch (IOException)
{
// Мусор в temp допустим, если каталог занят (Windows).
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ml", "Deal.Ml\Deal.Ml.csproj", "{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{F439AFEB-753E-4052-A7DC-FA96F6326FED}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ml.Tests", "Deal.Ml.Tests\Deal.Ml.Tests.csproj", "{77DCB23F-E79F-4EBB-81ED-487C922E00DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{4C184075-7864-425F-BAD5-9695B7771A99}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x64.ActiveCfg = Debug|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x64.Build.0 = Debug|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x86.ActiveCfg = Debug|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x86.Build.0 = Debug|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|Any CPU.Build.0 = Release|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x64.ActiveCfg = Release|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x64.Build.0 = Release|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x86.ActiveCfg = Release|Any CPU
{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x86.Build.0 = Release|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x64.ActiveCfg = Debug|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x64.Build.0 = Debug|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x86.ActiveCfg = Debug|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x86.Build.0 = Debug|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|Any CPU.Build.0 = Release|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x64.ActiveCfg = Release|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x64.Build.0 = Release|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x86.ActiveCfg = Release|Any CPU
{F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x86.Build.0 = Release|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x64.ActiveCfg = Debug|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x64.Build.0 = Debug|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x86.ActiveCfg = Debug|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x86.Build.0 = Debug|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|Any CPU.Build.0 = Release|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x64.ActiveCfg = Release|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x64.Build.0 = Release|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x86.ActiveCfg = Release|Any CPU
{77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x86.Build.0 = Release|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x64.ActiveCfg = Debug|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x64.Build.0 = Debug|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x86.ActiveCfg = Debug|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x86.Build.0 = Debug|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Release|Any CPU.Build.0 = Release|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Release|x64.ActiveCfg = Release|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Release|x64.Build.0 = Release|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Release|x86.ActiveCfg = Release|Any CPU
{4C184075-7864-425F-BAD5-9695B7771A99}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+48
View File
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Deal.Ml — gRPC-хост ml-service (план Task 3; Ruling 1/2/4/12).
Кодогенерация .proto — в общем проекте src/contracts/Deal.Proto.csproj (Task 1, Ruling 1):
сервис подключает его ProjectReference и использует сгенерированную серверную базу
Deal.Grpc.Ml.MlService.MlServiceBase (решение по способу подключения — T2, см. task-2-report).
Клиентская сторона ml.proto сгенерирована в Deal.Proto (GrpcServices="Both") — понадобится
core-адаптеру GrpcMlClient (Ruling 6, задача 16).
Логика инкрементальной наивно-байесовской модели (НЕ ONNX/ML.NET — Ruling 4) живёт в
задачах 5–6; здесь — каркас: хост, health, service-token, заглушки RPC и DI-заготовка
(место регистрации модель-менеджера зарезервировано в MlServiceHost).
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors, Directory.Build.props каталога сервиса).
-->
<PropertyGroup>
<AssemblyName>Deal.Ml</AssemblyName>
<RootNamespace>Deal.Ml</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Структурированные логи Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл
data/logs/deal-ml-*.json; конфигурация — Deal.Ml/DealLogging.cs (Program.cs).
Пакет тянет консоль/файл/compact-формат транзитивно. -->
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Kestrel HTTP/2) + стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
</ItemGroup>
<ItemGroup>
<!-- Веса моделей per-tenant — SQLite-файлы data/ml/<tenantId>.sqlite (Ruling 4, план Task 5). -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<!-- Общая серверная обвязка gRPC-хостов (C31): интерцепторы/mTLS/DealLogging/GrpcServer —
единый источник вместо копий в трёх сервисах. -->
<ProjectReference Include="..\..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj" />
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
</ItemGroup>
</Project>
+38
View File
@@ -0,0 +1,38 @@
# ml-service: gRPC-хост ML (план Task 3; Ruling 12 — запись в deploy/compose.dev.yml).
#
# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ml.csproj ссылается на src/contracts/Deal.Proto.csproj
# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из
# src/ml-service. Запуск из корня: docker build -f src/ml-service/Deal.Ml/Dockerfile .
# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5103.
# --- Этап сборки: restore + publish ---
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
COPY src/contracts/Deal.Proto.csproj src/contracts/
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
COPY src/ml-service/Directory.Build.props src/ml-service/
COPY src/ml-service/Deal.Ml/Deal.Ml.csproj src/ml-service/Deal.Ml/
RUN dotnet restore src/ml-service/Deal.Ml/Deal.Ml.csproj
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
COPY src/contracts/ src/contracts/
COPY src/grpc-hosting/ src/grpc-hosting/
COPY src/ml-service/Deal.Ml/ src/ml-service/Deal.Ml/
RUN dotnet publish src/ml-service/Deal.Ml/Deal.Ml.csproj -c Release -o /app/publish
# --- Runtime-этап ---
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 5103
COPY --from=build /app/publish .
# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token
# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata.
COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe
# Файлы моделей тенантов — data/ml/<tenantId>.sqlite (Ruling 4): каталог монтируется volume-ом
# deal_ml_data из compose.dev.yml; каталог и файлы создаёт движок модели (задачи 5–6).
ENTRYPOINT ["dotnet", "Deal.Ml.dll"]
+73
View File
@@ -0,0 +1,73 @@
using Deal.Grpc.Hosting;
using Deal.Ml.Model;
namespace Deal.Ml;
/// <summary>
/// Собирает WebApplication gRPC-хоста ml-service (план Task 3/5/6; L240248 + движок и RPC).
///
/// Продакшн-точка входа вызывает <see cref="Create"/> из Program.cs (порт из env GRPC_PORT/PORT);
/// интеграционные тесты (Deal.Ml.Tests) — из своего процесса на эфемерном порту, поэтому
/// конфигурация хоста живёт здесь один раз и не дублируется в тестах.
/// Транспорт/AddGrpc/health — общая серверная обвязка <see cref="GrpcServer"/> (Deal.Grpc.Hosting,
/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и
/// access-лога, gRPC-health; здесь — только регистрации логики ml-service.
/// Регистрации логики (план Task 5/6, Ruling 4): каталог файлов моделей (MlOptions — env
/// DEAL_ML_DATA_DIR, volume /data/ml в compose) и пул инкрементальных наивно-байесовских моделей
/// per-tenant (ModelPool: lazy-загрузка SQLite-файла data/ml/&lt;tenantId&gt;.sqlite, lock на модель).
/// gRPC-сервис поверх пула — <see cref="MlServiceImpl"/> (Predict/Status/Reset/TrainBatch).
/// </summary>
public static class MlServiceHost
{
/// <summary>
/// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort,
/// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным
/// сертификатом и требованием клиентского, Ruling 6/Task 13), затем пул моделей и маппинг
/// <see cref="MlServiceImpl"/>.
/// </summary>
/// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
/// <param name="configureServices">
/// Опциональный хук DI для тестов (подмена зависимостей фейками; для ml-логики обычно не нужен —
/// харнессы тестов направляют каталог моделей env DEAL_ML_DATA_DIR во временную папку).
/// </param>
/// <param name="configureBuilder">
/// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog
/// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование
/// файлов/консоли тестам не нужно.
/// </param>
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
public static WebApplication Create(
int grpcPort,
string[]? args = null,
Action<IServiceCollection>? configureServices = null,
Action<WebApplicationBuilder>? configureBuilder = null)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []);
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh);
// Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc
// (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12).
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост ml-service готов");
// Движок инкрементальной модели (план Task 5/6, Ruling 4): каталог SQLite-файлов тенантов
// (env DEAL_ML_DATA_DIR; по умолчанию data/ml под ContentRoot) + пул моделей per-tenant.
MlOptions mlOptions = MlOptions.FromConfiguration(builder.Configuration, builder.Environment);
builder.Services.AddSingleton(mlOptions);
builder.Services.AddSingleton<ModelPool>();
configureServices?.Invoke(builder.Services);
configureBuilder?.Invoke(builder);
WebApplication app = builder.Build();
app.MapGrpcService<MlServiceImpl>();
app.MapGrpcHealthChecksService();
return app;
}
}
+288
View File
@@ -0,0 +1,288 @@
using Deal.Grpc.Ml;
using Deal.Ml.Model;
using Grpc.Core;
namespace Deal.Ml;
/// <summary>
/// Реализация серверной стороны Deal.Grpc.Ml.MlService — команды ядра в ml-service
/// (ml.proto, контракты Task 1; Ruling 1/4/6). План Task 6: Predict/Status/Reset/TrainBatch поверх
/// <see cref="ModelPool"/> (движок Task 5). tenantId — только из gRPC-metadata, полю не доверяем
/// (Ruling 1); модели нет — она создаётся лениво: Predict без опыта отвечает «не уверен», а не
/// ошибкой (README src/contracts L2123). Формы ответов 1:1 с model.py predict/status/reset и
/// mlservice/server.py (эталон).
/// </summary>
public sealed class MlServiceImpl : MlService.MlServiceBase
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
// Деталь отказа: tenant-id отсутствует в metadata (UNAUTHENTICATED, шаблон T5).
private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata";
// Деталь отказа: tenant-id некорректен как имя файла модели (INVALID_ARGUMENT).
private const string InvalidTenantIdDetail = "Некорректный tenant-id";
// Деталь отказа: хранилище модели недоступно (UNAVAILABLE — безопасный повтор, Ruling 1).
private const string StorageUnavailableDetail = "Хранилище модели недоступно — повторите запрос позже";
// Потолок примеров батча обучения (контракт ml.proto: ядро шлёт ≤100 за цикл — Ruling 6).
private const int MaxTrainBatchItems = 100;
// Потолок длины текста примера/предсказания (source_msg ядро обрезает до 4000).
private const int MaxTextLength = 4000;
// Потолок длины метки примера (id доски/spam/t:hire/t:order — короткие значения).
private const int MaxExampleLabelLength = 64;
// Деталь отказа: батч больше контрактного лимита (INVALID_ARGUMENT).
private const string TrainBatchTooLargeDetail = "Батч обучения больше 100 примеров";
// Деталь отказа: текст обучающего примера длиннее лимита (INVALID_ARGUMENT).
private const string ExampleTextTooLongDetail = "Слишком длинный текст обучающего примера";
// Деталь отказа: метка обучающего примера длиннее лимита (INVALID_ARGUMENT).
private const string ExampleLabelTooLongDetail = "Слишком длинная метка обучающего примера";
// Деталь отказа: текст предсказания длиннее лимита (INVALID_ARGUMENT).
private const string PredictTextTooLongDetail = "Слишком длинный текст сообщения";
// Фиксированный текст мягкой ошибки сброса (детали сбоя/пути — только в лог, Ruling 13).
private const string ResetFailedDetail = "Не удалось сбросить модель — повторите попытку позже";
private readonly ModelPool _pool;
private readonly ILogger<MlServiceImpl> _logger;
/// <summary>
/// Создаёт сервис команд ядра поверх пула моделей.
/// </summary>
/// <param name="pool">Пул моделей тенантов (ленивое создание/загрузка).</param>
/// <param name="logger">Логгер аудита (Ruling 13).</param>
public MlServiceImpl(ModelPool pool, ILogger<MlServiceImpl> logger)
{
_pool = pool;
_logger = logger;
}
/// <summary>
/// Predict — решение по тексту сообщения (model.py predict L184293): take/label/scores/hits/
/// ready/margin/terms/type. Неготовая или пустая модель отвечает «не уверен» — не ошибка.
/// </summary>
public override Task<PredictReply> Predict(PredictRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
EnsureLengthAtMost(request.Text, MaxTextLength, PredictTextTooLongDetail);
TenantModel model = ResolveModel(tenantId);
try
{
MlPredictResult result = model.Predict(request.Text);
_logger.LogInformation(
"Аудит: tenant {TenantId} predict → take={Take}, label={Label}, ready={Ready}",
tenantId, result.Take, result.Label, result.Ready);
return Task.FromResult(ToPredictReply(result));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Аудит: tenant {TenantId} predict → хранилище недоступно", tenantId);
throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail));
}
}
/// <summary>
/// Status — статус модели тенанта (model.py status L325345): ready/classes/learned/eval;
/// модель создаётся лениво по первому обращению (отсутствие опыта — не ошибка).
/// </summary>
public override Task<StatusReply> Status(StatusRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantModel model = ResolveModel(tenantId);
try
{
MlStatusResult result = model.Status();
_logger.LogInformation(
"Аудит: tenant {TenantId} status → ready={Ready}, learned={Learned}",
tenantId, result.Ready, result.Learned);
return Task.FromResult(ToStatusReply(result));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Аудит: tenant {TenantId} status → хранилище недоступно", tenantId);
throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail));
}
}
/// <summary>
/// Reset — полный сброс модели тенанта (model.py reset L348354): очистка классов, терминов и
/// журнала самооценки + пересоздание файла. Ok=true при успехе; сбой — мягкая ошибка
/// Ok=false + error (ядро чистит свою ml_outbox только при успехе, Ruling 6).
/// </summary>
public override Task<ResetReply> Reset(ResetRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantModel model = ResolveModel(tenantId);
try
{
model.Reset();
_logger.LogInformation("Аудит: tenant {TenantId} reset → ok", tenantId);
return Task.FromResult(new ResetReply { Ok = true });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Аудит: tenant {TenantId} reset → сбой хранилища", tenantId);
return Task.FromResult(new ResetReply { Ok = false, Error = ResetFailedDetail });
}
}
/// <summary>
/// TrainBatch — пакетное обучение (model.py learn_batch L147173): одна транзакция на батч,
/// ответ — число применённых примеров (пустые text/label пропускаются; ядро шлёт ≤100, Ruling 6).
/// </summary>
public override Task<TrainBatchReply> TrainBatch(TrainBatchRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
EnsureTrainBatchWithinBounds(request);
TenantModel model = ResolveModel(tenantId);
try
{
var items = new List<LearnItem>(request.Items.Count);
foreach (TrainExample example in request.Items)
{
items.Add(new LearnItem(example.Text, example.Label, example.Delta));
}
int learned = model.LearnBatch(items);
_logger.LogInformation(
"Аудит: tenant {TenantId} train_batch → learned={Learned} (items={Items})",
tenantId, learned, items.Count);
return Task.FromResult(new TrainBatchReply { Learned = learned });
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Аудит: tenant {TenantId} train_batch → хранилище недоступно", tenantId);
throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail));
}
}
// INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ml.proto).
// value: Значение поля запроса (в proto строка не бывает null).
// maxLength: Допустимый максимум символов.
// detail: Текст отказа (detail RPC).
private static void EnsureLengthAtMost(string value, int maxLength, string detail)
{
if (value.Length > maxLength)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, detail));
}
}
// Проверяет батч обучения на границе: число примеров ≤100 и длины text/label (INVALID_ARGUMENT).
// request: Запрос обучения.
private static void EnsureTrainBatchWithinBounds(TrainBatchRequest request)
{
if (request.Items.Count > MaxTrainBatchItems)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, TrainBatchTooLargeDetail));
}
foreach (TrainExample example in request.Items)
{
EnsureLengthAtMost(example.Text, MaxTextLength, ExampleTextTooLongDetail);
EnsureLengthAtMost(example.Label, MaxExampleLabelLength, ExampleLabelTooLongDetail);
}
}
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
// context: Контекст вызова.
private static string RequireTenantId(ServerCallContext context)
{
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail));
}
return tenantId;
}
// Берёт модель тенанта из пула (создаёт лениво); некорректный id — INVALID_ARGUMENT.
// tenantId: Id тенанта (непустой).
private TenantModel ResolveModel(string tenantId)
{
try
{
return _pool.GetOrCreate(tenantId);
}
catch (ArgumentException)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, InvalidTenantIdDetail));
}
}
// Собирает PredictReply из результата модели (1:1 ml.proto / MlPredictResultDto).
// result: Результат предсказания.
private static PredictReply ToPredictReply(MlPredictResult result)
{
var reply = new PredictReply
{
Take = result.Take,
Hits = result.Hits,
Ready = result.Ready,
};
if (result.Label is not null)
{
reply.Label = result.Label;
}
foreach ((string label, double score) in result.Scores)
{
reply.Scores[label] = score;
}
if (result.Margin.HasValue)
{
reply.Margin = result.Margin.Value;
}
reply.Terms.AddRange(result.Terms);
if (result.Type is not null)
{
reply.Type = new TypeDecision
{
Take = result.Type.Take,
Label = result.Type.Label,
Value = result.Type.Value,
Margin = result.Type.Margin,
};
}
return reply;
}
// Собирает StatusReply из результата модели (1:1 ml.proto / MlServiceStatusDto).
// result: Результат статуса.
private static StatusReply ToStatusReply(MlStatusResult result)
{
var reply = new StatusReply
{
Ready = result.Ready,
Learned = result.Learned,
Eval = new ModelEval
{
Count = result.Eval.Count,
Correct = result.Eval.Correct,
Accuracy = result.Eval.Accuracy,
},
};
foreach ((string label, double weight) in result.Classes)
{
reply.Classes[label] = weight;
}
return reply;
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Deal.Ml.Model;
/// <summary>
/// Строка журнала самооценки модели (млservice model.py eval_log, L296322): каждое реальное
/// действие пользователя (delta=1, не t:*) сверяется с текущим предсказанием. Хранится в
/// SQLite (eval_log) и в памяти тенантной модели (окно EVAL_KEEP/EvalWindowSize).
/// </summary>
/// <param name="CreatedAtMs">Момент решения (epoch-ms UTC).</param>
/// <param name="ExpectedLabel">Метка действия пользователя («правильный ответ»).</param>
/// <param name="PredictedLabel">Метка, которую предсказала модель (пусто — не брала).</param>
/// <param name="Correct">Совпало ли предсказание с действием.</param>
public sealed record EvalEntry(long CreatedAtMs, string ExpectedLabel, string PredictedLabel, bool Correct);
+11
View File
@@ -0,0 +1,11 @@
namespace Deal.Ml.Model;
/// <summary>
/// Один обучающий пример тенанта (1:1 TrainExample ml.proto и строка ml_outbox ядра:
/// text/label/delta). delta — вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
/// 0.4/0.6 — гипотезы ИИ/правил (Ruling 4; ml_client.py L2628).
/// </summary>
/// <param name="Text">Текст примера (source_msg карточки или title).</param>
/// <param name="Label">Метка: id колонки (b_…), <c>spam</c> либо тип t:hire/t:order.</param>
/// <param name="Delta">Вес сигнала (знак — учить/разучивать).</param>
public sealed record LearnItem(string Text, string Label, double Delta);
@@ -0,0 +1,9 @@
namespace Deal.Ml.Model;
/// <summary>
/// Окно самооценки модели (model.py status L329339; 1:1 ModelEval ml.proto и MlEvalDto ядра).
/// </summary>
/// <param name="Count">Решений в окне (последние EVAL_WINDOW подтверждённых решений).</param>
/// <param name="Correct">Из них совпавших с действием пользователя.</param>
/// <param name="Accuracy">Доля верных (correct/count, 0..1; 0 при пустом окне; round 3).</param>
public sealed record MlEvalInfo(int Count, int Correct, double Accuracy);
+77
View File
@@ -0,0 +1,77 @@
namespace Deal.Ml.Model;
/// <summary>
/// Конфигурация хранения моделей ml-service (план Task 5, Ruling 4/12).
///
/// Каждый тенант держит собственный SQLite-файл весов
/// <c>&lt;dataDir&gt;/&lt;tenantId&gt;.sqlite</c>. Каталог задаётся env
/// DEAL_ML_DATA_DIR (в compose — volume <c>/data/ml</c>, Ruling 12); по умолчанию —
/// <c>data/ml</c> относительно ContentRoot хоста. Два источника — только env и корень
/// хоста (как TgOptions telegram-service, шаблон T5).
/// </summary>
public sealed class MlOptions
{
/// <summary>
/// Env-ключ каталога файлов моделей (volume /data/ml в compose.dev.yml).
/// </summary>
public const string DataDirEnvVarName = "DEAL_ML_DATA_DIR";
/// <summary>
/// Относительный каталог моделей по умолчанию (под ContentRoot хоста).
/// </summary>
public const string DefaultDataDirRelative = "data/ml";
private MlOptions(string dataDirectory)
{
DataDirectory = dataDirectory;
}
/// <summary>
/// Абсолютный путь к каталогу файлов моделей data/ml/&lt;tenantId&gt;.sqlite.
/// </summary>
public string DataDirectory { get; }
/// <summary>
/// Создаёт опции с уже известным каталогом (unit-тесты пула/хранилища; прод-путь —
/// <see cref="FromConfiguration"/>).
/// </summary>
/// <param name="dataDirectory">Каталог файлов моделей.</param>
public static MlOptions Create(string dataDirectory)
{
if (string.IsNullOrWhiteSpace(dataDirectory))
{
throw new ArgumentException("Каталог данных моделей не задан.", nameof(dataDirectory));
}
return new MlOptions(Path.GetFullPath(dataDirectory.Trim()));
}
/// <summary>
/// Читает конфигурацию из env и корня хоста (DEAL_ML_DATA_DIR или data/ml).
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <param name="environment">Окружение хоста (ContentRootPath для каталога по умолчанию).</param>
/// <returns>Опции хранения моделей.</returns>
public static MlOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment)
{
string? configuredDir = configuration[DataDirEnvVarName];
string dataDirectory = ResolveDataDirectory(configuredDir, environment.ContentRootPath);
return new MlOptions(dataDirectory);
}
// Разрешает каталог моделей: абсолютный env-путь как есть, иначе — под ContentRoot.
// configuredDir: Значение DEAL_ML_DATA_DIR (может быть пустым).
// contentRootPath: ContentRoot хоста.
private static string ResolveDataDirectory(string? configuredDir, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredDir))
{
return Path.Combine(contentRootPath, DefaultDataDirRelative);
}
string trimmed = configuredDir.Trim();
return Path.IsPathRooted(trimmed)
? trimmed
: Path.Combine(contentRootPath, trimmed);
}
}
@@ -0,0 +1,25 @@
namespace Deal.Ml.Model;
/// <summary>
/// Результат предсказания модели (model.py predict L184293; 1:1 PredictReply ml.proto и
/// MlPredictResultDto ядра). Неготовая/пустая модель отвечает фиксированным «не уверен»:
/// Take=false, Label=null, Scores пусто, Hits=0, Ready по состоянию, Margin=null, Terms пусто,
/// Type=null (README src/contracts L2123).
/// </summary>
/// <param name="Take">True — модель уверена и решение можно использовать без ИИ.</param>
/// <param name="Label">Класс решения: id колонки канбана (b_…) или <c>spam</c> (null — не уверена).</param>
/// <param name="Scores">Веса классов: «label → вес» (до 5 лучших, round 3).</param>
/// <param name="Hits">Сколько терминов класса-победителя модель узнала в тексте.</param>
/// <param name="Ready">Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM).</param>
/// <param name="Margin">Порог уверенности (адаптивный отрыв, 2 знака) или null — нет решения.</param>
/// <param name="Terms">Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).</param>
/// <param name="Type">Решение о типе заявки (hire/order) или null.</param>
public sealed record MlPredictResult(
bool Take,
string? Label,
IReadOnlyDictionary<string, double> Scores,
int Hits,
bool Ready,
double? Margin,
IReadOnlyList<string> Terms,
MlTypeDecision? Type);
@@ -0,0 +1,15 @@
namespace Deal.Ml.Model;
/// <summary>
/// Статус модели тенанта (model.py status L325345; 1:1 StatusReply ml.proto и MlServiceStatusDto
/// ядра): готовность, веса классов, всего примеров и окно самооценки.
/// </summary>
/// <param name="Ready">Модель готова принимать решения (пороги Ruling 4).</param>
/// <param name="Classes">Классы модели: «label → вес» (round 2, по убыванию).</param>
/// <param name="Learned">Всего примеров, на которых модель обучалась (сумма по классам, int).</param>
/// <param name="Eval">Самооценка по последним подтверждённым решениям.</param>
public sealed record MlStatusResult(
bool Ready,
IReadOnlyDictionary<string, double> Classes,
int Learned,
MlEvalInfo Eval);
@@ -0,0 +1,56 @@
using System.Text.RegularExpressions;
namespace Deal.Ml.Model;
/// <summary>
/// Разбиение текста на термины модели (план Task 5; 1:1 mlservice/model.py tokenize L7887).
///
/// Ссылки и markdown-ссылки удаляются до токенизации (иначе модель учит мусор из URL — utm,
/// source, campaign — и режет по нему заявки). Термин — серия [a-zа-яё0-9@+.#]+ (в обеих
/// регистрах, затем lowercase); слова короче 3 символов отбрасываются, слова длиной ≥ 6
/// дополнительно дают «хвостовой» термин «~» + первые 4 символа. Порядок токенов сохраняется
/// (повторы слова дают повторы термина — так же, как python-executemany в learn).
/// </summary>
public static class MlTokenizer
{
// Шаблон ссылок (http/https, www, markdown [text](url)) — удаляются целиком.
private const string LinkPattern = @"https?://[^\s<>""']+|www\.[^\s<>""']+|\[[^\]]*\]\([^)\s]+\)";
// Шаблон термина: буквы (латиница/кириллица/ё) в обеих регистрах, цифры, @ + . #.
private const string TokenPattern = "[a-zA-Zа-яА-ЯёЁ0-9@+.#]+";
private static readonly Regex LinkRegex = new(
LinkPattern,
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex TokenRegex = new(
TokenPattern,
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Токенизирует текст в термины модели: удаляет ссылки, приводит к lowercase, отбрасывает
/// короткие слова и добавляет «~prefix» для длинных (1:1 tokenize L7887).
/// </summary>
/// <param name="text">Текст сообщения/карточки (null — пустой).</param>
/// <returns>Список терминов в порядке появления (включая повторы).</returns>
public static string[] Tokenize(string? text)
{
string cleared = LinkRegex.Replace(text ?? string.Empty, " ");
var terms = new List<string>();
foreach (Match match in TokenRegex.Matches(cleared))
{
string word = match.Value.ToLowerInvariant();
if (word.Length >= ModelConstants.MinTokenLengthForPrefix)
{
terms.Add(word);
terms.Add(ModelConstants.TokenPrefixMarker + word[..ModelConstants.PrefixLength]);
}
else if (word.Length >= ModelConstants.MinTokenLength)
{
terms.Add(word);
}
}
return terms.ToArray();
}
}
@@ -0,0 +1,12 @@
namespace Deal.Ml.Model;
/// <summary>
/// Решение ML о типе заявки (model.py predict L233238; 1:1 TypeDecision ml.proto и
/// MlTypeDecisionDto ядра). Возникает только когда в модели есть оба внутренних класса
/// t:hire/t:order с достаточным опытом и отрывом по адаптивному порогу.
/// </summary>
/// <param name="Take">True — модель уверена в типе.</param>
/// <param name="Label">Тип для UI: <c>hire</c> | <c>order</c>.</param>
/// <param name="Value">Внутренний класс ML: <c>t:hire</c> | <c>t:order</c> (UI не показывается).</param>
/// <param name="Margin">Запас уверенности (адаптивный порог, 2 знака).</param>
public sealed record MlTypeDecision(bool Take, string Label, string Value, double Margin);
@@ -0,0 +1,154 @@
namespace Deal.Ml.Model;
/// <summary>
/// Пороги и константы наивно-байесовской модели по терминам (Ruling 4; 1:1
/// mlservice/model.py L2031, L4255, L178181). Именованные константы вместо магических
/// чисел (код-стайл этапа); значения НЕ входят в .proto-контракт (README src/contracts L164166).
/// </summary>
public static class ModelConstants
{
/// <summary>
/// Суммарно примеров по всем классам, чтобы модель «включилась» (MIN_TOTAL).
/// </summary>
public const double MinTotalExamples = 20.0;
/// <summary>
/// Минимум примеров у класса-победителя (не-спам; MIN_WINNER).
/// </summary>
public const double MinWinnerExamples = 6.0;
/// <summary>
/// Минимум примеров у класса-победителя «spam» (MIN_WINNER_SPAM).
/// </summary>
public const double MinWinnerSpamExamples = 4.0;
/// <summary>
/// Минимум различных терминов, встреченных у победителя (MIN_HITS).
/// </summary>
public const int MinHits = 2;
/// <summary>
/// Минимум примеров класса типа t:*, чтобы ML выдавал тип (MIN_TYPE_WINNER).
/// </summary>
public const double MinTypeWinnerExamples = 4.0;
/// <summary>
/// Метка класса «спам» (специальная роль — порог 4 и «корзина» карточки).
/// </summary>
public const string SpamLabel = "spam";
/// <summary>
/// Префикс внутренних классов типа заявки (t:hire/t:order отсекаются из колонок).
/// </summary>
public const string TypeLabelPrefix = "t:";
/// <summary>
/// Внутренний класс типа заявки «найм» (в UI — hire).
/// </summary>
public const string TypeClassHire = "t:hire";
/// <summary>
/// Внутренний класс типа заявки «разовая сделка» (в UI — order).
/// </summary>
public const string TypeClassOrder = "t:order";
/// <summary>
/// Множитель prior при ранжировании классов: score + 3·prior (predict L226256).
/// </summary>
public const double PriorWeight = 3.0;
/// <summary>
/// ln-отрыв от второго класса на старте (MARGIN; адаптив 0.35/0.5/0.7 — см. ниже).
/// </summary>
public const double InitialMargin = 0.9;
/// <summary>
/// Суммарно примеров, после которых порог отрыва — 0.7.
/// </summary>
public const double TotalExamplesForMargin0_7 = 60.0;
/// <summary>
/// Суммарно примеров, после которых порог отрыва — 0.5.
/// </summary>
public const double TotalExamplesForMargin0_5 = 150.0;
/// <summary>
/// Суммарно примеров, после которых порог отрыва — 0.35.
/// </summary>
public const double TotalExamplesForMargin0_35 = 400.0;
/// <summary>
/// Адаптивный отрыв после 60 примеров.
/// </summary>
public const double MarginAfter60Examples = 0.7;
/// <summary>
/// Адаптивный отрыв после 150 примеров.
/// </summary>
public const double MarginAfter150Examples = 0.5;
/// <summary>
/// Адаптивный отрыв после 400 примеров.
/// </summary>
public const double MarginAfter400Examples = 0.35;
/// <summary>
/// Окно самооценки, которое отдаётся в /status (EVAL_WINDOW).
/// </summary>
public const int EvalWindowSize = 50;
/// <summary>
/// Сколько последних решений самооценки хранится в БД модели (EVAL_KEEP).
/// </summary>
public const int EvalKeepCount = 200;
/// <summary>
/// Сколько лучших весов классов отдаётся в predict (scores ≤ 5).
/// </summary>
public const int MaxScoresInReply = 5;
/// <summary>
/// Сколько узнанных терминов отдаётся в predict (terms ≤ 8).
/// </summary>
public const int MaxMatchedTerms = 8;
/// <summary>
/// Точность округления весов в scores (python round(…, 3)).
/// </summary>
public const int ScoresPrecision = 3;
/// <summary>
/// Точность округления весов классов в status (python round(…, 2)).
/// </summary>
public const int ClassesPrecision = 2;
/// <summary>
/// Точность округления отступа margin (python round(…, 2)).
/// </summary>
public const int MarginPrecision = 2;
/// <summary>
/// Точность округления доли верных в eval (python round(…, 3)).
/// </summary>
public const int AccuracyPrecision = 3;
/// <summary>
/// Минимальная длина токена, попадающего в модель (L83–86).
/// </summary>
public const int MinTokenLength = 3;
/// <summary>
/// Длина токена, при которой добавляется «хвостовой» префикс-термин.
/// </summary>
public const int MinTokenLengthForPrefix = 6;
/// <summary>
/// Длина префикса хвостового токена (w[:4]).
/// </summary>
public const int PrefixLength = 4;
/// <summary>
/// Префикс хвостового токена («~pyth» для «python») — не участвует в terms-подсказках.
/// </summary>
public const string TokenPrefixMarker = "~";
}
+90
View File
@@ -0,0 +1,90 @@
using System.Collections.Concurrent;
using Deal.Ml.Storage;
namespace Deal.Ml.Model;
/// <summary>
/// Пул моделей тенантов (план Task 5/6, Ruling 4): ConcurrentDictionary tenantId → TenantModel,
/// модель создаётся лениво по первому обращению (Predict без опыта даёт «не готов» — не ошибка),
/// у каждой модели свой lock (predict/learn сериализованы на тенанта). Файл весов —
/// data/ml/&lt;tenantId&gt;.sqlite. Никакой бизнес-логики и БД тенантов (Ruling 1) — только файлы моделей.
/// </summary>
public sealed class ModelPool : IDisposable
{
// Расширение файла модели (data/ml/&lt;tenantId&gt;.sqlite, Ruling 4).
private const string DbFileExtension = ".sqlite";
// Верхняя граница длины tenant-id (защита пути; реальные id заметно короче).
private const int MaxTenantIdLength = 128;
private static readonly char[] InvalidTenantCharacters = Path.GetInvalidFileNameChars();
private readonly MlOptions _options;
private readonly ConcurrentDictionary<string, TenantModel> _models = new(StringComparer.Ordinal);
/// <summary>
/// Каталог файлов моделей (data/ml; диагностика/тесты).
/// </summary>
public string DataDirectory => _options.DataDirectory;
/// <summary>
/// Создаёт пул над каталогом файлов моделей.
/// </summary>
/// <param name="options">Конфигурация хранения моделей (каталог data/ml).</param>
public ModelPool(MlOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Возвращает модель тенанта, создавая её лениво (первое обращение грузит веса из файла).
/// Tenant-id — только из gRPC-metadata (Ruling 1); некорректный id (путь вне каталога) — ошибка.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <returns>Модель тенанта (в пуле до Reset/Dispose).</returns>
/// <exception cref="ArgumentException">Tenant-id пуст/некорректен для имени файла.</exception>
public TenantModel GetOrCreate(string tenantId)
{
ValidateTenantId(tenantId);
return _models.GetOrAdd(tenantId, id => new TenantModel(id, new MlDb(DatabasePath(id))));
}
/// <inheritdoc />
public void Dispose()
{
foreach (TenantModel model in _models.Values)
{
model.Dispose();
}
_models.Clear();
}
// Путь к файлу модели тенанта (data/ml/&lt;tenantId&gt;.sqlite).
// tenantId: Id тенанта (уже валидирован).
private string DatabasePath(string tenantId)
=> Path.Combine(_options.DataDirectory, tenantId + DbFileExtension);
// Проверяет tenant-id как безопасное имя файла: непустой, без разделителей/недопустимых
// символов путей, не «..», ограниченной длины. Иначе модель могла бы писаться вне каталога.
// tenantId: Id тенанта из metadata.
// Исключение ArgumentException: Tenant-id некорректен.
private static void ValidateTenantId(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("tenant-id не задан.", nameof(tenantId));
}
if (tenantId.Length > MaxTenantIdLength)
{
throw new ArgumentException($"tenant-id длиннее {MaxTenantIdLength} символов.", nameof(tenantId));
}
if (tenantId.IndexOfAny(InvalidTenantCharacters) >= 0
|| tenantId.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException("tenant-id содержит недопустимые для имени файла символы.", nameof(tenantId));
}
}
}
@@ -0,0 +1,38 @@
namespace Deal.Ml.Model;
/// <summary>
/// Состояние модели тенанта в памяти: веса классов и терминов + журнал самооценки.
/// Зеркалит SQLite-файл data/ml/&lt;tenantId&gt;.sqlite (план Task 5, ModelState.cs): изменения
/// применяются транзакцией в MlDb, затем повторяются в этом состоянии в том же порядке.
///
/// Инварианты 1:1 с БД (model.py): <see cref="Classes"/> содержит только классы с n &gt; 0;
/// <see cref="TermsByLabel"/> хранит термины с count &gt; 0 и может содержать метки, у которых
/// класс удалён (python оставляет строки terms при удалении класса — «фантомные» веса).
/// </summary>
public sealed class ModelState
{
/// <summary>
/// Веса классов: label → n (только n &gt; 0; аналог таблицы classes).
/// </summary>
public Dictionary<string, double> Classes { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Веса терминов по классам: label → (term → count; только count &gt; 0; таблица terms).
/// </summary>
public Dictionary<string, Dictionary<string, double>> TermsByLabel { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Журнал самооценки в порядке накопления (последние EVAL_KEEP, таблица eval_log).
/// </summary>
public List<EvalEntry> EvalLog { get; } = [];
/// <summary>
/// Очищает состояние (reset модели; 1:1 model.py reset L348354).
/// </summary>
public void Clear()
{
Classes.Clear();
TermsByLabel.Clear();
EvalLog.Clear();
}
}
@@ -0,0 +1,327 @@
namespace Deal.Ml.Model;
/// <summary>
/// Инкрементальная наивно-байесовская модель по терминам (план Task 5; Ruling 4). Чистая
/// математика predict/status/ready/margin поверх состояния <see cref="ModelState"/> — 1:1 с
/// mlservice/model.py L184345: score термина, prior, адаптивный отрыв, type-решение, окно
/// самооценки. Обучение/сохранение — в <see cref="TenantModel"/> + Storage.MlDb.
/// </summary>
public static class OnlineNaiveBayes
{
/// <summary>
/// Готовность модели: суммарно ≥ MIN_TOTAL примеров, у «spam» ≥ MIN_WINNER_SPAM, у остальных
/// классов вместе ≥ MIN_WINNER (model.py ready L96102).
/// </summary>
/// <param name="state">Состояние модели.</param>
public static bool Ready(ModelState state)
{
double total = Total(state);
if (total < ModelConstants.MinTotalExamples)
{
return false;
}
double spam = state.Classes.GetValueOrDefault(ModelConstants.SpamLabel);
return spam >= ModelConstants.MinWinnerSpamExamples
&& total - spam >= ModelConstants.MinWinnerExamples;
}
/// <summary>
/// Адаптивный отрыв от второго класса (model.py _adaptive_margin L4255): чем больше примеров
/// модель видела, тем ниже порог — ML постепенно заменяет ИИ на типовых сообщениях.
/// </summary>
/// <param name="total">Суммарно примеров по всем классам.</param>
public static double AdaptiveMargin(double total)
{
if (total >= ModelConstants.TotalExamplesForMargin0_35)
{
return ModelConstants.MarginAfter400Examples;
}
if (total >= ModelConstants.TotalExamplesForMargin0_5)
{
return ModelConstants.MarginAfter150Examples;
}
if (total >= ModelConstants.TotalExamplesForMargin0_7)
{
return ModelConstants.MarginAfter60Examples;
}
return ModelConstants.InitialMargin;
}
/// <summary>
/// Суммарно примеров по всем классам (n &gt; 0).
/// </summary>
/// <param name="state">Состояние модели.</param>
public static double Total(ModelState state) => state.Classes.Values.Sum();
/// <summary>
/// Предсказание по тексту (model.py predict L184293): веса классов по узнанным терминам,
/// решение «взяла/не взяла» по порогам, type-решение t:hire/t:order, термины-подсказки.
/// </summary>
/// <param name="state">Состояние модели.</param>
/// <param name="text">Текст сообщения.</param>
public static MlPredictResult Predict(ModelState state, string text)
{
string[] tokens = MlTokenizer.Tokenize(text);
// Нет классов или нет терминов — «не уверен» (ready по факту; L186188).
if (state.Classes.Count == 0 || tokens.Length == 0)
{
return NotReady(Ready(state));
}
// Модель «включается» только с опытом: пока примеров мало, она ничего не решает
// и не может ошибочно удалить заявку как спам (L191–192).
if (!Ready(state))
{
return NotReady(ready: false);
}
double total = Total(state);
var scores = new Dictionary<string, double>(StringComparer.Ordinal);
var hits = new Dictionary<string, int>(StringComparer.Ordinal);
string[] distinctTokens = tokens.Distinct(StringComparer.Ordinal).ToArray();
foreach (string label in state.Classes.Keys)
{
Dictionary<string, double>? weights = TryGetTerms(state, label);
double score = 0.0;
int hit = 0;
foreach (string token in distinctTokens)
{
if (weights is not null && weights.TryGetValue(token, out double weight) && weight > 0)
{
score += TermScore(weight);
hit += 1;
}
}
if (hit > 0)
{
scores[label] = score;
hits[label] = hit;
}
}
double margin = AdaptiveMargin(total);
var prior = new Dictionary<string, double>(state.Classes.Count);
foreach ((string label, double n) in state.Classes)
{
prior[label] = n / total;
}
MlTypeDecision? typeDecision = DecideType(state, scores, prior, margin);
// ── колонка/спам: без t:* классов ────────────────────────────────────────────────
List<string> regularLabels = state.Classes.Keys.Where(label => !IsTypeLabel(label)).ToList();
if (regularLabels.Count == 0 || scores.Count == 0)
{
return TakeFalse(state, typeDecision);
}
List<(string Label, double Score)> ranked = regularLabels
.Where(scores.ContainsKey)
.Select(label => (Label: label, Score: scores[label]))
.OrderByDescending(pair => pair.Score)
.ToList();
if (ranked.Count == 0)
{
return TakeFalse(state, typeDecision);
}
string bestLabel = ranked[0].Label;
double bestScore = ranked[0].Score;
(string Label, double Score)? second = ranked.Count > 1 ? ranked[1] : null;
double bestTotal = bestScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(bestLabel);
double secondTotal = second.HasValue
? second.Value.Score + ModelConstants.PriorWeight * prior.GetValueOrDefault(second.Value.Label)
: 0.0;
bool isSpam = string.Equals(bestLabel, ModelConstants.SpamLabel, StringComparison.Ordinal);
double minWinner = isSpam ? ModelConstants.MinWinnerSpamExamples : ModelConstants.MinWinnerExamples;
bool take = state.Classes.GetValueOrDefault(bestLabel) >= minWinner
&& hits.GetValueOrDefault(bestLabel) >= ModelConstants.MinHits
&& bestTotal - secondTotal >= margin;
IReadOnlyList<string> matchedTerms = take && !isSpam
? MatchTerms(state, bestLabel, tokens)
: Array.Empty<string>();
Dictionary<string, double> topScores = scores
.OrderByDescending(pair => pair.Value)
.Take(ModelConstants.MaxScoresInReply)
.ToDictionary(pair => pair.Key, pair => Round(pair.Value, ModelConstants.ScoresPrecision));
return new MlPredictResult(
Take: take,
Label: take ? bestLabel : null,
Scores: topScores,
Hits: hits.GetValueOrDefault(bestLabel),
Ready: true,
Margin: Round(margin, ModelConstants.MarginPrecision),
Terms: matchedTerms,
Type: typeDecision);
}
/// <summary>
/// Статус модели (model.py status L325345): готовность, веса классов (round 2, по убыванию),
/// всего примеров и окно самооценки по последним подтверждённым решениям.
/// </summary>
/// <param name="state">Состояние модели.</param>
public static MlStatusResult Status(ModelState state)
{
bool ready = Ready(state);
Dictionary<string, double> classes = state.Classes
.OrderByDescending(pair => pair.Value)
.ToDictionary(pair => pair.Key, pair => Round(pair.Value, ModelConstants.ClassesPrecision));
int learned = (int)Total(state);
return new MlStatusResult(ready, classes, learned, EvalWindow(state));
}
// Вес термина в score класса (predict L207208): count &lt; 1 → 1.0, иначе 1+(w1)/(w+1).
// weight: Вес (count) термина в классе.
private static double TermScore(double weight)
=> weight < 1.0 ? 1.0 : 1.0 + (weight - 1.0) / (weight + 1.0);
// Решение о типе заявки по классам t:hire/t:order (predict L217238): лучший тип со score&gt;0,
// опытом ≥ MIN_TYPE_WINNER и отрывом best_total second_total ≥ margin.
// state: Состояние модели.
// scores: Веса классов по тексту (label → score).
// prior: Априорные доли классов.
// margin: Адаптивный порог отрыва.
private static MlTypeDecision? DecideType(
ModelState state,
IReadOnlyDictionary<string, double> scores,
IReadOnlyDictionary<string, double> prior,
double margin)
{
List<string> typeClasses = state.Classes.Keys
.Where(label => label is ModelConstants.TypeClassHire or ModelConstants.TypeClassOrder)
.ToList();
if (typeClasses.Count < 2)
{
return null;
}
List<(string Label, double Score)> ranked = typeClasses
.Select(label => (Label: label, Score: scores.GetValueOrDefault(label)))
.OrderByDescending(pair => pair.Score)
.ToList();
string bestLabel = ranked[0].Label;
double bestScore = ranked[0].Score;
double secondScore = ranked[1].Score;
double bestTotal = bestScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(bestLabel);
double secondTotal = secondScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(ranked[1].Label);
if (bestScore <= 0
|| state.Classes.GetValueOrDefault(bestLabel) < ModelConstants.MinTypeWinnerExamples
|| bestTotal - secondTotal < margin)
{
return null;
}
bool isHire = string.Equals(bestLabel, ModelConstants.TypeClassHire, StringComparison.Ordinal);
return new MlTypeDecision(
Take: true,
Label: isHire ? "hire" : "order",
Value: bestLabel,
Margin: Round(margin, ModelConstants.MarginPrecision));
}
// Термины, которые модель «узнала» в тексте у класса-победителя (predict L266282): подсказка
// для структурирования карточки без ИИ. Хвостовые «~»-термины исключаются; до 8 по весу.
// state: Состояние модели.
// label: Класс-победитель (не spam).
// tokens: Термины текста (в порядке появления).
private static IReadOnlyList<string> MatchTerms(ModelState state, string label, string[] tokens)
{
Dictionary<string, double>? weights = TryGetTerms(state, label);
if (weights is null)
{
return Array.Empty<string>();
}
var seen = new List<string>();
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (string token in tokens)
{
if (token.StartsWith(ModelConstants.TokenPrefixMarker, StringComparison.Ordinal)
|| string.Equals(token, label, StringComparison.Ordinal)
|| !weights.ContainsKey(token)
|| !known.Add(token))
{
continue;
}
seen.Add(token);
}
return seen
.OrderByDescending(term => weights[term])
.Take(ModelConstants.MaxMatchedTerms)
.ToArray();
}
// Окно самооценки (model.py status L329339): последние EVAL_WINDOW подтверждённых решений.
// state: Состояние модели.
private static MlEvalInfo EvalWindow(ModelState state)
{
int count = Math.Min(state.EvalLog.Count, ModelConstants.EvalWindowSize);
int correct = state.EvalLog.Count == 0
? 0
: state.EvalLog.Skip(Math.Max(0, state.EvalLog.Count - ModelConstants.EvalWindowSize)).Count(entry => entry.Correct);
double accuracy = count > 0 ? Round((double)correct / count, ModelConstants.AccuracyPrecision) : 0.0;
return new MlEvalInfo(count, correct, accuracy);
}
// Термины класса или null, если класса нет/нет терминов (аналог SQL WHERE count &gt; 0).
// state: Состояние модели.
// label: Метка класса.
private static Dictionary<string, double>? TryGetTerms(ModelState state, string label)
=> state.TermsByLabel.TryGetValue(label, out Dictionary<string, double>? terms) ? terms : null;
// Метка внутреннего типа заявки (префикс t:).
// label: Метка класса.
private static bool IsTypeLabel(string label)
=> label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal);
// Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает).
// ready: Готовность модели на момент вызова.
private static MlPredictResult NotReady(bool ready)
=> new(
Take: false,
Label: null,
Scores: new Dictionary<string, double>(),
Hits: 0,
Ready: ready,
Margin: null,
Terms: Array.Empty<string>(),
Type: null);
// Ответ «не взяла» при готовой модели, но без уверенного класса (scores/ранг пусты).
// state: Состояние модели.
// typeDecision: Решение о типе заявки (если есть).
private static MlPredictResult TakeFalse(ModelState state, MlTypeDecision? typeDecision)
=> new(
Take: false,
Label: null,
Scores: new Dictionary<string, double>(),
Hits: 0,
Ready: true,
Margin: null,
Terms: Array.Empty<string>(),
Type: typeDecision);
// Округление как python round (banker's rounding).
// value: Значение.
// digits: Число знаков после запятой.
private static double Round(double value, int digits) => Math.Round(value, digits);
}
+231
View File
@@ -0,0 +1,231 @@
using Deal.Ml.Storage;
namespace Deal.Ml.Model;
/// <summary>
/// Модель одного тенанта: состояние в памяти + SQLite-файл весов (план Task 5, Ruling 4).
/// Создаётся пулом лениво по первому обращению и живёт в пуле, пока не вызван reset/Dispose.
/// Predict/learn/status/reset сериализованы на тенанта собственным lock (predict и learn не
/// перемешиваются; пул соединений per-tenant — один MlDb на модель). Математика 1:1 с
/// mlservice/model.py вынесена в <see cref="OnlineNaiveBayes"/>, персистентность — <see cref="MlDb"/>.
/// </summary>
public sealed class TenantModel : IDisposable
{
private readonly string _tenantId;
private readonly MlDb _db;
private readonly object _sync = new();
private ModelState _state = new();
private bool _loaded;
/// <summary>
/// Создаёт модель тенанта над своим SQLite-файлом (файл не трогается до первого RPC).
/// </summary>
/// <param name="tenantId">Id тенанта (владелец модели).</param>
/// <param name="db">Хранилище весов модели (файл data/ml/&lt;tenantId&gt;.sqlite).</param>
public TenantModel(string tenantId, MlDb db)
{
_tenantId = tenantId;
_db = db;
}
/// <summary>
/// Путь к SQLite-файлу модели (диагностика/тесты).
/// </summary>
public string DatabasePath => _db.DatabasePath;
/// <summary>
/// Предсказание по тексту (model.py predict L184293): отсутствие опыта — «не уверен», не ошибка.
/// </summary>
/// <param name="text">Текст сообщения.</param>
public MlPredictResult Predict(string text)
{
lock (_sync)
{
EnsureLoaded();
return OnlineNaiveBayes.Predict(_state, text);
}
}
/// <summary>
/// Статус модели (model.py status L325345).
/// </summary>
public MlStatusResult Status()
{
lock (_sync)
{
EnsureLoaded();
return OnlineNaiveBayes.Status(_state);
}
}
/// <summary>
/// Пакетное обучение (model.py learn_batch L147173): самооценка по реальным действиям
/// пользователя до применения батча, одна транзакция на батч. Возвращает число применённых
/// примеров (пустые text/label пропускаются — тихий no-op, 1:1 _upsert_one L112114).
/// </summary>
/// <param name="items">Обучающие примеры (text/label/delta).</param>
public int LearnBatch(IReadOnlyList<LearnItem> items)
{
lock (_sync)
{
EnsureLoaded();
var applied = new List<(string Label, double Delta, string[] Tokens)>();
var evalRows = new List<EvalEntry>();
foreach (LearnItem item in items)
{
string label = (item.Label ?? string.Empty).Trim();
string text = item.Text ?? string.Empty;
if (label.Length == 0 || string.IsNullOrWhiteSpace(text))
{
continue;
}
EvalEntry? evalRow = TrySelfEval(label, text, item.Delta);
if (evalRow is not null)
{
evalRows.Add(evalRow);
}
applied.Add((label, item.Delta, MlTokenizer.Tokenize(text)));
}
if (applied.Count == 0 && evalRows.Count == 0)
{
return 0;
}
// Сначала персистентность (одна транзакция; при сбое память не менялась), затем —
// повтор изменений в памяти в том же порядке (1:1 с upsert-семантикой python).
_db.ApplyLearnBatch(applied, evalRows);
foreach ((string label, double delta, string[] tokens) in applied)
{
ApplyToMemory(label, delta, tokens);
}
AppendEvalToMemory(evalRows);
return applied.Count;
}
}
/// <summary>
/// Полный сброс модели (model.py reset L348354; план Task 6 — пересоздание файла): файл
/// удаляется и пересоздаётся пустым при следующем обращении. Ошибка хранилища пробрасывается —
/// RPC Reset отвечает мягким ok=false + error (контракт ml.proto).
/// </summary>
public void Reset()
{
lock (_sync)
{
_db.DeleteFile();
_state.Clear();
_loaded = false;
}
}
/// <inheritdoc />
public void Dispose() => _db.Dispose();
// Самооценка перед обучением на реальном действии пользователя (model.py _maybe_eval
// L296322): delta=1.0, не тип t:*, модель уже включена и уверенно взяла решение —
// сверяем его с действием и пишем строку журнала. Гипотезы ИИ (delta&lt;1) и «разучивание»
// (delta&lt;0) не оцениваются.
// label: Метка действия пользователя.
// text: Текст примера.
// delta: Вес сигнала.
private EvalEntry? TrySelfEval(string label, string text, double delta)
{
if (delta != 1.0
|| label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal)
|| !OnlineNaiveBayes.Ready(_state))
{
return null;
}
MlPredictResult prediction = OnlineNaiveBayes.Predict(_state, text);
if (!prediction.Take || prediction.Label is null)
{
return null; // модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой
}
return new EvalEntry(
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
label,
prediction.Label,
string.Equals(prediction.Label, label, StringComparison.Ordinal));
}
// Повтор изменения одного примера в памяти (1:1 _upsert_one L105131): класс и термины
// обновляются до применения удаления «обнулённых» строк при delta &lt; 0. Удалённый класс
// может оставить «фантомные» термины с count &gt; 0 (python хранит строки terms).
// label: Метка класса.
// delta: Вес сигнала.
// tokens: Термины текста (повторы — повторы инкрементов).
private void ApplyToMemory(string label, double delta, string[] tokens)
{
double newCount = _state.Classes.GetValueOrDefault(label) + delta;
if (newCount > 0)
{
_state.Classes[label] = newCount;
}
else
{
_state.Classes.Remove(label);
}
if (tokens.Length > 0)
{
if (!_state.TermsByLabel.TryGetValue(label, out Dictionary<string, double>? terms))
{
terms = new Dictionary<string, double>(StringComparer.Ordinal);
_state.TermsByLabel[label] = terms;
}
foreach (string token in tokens)
{
double tokenCount = terms.GetValueOrDefault(token) + delta;
if (tokenCount > 0)
{
terms[token] = tokenCount;
}
else
{
terms.Remove(token);
}
}
if (terms.Count == 0)
{
_state.TermsByLabel.Remove(label);
}
}
}
// Добавляет строки самооценки в память и оставляет последние EVAL_KEEP (L319322).
// rows: Новые строки (уже в порядке накопления).
private void AppendEvalToMemory(IReadOnlyList<EvalEntry> rows)
{
if (rows.Count == 0)
{
return;
}
_state.EvalLog.AddRange(rows);
if (_state.EvalLog.Count > ModelConstants.EvalKeepCount)
{
_state.EvalLog.RemoveRange(0, _state.EvalLog.Count - ModelConstants.EvalKeepCount);
}
}
// Ленивая загрузка модели из файла (первое обращение к тенанту; Ruling 4).
private void EnsureLoaded()
{
if (_loaded)
{
return;
}
_state = _db.LoadState();
_loaded = true;
}
}
+54
View File
@@ -0,0 +1,54 @@
// ml-service — точка входа gRPC-хоста (план Task 3, L240248; Ruling 1/2/4/12).
//
// Kestrel HTTP/2 на порту 5103 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext
// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13;
// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed:
// Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
// MlServiceHost.Create используется и интеграционными тестами (Deal.Ml.Tests), которые поднимают
// его в своём процессе на эфемерном порту. Реальная логика (план Task 5/6): инкрементальный
// наивный Байес по терминам per-tenant (файлы data/ml/<tenantId>.sqlite, Ruling 4) — движок/пул
// в Model/, хранилище в Storage/, gRPC Predict/Status/Reset/TrainBatch — MlServiceImpl.
using Deal.Grpc.Hosting;
using Deal.Ml;
// Порт по умолчанию — 5103 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер)
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
const int defaultGrpcPort = 5103;
// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-ml-<дата>.json.
const string mlProcessName = "ml";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A).
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-ml-*.json —
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
// без Serilog, DealLogging.Configure в MlServiceHost/Create вызывается только здесь). Метрики
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
WebApplication app = MlServiceHost.Create(
grpcPort,
configureBuilder: builder =>
{
DealLogging.Configure(builder, mlProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
});
// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
DealMetricsHosting.MapDealMetrics(app);
// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1.
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать
// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше.
GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions);
app.Logger.LogInformation(
"ml-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)",
mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token",
grpcPort);
await app.RunAsync();
+297
View File
@@ -0,0 +1,297 @@
using Deal.Ml.Model;
using Microsoft.Data.Sqlite;
namespace Deal.Ml.Storage;
/// <summary>
/// SQLite-хранилище весов модели одного тенанта (план Task 5; Ruling 4; 1:1 db-схемы
/// mlservice/model.py L6475). Файл data/ml/&lt;tenantId&gt;.sqlite, таблицы
/// classes(label,n,updated_at)/terms(label,term,count)/eval_log(created_at,expected,predicted,
/// correct). Одно долгоживущее соединение на экземпляр (пул соединений per-tenant: один
/// TenantModel ↔ один MlDb); все операции вызываются под lock модели тенанта
/// (<see cref="TenantModel"/>), поэтому дополнительная синхронизация не нужна.
/// Запись — транзакциями, термины пишутся батчем per-пример (1:1 learn_batch L147173).
/// </summary>
public sealed class MlDb : IDisposable
{
private const string ClassesDdl =
"CREATE TABLE IF NOT EXISTS classes (" +
"label TEXT NOT NULL PRIMARY KEY, n REAL NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)";
private const string TermsDdl =
"CREATE TABLE IF NOT EXISTS terms (" +
"label TEXT NOT NULL, term TEXT NOT NULL, count REAL NOT NULL DEFAULT 0, PRIMARY KEY (label, term))";
private const string EvalLogDdl =
"CREATE TABLE IF NOT EXISTS eval_log (" +
"created_at INTEGER NOT NULL, expected TEXT NOT NULL, predicted TEXT NOT NULL DEFAULT '', " +
"correct INTEGER NOT NULL)";
private const string EvalLogIndexDdl =
"CREATE INDEX IF NOT EXISTS ix_eval_log_created_at ON eval_log (created_at)";
private readonly string _filePath;
private SqliteConnection? _connection;
/// <summary>
/// Путь к SQLite-файлу модели (диагностика, тесты перезапуска пула).
/// </summary>
public string DatabasePath => _filePath;
/// <summary>
/// Создаёт хранилище модели для файла по пути (файл/каталог создаются лениво).
/// </summary>
/// <param name="filePath">Путь к SQLite-файлу модели (&lt;dataDir&gt;/&lt;tenantId&gt;.sqlite).</param>
public MlDb(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("Путь к файлу модели не задан.", nameof(filePath));
}
_filePath = filePath;
}
/// <summary>
/// Открывает соединение и создаёт схему при первом обращении (model.py _db L5875).
/// Файл пересоздаётся автоматически после <see cref="DeleteFile"/>.
/// </summary>
public void EnsureCreated()
{
if (_connection is not null)
{
return;
}
string? directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var connection = new SqliteConnection($"Data Source={_filePath};Pooling=False");
connection.Open();
using (SqliteCommand command = connection.CreateCommand())
{
command.CommandText = $"{ClassesDdl}; {TermsDdl}; {EvalLogDdl}; {EvalLogIndexDdl}";
command.ExecuteNonQuery();
}
_connection = connection;
}
/// <summary>
/// Читает полное состояние модели из файла (lazy-load модели тенанта): классы n &gt; 0,
/// термины count &gt; 0 (в т.ч. «фантомные» метки удалённых классов — 1:1 с python) и
/// последние <see cref="ModelConstants.EvalKeepCount"/> строк журнала самооценки.
/// </summary>
public ModelState LoadState()
{
EnsureCreated();
var state = new ModelState();
using (SqliteCommand command = _connection!.CreateCommand())
{
command.CommandText = "SELECT label, n FROM classes WHERE n > 0";
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
state.Classes[reader.GetString(0)] = reader.GetDouble(1);
}
}
using (SqliteCommand command = _connection.CreateCommand())
{
command.CommandText = "SELECT label, term, count FROM terms WHERE count > 0";
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
string label = reader.GetString(0);
if (!state.TermsByLabel.TryGetValue(label, out Dictionary<string, double>? terms))
{
terms = new Dictionary<string, double>(StringComparer.Ordinal);
state.TermsByLabel[label] = terms;
}
terms[reader.GetString(1)] = reader.GetDouble(2);
}
}
using (SqliteCommand command = _connection.CreateCommand())
{
command.CommandText =
"SELECT created_at, expected, predicted, correct FROM (" +
"SELECT created_at, expected, predicted, correct, rowid AS seq FROM eval_log " +
"ORDER BY created_at DESC, rowid DESC LIMIT $keep) ORDER BY created_at ASC, seq ASC";
command.Parameters.AddWithValue("$keep", ModelConstants.EvalKeepCount);
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
state.EvalLog.Add(new EvalEntry(
reader.GetInt64(0),
reader.GetString(1),
reader.GetString(2),
reader.GetInt64(3) != 0));
}
}
return state;
}
/// <summary>
/// Применяет батч обучения одной транзакцией (model.py learn_batch L147173): upsert классов,
/// пакетная вставка терминов per-пример, удаление «обнулённых» строк при разучивании (delta &lt; 0),
/// журнал самооценки + его прунинг до <see cref="ModelConstants.EvalKeepCount"/>. Вызывается под
/// lock модели тенанта. При сбое — ROLLBACK и проброс исключения (состояние памяти не менялось).
/// </summary>
/// <param name="items">Применяемые примеры: label, delta и термины текста (в порядке появления).</param>
/// <param name="newEvalRows">Новые строки самооценки (решения до применения батча).</param>
public void ApplyLearnBatch(
IReadOnlyList<(string Label, double Delta, string[] Tokens)> items,
IReadOnlyList<EvalEntry> newEvalRows)
{
if (items.Count == 0 && newEvalRows.Count == 0)
{
return;
}
EnsureCreated();
using SqliteTransaction transaction = _connection!.BeginTransaction();
try
{
foreach ((string label, double delta, string[] tokens) in items)
{
ApplyExample(transaction, label, delta, tokens);
}
InsertEvalRows(transaction, newEvalRows);
if (newEvalRows.Count > 0)
{
PruneEvalLog(transaction);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
/// <summary>
/// Пересоздаёт файл модели (reset, план Task 6): закрывает соединение и удаляет файл.
/// </summary>
public void DeleteFile()
{
DisposeConnection();
File.Delete(_filePath);
}
/// <inheritdoc />
public void Dispose() => DisposeConnection();
// Применяет один пример внутри транзакции (1:1 model.py _upsert_one L105131): upsert класса,
// пакетные upsert терминов; при delta &lt; 0 — удаление строк count ≤ 0 и классов n ≤ 0.
// transaction: Транзакция батча.
// label: Метка класса.
// delta: Вес сигнала (знак — учить/разучивать).
// tokens: Термины текста (повторы слова — повторы строк, как python-executemany).
private void ApplyExample(SqliteTransaction transaction, string label, double delta, string[] tokens)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
using (SqliteCommand command = _connection!.CreateCommand())
{
command.Transaction = transaction;
command.CommandText =
"INSERT INTO classes (label, n, updated_at) VALUES ($label, $delta, $now) " +
"ON CONFLICT(label) DO UPDATE SET n = classes.n + excluded.n, updated_at = excluded.updated_at";
command.Parameters.AddWithValue("$label", label);
command.Parameters.AddWithValue("$delta", delta);
command.Parameters.AddWithValue("$now", nowMs);
command.ExecuteNonQuery();
}
using (SqliteCommand command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText =
"INSERT INTO terms (label, term, count) VALUES ($label, $term, $delta) " +
"ON CONFLICT(label, term) DO UPDATE SET count = terms.count + excluded.count";
command.Parameters.AddWithValue("$label", label);
SqliteParameter termParameter = command.Parameters.AddWithValue("$term", string.Empty);
command.Parameters.AddWithValue("$delta", delta);
foreach (string term in tokens)
{
termParameter.Value = term;
command.ExecuteNonQuery();
}
}
if (delta < 0)
{
using SqliteCommand deleteTerms = _connection.CreateCommand();
deleteTerms.Transaction = transaction;
deleteTerms.CommandText = "DELETE FROM terms WHERE label = $label AND count <= 0";
deleteTerms.Parameters.AddWithValue("$label", label);
deleteTerms.ExecuteNonQuery();
using SqliteCommand deleteClasses = _connection.CreateCommand();
deleteClasses.Transaction = transaction;
deleteClasses.CommandText = "DELETE FROM classes WHERE n <= 0";
deleteClasses.ExecuteNonQuery();
}
}
// Вставляет строки журнала самооценки (executemany-эквивалент).
// transaction: Транзакция батча.
// rows: Новые строки.
private void InsertEvalRows(SqliteTransaction transaction, IReadOnlyList<EvalEntry> rows)
{
if (rows.Count == 0)
{
return;
}
using SqliteCommand command = _connection!.CreateCommand();
command.Transaction = transaction;
command.CommandText =
"INSERT INTO eval_log (created_at, expected, predicted, correct) VALUES ($at, $expected, $predicted, $correct)";
SqliteParameter atParameter = command.Parameters.AddWithValue("$at", 0L);
SqliteParameter expectedParameter = command.Parameters.AddWithValue("$expected", string.Empty);
SqliteParameter predictedParameter = command.Parameters.AddWithValue("$predicted", string.Empty);
SqliteParameter correctParameter = command.Parameters.AddWithValue("$correct", 0);
foreach (EvalEntry row in rows)
{
atParameter.Value = row.CreatedAtMs;
expectedParameter.Value = row.ExpectedLabel;
predictedParameter.Value = row.PredictedLabel;
correctParameter.Value = row.Correct ? 1 : 0;
command.ExecuteNonQuery();
}
}
// Оставляет ровно последние EVAL_KEEP строк журнала (аналог model.py L319322).
// Python удаляет по created_at и при одинаковых миллисекундах может оставить больше EVAL_KEEP;
// здесь прунинг детерминирован по порядку вставки (rowid) — память модели хранит те же самые
// последние EVAL_KEEP строк, поэтому состояние памяти и файла не расходится после перезапуска.
// transaction: Транзакция батча.
private void PruneEvalLog(SqliteTransaction transaction)
{
using SqliteCommand command = _connection!.CreateCommand();
command.Transaction = transaction;
command.CommandText =
"DELETE FROM eval_log WHERE rowid < (" +
"SELECT rowid FROM eval_log ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET $skip)";
command.Parameters.AddWithValue("$skip", ModelConstants.EvalKeepCount - 1);
command.ExecuteNonQuery();
}
// Закрывает и освобождает соединение (Dispose/DeleteFile).
private void DisposeConnection()
{
_connection?.Dispose();
_connection = null;
}
}
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>