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