Отформатировать списки параметров по код-стайлу

Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:22:56 +03:00
parent 410194b0cb
commit 492950bdd0
255 changed files with 2672 additions and 668 deletions
+15
View File
@@ -165,6 +165,21 @@
- Функции, возвращающие массив/коллекцию, всегда возвращают массив/коллекцию: если данных нет — пустой - Функции, возвращающие массив/коллекцию, всегда возвращают массив/коллекцию: если данных нет — пустой
экземпляр, но не `null`. экземпляр, но не `null`.
- Не более 7 параметров у функции. Больше — объединять в класс/DTO. - Не более 7 параметров у функции. Больше — объединять в класс/DTO.
- **Перенос параметров:** если параметров **больше двух** — каждый на **отдельной строке** (открывающая `(` — в конце первой строки, закрывающая `)` — на отдельной строке с отступом объявления); если **два или меньше** — все параметры **в одну строку**.
Больше двух:
```csharp
public async Task<CardMoveResultDto> MoveAsync(
string cardId,
string toContainerId,
TransitionContext ctx,
CancellationToken ct)
```
Два или меньше:
```csharp
public User FindUser(string login, CancellationToken ct) { }
```
## 8. Управление выполнением программы ## 8. Управление выполнением программы
@@ -128,7 +128,10 @@ public sealed class AiServiceHostTests
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
// expected: Ожидаемый StatusCode ответа. // expected: Ожидаемый StatusCode ответа.
private static async Task AssertDealRpcRejectedAsync(string? serviceToken, string? tokenHeader, StatusCode expected) private static async Task AssertDealRpcRejectedAsync(
string? serviceToken,
string? tokenHeader,
StatusCode expected)
{ {
await RunHostScenarioAsync( await RunHostScenarioAsync(
serviceToken, serviceToken,
@@ -141,7 +144,10 @@ public sealed class AiServiceHostTests
// channel: Канал к хосту ai-service. // channel: Канал к хосту ai-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка). // tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode. // expected: Ожидаемый StatusCode.
private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader, StatusCode expected) private static async Task AssertRejectedAsync(
GrpcChannel channel,
string? tokenHeader,
StatusCode expected)
{ {
var client = new AiService.AiServiceClient(channel); var client = new AiService.AiServiceClient(channel);
Metadata metadata = new() { { "tenant-id", TenantId } }; Metadata metadata = new() { { "tenant-id", TenantId } };
+1 -3
View File
@@ -52,9 +52,7 @@ internal static class AiTestHost
/// </summary> /// </summary>
/// <param name="configureServices">Настройка DI сценария (фейк-провайдер, мгновенные ретраи).</param> /// <param name="configureServices">Настройка DI сценария (фейк-провайдер, мгновенные ретраи).</param>
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param> /// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
public static Task RunAsync( public static Task RunAsync(Action<IServiceCollection> configureServices, Func<GrpcChannel, Task> scenario)
Action<IServiceCollection> configureServices,
Func<GrpcChannel, Task> scenario)
=> RunAsync(DefaultToken, configureServices, scenario); => RunAsync(DefaultToken, configureServices, scenario);
/// <summary> /// <summary>
@@ -26,9 +26,7 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler
/// </summary> /// </summary>
/// <param name="responder">Ответчик: request → response.</param> /// <param name="responder">Ответчик: request → response.</param>
/// <param name="delay">Необязательная задержка ответа (тест таймаута).</param> /// <param name="delay">Необязательная задержка ответа (тест таймаута).</param>
public StubHttpMessageHandler( public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder, TimeSpan? delay = null)
Func<HttpRequestMessage, HttpResponseMessage> responder,
TimeSpan? delay = null)
{ {
_responder = responder; _responder = responder;
_delay = delay; _delay = delay;
@@ -45,9 +43,7 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler
public CapturedHttpRequest LastRequest => Requests[^1]; public CapturedHttpRequest LastRequest => Requests[^1];
/// <inheritdoc /> /// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync( protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
HttpRequestMessage request,
CancellationToken cancellationToken)
{ {
string? body = request.Content is null string? body = request.Content is null
? null ? null
+15 -7
View File
@@ -239,8 +239,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
/// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую /// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую
/// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE. /// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE.
/// </summary> /// </summary>
public override async Task<GenerateKeywordsReply> GenerateKeywords( public override async Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context)
GenerateKeywordsRequest request, ServerCallContext context)
{ {
string tenantId = RequireTenantId(context); string tenantId = RequireTenantId(context);
EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail);
@@ -277,8 +276,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
/// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled; /// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled;
/// сбой — фолбэк на эвристику (Ruling 10). /// сбой — фолбэк на эвристику (Ruling 10).
/// </summary> /// </summary>
public override async Task<EvaluateFitReply> EvaluateFit( public override async Task<EvaluateFitReply> EvaluateFit(EvaluateFitRequest request, ServerCallContext context)
EvaluateFitRequest request, ServerCallContext context)
{ {
string tenantId = RequireTenantId(context); string tenantId = RequireTenantId(context);
EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail);
@@ -333,7 +331,10 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// value: Значение поля запроса (в proto строка не бывает null). // value: Значение поля запроса (в proto строка не бывает null).
// maxLength: Допустимый максимум символов. // maxLength: Допустимый максимум символов.
// detail: Текст отказа (detail RPC). // detail: Текст отказа (detail RPC).
private static void EnsureLengthAtMost(string value, int maxLength, string detail) private static void EnsureLengthAtMost(
string value,
int maxLength,
string detail)
{ {
if (value.Length > maxLength) if (value.Length > maxLength)
{ {
@@ -393,7 +394,11 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// tenantId: Id тенанта. // tenantId: Id тенанта.
// config: Конфиг провайдера вызова. // config: Конфиг провайдера вызова.
// callError: Итоговая ошибка фасада. // callError: Итоговая ошибка фасада.
private void LogAiUnavailable(string method, string tenantId, LlmConfig config, LlmCallException callError) private void LogAiUnavailable(
string method,
string tenantId,
LlmConfig config,
LlmCallException callError)
=> _logger.LogWarning( => _logger.LogWarning(
"Аудит: tenant {TenantId} {Method} → ИИ ({Provider}) недоступен ({Kind})", "Аудит: tenant {TenantId} {Method} → ИИ ({Provider}) недоступен ({Kind})",
tenantId, tenantId,
@@ -412,7 +417,10 @@ public sealed class AiServiceImpl : AiService.AiServiceBase
// json: Корневой объект ответа модели. // json: Корневой объект ответа модели.
// fieldName: Имя поля. // fieldName: Имя поля.
// defaultValue: Значение при отсутствии/неразбираемости поля. // defaultValue: Значение при отсутствии/неразбираемости поля.
private static bool ReadBoolField(JsonObject json, string fieldName, bool defaultValue) private static bool ReadBoolField(
JsonObject json,
string fieldName,
bool defaultValue)
{ {
if (json[fieldName] is not JsonValue value) if (json[fieldName] is not JsonValue value)
{ {
@@ -16,7 +16,10 @@ public sealed class LlmCallException : Exception
/// <param name="kind">Причина исчерпания (см. <see cref="LlmCallFailureKind"/>).</param> /// <param name="kind">Причина исчерпания (см. <see cref="LlmCallFailureKind"/>).</param>
/// <param name="providerDisplayName">Отображаемое имя провайдера (без ключей).</param> /// <param name="providerDisplayName">Отображаемое имя провайдера (без ключей).</param>
/// <param name="usage">Usage последней ответившей попытки (для Kind=AnswerNotJson; иначе null).</param> /// <param name="usage">Usage последней ответившей попытки (для Kind=AnswerNotJson; иначе null).</param>
public LlmCallException(LlmCallFailureKind kind, string providerDisplayName, LlmUsage? usage = null) public LlmCallException(
LlmCallFailureKind kind,
string providerDisplayName,
LlmUsage? usage = null)
: base(string.Format(DetailTemplate, providerDisplayName)) : base(string.Format(DetailTemplate, providerDisplayName))
{ {
Kind = kind; Kind = kind;
+12 -3
View File
@@ -58,7 +58,10 @@ public sealed class LlmHttpClient : IProviderClient
// httpClient: HttpClient над фейковым обработчиком. // httpClient: HttpClient над фейковым обработчиком.
// openAiCallTimeout: Таймаут OpenAI-совместимой попытки. // openAiCallTimeout: Таймаут OpenAI-совместимой попытки.
// anthropicCallTimeout: Таймаут Anthropic-попытки. // anthropicCallTimeout: Таймаут Anthropic-попытки.
internal LlmHttpClient(HttpClient httpClient, TimeSpan openAiCallTimeout, TimeSpan anthropicCallTimeout) internal LlmHttpClient(
HttpClient httpClient,
TimeSpan openAiCallTimeout,
TimeSpan anthropicCallTimeout)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_openAiCallTimeout = openAiCallTimeout; _openAiCallTimeout = openAiCallTimeout;
@@ -110,7 +113,10 @@ public sealed class LlmHttpClient : IProviderClient
// config: Конфиг провайдера. // config: Конфиг провайдера.
// systemPrompt: Системный промпт. // systemPrompt: Системный промпт.
// userText: Пользовательское сообщение. // userText: Пользовательское сообщение.
private static HttpRequestMessage BuildOpenAiRequest(LlmConfig config, string systemPrompt, string userText) private static HttpRequestMessage BuildOpenAiRequest(
LlmConfig config,
string systemPrompt,
string userText)
{ {
var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + OpenAiCompletionsPath); var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + OpenAiCompletionsPath);
if (!string.IsNullOrEmpty(config.ApiKey)) if (!string.IsNullOrEmpty(config.ApiKey))
@@ -137,7 +143,10 @@ public sealed class LlmHttpClient : IProviderClient
// config: Конфиг провайдера. // config: Конфиг провайдера.
// systemPrompt: Системный промпт. // systemPrompt: Системный промпт.
// userText: Пользовательское сообщение. // userText: Пользовательское сообщение.
private static HttpRequestMessage BuildAnthropicRequest(LlmConfig config, string systemPrompt, string userText) private static HttpRequestMessage BuildAnthropicRequest(
LlmConfig config,
string systemPrompt,
string userText)
{ {
var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + AnthropicMessagesPath); var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + AnthropicMessagesPath);
request.Headers.TryAddWithoutValidation(AnthropicApiKeyHeader, config.ApiKey ?? string.Empty); request.Headers.TryAddWithoutValidation(AnthropicApiKeyHeader, config.ApiKey ?? string.Empty);
+4 -1
View File
@@ -16,7 +16,10 @@ public static class TokenEstimator
/// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param> /// <param name="providerUsage">Usage из API-ответа (null — провайдер его не вернул).</param>
/// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</param> /// <param name="promptText">Полный текст запроса (система + пользователь) для оценки.</param>
/// <param name="completionText">Текст ответа модели для оценки.</param> /// <param name="completionText">Текст ответа модели для оценки.</param>
public static LlmUsage Resolve(ProviderUsage? providerUsage, string promptText, string completionText) public static LlmUsage Resolve(
ProviderUsage? providerUsage,
string promptText,
string completionText)
{ {
if (providerUsage is not null) if (providerUsage is not null)
{ {
@@ -128,7 +128,10 @@ public static class AiCheckEndpoint
// providerId: Активный провайдер (id из каталога). // providerId: Активный провайдер (id из каталога).
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт. // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(ISettingsStore store, string providerId, CancellationToken ct) private static async Task<AiConfigSetting> ReadEffectiveConfigAsync(
ISettingsStore store,
string providerId,
CancellationToken ct)
{ {
AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId]; AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct); SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
@@ -117,7 +117,10 @@ public static class CardDetailsEndpoints
} }
// POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка. // POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка.
private static async Task<IResult> CreateCardAsync(CreateCardRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CreateCardAsync(
CreateCardRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -133,7 +136,10 @@ public static class CardDetailsEndpoints
} }
// POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка. // POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка.
private static async Task<IResult> TakeCardAsync(TakeCardRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> TakeCardAsync(
TakeCardRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -163,7 +169,10 @@ public static class CardDetailsEndpoints
// PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget). // PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget).
// Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей // Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей
// (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка. // (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка.
private static async Task<IResult> PatchCardAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchCardAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -184,7 +193,11 @@ public static class CardDetailsEndpoints
} }
// POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка. // POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка.
private static async Task<IResult> AddLinkAsync(string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> AddLinkAsync(
string cardId,
CardLinkRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -208,7 +221,11 @@ public static class CardDetailsEndpoints
} }
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
private static async Task<IResult> RemoveLinkAsync(string cardId, string linkId, HttpContext context, CancellationToken ct) private static async Task<IResult> RemoveLinkAsync(
string cardId,
string linkId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -225,7 +242,10 @@ public static class CardDetailsEndpoints
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
// Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл: // Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл:
// имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404). // имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404).
private static async Task<IResult> UploadFilesAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> UploadFilesAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -265,7 +285,11 @@ public static class CardDetailsEndpoints
// GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения. // GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения.
// Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток // Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток
// с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек. // с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек.
private static async Task<IResult> DownloadFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct) private static async Task<IResult> DownloadFileAsync(
string cardId,
string fileId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -314,7 +338,11 @@ public static class CardDetailsEndpoints
} }
// DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка. // DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка.
private static async Task<IResult> RemoveFileAsync(string cardId, string fileId, HttpContext context, CancellationToken ct) private static async Task<IResult> RemoveFileAsync(
string cardId,
string fileId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -329,7 +357,11 @@ public static class CardDetailsEndpoints
} }
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
private static async Task<IResult> SetReminderAsync(string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> SetReminderAsync(
string cardId,
ReminderSetRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -354,7 +386,10 @@ public static class CardDetailsEndpoints
} }
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
private static async Task<IResult> ClearReminderAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> ClearReminderAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -368,7 +403,10 @@ public static class CardDetailsEndpoints
} }
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
private static async Task<IResult> SnoozeReminderAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> SnoozeReminderAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -386,7 +424,10 @@ public static class CardDetailsEndpoints
// cardId: Id карточки. // cardId: Id карточки.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: 200 с единой карточкой либо 404. // Возвращает: 200 с единой карточкой либо 404.
private static async Task<IResult> ReadCardAsync(HttpContext context, string cardId, CancellationToken ct) private static async Task<IResult> ReadCardAsync(
HttpContext context,
string cardId,
CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto? card = await cardsService.GetCardAsync(cardId, ct);
+59 -14
View File
@@ -100,7 +100,11 @@ public static class CardsEndpoints
// GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер». // GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер».
// Параметр col принят как алиас containerId (совместимость со старым фронтом). // Параметр col принят как алиас containerId (совместимость со старым фронтом).
private static async Task<IResult> ListCardsAsync(string? containerId, string? col, HttpContext context, CancellationToken ct) private static async Task<IResult> ListCardsAsync(
string? containerId,
string? col,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -143,7 +147,10 @@ public static class CardsEndpoints
} }
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168). // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
private static async Task<IResult> GetCardAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> GetCardAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -171,7 +178,10 @@ public static class CardsEndpoints
} }
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}. // POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
private static async Task<IResult> MarkColSeenAsync(MarkColBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> MarkColSeenAsync(
MarkColBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -195,7 +205,11 @@ public static class CardsEndpoints
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания // доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при // (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
// несуществующем контейнере, 404 — карточки нет. // несуществующем контейнере, 404 — карточки нет.
private static async Task<IResult> MoveAsync(string cardId, MoveBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> MoveAsync(
string cardId,
MoveBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -225,7 +239,10 @@ public static class CardsEndpoints
} }
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404. // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203207); ответ {ok:true}; 404.
private static async Task<IResult> TrashAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> TrashAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -245,7 +262,10 @@ public static class CardsEndpoints
} }
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404. // POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
private static async Task<IResult> RestoreAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> RestoreAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -265,7 +285,10 @@ public static class CardsEndpoints
} }
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404. // DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217221); ответ {ok:true}; 404.
private static async Task<IResult> DeleteAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -285,7 +308,10 @@ public static class CardsEndpoints
} }
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400. // POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
private static async Task<IResult> ClearColAsync(ClearColBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> ClearColAsync(
ClearColBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -300,7 +326,11 @@ public static class CardsEndpoints
} }
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404. // POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
private static async Task<IResult> AddCommentAsync(string cardId, CommentBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> AddCommentAsync(
string cardId,
CommentBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -332,7 +362,10 @@ public static class CardsEndpoints
// body: Тело запроса (ids — опционально). // body: Тело запроса (ids — опционально).
// context: Контекст запроса. // context: Контекст запроса.
// ct: Токен отмены. // ct: Токен отмены.
private static async Task<IResult> ReclassifyAsync(ReclassifyBody? body, HttpContext context, CancellationToken ct) private static async Task<IResult> ReclassifyAsync(
ReclassifyBody? body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -350,7 +383,10 @@ public static class CardsEndpoints
// cardId: Id карточки (c_...). // cardId: Id карточки (c_...).
// context: Контекст запроса. // context: Контекст запроса.
// ct: Токен отмены. // ct: Токен отмены.
private static async Task<IResult> ReclassifyOneAsync(string cardId, HttpContext context, CancellationToken ct) private static async Task<IResult> ReclassifyOneAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -412,7 +448,10 @@ public static class CardsEndpoints
// context: Контекст запроса. // context: Контекст запроса.
// result: Итог прохода. // result: Итог прохода.
// ct: Токен отмены. // ct: Токен отмены.
private static Task AppendReclassifyAuditAsync(HttpContext context, ReclassifyResultDto result, CancellationToken ct) private static Task AppendReclassifyAuditAsync(
HttpContext context,
ReclassifyResultDto result,
CancellationToken ct)
{ {
if (result.Reclassified == 0) if (result.Reclassified == 0)
{ {
@@ -427,7 +466,10 @@ public static class CardsEndpoints
} }
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254256). Ответ {leads, messages: []}. // GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254256). Ответ {leads, messages: []}.
private static async Task<IResult> SearchAsync(string? q, HttpContext context, CancellationToken ct) private static async Task<IResult> SearchAsync(
string? q,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -448,7 +490,10 @@ public static class CardsEndpoints
// context: Контекст запроса (для резолва ContainersService). // context: Контекст запроса (для резолва ContainersService).
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: True — контейнер допустим для фильтра. // Возвращает: True — контейнер допустим для фильтра.
private static async Task<bool> IsKnownContainerAsync(string col, HttpContext context, CancellationToken ct) private static async Task<bool> IsKnownContainerAsync(
string col,
HttpContext context,
CancellationToken ct)
{ {
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>(); ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
return await containers.GetAsync(col, ct) is not null; return await containers.GetAsync(col, ct) is not null;
@@ -69,7 +69,10 @@ public static class ContainersEndpoints
} }
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками. // GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
private static async Task<IResult> ListContainersAsync(string? space, HttpContext context, CancellationToken ct) private static async Task<IResult> ListContainersAsync(
string? space,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -81,7 +84,10 @@ public static class ContainersEndpoints
} }
// POST /api/containers: создать контейнер; ответ {id}. // POST /api/containers: создать контейнер; ответ {id}.
private static async Task<IResult> CreateContainerAsync(ContainerCreateRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CreateContainerAsync(
ContainerCreateRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -112,7 +118,11 @@ public static class ContainersEndpoints
} }
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден». // PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
private static async Task<IResult> PatchContainerAsync(string containerId, JsonElement body, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchContainerAsync(
string containerId,
JsonElement body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -172,7 +182,10 @@ public static class ContainersEndpoints
} }
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false). // POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
private static async Task<IResult> AcceptSuggestedAsync(string containerId, HttpContext context, CancellationToken ct) private static async Task<IResult> AcceptSuggestedAsync(
string containerId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -192,7 +205,10 @@ public static class ContainersEndpoints
} }
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми. // DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
private static async Task<IResult> DeleteContainerAsync(string containerId, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteContainerAsync(
string containerId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -208,7 +224,10 @@ public static class ContainersEndpoints
} }
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}. // POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
private static async Task<IResult> ReorderContainersAsync(OrderBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> ReorderContainersAsync(
OrderBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -246,7 +265,11 @@ public static class ContainersEndpoints
} }
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки. // PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
private static async Task<IResult> PatchColumnStateAsync(string containerId, ColStateBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchColumnStateAsync(
string containerId,
ColStateBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -146,7 +146,10 @@ public static class DiscoveryEndpoints
} }
// POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе). // POST /api/discovery/tasks: создать задачу поиска (create_task L146151; дефолты — в сервисе).
private static async Task<IResult> CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> CreateTaskAsync(
DiscoveryTaskCreateBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -166,7 +169,11 @@ public static class DiscoveryEndpoints
} }
// PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400). // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154161; 404/400).
private static async Task<IResult> PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> PatchTaskAsync(
string task_id,
DiscoveryTaskPatchBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -186,7 +193,10 @@ public static class DiscoveryEndpoints
} }
// DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168). // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164168).
private static async Task<IResult> DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteTaskAsync(
string task_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -199,7 +209,10 @@ public static class DiscoveryEndpoints
} }
// POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400). // POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171179; пустые ключи → 400).
private static async Task<IResult> StartTaskAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> StartTaskAsync(
string task_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -219,7 +232,10 @@ public static class DiscoveryEndpoints
} }
// POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187). // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181187).
private static async Task<IResult> PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> PauseTaskAsync(
string task_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -236,7 +252,10 @@ public static class DiscoveryEndpoints
// Очистка ключей ответа — CleanKeywords (python _clean_keywords L111128: ≤30, ≤60 // Очистка ключей ответа — CleanKeywords (python _clean_keywords L111128: ≤30, ≤60
// символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints). // символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints).
// Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины.
private static async Task<IResult> GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> GenerateKeywordsAsync(
string task_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -283,7 +302,11 @@ public static class DiscoveryEndpoints
// GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром // GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром
// new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список). // new|review|joined|rejected (list_candidates L216224; невалидный статус — пустой список).
private static async Task<IResult> ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct) private static async Task<IResult> ListCandidatesAsync(
string task_id,
string? status,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -305,7 +328,10 @@ public static class DiscoveryEndpoints
// POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226251). // POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226251).
// RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка → // RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка →
// mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины.
private static async Task<IResult> JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> JoinCandidateAsync(
string dialog_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -361,7 +387,10 @@ public static class DiscoveryEndpoints
// POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список // POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список
// (reject_candidate L253264; уже вступившего — нельзя, 400). // (reject_candidate L253264; уже вступившего — нельзя, 400).
private static async Task<IResult> RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> RejectCandidateAsync(
string dialog_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -405,7 +434,10 @@ public static class DiscoveryEndpoints
} }
// DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277). // DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274277).
private static async Task<IResult> RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> RemoveBlacklistAsync(
string dialog_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -418,7 +450,10 @@ public static class DiscoveryEndpoints
} }
// GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым. // GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282285), события от новых к старым.
private static async Task<IResult> TaskLogAsync(string task_id, HttpContext context, CancellationToken ct) private static async Task<IResult> TaskLogAsync(
string task_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -110,7 +110,10 @@ public static class EventsEndpoint
// response: Ответ (stream уже начат). // response: Ответ (stream уже начат).
// frame: Frame протокола SSE. // frame: Frame протокола SSE.
// ct: Токен отмены запроса. // ct: Токен отмены запроса.
private static async Task WriteFrameAsync(HttpResponse response, string frame, CancellationToken ct) private static async Task WriteFrameAsync(
HttpResponse response,
string frame,
CancellationToken ct)
{ {
await response.WriteAsync(frame, ct); await response.WriteAsync(frame, ct);
await response.Body.FlushAsync(ct); await response.Body.FlushAsync(ct);
@@ -47,7 +47,10 @@ public static class FilterTesterEndpoints
} }
// POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284). // POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267284).
private static async Task<IResult> CheckAsync(CheckMessageRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CheckAsync(
CheckMessageRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
+12 -3
View File
@@ -104,7 +104,10 @@ public static class MlEndpoints
} }
// POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490). // POST /api/ml/predict: проверка ML на тексте (ml_routes.py L8490).
private static async Task<IResult> PredictAsync(MlPredictRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> PredictAsync(
MlPredictRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -139,7 +142,10 @@ public static class MlEndpoints
} }
// POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112134). // POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112134).
private static async Task<IResult> CandidatesAsync(MlCandidatesRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> CandidatesAsync(
MlCandidatesRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -152,7 +158,10 @@ public static class MlEndpoints
} }
// POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171). // POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137171).
private static async Task<IResult> ApplyAsync(MlApplyRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> ApplyAsync(
MlApplyRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -143,7 +143,10 @@ public static class OperatorAuthEndpoints
// context: Контекст запроса. // context: Контекст запроса.
// options: Настройки куки из конфигурации (секция OperatorCookies). // options: Настройки куки из конфигурации (секция OperatorCookies).
// rawToken: Raw-токен операторской сессии. // rawToken: Raw-токен операторской сессии.
private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken) private static void SetOperatorSessionCookie(
HttpContext context,
OperatorCookieOptions options,
string rawToken)
{ {
// MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на
// OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions).
@@ -93,7 +93,10 @@ public static class PipelineEndpoints
// GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L2331). // GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L2331).
// Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218241 + queue_counts L207215 + // Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218241 + queue_counts L207215 +
// rejected_count L201202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync). // rejected_count L201202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync).
private static async Task<IResult> QueueAsync(int? limit, HttpContext context, CancellationToken ct) private static async Task<IResult> QueueAsync(
int? limit,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -110,7 +113,12 @@ public static class PipelineEndpoints
// GET /api/pipeline/rejected?q=&amp;offset=&amp;limit=: страница отсева (processing_routes.py L3442, list_rejected L246312). // GET /api/pipeline/rejected?q=&amp;offset=&amp;limit=: страница отсева (processing_routes.py L3442, list_rejected L246312).
// q — поиск по тексту/причине/фразе/имени канала (FTS LIKE, Ruling 6), пустой q — весь отсев свежими // q — поиск по тексту/причине/фразе/имени канала (FTS LIKE, Ruling 6), пустой q — весь отсев свежими
// первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}. // первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}.
private static async Task<IResult> RejectedAsync(string? q, int? offset, int? limit, HttpContext context, CancellationToken ct) private static async Task<IResult> RejectedAsync(
string? q,
int? offset,
int? limit,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -137,7 +145,10 @@ public static class PipelineEndpoints
// DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196198, Ruling 10). // DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196198, Ruling 10).
// Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»). // Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»).
private static async Task<IResult> DeleteAsync(string rejId, HttpContext context, CancellationToken ct) private static async Task<IResult> DeleteAsync(
string rejId,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -153,7 +164,11 @@ public static class PipelineEndpoints
// Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10); // Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10);
// причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с // причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с
// прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов). // прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов).
private static async Task<IResult> ReturnAsync(string rejId, ReturnReasonRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> ReturnAsync(
string rejId,
ReturnReasonRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -140,7 +140,10 @@ public static class TelegramEndpoints
} }
// POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147). // POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L6874; python L134147).
private static async Task<IResult> StartPhoneAsync(TgStartPhoneRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> StartPhoneAsync(
TgStartPhoneRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -209,7 +212,10 @@ public static class TelegramEndpoints
} }
// POST /api/tg/send-code: отправить SMS-код (tg_routes.py L8694; python submit_code L149166). // POST /api/tg/send-code: отправить SMS-код (tg_routes.py L8694; python submit_code L149166).
private static async Task<IResult> SendCodeAsync(TgSendCodeRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> SendCodeAsync(
TgSendCodeRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -235,7 +241,10 @@ public static class TelegramEndpoints
} }
// POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97103; python submit_password L168176). // POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97103; python submit_password L168176).
private static async Task<IResult> SendPasswordAsync(TgSendPasswordRequest body, HttpContext context, CancellationToken ct) private static async Task<IResult> SendPasswordAsync(
TgSendPasswordRequest body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -355,7 +364,10 @@ public static class TelegramEndpoints
} }
// POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125129; L548567). // POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125129; L548567).
private static async Task<IResult> MonitorAllAsync(TgMonitorBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> MonitorAllAsync(
TgMonitorBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -406,7 +418,11 @@ public static class TelegramEndpoints
} }
// POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139142; L536546). // POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139142; L536546).
private static async Task<IResult> SetMonitorAsync(string dialog_id, TgMonitorBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> SetMonitorAsync(
string dialog_id,
TgMonitorBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -439,7 +455,10 @@ public static class TelegramEndpoints
// POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145148; L349390). // POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145148; L349390).
// Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала. // Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала.
private static async Task<IResult> BackfillDialogAsync(string dialog_id, HttpContext context, CancellationToken ct) private static async Task<IResult> BackfillDialogAsync(
string dialog_id,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
@@ -459,7 +478,10 @@ public static class TelegramEndpoints
} }
// POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151153; dialog_messages L583620). // POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151153; dialog_messages L583620).
private static async Task<IResult> PreviewAsync(TgPreviewBody body, HttpContext context, CancellationToken ct) private static async Task<IResult> PreviewAsync(
TgPreviewBody body,
HttpContext context,
CancellationToken ct)
{ {
if (!context.HasUser()) if (!context.HasUser())
{ {
+4 -1
View File
@@ -96,7 +96,10 @@ public sealed class SseBroker
/// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param> /// <param name="tenantId">Тенант-получатель; без подписчиков — no-op, не падает.</param>
/// <param name="eventType">Тип события (new_card/toast этапа 3; api.js L7879).</param> /// <param name="eventType">Тип события (new_card/toast этапа 3; api.js L7879).</param>
/// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param> /// <param name="payload">Полезная нагрузка — сериализуется в JSON (camelCase, без \u).</param>
public void Publish(Guid tenantId, string eventType, object payload) => public void Publish(
Guid tenantId,
string eventType,
object payload) =>
Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions)));
/// <summary> /// <summary>
+10 -2
View File
@@ -28,7 +28,11 @@ public static class AuditAppender
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct) public static async Task AppendTenantAsync(
HttpContext context,
string eventType,
object? details,
CancellationToken ct)
{ {
CurrentUser? user = context.GetCurrentUser(); CurrentUser? user = context.GetCurrentUser();
if (user is null) if (user is null)
@@ -55,7 +59,11 @@ public static class AuditAppender
/// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param> /// <param name="eventType">Тип события — константа <see cref="AuditEvents"/>.</param>
/// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param> /// <param name="details">Минимальные детали события (обычно анонимный объект) или null.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct) public static async Task AppendOperatorAsync(
HttpContext context,
string eventType,
object? details,
CancellationToken ct)
{ {
CurrentOperator? operatorIdentity = context.GetCurrentOperator(); CurrentOperator? operatorIdentity = context.GetCurrentOperator();
if (operatorIdentity is null) if (operatorIdentity is null)
+16 -4
View File
@@ -64,7 +64,10 @@ public sealed class LoginAttemptGuard
/// <param name="options">Настройки rate limiting (секция RateLimit).</param> /// <param name="options">Настройки rate limiting (секция RateLimit).</param>
/// <param name="store">Хранилище счётчиков фиксированного окна (public.rate_limit_counters).</param> /// <param name="store">Хранилище счётчиков фиксированного окна (public.rate_limit_counters).</param>
/// <param name="clock">Источник «сейчас».</param> /// <param name="clock">Источник «сейчас».</param>
public LoginAttemptGuard(RateLimitOptions options, IRateLimitCounterStore store, Func<DateTimeOffset> clock) public LoginAttemptGuard(
RateLimitOptions options,
IRateLimitCounterStore store,
Func<DateTimeOffset> clock)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(store);
@@ -84,7 +87,10 @@ public sealed class LoginAttemptGuard
/// <param name="login">Нормализованный логин; пустой/пробельный — блокировке не подлежит.</param> /// <param name="login">Нормализованный логин; пустой/пробельный — блокировке не подлежит.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>true — следующий вход ключа отклоняется 429 до проверки учётных данных.</returns> /// <returns>true — следующий вход ключа отклоняется 429 до проверки учётных данных.</returns>
public async Task<bool> IsBlockedAsync(string? ip, string? login, CancellationToken ct) public async Task<bool> IsBlockedAsync(
string? ip,
string? login,
CancellationToken ct)
{ {
if (!_enabled || string.IsNullOrWhiteSpace(login)) if (!_enabled || string.IsNullOrWhiteSpace(login))
{ {
@@ -101,7 +107,10 @@ public sealed class LoginAttemptGuard
/// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param> /// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param>
/// <param name="login">Нормализованный логин; пустой/пробельный — не записывается.</param> /// <param name="login">Нормализованный логин; пустой/пробельный — не записывается.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public async Task RecordFailureAsync(string? ip, string? login, CancellationToken ct) public async Task RecordFailureAsync(
string? ip,
string? login,
CancellationToken ct)
{ {
if (!_enabled || string.IsNullOrWhiteSpace(login)) if (!_enabled || string.IsNullOrWhiteSpace(login))
{ {
@@ -118,7 +127,10 @@ public sealed class LoginAttemptGuard
/// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param> /// <param name="ip">IP клиента (null/пустой — фолбэк unknown).</param>
/// <param name="login">Логин успешно вошедшего (нормализованный).</param> /// <param name="login">Логин успешно вошедшего (нормализованный).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public async Task ResetAsync(string? ip, string? login, CancellationToken ct) public async Task ResetAsync(
string? ip,
string? login,
CancellationToken ct)
{ {
if (!_enabled || string.IsNullOrWhiteSpace(login)) if (!_enabled || string.IsNullOrWhiteSpace(login))
{ {
@@ -23,7 +23,10 @@ public static class SessionCookieWriter
/// <param name="context">Контекст запроса.</param> /// <param name="context">Контекст запроса.</param>
/// <param name="options">Настройки куки из конфигурации (секция Cookies).</param> /// <param name="options">Настройки куки из конфигурации (секция Cookies).</param>
/// <param name="rawToken">Raw-токен сессии.</param> /// <param name="rawToken">Raw-токен сессии.</param>
public static void Append(HttpContext context, CookieOptions options, string rawToken) public static void Append(
HttpContext context,
CookieOptions options,
string rawToken)
{ {
context.Response.Cookies.Append( context.Response.Cookies.Append(
options.Name, options.Name,
@@ -70,7 +70,10 @@ public sealed class HttpAccessLogMiddleware
// context: Контекст запроса (метод/путь/статус ответа). // context: Контекст запроса (метод/путь/статус ответа).
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp). // startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
// exception: Необработанное исключение (null — запрос завершился штатно). // exception: Необработанное исключение (null — запрос завершился штатно).
private void LogCall(HttpContext context, long startedAt, Exception? exception) private void LogCall(
HttpContext context,
long startedAt,
Exception? exception)
{ {
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
string method = context.Request.Method; string method = context.Request.Method;
@@ -30,9 +30,7 @@ public sealed class OperatorSessionMiddleware
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions; private readonly IOptionsMonitor<OperatorCookieOptions> _cookieOptions;
public OperatorSessionMiddleware( public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor<OperatorCookieOptions> cookieOptions)
RequestDelegate next,
IOptionsMonitor<OperatorCookieOptions> cookieOptions)
{ {
_next = next; _next = next;
_cookieOptions = cookieOptions; _cookieOptions = cookieOptions;
@@ -102,7 +102,10 @@ public static class RateLimitPolicies
// scope: Пространство ключей (имя политики или "global" для глобального лимитера). // scope: Пространство ключей (имя политики или "global" для глобального лимитера).
// permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute). // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute).
// Возвращает: Партиция лимитера с ключом tenant:{id} либо IP. // Возвращает: Партиция лимитера с ключом tenant:{id} либо IP.
private static RateLimitPartition<string> ApiPartition(HttpContext context, string scope, int permitsPerMinute) private static RateLimitPartition<string> ApiPartition(
HttpContext context,
string scope,
int permitsPerMinute)
{ {
string partitionKey = context.GetCurrentUser() is { } user string partitionKey = context.GetCurrentUser() is { } user
? TenantKeyPrefix + user.TenantId.ToString("N") ? TenantKeyPrefix + user.TenantId.ToString("N")
@@ -117,7 +120,10 @@ public static class RateLimitPolicies
// permitsPerMinute: Разрешено запросов в минуту. // permitsPerMinute: Разрешено запросов в минуту.
// Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций). // Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций).
private static RateLimitPartition<string> StorePartition( private static RateLimitPartition<string> StorePartition(
HttpContext context, string scope, string partitionKey, int permitsPerMinute) HttpContext context,
string scope,
string partitionKey,
int permitsPerMinute)
{ {
string storeKey = HttpKeyPrefix + scope + ":" + partitionKey; string storeKey = HttpKeyPrefix + scope + ":" + partitionKey;
return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter( return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter(
@@ -102,7 +102,9 @@ public sealed class TelegramBackfillScheduler(
// «Перечитать» всех включённых каналов в фоне (ReadRecentAsync продолжает при частичном падении). // «Перечитать» всех включённых каналов в фоне (ReadRecentAsync продолжает при частичном падении).
private static async Task RunReadRecentAsync( private static async Task RunReadRecentAsync(
IServiceProvider provider, ILogger<TelegramBackfillScheduler> runLogger, CancellationToken ct) IServiceProvider provider,
ILogger<TelegramBackfillScheduler> runLogger,
CancellationToken ct)
{ {
DialogsService dialogs = provider.GetRequiredService<DialogsService>(); DialogsService dialogs = provider.GetRequiredService<DialogsService>();
int count = await dialogs.ReadRecentAsync(ct); int count = await dialogs.ReadRecentAsync(ct);
@@ -79,7 +79,10 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
// context: Контекст вызова (метод). // context: Контекст вызова (метод).
// startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp). // startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp).
// statusCode: Итоговый gRPC-статус; null — успех (OK). // statusCode: Итоговый gRPC-статус; null — успех (OK).
private void LogCall(ServerCallContext context, long startedAt, StatusCode? statusCode) private void LogCall(
ServerCallContext context,
long startedAt,
StatusCode? statusCode)
{ {
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
_logger.LogInformation( _logger.LogInformation(
@@ -328,7 +328,10 @@ public sealed class TelegramIngressService(
// request: Сообщение PushMessage. // request: Сообщение PushMessage.
// ct: Токен отмены. // ct: Токен отмены.
private async Task SavePreviewSafelyAsync( private async Task SavePreviewSafelyAsync(
AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct) AsyncServiceScope tenantScope,
TenantRecordDto tenant,
PushMessageRequest request,
CancellationToken ct)
{ {
try try
{ {
@@ -356,7 +359,10 @@ public sealed class TelegramIngressService(
// tenantId: Тенант канала (реестровый Guid). // tenantId: Тенант канала (реестровый Guid).
// previous: Предыдущий снимок из KV (null — первый репорт). // previous: Предыдущий снимок из KV (null — первый репорт).
// current: Текущий снимок репорта. // current: Текущий снимок репорта.
private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current) private void PublishStatusEvents(
Guid tenantId,
TgReportedStatus? previous,
TgReportedStatus current)
{ {
broker.Publish(tenantId, SystemStatusEventType, current); broker.Publish(tenantId, SystemStatusEventType, current);
if (previous is null) if (previous is null)
@@ -379,7 +385,10 @@ public sealed class TelegramIngressService(
// tenantId: Тенант-получатель. // tenantId: Тенант-получатель.
// text: Текст тоста. // text: Текст тоста.
// icon: Иконка тоста (набор Icon.vue фронта). // icon: Иконка тоста (набор Icon.vue фронта).
private void PublishToast(Guid tenantId, string text, string icon) private void PublishToast(
Guid tenantId,
string text,
string icon)
{ {
broker.Publish(tenantId, ToastEventType, new { text, icon }); broker.Publish(tenantId, ToastEventType, new { text, icon });
} }
@@ -96,7 +96,10 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe
/// <param name="apiHash">api_hash приложения (непустой секрет).</param> /// <param name="apiHash">api_hash приложения (непустой секрет).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <exception cref="ArgumentException">Значения не прошли валидацию (см. <see cref="IsValidApiId"/>/<see cref="IsValidApiHash"/>).</exception> /// <exception cref="ArgumentException">Значения не прошли валидацию (см. <see cref="IsValidApiId"/>/<see cref="IsValidApiHash"/>).</exception>
public async Task SaveAsync(string apiId, string apiHash, CancellationToken ct) public async Task SaveAsync(
string apiId,
string apiHash,
CancellationToken ct)
{ {
string trimmedApiId = (apiId ?? string.Empty).Trim(); string trimmedApiId = (apiId ?? string.Empty).Trim();
string trimmedApiHash = (apiHash ?? string.Empty).Trim(); string trimmedApiHash = (apiHash ?? string.Empty).Trim();
@@ -43,5 +43,8 @@ public interface IAiTools
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Решение fit + краткая причина (потолок причины 200, как _AI_REASON_LIMIT L43).</returns> /// <returns>Решение fit + краткая причина (потолок причины 200, как _AI_REASON_LIMIT L43).</returns>
public Task<AiEvaluateFitResultDto> EvaluateFitAsync( public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct); string text,
string description,
IReadOnlyCollection<string> keywords,
CancellationToken ct);
} }
@@ -39,7 +39,11 @@ public interface IFileStorage
/// <param name="contentType">MIME-тип (например, image/png); может быть пустым.</param> /// <param name="contentType">MIME-тип (например, image/png); может быть пустым.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Сохранённый objectKey (как передан).</returns> /// <returns>Сохранённый objectKey (как передан).</returns>
public Task<string> PutAsync(string objectKey, Stream content, string contentType, CancellationToken ct); public Task<string> PutAsync(
string objectKey,
Stream content,
string contentType,
CancellationToken ct);
/// <summary> /// <summary>
/// Возвращает содержимое объекта потоком (get object_store.py L8293). /// Возвращает содержимое объекта потоком (get object_store.py L8293).
@@ -51,5 +51,9 @@ public interface IMlClient
/// <param name="delta">Вес сигнала: 1.0 — действие пользователя, −1.0 — снять метку; ИИ-сигналы (0.4/0.6) — этапы 4/6.</param> /// <param name="delta">Вес сигнала: 1.0 — действие пользователя, −1.0 — снять метку; ИИ-сигналы (0.4/0.6) — этапы 4/6.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Задача завершается после записи строки в outbox (отправка в ML-сервис — фоновый воркер этапа 6).</returns> /// <returns>Задача завершается после записи строки в outbox (отправка в ML-сервис — фоновый воркер этапа 6).</returns>
public Task PushAsync(string text, string label, double delta, CancellationToken ct); public Task PushAsync(
string text,
string label,
double delta,
CancellationToken ct);
} }
@@ -31,7 +31,11 @@ public interface ITelegramGateway
/// <param name="apiHash">api_hash приложения Telegram (глобальные ключи, задаёт оператор).</param> /// <param name="apiHash">api_hash приложения Telegram (глобальные ключи, задаёт оператор).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Новая фаза ("code"); нет ключей — ядро отвечает 400 «Ключи Telegram не заданы оператором» до вызова.</returns> /// <returns>Новая фаза ("code"); нет ключей — ядро отвечает 400 «Ключи Telegram не заданы оператором» до вызова.</returns>
public Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct); public Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct);
/// <summary> /// <summary>
/// Начать QR-вход (qr_start python L286300). /// Начать QR-вход (qr_start python L286300).
@@ -40,7 +44,10 @@ public interface ITelegramGateway
/// <param name="apiHash">api_hash приложения Telegram (глобальные ключи, задаёт оператор).</param> /// <param name="apiHash">api_hash приложения Telegram (глобальные ключи, задаёт оператор).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Фаза + qrUrl; аккаунт уже авторизован — фаза "ready", qrUrl пуст.</returns> /// <returns>Фаза + qrUrl; аккаунт уже авторизован — фаза "ready", qrUrl пуст.</returns>
public Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct); public Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct);
/// <summary> /// <summary>
/// Отправить SMS-код (submit_code python L149166). /// Отправить SMS-код (submit_code python L149166).
@@ -79,7 +86,10 @@ public interface ITelegramGateway
/// <param name="enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param> /// <param name="enabled">True — мониторить (сообщения → PushMessage в ядро), false — выключить.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Завершается после обновления зеркала сервиса.</returns> /// <returns>Завершается после обновления зеркала сервиса.</returns>
public Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct); public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct);
/// <summary> /// <summary>
/// Мониторинг всех диалогов сразу (set_monitor_all python L548567): зеркало сервиса = каталог. /// Мониторинг всех диалогов сразу (set_monitor_all python L548567): зеркало сервиса = каталог.
@@ -96,7 +106,10 @@ public interface ITelegramGateway
/// <param name="force">True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).</param> /// <param name="force">True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Сколько сообщений отправлено в ядро потоком PushMessage.</returns> /// <returns>Сколько сообщений отправлено в ядро потоком PushMessage.</returns>
public Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct); public Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct);
/// <summary> /// <summary>
/// Последние сообщения диалога для превью (dialog_messages python L583620), свежие из Telegram. /// Последние сообщения диалога для превью (dialog_messages python L583620), свежие из Telegram.
@@ -105,7 +118,10 @@ public interface ITelegramGateway
/// <param name="limit">Сколько последних сообщений (1..50; api-map /dialogs/preview).</param> /// <param name="limit">Сколько последних сообщений (1..50; api-map /dialogs/preview).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Сообщения от новых к старым; признак lead и фолбэк на БД добавляет ядро (Ruling 7).</returns> /// <returns>Сообщения от новых к старым; признак lead и фолбэк на БД добавляет ядро (Ruling 7).</returns>
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct); public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct);
/// <summary> /// <summary>
/// Глобальный поиск каналов/групп по ключу (discovery_search python L624664). /// Глобальный поиск каналов/групп по ключу (discovery_search python L624664).
@@ -114,7 +130,10 @@ public interface ITelegramGateway
/// <param name="limit">Верхняя граница результатов (прототип: default 30).</param> /// <param name="limit">Верхняя граница результатов (прототип: default 30).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро — Ruling 10).</returns> /// <returns>Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро — Ruling 10).</returns>
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct); public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct);
/// <summary> /// <summary>
/// Инфо об источнике для оценки (discovery_info python L666716). /// Инфо об источнике для оценки (discovery_info python L666716).
@@ -131,7 +150,10 @@ public interface ITelegramGateway
/// <param name="limit">Размер выборки (прототип discovery_read: limit сообщений/тем).</param> /// <param name="limit">Размер выборки (прототип discovery_read: limit сообщений/тем).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>ok + сообщения (форумы — по активным темам) либо ok=false + error="no_history".</returns> /// <returns>ok + сообщения (форумы — по активным темам) либо ok=false + error="no_history".</returns>
public Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct); public Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct);
/// <summary> /// <summary>
/// Вступить в канал/группу по @username (discovery_join python L818839). /// Вступить в канал/группу по @username (discovery_join python L818839).
@@ -89,7 +89,10 @@ public sealed class MtlsCertificates
/// <param name="certificate">Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен).</param> /// <param name="certificate">Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен).</param>
/// <param name="chain">Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).</param> /// <param name="chain">Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).</param>
/// <param name="sslPolicyErrors">Ошибки стандартной проверки TLS.</param> /// <param name="sslPolicyErrors">Ошибки стандартной проверки TLS.</param>
public bool ValidateClientCertificate(X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) public bool ValidateClientCertificate(
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{ {
if (certificate is null) if (certificate is null)
{ {
@@ -132,7 +135,11 @@ public sealed class MtlsCertificates
// certificate: Сертификат сервера из рукопожатия. // certificate: Сертификат сервера из рукопожатия.
// chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). // chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).
// sslPolicyErrors: Ошибки стандартной проверки TLS. // sslPolicyErrors: Ошибки стандартной проверки TLS.
private bool ValidateServerCertificate(object? sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) private bool ValidateServerCertificate(
object? sender,
X509Certificate? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{ {
if (certificate is null) if (certificate is null)
{ {
@@ -222,7 +229,10 @@ public sealed class MtlsCertificates
// configuredPath: Путь из env. // configuredPath: Путь из env.
// envKey: Env-ключ пути (для сообщения об ошибке). // envKey: Env-ключ пути (для сообщения об ошибке).
// role: Роль сертификата (для сообщения об ошибке). // role: Роль сертификата (для сообщения об ошибке).
private static string RequireExistingFile(string configuredPath, string envKey, string role) private static string RequireExistingFile(
string configuredPath,
string envKey,
string role)
{ {
if (string.IsNullOrWhiteSpace(configuredPath)) if (string.IsNullOrWhiteSpace(configuredPath))
{ {
@@ -179,7 +179,11 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
// ok: Результат подключения. // ok: Результат подключения.
// message: Сообщение ветки. // message: Сообщение ветки.
// Возвращает: DTO ответа (наружу — camelCase). // Возвращает: DTO ответа (наружу — camelCase).
private static AiCheckResultDto BuildResult(AiCheckRequest request, string name, bool ok, string message) private static AiCheckResultDto BuildResult(
AiCheckRequest request,
string name,
bool ok,
string message)
{ {
return new AiCheckResultDto( return new AiCheckResultDto(
Ok: ok, Ok: ok,
@@ -216,7 +220,10 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый). // apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
// modelsUri: URL списка моделей (валиден только при возврате true). // modelsUri: URL списка моделей (валиден только при возврате true).
// Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт). // Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт).
private static bool TryBuildModelsUri(string baseUrl, string? apiStyle, [NotNullWhen(true)] out Uri? modelsUri) private static bool TryBuildModelsUri(
string baseUrl,
string? apiStyle,
[NotNullWhen(true)] out Uri? modelsUri)
{ {
modelsUri = null; modelsUri = null;
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed) if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed)
@@ -241,7 +248,10 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
// httpRequest: Запрос списка моделей. // httpRequest: Запрос списка моделей.
// apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена). // apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена).
// apiStyle: Стиль API провайдера. // apiStyle: Стиль API провайдера.
private static void AddAuthHeaders(HttpRequestMessage httpRequest, string apiKey, string? apiStyle) private static void AddAuthHeaders(
HttpRequestMessage httpRequest,
string apiKey,
string? apiStyle)
{ {
if (apiStyle == AnthropicApiStyle) if (apiStyle == AnthropicApiStyle)
{ {
@@ -98,7 +98,10 @@ public sealed class BudgetedAiTools : IAiTools
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync( public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct) string text,
string description,
IReadOnlyCollection<string> keywords,
CancellationToken ct)
{ {
BudgetStateDto state = await GateStateAsync(ct); BudgetStateDto state = await GateStateAsync(ct);
if (state.Allowed) if (state.Allowed)
@@ -128,7 +128,10 @@ public sealed class GrpcAiTools : IAiTools
/// <inheritdoc /> /// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync( public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct) string text,
string description,
IReadOnlyCollection<string> keywords,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -210,7 +210,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task PushAsync(string text, string label, double delta, CancellationToken ct) public async Task PushAsync(
string text,
string label,
double delta,
CancellationToken ct)
{ {
// Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами // Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами
// делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L67). // делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L67).
@@ -332,7 +336,10 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
// deadline: Лимит времени вызова. // deadline: Лимит времени вызова.
// ct: Токен отмены вызова. // ct: Токен отмены вызова.
// Возвращает: Опции вызова с заголовками, deadline и отменой. // Возвращает: Опции вызова с заголовками, deadline и отменой.
private CallOptions CallOptions(string tenantId, TimeSpan deadline, CancellationToken ct) private CallOptions CallOptions(
string tenantId,
TimeSpan deadline,
CancellationToken ct)
=> new( => new(
headers: _connection.CreateMetadata(tenantId), headers: _connection.CreateMetadata(tenantId),
deadline: DateTime.UtcNow.Add(deadline), deadline: DateTime.UtcNow.Add(deadline),
@@ -102,7 +102,11 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct) public async Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -121,7 +125,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct) public async Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -211,7 +218,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct) public async Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -245,7 +255,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct) public async Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -263,7 +276,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct) public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -283,7 +299,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct) public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -327,7 +346,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct) public async Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
{ {
TenantId tenantId = RequireTenant(); TenantId tenantId = RequireTenant();
try try
@@ -404,7 +426,9 @@ public sealed class GrpcTelegramClient : ITelegramGateway
// call: Вызов клиента (принимает клиент и CallOptions). // call: Вызов клиента (принимает клиент и CallOptions).
// Возвращает: Ответ RPC. // Возвращает: Ответ RPC.
private async Task<TReply> CallAsync<TReply>( private async Task<TReply> CallAsync<TReply>(
TenantId tenantId, TimeSpan deadline, CancellationToken ct, TenantId tenantId,
TimeSpan deadline,
CancellationToken ct,
Func<TelegramService.TelegramServiceClient, CallOptions, AsyncUnaryCall<TReply>> call) Func<TelegramService.TelegramServiceClient, CallOptions, AsyncUnaryCall<TReply>> call)
where TReply : class where TReply : class
{ {
@@ -424,7 +448,10 @@ public sealed class GrpcTelegramClient : ITelegramGateway
// tenantId: Id тенанта (лог). // tenantId: Id тенанта (лог).
// operation: Имя RPC (лог-аудит). // operation: Имя RPC (лог-аудит).
// Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException. // Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException.
private Exception TranslateTransportFailure(Exception exception, TenantId tenantId, string operation) private Exception TranslateTransportFailure(
Exception exception,
TenantId tenantId,
string operation)
{ {
// Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно). // Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно).
if (exception is OperationCanceledException) if (exception is OperationCanceledException)
@@ -31,6 +31,9 @@ public sealed class LocalAiTools : IAiTools
/// <inheritdoc /> /// <inheritdoc />
public Task<AiEvaluateFitResultDto> EvaluateFitAsync( public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct) string text,
string description,
IReadOnlyCollection<string> keywords,
CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage); => throw new NotSupportedException(NotSupportedMessage);
} }
@@ -103,7 +103,11 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task PushAsync(string text, string label, double delta, CancellationToken ct) public async Task PushAsync(
string text,
string label,
double delta,
CancellationToken ct)
{ {
// Обучение гарантированно и локально (ml_client.push L4049): действие пользователя — строка // Обучение гарантированно и локально (ml_client.push L4049): действие пользователя — строка
// очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label, // очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label,
@@ -34,11 +34,18 @@ public sealed class LocalTelegramGateway : ITelegramGateway
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct) public Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct) public Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null)); => Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc /> /// <inheritdoc />
@@ -55,20 +62,32 @@ public sealed class LocalTelegramGateway : ITelegramGateway
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc /> /// <inheritdoc />
public Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct) => Task.CompletedTask; public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> /// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask; public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc /> /// <inheritdoc />
public Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct) => Task.FromResult(0); public Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct) public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]); => Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct) public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]); => Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc /> /// <inheritdoc />
@@ -76,7 +95,10 @@ public sealed class LocalTelegramGateway : ITelegramGateway
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false)); => Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
/// <inheritdoc /> /// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct) public Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", [])); => Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc /> /// <inheritdoc />
@@ -35,7 +35,12 @@ internal static class MlOutboxQueue
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param> /// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Задача завершается после записи строки (отправку делает фоновый флашер).</returns> /// <returns>Задача завершается после записи строки (отправку делает фоновый флашер).</returns>
public static async Task PushAsync(IMlLearningStore learningStore, string text, string label, double delta, CancellationToken ct) public static async Task PushAsync(
IMlLearningStore learningStore,
string text,
string label,
double delta,
CancellationToken ct)
{ {
string trimmedText = (text ?? string.Empty).Trim(); string trimmedText = (text ?? string.Empty).Trim();
string trimmedLabel = (label ?? string.Empty).Trim(); string trimmedLabel = (label ?? string.Empty).Trim();
@@ -91,7 +91,10 @@ public sealed class MlStatusCache
/// <param name="tenantId">Id тенанта (формат N).</param> /// <param name="tenantId">Id тенанта (формат N).</param>
/// <param name="service">Статус модели.</param> /// <param name="service">Статус модели.</param>
/// <param name="reachable">Доступность сервиса.</param> /// <param name="reachable">Доступность сервиса.</param>
public void Set(string tenantId, MlServiceStatusDto service, bool reachable) public void Set(
string tenantId,
MlServiceStatusDto service,
bool reachable)
{ {
_entries[tenantId] = new Snapshot(service, reachable, _utcNow().ToUnixTimeMilliseconds()); _entries[tenantId] = new Snapshot(service, reachable, _utcNow().ToUnixTimeMilliseconds());
} }
@@ -89,7 +89,11 @@ public sealed class TokenUsageRecorder
/// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param> /// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param>
/// <param name="model">Модель провайдера (событие истории).</param> /// <param name="model">Модель провайдера (событие истории).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public async Task AddAsync(Usage? usage, string provider, string model, CancellationToken ct) public async Task AddAsync(
Usage? usage,
string provider,
string model,
CancellationToken ct)
{ {
if (usage is null) if (usage is null)
{ {
@@ -124,7 +128,11 @@ public sealed class TokenUsageRecorder
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param> /// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns> /// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
public async Task<long> AddEstimatedAsync(string? text, string provider, string model, CancellationToken ct) public async Task<long> AddEstimatedAsync(
string? text,
string provider,
string model,
CancellationToken ct)
{ {
long promptTokens = EstimateTokens(text); long promptTokens = EstimateTokens(text);
// Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка, // Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка,
@@ -47,7 +47,11 @@ public sealed class LocalFileStorage : IFileStorage
public override string ToString() => $"LocalFileStorage (root: {_rootPath})"; public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> PutAsync(string objectKey, Stream content, string contentType, CancellationToken ct) public async Task<string> PutAsync(
string objectKey,
Stream content,
string contentType,
CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
@@ -83,7 +83,11 @@ public sealed class MinioFileStorage : IFileStorage
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> PutAsync(string objectKey, Stream content, string contentType, CancellationToken ct) public async Task<string> PutAsync(
string objectKey,
Stream content,
string contentType,
CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
@@ -105,7 +105,10 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken ct) public async Task UpdatePasswordHashAsync(
Guid userId,
string passwordHash,
CancellationToken ct)
{ {
await dbContext.Users await dbContext.Users
.Where(u => u.Id == userId) .Where(u => u.Id == userId)
@@ -18,7 +18,11 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct) public async Task UpsertBlacklistAsync(
string dialogId,
string name,
string reason,
CancellationToken ct)
{ {
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
@@ -19,7 +19,10 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct) public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(
string taskId,
string? status,
CancellationToken ct)
{ {
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId); IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
if (status is not null) if (status is not null)
@@ -78,7 +81,10 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct) public async Task<bool> PatchCandidateAsync(
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -94,7 +100,10 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct) public async Task<bool> SetCandidateStatusAsync(
string dialogId,
string status,
CancellationToken ct)
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -110,7 +119,10 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct) public async Task<bool> SetCandidateJoinedAsync(
string dialogId,
bool autoJoined,
CancellationToken ct)
{ {
DiscCandidateEntity? row = await _dbContext.DiscCandidates DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -18,7 +18,12 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore public sealed partial class DiscoveryStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct) public async Task AddLogAsync(
string logId,
string taskId,
string logEvent,
string text,
CancellationToken ct)
{ {
_dbContext.DiscLog.Add(new DiscLogEntity _dbContext.DiscLog.Add(new DiscLogEntity
{ {
@@ -32,14 +37,20 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct) public async Task<int> CountLogEventAsync(
string logEvent,
DateTimeOffset sinceUtc,
CancellationToken ct)
{ {
// ban_guard.joins_today_auto L2935: число событий лога по типу с начала UTC-суток (счётчик квоты). // ban_guard.joins_today_auto L2935: число событий лога по типу с начала UTC-суток (счётчик квоты).
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct); return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct) public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(
string taskId,
int limit,
CancellationToken ct)
{ {
List<DiscLogEntity> rows = await _dbContext.DiscLog List<DiscLogEntity> rows = await _dbContext.DiscLog
.AsNoTracking() .AsNoTracking()
@@ -61,7 +61,10 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct) public async Task<bool> PatchTaskAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -95,7 +98,10 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct) public async Task<bool> SetTaskRunningAsync(
string taskId,
bool resetProgress,
CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -155,7 +161,11 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct) public async Task<bool> BumpTaskCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -189,7 +199,11 @@ public sealed partial class DiscoveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct) public async Task<bool> AdvanceSearchAsync(
string taskId,
int nextIndex,
bool searchDone,
CancellationToken ct)
{ {
DiscTaskEntity? row = await _dbContext.DiscTasks DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct); .FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -29,7 +29,10 @@ public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettin
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetAsync(string key, string valueJson, CancellationToken ct) public async Task SetAsync(
string key,
string valueJson,
CancellationToken ct)
{ {
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
GlobalSettingEntity? entity = await dbContext.GlobalSettings GlobalSettingEntity? entity = await dbContext.GlobalSettings
@@ -57,7 +57,11 @@ public sealed class InviteStore(DealDbContext dbContext) : IInviteStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> UpdateStatusAsync(string code, string status, DateTimeOffset? activatedAt, CancellationToken ct) public async Task<bool> UpdateStatusAsync(
string code,
string status,
DateTimeOffset? activatedAt,
CancellationToken ct)
{ {
var entity = await dbContext.Invites.SingleOrDefaultAsync(i => i.Code == code, ct); var entity = await dbContext.Invites.SingleOrDefaultAsync(i => i.Code == code, ct);
if (entity is null) if (entity is null)
@@ -72,7 +76,10 @@ public sealed class InviteStore(DealDbContext dbContext) : IInviteStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> TryActivateAsync(string code, DateTimeOffset activatedAt, CancellationToken ct) public async Task<bool> TryActivateAsync(
string code,
DateTimeOffset activatedAt,
CancellationToken ct)
{ {
// CAS (Task 6): атомарный условный UPDATE — pending → activated только если статус всё ещё pending, // CAS (Task 6): атомарный условный UPDATE — pending → activated только если статус всё ещё pending,
// иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL). // иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL).
@@ -39,7 +39,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string q, int limit, CancellationToken ct) public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct)
{ {
string query = q.Trim(); string query = q.Trim();
if (query.Length == 0) if (query.Length == 0)
@@ -87,7 +90,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<CardDto?> GetCardBySourceAsync(string dialogId, long msgId, CancellationToken ct) public async Task<CardDto?> GetCardBySourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
{ {
if (string.IsNullOrEmpty(dialogId)) if (string.IsNullOrEmpty(dialogId))
{ {
@@ -192,7 +198,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateSeenAsync(string? cardId, string? col, CancellationToken ct) public async Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{ {
// Три режима leads.py mark_seen L250256: одна карточка | колонка | все карточки. // Три режима leads.py mark_seen L250256: одна карточка | колонка | все карточки.
IQueryable<CardEntity> queryable = _dbContext.Cards; IQueryable<CardEntity> queryable = _dbContext.Cards;
@@ -33,7 +33,12 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct) public async Task AddCommentAsync(
string commentId,
string cardId,
string by,
string text,
CancellationToken ct)
{ {
// CreatedAt — UTC-now (leads.py add_comment L259265). Целостность ссылки на карточку держит FK: // CreatedAt — UTC-now (leads.py add_comment L259265). Целостность ссылки на карточку держит FK:
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
@@ -93,7 +93,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task ReorderContainersAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct) public async Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
{ {
// Позиции 0..N-1 — одна транзакция: при сбое середины порядок не остаётся частично проставленным. // Позиции 0..N-1 — одна транзакция: при сбое середины порядок не остаётся частично проставленным.
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
@@ -33,7 +33,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> PatchCardAsync(string cardId, CardPatch patch, CancellationToken ct) public async Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct)
{ {
// Точечная правка по присутствующим полям патча: null-поле не меняется, JSON-поля заменяются // Точечная правка по присутствующим полям патча: null-поле не меняется, JSON-поля заменяются
// целиком, в конце — bump UpdatedAt (patch_card projects.py L159187). // целиком, в конце — bump UpdatedAt (patch_card projects.py L159187).
@@ -71,7 +74,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AddLinkAsync(string cardId, CardLinkDto link, CancellationToken ct) public async Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct)
{ {
// Один UPDATE — jsonb-append в конец массива links (add_link L133143 + bump UpdatedAt). // Один UPDATE — jsonb-append в конец массива links (add_link L133143 + bump UpdatedAt).
return await ExecuteJsonMutationAsync( return await ExecuteJsonMutationAsync(
@@ -86,7 +92,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct) public async Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{ {
// Один UPDATE — jsonb-фильтрация массива по id элемента (remove_link L146150 + bump UpdatedAt). // Один UPDATE — jsonb-фильтрация массива по id элемента (remove_link L146150 + bump UpdatedAt).
return await ExecuteJsonMutationAsync( return await ExecuteJsonMutationAsync(
@@ -105,7 +114,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> AddFileAsync(string cardId, CardFileDto file, CancellationToken ct) public async Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct)
{ {
// Один UPDATE — jsonb-append в конец массива files (files.py add_file L5775 + bump UpdatedAt). // Один UPDATE — jsonb-append в конец массива files (files.py add_file L5775 + bump UpdatedAt).
return await ExecuteJsonMutationAsync( return await ExecuteJsonMutationAsync(
@@ -120,7 +132,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> RemoveFileAsync(string cardId, string fileId, CancellationToken ct) public async Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{ {
// Один UPDATE — jsonb-фильтрация массива files по id записи (files.py remove_file L8694 + bump). // Один UPDATE — jsonb-фильтрация массива files по id записи (files.py remove_file L8694 + bump).
return await ExecuteJsonMutationAsync( return await ExecuteJsonMutationAsync(
@@ -140,7 +155,11 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> MoveCardStageAsync( public async Task<bool> MoveCardStageAsync(
string cardId, string containerId, CardHistoryDto historyEntry, long atMs, CancellationToken ct) string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct)
{ {
// Смена контейнера-стадии: Col=containerId, сброс напоминания, updated_at=atMs, история + запись // Смена контейнера-стадии: Col=containerId, сброс напоминания, updated_at=atMs, история + запись
// (move_stage projects.py L202216; Ruling 7). Нетрекинговое чтение + ExecuteUpdate. // (move_stage projects.py L202216; Ruling 7). Нетрекинговое чтение + ExecuteUpdate.
@@ -169,7 +188,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetReminderAsync(string cardId, long atMs, CancellationToken ct) public async Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
{ {
await _dbContext.Cards await _dbContext.Cards
.Where(card => card.Id == cardId) .Where(card => card.Id == cardId)
@@ -41,7 +41,10 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct) public async Task<int> ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct)
{ {
// Автоархив тика пачкой (tick_storage L462473): один UPDATE вместо N по-карточных — как по-карточный // Автоархив тика пачкой (tick_storage L462473): один UPDATE вместо N по-карточных — как по-карточный
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто; // UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
@@ -121,7 +124,12 @@ public sealed partial class KanbanStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct) public async Task UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
string convCur,
CancellationToken ct)
{ {
// Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята.
await _dbContext.Cards await _dbContext.Cards
@@ -29,7 +29,12 @@ public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStor
public Task<int> CountOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.CountAsync(ct); public Task<int> CountOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.CountAsync(ct);
/// <inheritdoc /> /// <inheritdoc />
public async Task AddOutboxAsync(string id, string text, string label, double delta, CancellationToken ct) public async Task AddOutboxAsync(
string id,
string text,
string label,
double delta,
CancellationToken ct)
{ {
dbContext.MlOutbox.Add(new MlOutboxEntity dbContext.MlOutbox.Add(new MlOutboxEntity
{ {
@@ -36,7 +36,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
// ── Очередь (QueueItems) ─────────────────────────────────────────────── // ── Очередь (QueueItems) ───────────────────────────────────────────────
/// <inheritdoc /> /// <inheritdoc />
public Task<bool> ExistsDuplicateAsync(string dialogId, long? msgId, CancellationToken ct) public Task<bool> ExistsDuplicateAsync(
string dialogId,
long? msgId,
CancellationToken ct)
{ {
// Дубль-гвард приёма (Ruling 2, enqueue L7077): msgId null — проверять нечего. // Дубль-гвард приёма (Ruling 2, enqueue L7077): msgId null — проверять нечего.
return msgId is null return msgId is null
@@ -52,7 +55,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<QueueItemDto>> ListAsync(string? status, int limit, CancellationToken ct) public async Task<IReadOnlyList<QueueItemDto>> ListAsync(
string? status,
int limit,
CancellationToken ct)
{ {
IQueryable<QueueItemEntity> queryable = dbContext.QueueItems.AsNoTracking(); IQueryable<QueueItemEntity> queryable = dbContext.QueueItems.AsNoTracking();
if (status is not null) if (status is not null)
@@ -72,7 +78,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
=> dbContext.QueueItems.CountAsync(item => item.Status == status, ct); => dbContext.QueueItems.CountAsync(item => item.Status == status, ct);
/// <inheritdoc /> /// <inheritdoc />
public async Task SetStatusAsync(string id, string status, CancellationToken ct) public async Task SetStatusAsync(
string id,
string status,
CancellationToken ct)
{ {
// Смена статуса строки очереди (new → filtered в воркере): UpdatedAt = UTC-now (порт). // Смена статуса строки очереди (new → filtered в воркере): UpdatedAt = UTC-now (порт).
await dbContext.QueueItems await dbContext.QueueItems
@@ -131,7 +140,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(int offset, int limit, CancellationToken ct) public async Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(
int offset,
int limit,
CancellationToken ct)
{ {
List<RejectedItemEntity> entities = await dbContext.RejectedItems List<RejectedItemEntity> entities = await dbContext.RejectedItems
.AsNoTracking() .AsNoTracking()
@@ -143,7 +155,11 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<RejectedItemDto>> SearchAsync(string q, int limitFts, int limitLike, CancellationToken ct) public async Task<IReadOnlyList<RejectedItemDto>> SearchAsync(
string q,
int limitFts,
int limitLike,
CancellationToken ct)
{ {
string query = q.Trim(); string query = q.Trim();
if (query.Length == 0) if (query.Length == 0)
@@ -231,7 +247,11 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task MarkReturnedAsync(string id, string reason, DateTimeOffset returnedAt, CancellationToken ct) public async Task MarkReturnedAsync(
string id,
string reason,
DateTimeOffset returnedAt,
CancellationToken ct)
{ {
// Возврат из отсева: аудит-поля записи, строка не удаляется (return_to_queue L156159). // Возврат из отсева: аудит-поля записи, строка не удаляется (return_to_queue L156159).
await dbContext.RejectedItems await dbContext.RejectedItems
@@ -277,7 +297,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task LinkAsync(string hash, string cardId, CancellationToken ct) public async Task LinkAsync(
string hash,
string cardId,
CancellationToken ct)
{ {
// Связь заявки с созданной карточкой (Ruling 4, порядок AddCard → LinkDedup L512513). // Связь заявки с созданной карточкой (Ruling 4, порядок AddCard → LinkDedup L512513).
await dbContext.DedupEntries await dbContext.DedupEntries
@@ -46,7 +46,11 @@ public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitC
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> IncrementAsync( public async Task<int> IncrementAsync(
string key, DateTimeOffset windowStart, DateTimeOffset windowEnd, int amount, CancellationToken ct) string key,
DateTimeOffset windowStart,
DateTimeOffset windowEnd,
int amount,
CancellationToken ct)
{ {
ArgumentException.ThrowIfNullOrEmpty(key); ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentOutOfRangeException.ThrowIfNegative(amount); ArgumentOutOfRangeException.ThrowIfNegative(amount);
@@ -94,7 +98,11 @@ public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitC
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Значение счётчика после операции. // Возвращает: Значение счётчика после операции.
private async Task<int> IncrementNpgsqlAsync( private async Task<int> IncrementNpgsqlAsync(
string key, DateTimeOffset windowStart, DateTimeOffset windowEnd, int amount, CancellationToken ct) string key,
DateTimeOffset windowStart,
DateTimeOffset windowEnd,
int amount,
CancellationToken ct)
{ {
DbConnection connection = dbContext.Database.GetDbConnection(); DbConnection connection = dbContext.Database.GetDbConnection();
bool openedHere = connection.State != ConnectionState.Open; bool openedHere = connection.State != ConnectionState.Open;
@@ -127,7 +135,10 @@ public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitC
// command: Команда upsert. // command: Команда upsert.
// name: Имя параметра (с префиксом @). // name: Имя параметра (с префиксом @).
// value: Значение. // value: Значение.
private static void AddParameter(DbCommand command, string name, object value) private static void AddParameter(
DbCommand command,
string name,
object value)
{ {
DbParameter parameter = command.CreateParameter(); DbParameter parameter = command.CreateParameter();
parameter.ParameterName = name; parameter.ParameterName = name;
@@ -136,7 +147,10 @@ public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitC
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> GetCountAsync(string key, DateTimeOffset windowStart, CancellationToken ct) public async Task<int> GetCountAsync(
string key,
DateTimeOffset windowStart,
CancellationToken ct)
{ {
ArgumentException.ThrowIfNullOrEmpty(key); ArgumentException.ThrowIfNullOrEmpty(key);
@@ -41,7 +41,10 @@ public sealed class SettingsStore(TenantDbContext dbContext) : ISettingsStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task SetAsync(string key, string valueJson, CancellationToken ct) public async Task SetAsync(
string key,
string valueJson,
CancellationToken ct)
{ {
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
TenantSettingEntity? entity = await dbContext.Settings TenantSettingEntity? entity = await dbContext.Settings
@@ -22,7 +22,9 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<int> SyncFromTelegramAsync( public async Task<int> SyncFromTelegramAsync(
IReadOnlyCollection<TelegramDialogEntryDto> entries, bool autoMonitorNew, CancellationToken ct) IReadOnlyCollection<TelegramDialogEntryDto> entries,
bool autoMonitorNew,
CancellationToken ct)
{ {
if (entries.Count == 0) if (entries.Count == 0)
{ {
@@ -95,7 +97,10 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
} }
/// <inheritdoc /> /// <inheritdoc />
public Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct) public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{ {
return dbContext.Dialogs return dbContext.Dialogs
.Where(dialog => dialog.Id == dialogId) .Where(dialog => dialog.Id == dialogId)
@@ -148,7 +153,12 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
/// <inheritdoc /> /// <inheritdoc />
public async Task UpsertDiscoveredMonitoredAsync( public async Task UpsertDiscoveredMonitoredAsync(
string dialogId, string name, string handle, string kind, string hue, CancellationToken ct) string dialogId,
string name,
string handle,
string kind,
string hue,
CancellationToken ct)
{ {
// 1:1 python add_dialog_monitored L850873 (INSERT/ON CONFLICT DO UPDATE): строка каталога после // 1:1 python add_dialog_monitored L850873 (INSERT/ON CONFLICT DO UPDATE): строка каталога после
// discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит // discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит
@@ -185,7 +195,11 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
/// <inheritdoc /> /// <inheritdoc />
public async Task SavePreviewAsync( public async Task SavePreviewAsync(
string messageId, string dialogId, string text, DateTimeOffset msgAt, CancellationToken ct) string messageId,
string dialogId,
string text,
DateTimeOffset msgAt,
CancellationToken ct)
{ {
bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct); bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct);
if (exists) if (exists)
@@ -205,7 +219,11 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
} }
/// <inheritdoc /> /// <inheritdoc />
public Task TouchDialogLastAsync(string dialogId, string text, DateTimeOffset at, CancellationToken ct) public Task TouchDialogLastAsync(
string dialogId,
string text,
DateTimeOffset at,
CancellationToken ct)
{ {
// python _on_message L270273: last_text/last_at/updated_at одним моментом приёма. // python _on_message L270273: last_text/last_at/updated_at одним моментом приёма.
return dbContext.Dialogs return dbContext.Dialogs
@@ -219,7 +237,10 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(string dialogId, int limit, CancellationToken ct) public async Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(
string dialogId,
int limit,
CancellationToken ct)
{ {
List<TgMessageEntity> rows = await dbContext.TgMessages List<TgMessageEntity> rows = await dbContext.TgMessages
.Where(message => message.DialogId == dialogId) .Where(message => message.DialogId == dialogId)
@@ -75,7 +75,9 @@ public sealed class TenantLimitStore : ITenantLimitStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<TenantLimitDto> GetOrCreateAsync( public async Task<TenantLimitDto> GetOrCreateAsync(
Guid tenantId, CancellationToken ct, TokenLimitDefaults? defaults = null) Guid tenantId,
CancellationToken ct,
TokenLimitDefaults? defaults = null)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
return ToLimitDto(entity); return ToLimitDto(entity);
@@ -90,7 +92,10 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<BudgetStateDto> AddUsageAsync(Guid tenantId, long tokens, CancellationToken ct) public async Task<BudgetStateDto> AddUsageAsync(
Guid tenantId,
long tokens,
CancellationToken ct)
{ {
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
await ResetIfPeriodExpiredAsync(entity, ct); await ResetIfPeriodExpiredAsync(entity, ct);
@@ -120,7 +125,11 @@ public sealed class TenantLimitStore : ITenantLimitStore
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<BudgetStateDto> UpdateBudgetAsync(Guid tenantId, long budgetTokens, string period, CancellationToken ct) public async Task<BudgetStateDto> UpdateBudgetAsync(
Guid tenantId,
long budgetTokens,
string period,
CancellationToken ct)
{ {
ArgumentOutOfRangeException.ThrowIfNegative(budgetTokens); ArgumentOutOfRangeException.ThrowIfNegative(budgetTokens);
if (period != TenantLimitPeriods.Month && period != TenantLimitPeriods.Day) if (period != TenantLimitPeriods.Month && period != TenantLimitPeriods.Day)
@@ -175,7 +184,10 @@ public sealed class TenantLimitStore : ITenantLimitStore
// defaults: Дефолт-параметры создаваемой строки. // defaults: Дефолт-параметры создаваемой строки.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Отслеживаемая строка лимита. // Возвращает: Отслеживаемая строка лимита.
private async Task<TenantLimitEntity> LoadOrCreateAsync(Guid tenantId, TokenLimitDefaults defaults, CancellationToken ct) private async Task<TenantLimitEntity> LoadOrCreateAsync(
Guid tenantId,
TokenLimitDefaults defaults,
CancellationToken ct)
{ {
TenantLimitEntity? entity = await _dbContext.TenantLimits TenantLimitEntity? entity = await _dbContext.TenantLimits
.SingleOrDefaultAsync(x => x.TenantId == tenantId, ct); .SingleOrDefaultAsync(x => x.TenantId == tenantId, ct);
@@ -49,7 +49,10 @@ public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepositor
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct) public async Task<bool> UpdateStatusAsync(
Guid id,
string status,
CancellationToken ct)
{ {
// Отслеживаемая запись + SaveChanges (не ExecuteUpdateAsync): операция редкая (операторская админка), // Отслеживаемая запись + SaveChanges (не ExecuteUpdateAsync): операция редкая (операторская админка),
// зато семантика проверяема на InMemory-провайдере в unit-тестах. // зато семантика проверяема на InMemory-провайдере в unit-тестах.
@@ -97,8 +97,7 @@ public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageE
// source: Отфильтрованный запрос. // source: Отфильтрованный запрос.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Строки агрегатов по дням. // Возвращает: Строки агрегатов по дням.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync( private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
{ {
var rows = await source var rows = await source
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day }) .GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
@@ -131,8 +130,7 @@ public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageE
// source: Отфильтрованный запрос. // source: Отфильтрованный запрос.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Строки агрегатов по тенантам. // Возвращает: Строки агрегатов по тенантам.
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync( private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
{ {
var rows = await source var rows = await source
.GroupBy(e => e.TenantId) .GroupBy(e => e.TenantId)
@@ -62,8 +62,7 @@ public static class ServiceCollectionExtensions
/// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults). /// <param name="tenantLimitDefaults">Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults).
/// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8).</param> /// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8).</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns> /// <returns>Коллекция сервисов для цепочки вызовов.</returns>
public static IServiceCollection AddDealPersistence( public static IServiceCollection AddDealPersistence(this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null)
{ {
services.AddScoped<IAuthStore, AuthStore>(); services.AddScoped<IAuthStore, AuthStore>();
@@ -168,8 +167,11 @@ public static class ServiceCollectionExtensions
/// см. Program.cs (Tasks 6/8). /// см. Program.cs (Tasks 6/8).
/// </remarks> /// </remarks>
public static IServiceCollection AddDealIntegrations( public static IServiceCollection AddDealIntegrations(
this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions, this IServiceCollection services,
TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null) MlServiceOptions mlOptions,
AiServiceOptions aiOptions,
TelegramServiceOptions telegramOptions,
MtlsCertificates? mtlsCertificates = null)
{ {
// Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы // Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы
// остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос // остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос
@@ -26,7 +26,10 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<CardMoveResultDto> MoveAsync( public async Task<CardMoveResultDto> MoveAsync(
string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct) string cardId,
string toContainerId,
TransitionContext ctx,
CancellationToken ct)
{ {
// Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по // Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по
// цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного // цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного
@@ -52,7 +52,10 @@ public static class DefaultContainerProvisioner
/// <param name="db">Контекст схемы тенанта.</param> /// <param name="db">Контекст схемы тенанта.</param>
/// <param name="now">Текущее UTC-время (CreatedAt новых строк).</param> /// <param name="now">Текущее UTC-время (CreatedAt новых строк).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public static async Task EnsureAsync(TenantDbContext db, DateTimeOffset now, CancellationToken ct) public static async Task EnsureAsync(
TenantDbContext db,
DateTimeOffset now,
CancellationToken ct)
{ {
HashSet<string> existing = (await db.Containers HashSet<string> existing = (await db.Containers
.AsNoTracking() .AsNoTracking()
@@ -104,7 +107,12 @@ public static class DefaultContainerProvisioner
// now: Время создания строки. // now: Время создания строки.
// Возвращает: Строка контейнера. // Возвращает: Строка контейнера.
private static ContainerEntity BuildService( private static ContainerEntity BuildService(
string id, string name, string color, int order, ContainerPolicyDto policy, DateTimeOffset now) string id,
string name,
string color,
int order,
ContainerPolicyDto policy,
DateTimeOffset now)
{ {
return new ContainerEntity return new ContainerEntity
{ {
@@ -125,7 +133,10 @@ public static class DefaultContainerProvisioner
// db: Контекст тенанта. // db: Контекст тенанта.
// existing: Множество существующих id (пополняется). // existing: Множество существующих id (пополняется).
// container: Строка контейнера. // container: Строка контейнера.
private static void AddIfMissing(TenantDbContext db, HashSet<string> existing, ContainerEntity container) private static void AddIfMissing(
TenantDbContext db,
HashSet<string> existing,
ContainerEntity container)
{ {
if (existing.Add(container.Id)) if (existing.Add(container.Id))
{ {
@@ -66,7 +66,10 @@ public sealed class TenantProvisioningService(ConnectionStringProvider connectio
// tenantId: Идентификатор тенанта. // tenantId: Идентификатор тенанта.
// schemaName: Имя схемы тенанта (для MigrationsHistoryTable). // schemaName: Имя схемы тенанта (для MigrationsHistoryTable).
// ct: Токен отмены. // ct: Токен отмены.
private async Task ApplyTenantMigrationsAsync(TenantId tenantId, string schemaName, CancellationToken ct) private async Task ApplyTenantMigrationsAsync(
TenantId tenantId,
string schemaName,
CancellationToken ct)
{ {
// Строка уже с Search Path=tenant_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext // Строка уже с Search Path=tenant_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider). // лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
@@ -24,5 +24,8 @@ public interface ICardMover
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true).</returns> /// <returns>Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true).</returns>
public Task<CardMoveResultDto> MoveAsync( public Task<CardMoveResultDto> MoveAsync(
string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct); string cardId,
string toContainerId,
TransitionContext ctx,
CancellationToken ct);
} }
@@ -17,7 +17,13 @@ public sealed record DefaultContainer
/// <param name="order">Позиция в канбане.</param> /// <param name="order">Позиция в канбане.</param>
/// <param name="terminal">Терминальная зона (только ручная очистка, без возврата).</param> /// <param name="terminal">Терминальная зона (только ручная очистка, без возврата).</param>
/// <param name="restore">Возврат из контейнера разрешён.</param> /// <param name="restore">Возврат из контейнера разрешён.</param>
public DefaultContainer(string id, string name, string color, int order, bool terminal, bool restore) public DefaultContainer(
string id,
string name,
string color,
int order,
bool terminal,
bool restore)
{ {
Id = id; Id = id;
Name = name; Name = name;
@@ -52,7 +52,10 @@ public interface IDiscoveryStore
/// <param name="patch">Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива).</param> /// <param name="patch">Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — задачи нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — задачи нет (404-семантика сервиса).</returns>
public Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct); public Task<bool> PatchTaskAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct);
/// <summary> /// <summary>
/// Удаляет задачу вместе с её кандидатами и логом (delete_task L314318; чёрный список общий — не трогается). /// Удаляет задачу вместе с её кандидатами и логом (delete_task L314318; чёрный список общий — не трогается).
@@ -70,7 +73,10 @@ public interface IDiscoveryStore
/// счётчики found/evaluated/joined/rejected=0 (L333339); False — draft/paused: только статус (L342344).</param> /// счётчики found/evaluated/joined/rejected=0 (L333339); False — draft/paused: только статус (L342344).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns> /// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct); public Task<bool> SetTaskRunningAsync(
string taskId,
bool resetProgress,
CancellationToken ct);
/// <summary> /// <summary>
/// Переводит задачу в paused (pause_task L349356; прогресс поиска сохраняется). /// Переводит задачу в paused (pause_task L349356; прогресс поиска сохраняется).
@@ -96,7 +102,11 @@ public interface IDiscoveryStore
/// <param name="n">Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n) python).</param> /// <param name="n">Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n) python).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns> /// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct); public Task<bool> BumpTaskCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
CancellationToken ct);
/// <summary> /// <summary>
/// Продвигает индекс поиска (advance_search L371380: search_idx = nextIndex, search_done, bump UpdatedAt). /// Продвигает индекс поиска (advance_search L371380: search_idx = nextIndex, search_done, bump UpdatedAt).
@@ -106,7 +116,11 @@ public interface IDiscoveryStore
/// <param name="searchDone">search_done = nextIndex ≥ keywords.Count (считает сервис).</param> /// <param name="searchDone">search_done = nextIndex ≥ keywords.Count (считает сервис).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — задача обновлена; false — строки нет.</returns> /// <returns>True — задача обновлена; false — строки нет.</returns>
public Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct); public Task<bool> AdvanceSearchAsync(
string taskId,
int nextIndex,
bool searchDone,
CancellationToken ct);
/// <summary> /// <summary>
/// Сумма plan_joins активных задач (python _active_plan_sum L8591: status NOT IN done/failed). /// Сумма plan_joins активных задач (python _active_plan_sum L8591: status NOT IN done/failed).
@@ -125,7 +139,10 @@ public interface IDiscoveryStore
/// <param name="status">Статус-фильтр (см. <see cref="DiscoveryCandidateStatuses"/>); null — все статусы.</param> /// <param name="status">Статус-фильтр (см. <see cref="DiscoveryCandidateStatuses"/>); null — все статусы.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет.</returns> /// <returns>Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет.</returns>
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct); public Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(
string taskId,
string? status,
CancellationToken ct);
/// <summary> /// <summary>
/// Кандидат по dialog_id (python _get_candidate L400402; ключ — источник, кандидат один). /// Кандидат по dialog_id (python _get_candidate L400402; ключ — источник, кандидат один).
@@ -143,7 +160,10 @@ public interface IDiscoveryStore
/// <param name="sinceUtc">Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток).</param> /// <param name="sinceUtc">Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Число событий с начала суток (0 — событий нет).</returns> /// <returns>Число событий с начала суток (0 — событий нет).</returns>
public Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct); public Task<int> CountLogEventAsync(
string logEvent,
DateTimeOffset sinceUtc,
CancellationToken ct);
/// <summary> /// <summary>
/// Увеличивает join_failures кандидата, если запись жива и в статусе review (воркер L411416; bump UpdatedAt). /// Увеличивает join_failures кандидата, если запись жива и в статусе review (воркер L411416; bump UpdatedAt).
@@ -191,7 +211,10 @@ public interface IDiscoveryStore
/// <param name="patch">Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON).</param> /// <param name="patch">Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns> /// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct); public Task<bool> PatchCandidateAsync(
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct);
/// <summary> /// <summary>
/// Переводит кандидата в new/review (set_candidate_status L497515: только эти статусы; bump UpdatedAt). /// Переводит кандидата в new/review (set_candidate_status L497515: только эти статусы; bump UpdatedAt).
@@ -200,7 +223,10 @@ public interface IDiscoveryStore
/// <param name="status">Новый статус: new|review (валидирует сервис).</param> /// <param name="status">Новый статус: new|review (валидирует сервис).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns> /// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct); public Task<bool> SetCandidateStatusAsync(
string dialogId,
string status,
CancellationToken ct);
/// <summary> /// <summary>
/// Ставит кандидату joined + auto_joined (mark_joined L531534; bump UpdatedAt). /// Ставит кандидату joined + auto_joined (mark_joined L531534; bump UpdatedAt).
@@ -209,7 +235,10 @@ public interface IDiscoveryStore
/// <param name="autoJoined">True — авто-вступление воркера; false — ручное (join из UI).</param> /// <param name="autoJoined">True — авто-вступление воркера; false — ручное (join из UI).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — кандидата нет.</returns> /// <returns>True — строка обновлена; false — кандидата нет.</returns>
public Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct); public Task<bool> SetCandidateJoinedAsync(
string dialogId,
bool autoJoined,
CancellationToken ct);
/// <summary> /// <summary>
/// Ставит кандидату rejected (mark_rejected L555558; bump UpdatedAt; счётчик/лог/чёрный список — сервис). /// Ставит кандидату rejected (mark_rejected L555558; bump UpdatedAt; счётчик/лог/чёрный список — сервис).
@@ -228,7 +257,11 @@ public interface IDiscoveryStore
/// <param name="name">Имя источника (вызывающий передаёт нормализованное: пусто → DialogId).</param> /// <param name="name">Имя источника (вызывающий передаёт нормализованное: пусто → DialogId).</param>
/// <param name="reason">Причина добавления.</param> /// <param name="reason">Причина добавления.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct); public Task UpsertBlacklistAsync(
string dialogId,
string name,
string reason,
CancellationToken ct);
/// <summary> /// <summary>
/// Снимает источник с чёрного списка (remove_blacklist L581582; нет строки — no-op). /// Снимает источник с чёрного списка (remove_blacklist L581582; нет строки — no-op).
@@ -262,7 +295,12 @@ public interface IDiscoveryStore
/// <param name="logEvent">Событие (см. <see cref="DiscoveryLogEvents"/>).</param> /// <param name="logEvent">Событие (см. <see cref="DiscoveryLogEvents"/>).</param>
/// <param name="text">Текст/детали события.</param> /// <param name="text">Текст/детали события.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct); public Task AddLogAsync(
string logId,
string taskId,
string logEvent,
string text,
CancellationToken ct);
/// <summary> /// <summary>
/// Последние события задачи, новые сверху (task_log L601608: ORDER BY created_at DESC LIMIT). /// Последние события задачи, новые сверху (task_log L601608: ORDER BY created_at DESC LIMIT).
@@ -271,5 +309,8 @@ public interface IDiscoveryStore
/// <param name="limit">Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100).</param> /// <param name="limit">Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>События задачи от новых к старым; пусто — лога нет.</returns> /// <returns>События задачи от новых к старым; пусто — лога нет.</returns>
public Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct); public Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(
string taskId,
int limit,
CancellationToken ct);
} }
@@ -40,7 +40,10 @@ public sealed class DiscoveryBanGuard
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param> /// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон /// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон
/// FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param> /// FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
public DiscoveryBanGuard(IDiscoveryStore store, ISettingsStore settings, Func<DateTimeOffset>? utcNow = null) public DiscoveryBanGuard(
IDiscoveryStore store,
ISettingsStore settings,
Func<DateTimeOffset>? utcNow = null)
{ {
ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(settings);
@@ -26,7 +26,11 @@ public sealed class DiscoveryBlacklistService(IDiscoveryStore store)
/// <param name="reason">Причина добавления («отклонено вручную», метка воркера).</param> /// <param name="reason">Причина добавления («отклонено вручную», метка воркера).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Запись чёрного списка (echo после upsert; CreatedAt — первое добавление).</returns> /// <returns>Запись чёрного списка (echo после upsert; CreatedAt — первое добавление).</returns>
public async Task<DiscoveryBlacklistDto> AddAsync(string dialogId, string name, string reason, CancellationToken ct) public async Task<DiscoveryBlacklistDto> AddAsync(
string dialogId,
string name,
string reason,
CancellationToken ct)
{ {
string normalizedName = (name ?? string.Empty).Trim(); string normalizedName = (name ?? string.Empty).Trim();
if (normalizedName.Length == 0) if (normalizedName.Length == 0)
@@ -58,7 +58,10 @@ public sealed class DiscoveryCandidatesService(
/// <param name="status">Статус-фильтр (new|review|joined|rejected); null — все.</param> /// <param name="status">Статус-фильтр (new|review|joined|rejected); null — все.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Кандидаты задачи (marks/topics — списками).</returns> /// <returns>Кандидаты задачи (marks/topics — списками).</returns>
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListAsync(string taskId, string? status, CancellationToken ct) public Task<IReadOnlyList<DiscoveryCandidateDto>> ListAsync(
string taskId,
string? status,
CancellationToken ct)
{ {
return store.ListCandidatesAsync(taskId, status, ct); return store.ListCandidatesAsync(taskId, status, ct);
} }
@@ -91,7 +94,13 @@ public sealed class DiscoveryCandidatesService(
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Новый кандидат либо null — источник пропущен (в лог записан skip).</returns> /// <returns>Новый кандидат либо null — источник пропущен (в лог записан skip).</returns>
public async Task<DiscoveryCandidateDto?> AddAsync( public async Task<DiscoveryCandidateDto?> AddAsync(
string taskId, string dialogId, string name, string username, string kind, string hue, CancellationToken ct) string taskId,
string dialogId,
string name,
string username,
string kind,
string hue,
CancellationToken ct)
{ {
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (task is null) if (task is null)
@@ -156,7 +165,10 @@ public sealed class DiscoveryCandidatesService(
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns> /// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns>
public async Task<DiscoveryCandidateDto?> SetAsync( public async Task<DiscoveryCandidateDto?> SetAsync(
string taskId, string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct) string taskId,
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
{ {
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
@@ -190,7 +202,10 @@ public sealed class DiscoveryCandidatesService(
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns> /// <returns>Обновлённый кандидат либо null (кандидата нет).</returns>
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception> /// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
public async Task<DiscoveryCandidateDto?> SetStatusAsync(string dialogId, string status, CancellationToken ct) public async Task<DiscoveryCandidateDto?> SetStatusAsync(
string dialogId,
string status,
CancellationToken ct)
{ {
if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status)) if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status))
{ {
@@ -223,7 +238,10 @@ public sealed class DiscoveryCandidatesService(
/// <param name="auto">True — авто-вступление воркера; false — ручное (join из UI, Task 19).</param> /// <param name="auto">True — авто-вступление воркера; false — ручное (join из UI, Task 19).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528529).</returns> /// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528529).</returns>
public async Task<DiscoveryCandidateDto?> MarkJoinedAsync(string dialogId, bool auto, CancellationToken ct) public async Task<DiscoveryCandidateDto?> MarkJoinedAsync(
string dialogId,
bool auto,
CancellationToken ct)
{ {
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null) if (current is null)
@@ -251,7 +269,10 @@ public sealed class DiscoveryCandidatesService(
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552553).</returns> /// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552553).</returns>
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception> /// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
public async Task<DiscoveryCandidateDto?> MarkRejectedAsync(string dialogId, string reason, CancellationToken ct) public async Task<DiscoveryCandidateDto?> MarkRejectedAsync(
string dialogId,
string reason,
CancellationToken ct)
{ {
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null) if (current is null)
@@ -68,7 +68,10 @@ public sealed class DiscoveryEvaluator
/// <param name="settings">KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада).</param> /// <param name="settings">KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада).</param>
/// <param name="mlClient">ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»).</param> /// <param name="mlClient">ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»).</param>
/// <param name="aiTools">ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10).</param> /// <param name="aiTools">ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10).</param>
public DiscoveryEvaluator(ISettingsStore settings, IMlClient mlClient, IAiTools aiTools) public DiscoveryEvaluator(
ISettingsStore settings,
IMlClient mlClient,
IAiTools aiTools)
{ {
ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(mlClient); ArgumentNullException.ThrowIfNull(mlClient);
@@ -88,7 +91,9 @@ public sealed class DiscoveryEvaluator
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Агрегат выборки: fit_count, total, fit_ratio, per-message.</returns> /// <returns>Агрегат выборки: fit_count, total, fit_ratio, per-message.</returns>
public async Task<DiscoveryEvalSample> EvaluateSampleAsync( public async Task<DiscoveryEvalSample> EvaluateSampleAsync(
DiscoveryTaskDto task, IReadOnlyList<string> texts, CancellationToken ct) DiscoveryTaskDto task,
IReadOnlyList<string> texts,
CancellationToken ct)
{ {
// Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок // Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок
// настроек на выборку (C30) вместо двух GetAsync на каждое сообщение. // настроек на выборку (C30) вместо двух GetAsync на каждое сообщение.
@@ -121,7 +126,10 @@ public sealed class DiscoveryEvaluator
/// <param name="text">Текст сообщения.</param> /// <param name="text">Текст сообщения.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Вердикт: fit + причина + источник (heuristic|ml|ai).</returns> /// <returns>Вердикт: fit + причина + источник (heuristic|ml|ai).</returns>
public async Task<DiscoveryMessageFit> EvaluateMessageAsync(DiscoveryTaskDto task, string text, CancellationToken ct) public async Task<DiscoveryMessageFit> EvaluateMessageAsync(
DiscoveryTaskDto task,
string text,
CancellationToken ct)
{ {
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false); TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false);
return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false); return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false);
@@ -134,7 +142,10 @@ public sealed class DiscoveryEvaluator
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai). // Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai).
private async Task<DiscoveryMessageFit> EvaluateMessageCoreAsync( private async Task<DiscoveryMessageFit> EvaluateMessageCoreAsync(
DiscoveryTaskDto task, string text, TenantSettingsSnapshot settingsSnapshot, CancellationToken ct) DiscoveryTaskDto task,
string text,
TenantSettingsSnapshot settingsSnapshot,
CancellationToken ct)
{ {
string raw = text ?? string.Empty; string raw = text ?? string.Empty;
if (raw.Trim().Length < MinTextLength) if (raw.Trim().Length < MinTextLength)
@@ -34,7 +34,11 @@ public sealed class DiscoveryLogService(IDiscoveryStore store)
/// <param name="text">Текст/детали события (русская строка 1:1 с прототипом).</param> /// <param name="text">Текст/детали события (русская строка 1:1 с прототипом).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Завершается после записи.</returns> /// <returns>Завершается после записи.</returns>
public Task AddAsync(string taskId, string logEvent, string text, CancellationToken ct) public Task AddAsync(
string taskId,
string logEvent,
string text,
CancellationToken ct)
{ {
return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct); return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct);
} }
@@ -57,7 +61,10 @@ public sealed class DiscoveryLogService(IDiscoveryStore store)
/// <param name="limit">Запрошенный размер выборки.</param> /// <param name="limit">Запрошенный размер выборки.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>События задачи от новых к старым.</returns> /// <returns>События задачи от новых к старым.</returns>
public Task<IReadOnlyList<DiscoveryLogDto>> TaskLogAsync(string taskId, int limit, CancellationToken ct) public Task<IReadOnlyList<DiscoveryLogDto>> TaskLogAsync(
string taskId,
int limit,
CancellationToken ct)
{ {
int clamped = Math.Max(MinLogLimit, Math.Min(MaxLogLimit, limit)); int clamped = Math.Max(MinLogLimit, Math.Min(MaxLogLimit, limit));
return store.ListTaskLogAsync(taskId, clamped, ct); return store.ListTaskLogAsync(taskId, clamped, ct);
@@ -77,7 +77,10 @@ public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore set
/// <param name="excludeTaskId">Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание.</param> /// <param name="excludeTaskId">Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <exception cref="DiscoveryValidationException">Бюджет исчерпан (текст python L108110).</exception> /// <exception cref="DiscoveryValidationException">Бюджет исчерпан (текст python L108110).</exception>
public async Task AssertBudgetAsync(int planJoins, string? excludeTaskId, CancellationToken ct) public async Task AssertBudgetAsync(
int planJoins,
string? excludeTaskId,
CancellationToken ct)
{ {
int limit = await PlanLimitAsync(ct).ConfigureAwait(false); int limit = await PlanLimitAsync(ct).ConfigureAwait(false);
int used = await store.SumActivePlanAsync(excludeTaskId, ct).ConfigureAwait(false); int used = await store.SumActivePlanAsync(excludeTaskId, ct).ConfigureAwait(false);
@@ -128,7 +128,10 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns> /// <returns>Обновлённая задача либо null (404 «Задача не найдена»).</returns>
/// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception> /// <exception cref="DiscoveryValidationException">Новый план вне границ / бюджет исчерпан.</exception>
public async Task<DiscoveryTaskDto?> PatchAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct) public async Task<DiscoveryTaskDto?> PatchAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{ {
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null) if (current is null)
@@ -226,7 +229,11 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
/// <param name="n">Приращение (по умолчанию 1).</param> /// <param name="n">Приращение (по умолчанию 1).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — счётчик увеличен; false — задачи нет.</returns> /// <returns>True — счётчик увеличен; false — задачи нет.</returns>
public Task<bool> BumpCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct) public Task<bool> BumpCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
CancellationToken ct)
{ {
return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct); return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct);
} }
@@ -286,7 +293,10 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu
} }
// Кламп значения в границы [min, max] (эталон clamp python). // Кламп значения в границы [min, max] (эталон clamp python).
private static int Clamp(int value, int min, int max) private static int Clamp(
int value,
int min,
int max)
{ {
return Math.Max(min, Math.Min(max, value)); return Math.Max(min, Math.Min(max, value));
} }
@@ -18,7 +18,9 @@ public sealed partial class DiscoveryWorkerService
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: review — кандидат оценён; skip — пропущен (delete); error — сбой шага. // Возвращает: review — кандидат оценён; skip — пропущен (delete); error — сбой шага.
private async Task<DiscoveryWorkerOutcome> EvalStepAsync( private async Task<DiscoveryWorkerOutcome> EvalStepAsync(
DiscoveryTaskDto task, DiscoveryCandidateDto candidate, CancellationToken ct) DiscoveryTaskDto task,
DiscoveryCandidateDto candidate,
CancellationToken ct)
{ {
string taskId = task.Id; string taskId = task.Id;
string dialogId = candidate.DialogId; string dialogId = candidate.DialogId;
@@ -18,7 +18,9 @@ public sealed partial class DiscoveryWorkerService
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: join/reject/flood/error/skip — действие; none — выход без вступления (изменения за паузу). // Возвращает: join/reject/flood/error/skip — действие; none — выход без вступления (изменения за паузу).
private async Task<DiscoveryWorkerOutcome> JoinStepAsync( private async Task<DiscoveryWorkerOutcome> JoinStepAsync(
DiscoveryTaskDto task, DiscoveryCandidateDto candidate, CancellationToken ct) DiscoveryTaskDto task,
DiscoveryCandidateDto candidate,
CancellationToken ct)
{ {
string taskId = task.Id; string taskId = task.Id;
string dialogId = candidate.DialogId; string dialogId = candidate.DialogId;
@@ -72,7 +72,10 @@ public interface ICardStore
/// <param name="space">Пространство переставляемых контейнеров.</param> /// <param name="space">Пространство переставляемых контейнеров.</param>
/// <param name="containerIds">Id контейнеров в новом порядке.</param> /// <param name="containerIds">Id контейнеров в новом порядке.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task ReorderContainersAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct); public Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct);
// ── Карточки ────────────────────────────────────────────────────────── // ── Карточки ──────────────────────────────────────────────────────────
@@ -100,7 +103,10 @@ public interface ICardStore
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по /// <returns>Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по
/// убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC.</returns> /// убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC.</returns>
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(string q, int limit, CancellationToken ct); public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct);
/// <summary> /// <summary>
/// Одна карточка по id (GET /api/cards/{id}, а также перечитывание после переноса). /// Одна карточка по id (GET /api/cards/{id}, а также перечитывание после переноса).
@@ -119,7 +125,10 @@ public interface ICardStore
/// <param name="msgId">Id исходного сообщения в Telegram.</param> /// <param name="msgId">Id исходного сообщения в Telegram.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null, если сообщение не становилось карточкой.</returns> /// <returns>Карточка или null, если сообщение не становилось карточкой.</returns>
public Task<CardDto?> GetCardBySourceAsync(string dialogId, long msgId, CancellationToken ct); public Task<CardDto?> GetCardBySourceAsync(
string dialogId,
long msgId,
CancellationToken ct);
/// <summary> /// <summary>
/// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now). /// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now).
@@ -135,7 +144,10 @@ public interface ICardStore
/// <param name="patch">Изменяемые поля (null — поле не меняется; JSON-поля — полная замена).</param> /// <param name="patch">Изменяемые поля (null — поле не меняется; JSON-поля — полная замена).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> PatchCardAsync(string cardId, CardPatch patch, CancellationToken ct); public Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct);
/// <summary> /// <summary>
/// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt.
@@ -144,7 +156,10 @@ public interface ICardStore
/// <param name="link">Готовая ссылка {id,name,url} (id сгенерирован модулем).</param> /// <param name="link">Готовая ссылка {id,name,url} (id сгенерирован модулем).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> AddLinkAsync(string cardId, CardLinkDto link, CancellationToken ct); public Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct);
/// <summary> /// <summary>
/// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt.
@@ -153,7 +168,10 @@ public interface ICardStore
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param> /// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct); public Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct);
/// <summary> /// <summary>
/// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt.
@@ -162,7 +180,10 @@ public interface ICardStore
/// <param name="file">Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем).</param> /// <param name="file">Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> AddFileAsync(string cardId, CardFileDto file, CancellationToken ct); public Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct);
/// <summary> /// <summary>
/// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. /// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt.
@@ -171,7 +192,10 @@ public interface ICardStore
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> RemoveFileAsync(string cardId, string fileId, CancellationToken ct); public Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct);
/// <summary> /// <summary>
/// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs) /// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs)
@@ -183,7 +207,12 @@ public interface ICardStore
/// <param name="atMs">Время переноса, epoch-ms (пишется в updated_at и в запись истории).</param> /// <param name="atMs">Время переноса, epoch-ms (пишется в updated_at и в запись истории).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns> /// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> MoveCardStageAsync(string cardId, string containerId, CardHistoryDto historyEntry, long atMs, CancellationToken ct); public Task<bool> MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct);
/// <summary> /// <summary>
/// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236243). /// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236243).
@@ -191,7 +220,10 @@ public interface ICardStore
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param> /// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task SetReminderAsync(string cardId, long atMs, CancellationToken ct); public Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct);
/// <summary> /// <summary>
/// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246247). /// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246247).
@@ -253,7 +285,10 @@ public interface ICardStore
/// <param name="cardId">Id карточки либо null.</param> /// <param name="cardId">Id карточки либо null.</param>
/// <param name="col">Колонка либо null.</param> /// <param name="col">Колонка либо null.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task UpdateSeenAsync(string? cardId, string? col, CancellationToken ct); public Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct);
/// <summary> /// <summary>
/// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки /// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки
@@ -297,7 +332,12 @@ public interface ICardStore
/// <param name="by">Автор («Вы» — свои комментарии).</param> /// <param name="by">Автор («Вы» — свои комментарии).</param>
/// <param name="text">Текст (непустой — валидирует сервис, 400 «Пустой комментарий»).</param> /// <param name="text">Текст (непустой — валидирует сервис, 400 «Пустой комментарий»).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct); public Task AddCommentAsync(
string commentId,
string cardId,
string by,
string text,
CancellationToken ct);
// ── Журнал действий (CardMoves = learning_log) ──────────────────────── // ── Журнал действий (CardMoves = learning_log) ────────────────────────
@@ -348,7 +388,10 @@ public interface ICardStore
/// <param name="archivedAt">Момент архивации (один «now» тика).</param> /// <param name="archivedAt">Момент архивации (один «now» тика).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках).</returns> /// <returns>Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках).</returns>
public Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct); public Task<int> ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct);
/// <summary> /// <summary>
/// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475478). /// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475478).
@@ -392,7 +435,12 @@ public interface ICardStore
/// <param name="convTo">Сконвертированная верхняя граница, либо null.</param> /// <param name="convTo">Сконвертированная верхняя граница, либо null.</param>
/// <param name="convCur">Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята.</param> /// <param name="convCur">Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct); public Task UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
string convCur,
CancellationToken ct);
// ── Эвристика ИИ-предложений (Ruling 3, Task 14) ─────────────────────── // ── Эвристика ИИ-предложений (Ruling 3, Task 14) ───────────────────────
@@ -41,7 +41,12 @@ public interface IMlLearningStore
/// <param name="label">Метка обучения: id доски, <c>spam</c> либо <c>t:hire|t:order</c>.</param> /// <param name="label">Метка обучения: id доски, <c>spam</c> либо <c>t:hire|t:order</c>.</param>
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param> /// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task AddOutboxAsync(string id, string text, string label, double delta, CancellationToken ct); public Task AddOutboxAsync(
string id,
string text,
string label,
double delta,
CancellationToken ct);
/// <summary> /// <summary>
/// Полная очистка очереди обучения: DELETE FROM MlOutbox (ml_client.reset_model L122). Журнал CardMoves не трогается. /// Полная очистка очереди обучения: DELETE FROM MlOutbox (ml_client.reset_model L122). Журнал CardMoves не трогается.
@@ -68,7 +68,10 @@ public static class AmountParser
var occupied = new List<(int Start, int End)>(); var occupied = new List<(int Start, int End)>();
// Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106107. // Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106107.
static bool IsFree(List<(int Start, int End)> spans, int start, int end) static bool IsFree(
List<(int Start, int End)> spans,
int start,
int end)
{ {
foreach ((int os, int oe) in spans) foreach ((int os, int oe) in spans)
{ {
@@ -81,7 +84,12 @@ public static class AmountParser
return true; return true;
} }
void Add(double? from, double? to, string? cur, int start, int end) void Add(
double? from,
double? to,
string? cur,
int start,
int end)
{ {
if (cur is not null && (from is not null || to is not null) && IsFree(occupied, start, end)) if (cur is not null && (from is not null || to is not null) && IsFree(occupied, start, end))
{ {
@@ -28,7 +28,10 @@ public static class BudgetInRange
/// True — есть сумма, которая после конвертации (при необходимости) ≥ from и ≤ to; обе границы null → True; /// True — есть сумма, которая после конвертации (при необходимости) ≥ from и ≤ to; обе границы null → True;
/// пустые amounts или непереводимая валюта → False. /// пустые amounts или непереводимая валюта → False.
/// </returns> /// </returns>
public static bool IsInRange(IReadOnlyList<AmountRange> amounts, BudgetRangeDto budget, IReadOnlyDictionary<string, double>? rates) public static bool IsInRange(
IReadOnlyList<AmountRange> amounts,
BudgetRangeDto budget,
IReadOnlyDictionary<string, double>? rates)
{ {
double? lo = budget.From; double? lo = budget.From;
double? hi = budget.To; double? hi = budget.To;
@@ -24,7 +24,10 @@ public static class ColumnMatcher
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>); null — если бюджет в /// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>); null — если бюджет в
/// другой валюте, суммы не конвертируются и группа бюджета не совпадает.</param> /// другой валюте, суммы не конвертируются и группа бюджета не совпадает.</param>
/// <returns>True — текст прошёл правила в режиме all/any.</returns> /// <returns>True — текст прошёл правила в режиме all/any.</returns>
public static bool MatchText(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null) public static bool MatchText(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{ {
if (rules is null) if (rules is null)
{ {
@@ -28,7 +28,10 @@ public static class ColumnRules
/// False — сработало слово-исключение (veto) либо текст не прошёл активные правила; /// False — сработало слово-исключение (veto) либо текст не прошёл активные правила;
/// True — правил нет/неактивны или текст прошёл правила. /// True — правил нет/неактивны или текст прошёл правила.
/// </returns> /// </returns>
public static bool ContainerAccepts(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null) public static bool ContainerAccepts(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{ {
if (ColumnExclusions.IsExcluded(rules, text)) if (ColumnExclusions.IsExcluded(rules, text))
{ {
@@ -50,7 +53,10 @@ public static class ColumnRules
/// <param name="text">Текст сообщения.</param> /// <param name="text">Текст сообщения.</param>
/// <param name="rates">Курсы для конвертации бюджета.</param> /// <param name="rates">Курсы для конвертации бюджета.</param>
/// <returns>True — текст прошёл правила в режиме all/any (исключения не учитываются).</returns> /// <returns>True — текст прошёл правила в режиме all/any (исключения не учитываются).</returns>
public static bool Matches(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null) public static bool Matches(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{ {
return ColumnMatcher.MatchText(rules, text, rates); return ColumnMatcher.MatchText(rules, text, rates);
} }
@@ -75,7 +81,10 @@ public static class ColumnRules
/// Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без /// Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без
/// активных правил — [] (Ruling 2). Исключения (veto) в список не входят (прототип hits L271296). /// активных правил — [] (Ruling 2). Исключения (veto) в список не входят (прототип hits L271296).
/// </returns> /// </returns>
public static IReadOnlyList<MatchHitDto> ComputeHits(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null) public static IReadOnlyList<MatchHitDto> ComputeHits(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{ {
if (!ColumnMatcher.HasActiveRules(rules)) if (!ColumnMatcher.HasActiveRules(rules))
{ {
@@ -50,7 +50,10 @@ public static class MatchHitBuilder
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>).</param> /// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>).</param>
/// <returns>Список совпадений по группам; порядок — direction → keywords → stack → grade → levels → /// <returns>Список совпадений по группам; порядок — direction → keywords → stack → grade → levels →
/// locations → types → budget → prices.</returns> /// locations → types → budget → prices.</returns>
public static IReadOnlyList<MatchHitDto> BuildHits(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null) public static IReadOnlyList<MatchHitDto> BuildHits(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{ {
var result = new List<MatchHitDto>(); var result = new List<MatchHitDto>();
if (rules is null) if (rules is null)
@@ -144,7 +147,10 @@ public static class MatchHitBuilder
// label: Метка группы. // label: Метка группы.
// lower: Текст в нижнем регистре. // lower: Текст в нижнем регистре.
// Возвращает: Hits по каждому совпавшему терму (term сохраняет регистр пользователя). // Возвращает: Hits по каждому совпавшему терму (term сохраняет регистр пользователя).
private static IEnumerable<MatchHitDto> MatchedTermHits(IReadOnlyList<string>? rawTerms, string label, string lower) private static IEnumerable<MatchHitDto> MatchedTermHits(
IReadOnlyList<string>? rawTerms,
string label,
string lower)
{ {
if (rawTerms is null) if (rawTerms is null)
{ {
@@ -99,7 +99,10 @@ public static class RulesDescriber
// parts: Накопитель частей описания. // parts: Накопитель частей описания.
// range: Диапазон группы (null — группа выключена). // range: Диапазон группы (null — группа выключена).
// title: Подпись группы («бюджет»/«цена»). // title: Подпись группы («бюджет»/«цена»).
private static void AddRangePart(List<string> parts, BudgetRangeDto? range, string title) private static void AddRangePart(
List<string> parts,
BudgetRangeDto? range,
string title)
{ {
if (range is null || (range.From is null && range.To is null)) if (range is null || (range.From is null && range.To is null))
{ {
@@ -174,7 +174,11 @@ public static class BudgetNormalizer
// toCurrency: Целевая валюта (код). // toCurrency: Целевая валюта (код).
// rates: Курсы к рублю либо null. // rates: Курсы к рублю либо null.
// Возвращает: Сумма в целевой валюте или null. // Возвращает: Сумма в целевой валюте или null.
private static double? Convert(double amount, string fromCurrency, string toCurrency, IReadOnlyDictionary<string, double>? rates) private static double? Convert(
double amount,
string fromCurrency,
string toCurrency,
IReadOnlyDictionary<string, double>? rates)
{ {
return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates); return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates);
} }
@@ -101,7 +101,10 @@ public sealed partial class CardsService
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns> /// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns>
public async Task<CardFileDto?> GetFileEntryAsync(string cardId, string fileId, CancellationToken ct) public async Task<CardFileDto?> GetFileEntryAsync(
string cardId,
string fileId,
CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
@@ -124,7 +127,10 @@ public sealed partial class CardsService
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param> /// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns> /// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns>
public async Task<CardDto?> RemoveFileAsync(string cardId, string fileId, CancellationToken ct) public async Task<CardDto?> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
@@ -152,7 +158,10 @@ public sealed partial class CardsService
// fileId: Id записи файла (pf_...; уникальный суффикс ключа). // fileId: Id записи файла (pf_...; уникальный суффикс ключа).
// name: Имя файла как прислано (в ключ идёт санитизированная часть). // name: Имя файла как прислано (в ключ идёт санитизированная часть).
// Возвращает: Ключ объекта (opaque для хранилища). // Возвращает: Ключ объекта (opaque для хранилища).
private static string BuildObjectKey(string cardId, string fileId, string name) private static string BuildObjectKey(
string cardId,
string fileId,
string name)
{ {
return $"{ObjectRootSegment}/{cardId}/{fileId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{SanitizeKeyName(name)}"; return $"{ObjectRootSegment}/{cardId}/{fileId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{SanitizeKeyName(name)}";
} }
@@ -42,7 +42,12 @@ public sealed partial class CardsService
// hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2). // hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2).
// action: Действие журнала: move/trash. // action: Действие журнала: move/trash.
// ct: Токен отмены. // ct: Токен отмены.
private async Task MoveToColumnAsync(CardDto card, string toCol, IReadOnlyList<MatchHitDto> hits, string action, CancellationToken ct) private async Task MoveToColumnAsync(
CardDto card,
string toCol,
IReadOnlyList<MatchHitDto> hits,
string action,
CancellationToken ct)
{ {
await _store.UpdateColumnAsync(new CardColumnUpdateDto( await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: card.Id, CardId: card.Id,
@@ -60,7 +65,12 @@ public sealed partial class CardsService
// fromCol: Прежняя колонка (для comment — null). // fromCol: Прежняя колонка (для comment — null).
// toCol: Новая колонка (для comment — null). // toCol: Новая колонка (для comment — null).
// ct: Токен отмены. // ct: Токен отмены.
private async Task LogMoveAsync(string cardId, string action, string? fromCol, string? toCol, CancellationToken ct) private async Task LogMoveAsync(
string cardId,
string action,
string? fromCol,
string? toCol,
CancellationToken ct)
{ {
await _store.AddMoveAsync(new CardMoveDto( await _store.AddMoveAsync(new CardMoveDto(
PrefixId.New(KanbanIdPrefixes.CardMove), PrefixId.New(KanbanIdPrefixes.CardMove),
@@ -75,7 +85,10 @@ public sealed partial class CardsService
// text: Текст карточки для правил (source_msg или title). // text: Текст карточки для правил (source_msg или title).
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Список совпавших критериев; доски нет/правил нет → пусто. // Возвращает: Список совпавших критериев; доски нет/правил нет → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(string boardId, string text, CancellationToken ct) private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(
string boardId,
string text,
CancellationToken ct)
{ {
ContainerDto? board = await _store.GetContainerAsync(boardId, ct); ContainerDto? board = await _store.GetContainerAsync(boardId, ct);
return board is null return board is null
@@ -89,7 +102,10 @@ public sealed partial class CardsService
// text: Текст карточки для правил. // text: Текст карточки для правил.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто. // Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто.
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(ContainerRulesDto? rules, string text, CancellationToken ct) private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(
ContainerRulesDto? rules,
string text,
CancellationToken ct)
{ {
// Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService). // Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService).
IReadOnlyDictionary<string, double> rates = IReadOnlyDictionary<string, double> rates =
@@ -66,7 +66,10 @@ public sealed partial class CardsService
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param> /// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns> /// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveDashboardCardAsync(string cardId, string toCol, CancellationToken ct) public async Task<CardResultDto> MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{ {
ContainerDto? board = null; ContainerDto? board = null;
if (toCol != CardIds.Inbox) if (toCol != CardIds.Inbox)
@@ -138,7 +141,10 @@ public sealed partial class CardsService
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param> /// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns> /// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(string cardId, bool teach, CancellationToken ct) public async Task<CardDto?> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
@@ -255,7 +261,10 @@ public sealed partial class CardsService
/// <param name="text">Текст комментария (непустой после Trim).</param> /// <param name="text">Текст комментария (непустой после Trim).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns> /// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
public async Task<AddCommentResultDto> AddCommentAsync(string cardId, string text, CancellationToken ct) public async Task<AddCommentResultDto> AddCommentAsync(
string cardId,
string text,
CancellationToken ct)
{ {
string trimmed = (text ?? string.Empty).Trim(); string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length == 0) if (trimmed.Length == 0)
@@ -284,7 +293,10 @@ public sealed partial class CardsService
/// <param name="cardId">Id карточки либо null/пусто.</param> /// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param> /// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task MarkSeenAsync(string? cardId, string? col, CancellationToken ct) public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{ {
return _store.UpdateSeenAsync( return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId, string.IsNullOrEmpty(cardId) ? null : cardId,
@@ -48,7 +48,10 @@ public sealed partial class CardsService
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) | /// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) |
/// Card — карточка с напоминанием.</returns> /// Card — карточка с напоминанием.</returns>
public async Task<CardResultDto> SetReminderAsync(string cardId, long atMs, CancellationToken ct) public async Task<CardResultDto> SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
@@ -168,7 +168,10 @@ public sealed partial class CardsService
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param> /// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns> /// <returns>Обновлённая карточка или null — карточки нет (404).</returns>
public async Task<CardDto?> PatchCardAsync(string cardId, IReadOnlyDictionary<string, JsonElement> body, CancellationToken ct) public async Task<CardDto?> PatchCardAsync(
string cardId,
IReadOnlyDictionary<string, JsonElement> body,
CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(body); ArgumentNullException.ThrowIfNull(body);
@@ -190,7 +193,11 @@ public sealed partial class CardsService
/// <param name="url">URL ссылки (без схемы — добавится https://).</param> /// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns> /// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns>
public async Task<CardResultDto> AddLinkAsync(string cardId, string name, string url, CancellationToken ct) public async Task<CardResultDto> AddLinkAsync(
string cardId,
string name,
string url,
CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
@@ -236,7 +243,10 @@ public sealed partial class CardsService
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param> /// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns> /// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
public async Task<CardResultDto> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct) public async Task<CardResultDto> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{ {
if (!await _store.RemoveLinkAsync(cardId, linkId, ct)) if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{ {
@@ -262,7 +272,10 @@ public sealed partial class CardsService
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | /// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) |
/// Card — карточка после переноса.</returns> /// Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveStageCardAsync(string cardId, string containerId, CancellationToken ct) public async Task<CardResultDto> MoveStageCardAsync(
string cardId,
string containerId,
CancellationToken ct)
{ {
if (!CardsDefaultContainers.Contains(containerId)) if (!CardsDefaultContainers.Contains(containerId))
{ {
@@ -52,7 +52,11 @@ public sealed partial class CardsService
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param> /// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param> /// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param> /// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
public CardsService(ICardStore store, ISettingsStore settings, IMlClient mlClient, IFileStorage storage) public CardsService(
ICardStore store,
ISettingsStore settings,
IMlClient mlClient,
IFileStorage storage)
{ {
_store = store; _store = store;
_settings = settings; _settings = settings;
@@ -122,7 +122,10 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// <param name="patch">Изменения; null-поле означает «не менять».</param> /// <param name="patch">Изменения; null-поле означает «не менять».</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns> /// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns>
public async Task<ContainerDto?> PatchAsync(string containerId, ContainerPatchDto patch, CancellationToken ct) public async Task<ContainerDto?> PatchAsync(
string containerId,
ContainerPatchDto patch,
CancellationToken ct)
{ {
ContainerDto? current = await store.GetContainerAsync(containerId, ct); ContainerDto? current = await store.GetContainerAsync(containerId, ct);
if (current is null) if (current is null)
@@ -171,7 +174,10 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// <param name="space">Пространство переставляемых контейнеров.</param> /// <param name="space">Пространство переставляемых контейнеров.</param>
/// <param name="containerIds">Id контейнеров в новом порядке.</param> /// <param name="containerIds">Id контейнеров в новом порядке.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
public Task ReorderAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct) public Task ReorderAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
{ {
return store.ReorderContainersAsync(space, containerIds, ct); return store.ReorderContainersAsync(space, containerIds, ct);
} }
@@ -195,7 +201,10 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings)
/// <param name="patch">Изменяемые поля состояния.</param> /// <param name="patch">Изменяемые поля состояния.</param>
/// <param name="ct">Токен отмены.</param> /// <param name="ct">Токен отмены.</param>
/// <returns>Состояние колонки после merge.</returns> /// <returns>Состояние колонки после merge.</returns>
public async Task<ColumnStateDto> PatchColStateAsync(string colId, ColumnStateDto patch, CancellationToken ct) public async Task<ColumnStateDto> PatchColStateAsync(
string colId,
ColumnStateDto patch,
CancellationToken ct)
{ {
Dictionary<string, ColumnStateDto> state = await ReadColStateAsync(ct); Dictionary<string, ColumnStateDto> state = await ReadColStateAsync(ct);
state.TryGetValue(colId, out ColumnStateDto? current); state.TryGetValue(colId, out ColumnStateDto? current);

Some files were not shown because too many files have changed in this diff Show More