SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// Интеграционные тесты ml-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные
|
||||
// env-переменные (DEAL_SERVICE_TOKEN/DEAL_ML_DATA_DIR) на время сценария (MlTestHost и
|
||||
// MlServiceHostTests). Параллельный прогон классов дал бы гонки на env — тесты сериализованы
|
||||
// (тот же шаблон, что AssemblyInfo тестов telegram-service).
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,46 @@
|
||||
<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="NSubstitute" Version="6.1.0" />
|
||||
<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,281 @@
|
||||
using Deal.Grpc.Ml;
|
||||
using Deal.Ml.Tests.Ml;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Ml.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// In-proc gRPC-тесты MlService
|
||||
/// </summary>
|
||||
public sealed class MlRpcTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен».
|
||||
/// </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; модель остаётся неготовой
|
||||
/// </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 обнуляет модель тенанта
|
||||
/// </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>
|
||||
/// Изоляция тенантов
|
||||
/// </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.
|
||||
/// </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 в батче пропускаются
|
||||
/// </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>
|
||||
/// Серверный лимит батча
|
||||
/// </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>
|
||||
/// Серверный лимит длины текста примера
|
||||
/// </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,230 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Grpc.Ml;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Deal.Ml.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграционные тесты хоста ml-service.
|
||||
/// </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-сервис работают.
|
||||
/// </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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Status_WithoutToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: null,
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрос с неверным токеном → UNAUTHENTICATED.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Status_WithWrongToken_IsUnauthenticated()
|
||||
{
|
||||
await AssertDealRpcRejectedAsync(
|
||||
ValidToken,
|
||||
tokenHeader: "wrong-token",
|
||||
expected: StatusCode.Unauthenticated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Верный токен проходит интерцептор к методу Status
|
||||
/// </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: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Grpc.Hosting.Interceptors;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
using Deal.Ml.Model;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Deal.Ml.Tests.Grpc;
|
||||
|
||||
internal static class MlTestHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ каталога моделей
|
||||
/// </summary>
|
||||
public const string DataDirEnvKey = MlOptions.DataDirEnvVarName;
|
||||
|
||||
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
|
||||
|
||||
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 вызова
|
||||
/// </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
|
||||
/// </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,98 @@
|
||||
using Deal.Ml.Model;
|
||||
|
||||
namespace Deal.Ml.Tests.Ml;
|
||||
|
||||
internal static class LearningData
|
||||
{
|
||||
/// <summary>
|
||||
/// Id колонки канбана «разработка/найм»
|
||||
/// </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>
|
||||
/// Сообщение-заявка на разработчика
|
||||
/// </summary>
|
||||
public const string DevMessage =
|
||||
"нужен middle python разработчик в команду, удаленная работа, стек django postgres, оффер конкурентный";
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение-заказ сайта/лендинга
|
||||
/// </summary>
|
||||
public const string OrderMessage =
|
||||
"закажу сайт визитку и лендинг для малого бизнеса, недорого, дизайнер и верстка";
|
||||
|
||||
/// <summary>
|
||||
/// Спам-резюме/рассылка
|
||||
/// </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>
|
||||
/// Канонический батч, доводящий модель до готовности и уверенных предсказаний
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Deal.Ml.Model;
|
||||
|
||||
namespace Deal.Ml.Tests.Ml;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты токенизации
|
||||
/// </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>
|
||||
/// Кириллица/ё обрабатываются как в
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Tokenize_CyrillicWithYo()
|
||||
{
|
||||
string[] tokens = MlTokenizer.Tokenize("Программист Ёжик");
|
||||
|
||||
Assert.Equal(new[] { "программист", "~прог", "ёжик" }, tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разрешённый алфавит включает @ +.
|
||||
/// </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,154 @@
|
||||
using Deal.Ml.Model;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Deal.Ml.Tests.Ml;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты персистентности модели
|
||||
/// </summary>
|
||||
public sealed class ModelPersistenceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Перезапуск пула сохраняет веса
|
||||
/// </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>
|
||||
/// Окно самооценки
|
||||
/// </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>
|
||||
/// Прунинг переживает перезапуск
|
||||
/// </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,84 @@
|
||||
using Deal.Ml.Model;
|
||||
|
||||
namespace Deal.Ml.Tests.Ml;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты пула моделей
|
||||
/// </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>
|
||||
/// Имя файла модели — <tenantId>.sqlite в каталоге данных.
|
||||
/// </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,369 @@
|
||||
using Deal.Ml.Model;
|
||||
|
||||
namespace Deal.Ml.Tests.Ml;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты движка модели
|
||||
/// </summary>
|
||||
public sealed class TenantModelLearningTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Пустая модель: predict — фиксированный «не уверен»
|
||||
/// </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>
|
||||
/// Пока суммарно примеров < MIN_TOTAL
|
||||
/// </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)
|
||||
/// </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>
|
||||
/// Полный сценарий приёмки
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanonicalTraining_LearnsColumnsSpamAndTypes()
|
||||
{
|
||||
RunWithPool(pool =>
|
||||
{
|
||||
TenantModel model = pool.GetOrCreate("tenant-canonical");
|
||||
List<LearnItem> items = LearningData.CanonicalTrainItems();
|
||||
|
||||
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);
|
||||
|
||||
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: одно совпадение у победителя ещё не «взятие»
|
||||
/// </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>
|
||||
/// Незнакомый текст готовой модели
|
||||
/// </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
|
||||
/// </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 < 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>
|
||||
/// Дробные веса ИИ-сигналов
|
||||
/// </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 пропускаются тихо.
|
||||
/// </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 очищает модель и пересоздаёт файл при следующем обучении.
|
||||
/// </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).
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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).
|
||||
|
||||
# Non-root: процесс работает пользователем deal (UID 10001); /data/ml — каталог моделей (volume).
|
||||
RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \
|
||||
&& mkdir -p /data/ml \
|
||||
&& chown -R deal:deal /data/ml
|
||||
ENV HOME=/tmp
|
||||
USER deal
|
||||
|
||||
ENTRYPOINT ["dotnet", "Deal.Ml.dll"]
|
||||
@@ -0,0 +1,17 @@
|
||||
using Deal.Ml.Model;
|
||||
|
||||
namespace Deal.Ml.Extensions;
|
||||
|
||||
// Расширения string для меток классов ML-модели.
|
||||
internal static class LabelExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Метка внутреннего типа заявки
|
||||
/// </summary>
|
||||
/// <param name="label">Метка класса.</param>
|
||||
/// <returns>True — метка является внутренним типом заявки (префикс t).</returns>
|
||||
public static bool IsTypeLabel(this string label)
|
||||
{
|
||||
return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Deal.Grpc.Hosting.Interceptors;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
using Deal.Ml.Model;
|
||||
|
||||
namespace Deal.Ml;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает WebApplication gRPC-хоста ml-service.
|
||||
/// </summary>
|
||||
public static class MlServiceHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт (не запускает) хост
|
||||
/// </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. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</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 ?? []);
|
||||
|
||||
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
|
||||
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
|
||||
builder.Services.AddDealGrpcServer();
|
||||
builder.Services.AddReadyHealthCheck("хост ml-service готов");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using Deal.Grpc.Ml;
|
||||
using Deal.Ml.Model;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Ml;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация серверной стороны Deal.Grpc.Ml.MlService — команды ядра в ml-service.
|
||||
/// </summary>
|
||||
public sealed class MlServiceImpl : MlService.MlServiceBase
|
||||
{
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata";
|
||||
|
||||
// Деталь отказа: tenant-id некорректен как имя файла модели (INVALID_ARGUMENT).
|
||||
private const string InvalidTenantIdDetail = "Некорректный tenant-id";
|
||||
|
||||
private const string StorageUnavailableDetail = "Хранилище модели недоступно — повторите запрос позже";
|
||||
|
||||
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 = "Слишком длинный текст сообщения";
|
||||
|
||||
private const string ResetFailedDetail = "Не удалось сбросить модель — повторите попытку позже";
|
||||
|
||||
private readonly ModelPool _pool;
|
||||
private readonly ILogger<MlServiceImpl> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сервис команд ядра поверх пула моделей.
|
||||
/// </summary>
|
||||
/// <param name="pool">Пул моделей тенантов (ленивое создание/загрузка).</param>
|
||||
/// <param name="logger">Логгер аудита.</param>
|
||||
public MlServiceImpl(ModelPool pool, ILogger<MlServiceImpl> logger)
|
||||
{
|
||||
_pool = pool;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predict — решение по тексту сообщения
|
||||
/// </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 — статус модели тенанта
|
||||
/// </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 — полный сброс модели тенанта
|
||||
/// </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 — пакетное обучение
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Строка журнала самооценки модели
|
||||
/// </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);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Один обучающий пример тенанта.
|
||||
/// </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>
|
||||
/// Окно самооценки модели.
|
||||
/// </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);
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация хранения моделей ml-service.
|
||||
/// </summary>
|
||||
public sealed class MlOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ каталога файлов моделей
|
||||
/// </summary>
|
||||
public const string DataDirEnvVarName = "DEAL_ML_DATA_DIR";
|
||||
|
||||
/// <summary>
|
||||
/// Относительный каталог моделей по умолчанию
|
||||
/// </summary>
|
||||
public const string DefaultDataDirRelative = "data/ml";
|
||||
|
||||
private MlOptions(string dataDirectory)
|
||||
{
|
||||
DataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Абсолютный путь к каталогу файлов моделей data/ml/<tenantId>.sqlite.
|
||||
/// </summary>
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт опции с уже известным каталогом
|
||||
/// </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 и корня хоста
|
||||
/// </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,22 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Результат предсказания модели.
|
||||
/// </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,14 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Статус модели тенанта
|
||||
/// </summary>
|
||||
/// <param name="Ready">Модель готова принимать решения.</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,49 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Разбиение текста на термины модели.
|
||||
/// </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>
|
||||
/// Токенизирует текст в термины модели
|
||||
/// </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,10 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Решение ML о типе заявки.
|
||||
/// </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,152 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Пороги и константы наивно-байесовской модели по терминам.
|
||||
/// </summary>
|
||||
public static class ModelConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Суммарно примеров по всем классам, чтобы модель «включилась»
|
||||
/// </summary>
|
||||
public const double MinTotalExamples = 20.0;
|
||||
|
||||
/// <summary>
|
||||
/// Минимум примеров у класса-победителя
|
||||
/// </summary>
|
||||
public const double MinWinnerExamples = 6.0;
|
||||
|
||||
/// <summary>
|
||||
/// Минимум примеров у класса-победителя «spam»
|
||||
/// </summary>
|
||||
public const double MinWinnerSpamExamples = 4.0;
|
||||
|
||||
/// <summary>
|
||||
/// Минимум различных терминов, встреченных у победителя
|
||||
/// </summary>
|
||||
public const int MinHits = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Минимум примеров класса типа t:*, чтобы ML выдавал тип
|
||||
/// </summary>
|
||||
public const double MinTypeWinnerExamples = 4.0;
|
||||
|
||||
/// <summary>
|
||||
/// Метка класса «спам»
|
||||
/// </summary>
|
||||
public const string SpamLabel = "spam";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс внутренних классов типа заявки
|
||||
/// </summary>
|
||||
public const string TypeLabelPrefix = "t:";
|
||||
|
||||
/// <summary>
|
||||
/// Внутренний класс типа заявки «найм»
|
||||
/// </summary>
|
||||
public const string TypeClassHire = "t:hire";
|
||||
|
||||
/// <summary>
|
||||
/// Внутренний класс типа заявки «разовая сделка»
|
||||
/// </summary>
|
||||
public const string TypeClassOrder = "t:order";
|
||||
|
||||
/// <summary>
|
||||
/// Множитель prior при ранжировании классов
|
||||
/// </summary>
|
||||
public const double PriorWeight = 3.0;
|
||||
|
||||
/// <summary>
|
||||
/// ln-отрыв от второго класса на старте
|
||||
/// </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
|
||||
/// </summary>
|
||||
public const int EvalWindowSize = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько последних решений самооценки хранится в БД модели
|
||||
/// </summary>
|
||||
public const int EvalKeepCount = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько лучших весов классов отдаётся в predict
|
||||
/// </summary>
|
||||
public const int MaxScoresInReply = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько узнанных терминов отдаётся в predict
|
||||
/// </summary>
|
||||
public const int MaxMatchedTerms = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Точность округления весов в scores
|
||||
/// </summary>
|
||||
public const int ScoresPrecision = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Точность округления весов классов в status
|
||||
/// </summary>
|
||||
public const int ClassesPrecision = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Точность округления отступа margin
|
||||
/// </summary>
|
||||
public const int MarginPrecision = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Точность округления доли верных в eval
|
||||
/// </summary>
|
||||
public const int AccuracyPrecision = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина токена, попадающего в модель.
|
||||
/// </summary>
|
||||
public const int MinTokenLength = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Длина токена, при которой добавляется «хвостовой» префикс-термин.
|
||||
/// </summary>
|
||||
public const int MinTokenLengthForPrefix = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Длина префикса хвостового токена
|
||||
/// </summary>
|
||||
public const int PrefixLength = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Префикс хвостового токена — не участвует в terms-подсказках.
|
||||
/// </summary>
|
||||
public const string TokenPrefixMarker = "~";
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Deal.Ml.Storage;
|
||||
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Пул моделей тенантов
|
||||
/// </summary>
|
||||
public sealed class ModelPool : IDisposable
|
||||
{
|
||||
private const string DbFileExtension = ".sqlite";
|
||||
|
||||
// Верхняя граница длины tenant-id (защита пути; реальные id заметно короче).
|
||||
private const int MaxTenantIdLength = 128;
|
||||
|
||||
private readonly MlOptions _options;
|
||||
private readonly ConcurrentDictionary<string, TenantModel> _models = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Каталог файлов моделей
|
||||
/// </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>
|
||||
/// Возвращает модель тенанта, создавая её лениво
|
||||
/// </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/<tenantId>.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));
|
||||
}
|
||||
|
||||
// Имя файла модели должно быть портируемым между ОС: разделители пути («/», «\»)
|
||||
// валидны на Linux, поэтому проверяем явный белый список, а не GetInvalidFileNameChars.
|
||||
if (tenantId.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_'))
|
||||
{
|
||||
throw new ArgumentException("tenant-id содержит недопустимые для имени файла символы.", nameof(tenantId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Состояние модели тенанта в памяти
|
||||
/// </summary>
|
||||
public sealed class ModelState
|
||||
{
|
||||
/// <summary>
|
||||
/// Веса классов: label → n.
|
||||
/// </summary>
|
||||
public Dictionary<string, double> Classes { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Веса терминов по классам
|
||||
/// </summary>
|
||||
public Dictionary<string, Dictionary<string, double>> TermsByLabel { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Журнал самооценки в порядке накопления
|
||||
/// </summary>
|
||||
public List<EvalEntry> EvalLog { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Очищает состояние.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
Classes.Clear();
|
||||
TermsByLabel.Clear();
|
||||
EvalLog.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using Deal.Ml.Extensions;
|
||||
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Инкрементальная наивно-байесовская модель по терминам.
|
||||
/// </summary>
|
||||
public static class OnlineNaiveBayes
|
||||
{
|
||||
/// <summary>
|
||||
/// Готовность модели
|
||||
/// </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>
|
||||
/// Адаптивный отрыв от второго класса
|
||||
/// </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>
|
||||
/// Суммарно примеров по всем классам
|
||||
/// </summary>
|
||||
/// <param name="state">Состояние модели.</param>
|
||||
public static double Total(ModelState state) => state.Classes.Values.Sum();
|
||||
|
||||
/// <summary>
|
||||
/// Предсказание по тексту
|
||||
/// </summary>
|
||||
/// <param name="state">Состояние модели.</param>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
public static MlPredictResult Predict(ModelState state, string text)
|
||||
{
|
||||
string[] tokens = MlTokenizer.Tokenize(text);
|
||||
|
||||
if (state.Classes.Count == 0 || tokens.Length == 0)
|
||||
{
|
||||
return NotReady(Ready(state));
|
||||
}
|
||||
|
||||
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 => !label.IsTypeLabel()).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>
|
||||
/// Статус модели: готовность, веса классов
|
||||
/// </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));
|
||||
}
|
||||
|
||||
private static double TermScore(double weight)
|
||||
=> weight < 1.0 ? 1.0 : 1.0 + (weight - 1.0) / (weight + 1.0);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static Dictionary<string, double>? TryGetTerms(ModelState state, string label)
|
||||
=> state.TermsByLabel.TryGetValue(label, out Dictionary<string, double>? terms) ? terms : null;
|
||||
|
||||
// Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает).
|
||||
// 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);
|
||||
|
||||
private static double Round(double value, int digits) => Math.Round(value, digits);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using Deal.Ml.Storage;
|
||||
|
||||
namespace Deal.Ml.Model;
|
||||
|
||||
/// <summary>
|
||||
/// Модель одного тенанта
|
||||
/// </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-файлом
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (владелец модели).</param>
|
||||
/// <param name="db">Хранилище весов модели (файл data/ml/<tenantId>.sqlite).</param>
|
||||
public TenantModel(string tenantId, MlDb db)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Путь к SQLite-файлу модели
|
||||
/// </summary>
|
||||
public string DatabasePath => _db.DatabasePath;
|
||||
|
||||
/// <summary>
|
||||
/// Предсказание по тексту
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
public MlPredictResult Predict(string text)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
EnsureLoaded();
|
||||
return OnlineNaiveBayes.Predict(_state, text);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Статус модели.
|
||||
/// </summary>
|
||||
public MlStatusResult Status()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
EnsureLoaded();
|
||||
return OnlineNaiveBayes.Status(_state);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пакетное обучение
|
||||
/// </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;
|
||||
}
|
||||
|
||||
_db.ApplyLearnBatch(applied, evalRows);
|
||||
foreach ((string label, double delta, string[] tokens) in applied)
|
||||
{
|
||||
ApplyToMemory(label, delta, tokens);
|
||||
}
|
||||
|
||||
AppendEvalToMemory(evalRows);
|
||||
return applied.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полный сброс модели
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_db.DeleteFile();
|
||||
_state.Clear();
|
||||
_loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureLoaded()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = _db.LoadState();
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
|
||||
using Deal.Grpc.Hosting.Interceptors;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
using Deal.Ml;
|
||||
|
||||
const int defaultGrpcPort = 5103;
|
||||
const string mlProcessName = "ml";
|
||||
|
||||
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
|
||||
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
||||
|
||||
WebApplication app = MlServiceHost.Create(
|
||||
grpcPort,
|
||||
configureBuilder: builder =>
|
||||
{
|
||||
DealLogging.Configure(builder, mlProcessName);
|
||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||
});
|
||||
|
||||
DealMetricsHosting.MapDealMetrics(app);
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,276 @@
|
||||
using Deal.Ml.Model;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Deal.Ml.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-хранилище весов модели одного тенанта.
|
||||
/// </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-файлу модели (<dataDir>/<tenantId>.sqlite).</param>
|
||||
public MlDb(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
throw new ArgumentException("Путь к файлу модели не задан.", nameof(filePath));
|
||||
}
|
||||
|
||||
_filePath = filePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Открывает соединение и создаёт схему при первом обращении.
|
||||
/// </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>
|
||||
/// Читает полное состояние модели из файла
|
||||
/// </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>
|
||||
/// Применяет батч обучения одной транзакцией
|
||||
/// </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>
|
||||
/// Пересоздаёт файл модели
|
||||
/// </summary>
|
||||
public void DeleteFile()
|
||||
{
|
||||
DisposeConnection();
|
||||
File.Delete(_filePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => DisposeConnection();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user