SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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 = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||
|
||||
private const string RulesModeAny = "any";
|
||||
|
||||
/// <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: RulesModeAny,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user