Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
201 lines
8.3 KiB
C#
201 lines
8.3 KiB
C#
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);
|
||
}
|