Files
Deal/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs
T
stepan 1fbe29a838 Вынести магические строки статусов, видов и ключей в каталоги
Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
2026-09-13 20:51:36 +03:00

201 lines
8.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text.Json;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
namespace Deal.Infrastructure.Integrations.Services;
/// <summary>
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика.
/// </summary>
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
/// <param name="settings">KV-хранилище настроек тенанта.</param>
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
public sealed class LocalColumnSuggester(
ICardStore store,
ISettingsStore settings,
ContainersService containersService) : IColumnSuggester
{
private const long CooldownSeconds = 20 * 60;
private const string CooldownReason = "недавно предлагали — подождите";
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
/// <inheritdoc />
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
{
if (await WithinCooldownAsync(ct))
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
}
IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct);
if (inbox.Count < SuggestHeuristics.MinInbox)
{
return new SuggestColumnsResultDto(
Ok: false,
Created: 0,
Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
Cooldown: false);
}
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList<string> existingNames = containers
.Where(container => !container.Suggested)
.Select(container => container.Name)
.ToList();
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
if (plans.Count == 0)
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
if (created == 0)
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
}
await WriteLastSuggestAtAsync(ct);
return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
}
/// <inheritdoc />
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
{
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List<string> texts = cards
.Where(card => card.Col != CardIds.Trash
&& card.Col != CardIds.Archive
&& (card.Content.Text ?? string.Empty).Trim().Length > 0)
.Take(SuggestHeuristics.KeywordsSampleLimit)
.Select(card => (card.Content.Text ?? string.Empty).Trim())
.ToList();
if (texts.Count < SuggestHeuristics.MinKeywordsSample)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
}
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
if (keywords.Count == 0)
{
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
}
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
}
private async Task<int> StoreSuggestedColumnsAsync(
IReadOnlyList<CardDto> inbox,
IReadOnlyList<SuggestedColumnPlan> plans,
CancellationToken ct)
{
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
Dictionary<string, string> textByCardId = inbox
.ToDictionary(card => card.Id, card => card.Content.Text ?? string.Empty, StringComparer.Ordinal);
int created = 0;
foreach (SuggestedColumnPlan plan in plans)
{
var rules = new ContainerRulesDto(
Mode: ColumnRuleModes.Any,
Direction: Array.Empty<string>(),
Keywords: [plan.Word],
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null);
ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
Name: plan.Name,
Description: string.Empty,
Color: null,
Space: ContainerSpaces.Dashboard,
Kind: ContainerKinds.Board,
Suggested: true,
Rules: rules,
Note: plan.Note), ct);
int placed = 0;
foreach (string cardId in plan.CardIds)
{
if (!inboxIds.Contains(cardId))
{
continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
}
IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
await store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: container.Id,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
placed++;
}
if (placed == 0)
{
await containersService.DeleteAsync(container.Id, ct);
continue;
}
created++;
}
return created;
}
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
if (row is null)
{
return false;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
if (document.RootElement.ValueKind != JsonValueKind.Number)
{
return false;
}
long lastSuggestAt = document.RootElement.GetInt64();
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
}
catch (JsonException)
{
return false; // повреждённое значение не должно блокировать предложения
}
}
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
settings.SetAsync(
SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
ct);
}