Application разделён на Abstractions (25 интерфейсов), Models (13 доменных типов) и Dtos (CardMoveResultDto); namespace приведён к путям, using потребителей мигрированы.
297 lines
18 KiB
C#
297 lines
18 KiB
C#
using System.Text;
|
||
using Deal.Modules.Cards.Application.Abstractions;
|
||
using Deal.Modules.Cards.Application.Dtos;
|
||
using Deal.Modules.Cards.Application.Models;
|
||
using Deal.Modules.Kanban.Application.Models;
|
||
|
||
namespace Deal.Modules.Kanban.Application;
|
||
|
||
/// <summary>
|
||
/// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467–471, Ruling 3.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Детерминированная эвристика этапа 3 (реальные предложения ИИ — этап 6): без EF/HTTP/хранилища.
|
||
/// Тема колонки — повторяющееся слово-тема по source_msg карточек «Неразобранного»: текст
|
||
/// токенизируется на слова (только буквы, ≥3 символов, нижний регистр, минус стоп-слова), слово,
|
||
/// встречающееся в ≥2 карточках окна, становится кандидатом в колонку. Группы собираются жадным
|
||
/// алгоритмом: самый частотный кандидат (при равенстве — более длинное слово, затем лексикографически)
|
||
/// забирает свои карточки, следующие кандидаты получают только неразобранные (как used_msg прототипа,
|
||
/// suggest.py L129–143). Выход — до <see cref="MaxColumns"/> планов, каждый с ≥2 карточками; похожие на
|
||
/// существующие доски слова пропускаются (suggest.py _similar_exists L55–61). Suggest-keywords — те же
|
||
/// частотные слова по выборке карточек (≤60, ≤40 симв.) — прототип suggest_domain_keywords L166–193.
|
||
/// Все сравнения/порядки — Ordinal: одинаковый вход даёт одинаковый выход (Acceptance Task 14).
|
||
/// </remarks>
|
||
public static class SuggestHeuristics
|
||
{
|
||
// ── Пороги анализа (прототип suggest.py L48–52 и правила промпта L24–27) ──
|
||
|
||
/// <summary>
|
||
/// Минимум карточек в «Неразобранном» для анализа (suggest.py MIN_INBOX L48).
|
||
/// </summary>
|
||
public const int MinInbox = 6;
|
||
|
||
/// <summary>
|
||
/// Минимум карточек в одной колонке-предложении (MIN_INBOX_GROUP L49, «группируй… минимум 2 сообщения»).
|
||
/// </summary>
|
||
public const int MinInboxGroup = 2;
|
||
|
||
/// <summary>
|
||
/// Сколько сообщений берём в анализ — окно по received_at DESC (MAX_TEXT L50, LIMIT 12).
|
||
/// </summary>
|
||
public const int MaxText = 12;
|
||
|
||
/// <summary>
|
||
/// Колонок-предложений за один прогон максимум (прототип cols[:4] L132, «2–4 колонки максимум»).
|
||
/// </summary>
|
||
public const int MaxColumns = 4;
|
||
|
||
/// <summary>
|
||
/// Минимальная длина слова-темы: слова короче 3 букв не несут темы (план Task 14 L468).
|
||
/// </summary>
|
||
public const int MinWordLength = 3;
|
||
|
||
/// <summary>
|
||
/// Максимальная длина слова-темы/ключа: 1:1 с прототипом (имя колонки ≤40, L136; ключ ≤40, L191).
|
||
/// </summary>
|
||
public const int MaxKeywordLength = 40;
|
||
|
||
/// <summary>
|
||
/// Максимум ключей-маркеров suggest-keywords (прототип keywords[:60] L193).
|
||
/// </summary>
|
||
public const int MaxKeywordsTotal = 60;
|
||
|
||
/// <summary>
|
||
/// Окно карточек для suggest-keywords: свежие 40 с текстом (suggest.py L172–176, LIMIT 40).
|
||
/// </summary>
|
||
public const int KeywordsSampleLimit = 40;
|
||
|
||
/// <summary>
|
||
/// Минимум карточек для suggest-keywords: «нужно хотя бы 3» (suggest.py L178).
|
||
/// </summary>
|
||
public const int MinKeywordsSample = 3;
|
||
|
||
// Сколько карточек группы учитывается в note-обосновании (текст note, план L469–470).
|
||
private const string ColumnNoteFormat =
|
||
"Эвристика (этап 3): слово-тема «{0}» встречается у {1} карточек; реальные предложения ИИ — этап 6";
|
||
|
||
// Служебные слова, не несущие темы: предлоги/союзы/частицы и типовые обращения заявок («нужен», «ищу»…).
|
||
private static readonly HashSet<string> StopWords = new(StringComparer.Ordinal)
|
||
{
|
||
// Русские служебные слова (предлоги/союзы/частицы/местоимения).
|
||
"а", "без", "более", "будет", "бы", "в", "вам", "вас", "ведь", "весь", "вместе", "вот", "впрочем",
|
||
"всё", "все", "всего", "всех", "вы", "где", "да", "даже", "для", "до", "его", "её", "ей", "ему",
|
||
"если", "есть", "ещё", "еще", "же", "за", "затем", "здесь", "и", "из", "или", "им", "иногда", "их",
|
||
"к", "каждый", "как", "какая", "какие", "какой", "когда", "кого", "кому", "конечно", "которая",
|
||
"которого", "которые", "который", "которых", "кто", "куда", "ли", "лишь", "лучше", "между", "меня",
|
||
"мне", "много", "может", "можно", "мой", "моя", "мы", "на", "над", "надо", "нас", "не", "него",
|
||
"нее", "неё", "ней", "нельзя", "нет", "ни", "но", "ну", "о", "об", "один", "однако", "он", "она",
|
||
"они", "опять", "от", "очень", "перед", "по", "под", "после", "потом", "потому", "почти", "при",
|
||
"про", "раз", "с", "сам", "сама", "самое", "самый", "свою", "себе", "себя", "сейчас", "сколько",
|
||
"со", "совсем", "так", "такая", "такие", "такой", "там", "те", "тебе", "тебя", "тем", "теперь",
|
||
"то", "тогда", "того", "тоже", "только", "том", "тот", "ту", "тут", "ты", "у", "уже", "хотя",
|
||
"чего", "чем", "через", "что", "чтобы", "чуть", "эта", "эти", "этим", "этих", "это", "этого",
|
||
"этой", "этом", "этот", "эту", "я",
|
||
// Типовые обращения/глаголы-просьбы, общие для заявок любой сферы (не слова-темы).
|
||
"добрый", "здравствуйте", "привет", "пожалуйста", "напишите", "написать", "пишите", "писать",
|
||
"звоните", "свяжитесь", "связаться", "подробнее", "интересно", "интересует", "нужен", "нужна",
|
||
"нужно", "нужны", "ищу", "ищем", "ищет", "ищут", "искал", "искали", "требуется", "требуются",
|
||
"найти", "нанять", "рассмотрите", "рассмотрим",
|
||
// Английские служебные слова.
|
||
"the", "and", "for", "with", "from", "that", "this", "are", "was", "were", "have", "has", "had",
|
||
"not", "but", "you", "your", "our", "can", "will", "would", "should", "about", "into", "over",
|
||
"after", "before", "more", "most", "than", "then", "them", "they", "their", "there", "here", "when",
|
||
"where", "which", "what", "who", "whom", "its", "been", "being", "all", "any", "some", "such",
|
||
"only", "also", "very", "just", "please",
|
||
};
|
||
|
||
/// <summary>
|
||
/// Планы колонок-предложений по карточкам «Неразобранного» (suggest_from_inbox L76–163).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Анализируется окно из <see cref="MaxText"/> самых свежих карточек (received_at DESC — как SQL
|
||
/// L96–99); карточек в окне меньше <see cref="MinInbox"/> → пусто (причину «мало карточек…» называет
|
||
/// адаптер). Слова существующих НЕ-suggested досок исключаются из кандидатов (похожесть имени,
|
||
/// suggest.py L55–61). Каждая карточка попадает не более чем в одну группу (used_msg L129–143):
|
||
/// кандидаты перебираются от самого частотного, группе достаются только ещё не разобранные карточки.
|
||
/// Выход полностью детерминирован: сортировки Ordinal + порядок входа окна.
|
||
/// </remarks>
|
||
/// <param name="inbox">Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync).</param>
|
||
/// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param>
|
||
/// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns>
|
||
public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(
|
||
IReadOnlyList<CardDto> inbox,
|
||
IReadOnlyList<string> existingBoardNames)
|
||
{
|
||
// Окно анализа: свежие карточки с текстом, как выборка SQL L96–99 (сортировка — страховка
|
||
// детерминизма: адаптер уже отдаёт received_at DESC, но вход не должен влиять на выход).
|
||
List<CardDto> window = inbox
|
||
.Where(card => card.SourceMsg.Trim().Length > 0)
|
||
.OrderByDescending(card => card.ReceivedAtMs)
|
||
.ThenBy(card => card.Id, StringComparer.Ordinal)
|
||
.Take(MaxText)
|
||
.ToList();
|
||
if (window.Count < MinInbox)
|
||
{
|
||
return Array.Empty<SuggestedColumnPlan>();
|
||
}
|
||
|
||
// Слово → карточки окна, где оно встречается (у карточки слово учитывается один раз).
|
||
Dictionary<string, List<string>> termCards = new(StringComparer.Ordinal);
|
||
foreach (CardDto card in window)
|
||
{
|
||
// Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте.
|
||
foreach (string term in Tokenize(card.SourceMsg).Distinct(StringComparer.Ordinal))
|
||
{
|
||
if (!termCards.TryGetValue(term, out List<string>? cardIds))
|
||
{
|
||
cardIds = [];
|
||
termCards[term] = cardIds;
|
||
}
|
||
|
||
cardIds.Add(card.Id);
|
||
}
|
||
}
|
||
|
||
// Имена существующих досок — один раз в нижнем регистре (похожесть L55–61).
|
||
List<string> existingLowered = existingBoardNames
|
||
.Select(name => name.ToLowerInvariant())
|
||
.ToList();
|
||
|
||
// Кандидаты: слово в ≥2 карточках и не похожее на существующую доску; порядок — частота,
|
||
// при равенстве более длинное (специфичнее), затем лексикографически (детерминизм).
|
||
List<string> candidates = termCards
|
||
.Where(pair => pair.Value.Count >= MinInboxGroup && !SimilarToExisting(pair.Key, existingLowered))
|
||
.Select(pair => pair.Key)
|
||
.OrderByDescending(term => termCards[term].Count)
|
||
.ThenByDescending(term => term.Length)
|
||
.ThenBy(term => term, StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
// Жадная сборка групп: каждый кандидат забирает только неразобранные карточки (suggest.py
|
||
// used_msg L129–143); группа без ≥2 свободных карточек пропускается.
|
||
var usedCardIds = new HashSet<string>(StringComparer.Ordinal);
|
||
var plans = new List<SuggestedColumnPlan>();
|
||
foreach (string term in candidates)
|
||
{
|
||
if (plans.Count == MaxColumns)
|
||
{
|
||
break;
|
||
}
|
||
|
||
List<string> freeCardIds = termCards[term].Where(cardId => !usedCardIds.Contains(cardId)).ToList();
|
||
if (freeCardIds.Count < MinInboxGroup)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
usedCardIds.UnionWith(freeCardIds);
|
||
plans.Add(new SuggestedColumnPlan(
|
||
Word: term,
|
||
Name: Capitalize(term),
|
||
CardIds: freeCardIds,
|
||
Note: string.Format(ColumnNoteFormat, term, freeCardIds.Count)));
|
||
}
|
||
|
||
return plans;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Частотные слова-маркеры по текстам карточек (suggest_domain_keywords L166–193, эвристика Ruling 3).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Маркер — слово (те же правила токенизации, что для колонок), встречающееся в ≥2 текстах выборки.
|
||
/// Выход — до <see cref="MaxKeywordsTotal"/> слов, длина каждого ≤<see cref="MaxKeywordLength"/>;
|
||
/// порядок — частота по убыванию, при равенстве более длинное, затем лексикографически
|
||
/// (детерминизм: одинаковый вход → одинаковый выход). Выборку (свежие 40 вне trash/archive) и
|
||
/// проверку «мало карточек» выполняет адаптер LocalColumnSuggester.
|
||
/// </remarks>
|
||
/// <param name="texts">Тексты source_msg карточек выборки (непустые, свежие ≤40).</param>
|
||
/// <returns>Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах.</returns>
|
||
public static IReadOnlyList<string> SuggestDomainKeywords(IReadOnlyList<string> texts)
|
||
{
|
||
Dictionary<string, int> termCounts = new(StringComparer.Ordinal);
|
||
foreach (string text in texts)
|
||
{
|
||
// Маркер — слово, встречающееся в ≥2 ТЕКСТАХ выборки: повтор в одном тексте не увеличивает счёт.
|
||
foreach (string term in Tokenize(text).Distinct(StringComparer.Ordinal))
|
||
{
|
||
termCounts[term] = termCounts.GetValueOrDefault(term) + 1;
|
||
}
|
||
}
|
||
|
||
return termCounts
|
||
.Where(pair => pair.Value >= MinInboxGroup)
|
||
.OrderByDescending(pair => pair.Value)
|
||
.ThenByDescending(pair => pair.Key.Length)
|
||
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
||
.Select(pair => pair.Key)
|
||
.Take(MaxKeywordsTotal)
|
||
.ToList();
|
||
}
|
||
|
||
// Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова.
|
||
// text: Исходный текст (source_msg карточки).
|
||
// Возвращает: Уникальные слова текста в порядке появления (Ordinal).
|
||
private static List<string> Tokenize(string text)
|
||
{
|
||
var terms = new List<string>();
|
||
var buffer = new StringBuilder();
|
||
foreach (char c in text)
|
||
{
|
||
if (char.IsLetter(c))
|
||
{
|
||
buffer.Append(c);
|
||
continue;
|
||
}
|
||
|
||
AddTerm(buffer, terms);
|
||
}
|
||
|
||
AddTerm(buffer, terms);
|
||
return terms;
|
||
}
|
||
|
||
// Сбрасывает буфер слова в список, если слово проходит пороги (длина, стоп-слова).
|
||
// buffer: Накопленные буквы слова.
|
||
// terms: Список слов текста.
|
||
private static void AddTerm(StringBuilder buffer, List<string> terms)
|
||
{
|
||
if (buffer.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
string term = buffer.ToString().ToLowerInvariant();
|
||
buffer.Clear();
|
||
if (term.Length is < MinWordLength or > MaxKeywordLength || StopWords.Contains(term))
|
||
{
|
||
return;
|
||
}
|
||
|
||
terms.Add(term);
|
||
}
|
||
|
||
// Похоже ли слово-тема на существующую доску: равенство или вхождение имени в слово/наоборот
|
||
// (suggest.py _similar_exists L55–61, регистронезависимо).
|
||
// term: Слово-кандидат (нижний регистр).
|
||
// existingLowered: Имена существующих досок в нижнем регистре.
|
||
// Возвращает: True — тема уже покрыта существующей колонкой (кандидат пропускается).
|
||
private static bool SimilarToExisting(string term, List<string> existingLowered)
|
||
{
|
||
foreach (string name in existingLowered)
|
||
{
|
||
if (term == name || name.Contains(term, StringComparison.Ordinal) || term.Contains(name, StringComparison.Ordinal))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// Имя колонки из слова-темы: первая буква заглавная (python → Python, такси → Такси).
|
||
// term: Слово-тема в нижнем регистре (непустое).
|
||
// Возвращает: Слово с заглавной первой буквой.
|
||
private static string Capitalize(string term) => char.ToUpperInvariant(term[0]) + term[1..];
|
||
}
|