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;
///
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика.
///
/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).
/// KV-хранилище настроек тенанта.
/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.
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 = "ИИ не смог выделить ключи — попробуйте ещё раз";
///
public async Task SuggestColumnsAsync(CancellationToken ct)
{
if (await WithinCooldownAsync(ct))
{
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
}
IReadOnlyList 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 containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
IReadOnlyList existingNames = containers
.Where(container => !container.Suggested)
.Select(container => container.Name)
.ToList();
IReadOnlyList 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);
}
///
public async Task SuggestKeywordsAsync(CancellationToken ct)
{
IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct);
List 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 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 StoreSuggestedColumnsAsync(
IReadOnlyList inbox,
IReadOnlyList plans,
CancellationToken ct)
{
HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct))
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
Dictionary 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(),
Keywords: [plan.Word],
Stack: Array.Empty(),
Grade: Array.Empty(),
Exclude: Array.Empty(),
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 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 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);
}