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 = "ИИ не смог выделить ключи — попробуйте ещё раз"; private const string RulesModeAny = "any"; /// 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.SourceMsg.Trim().Length > 0) .Take(SuggestHeuristics.KeywordsSampleLimit) .Select(card => card.SourceMsg.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.SourceMsg, StringComparer.Ordinal); int created = 0; foreach (SuggestedColumnPlan plan in plans) { var rules = new ContainerRulesDto( Mode: RulesModeAny, 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); }