Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,294 @@
using System.Text;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467471, Ruling 3.
/// </summary>
/// <remarks>
/// Детерминированная эвристика этапа 3 (реальные предложения ИИ — этап 6): без EF/HTTP/хранилища.
/// Тема колонки — повторяющееся слово-тема по source_msg карточек «Неразобранного»: текст
/// токенизируется на слова (только буквы, ≥3 символов, нижний регистр, минус стоп-слова), слово,
/// встречающееся в ≥2 карточках окна, становится кандидатом в колонку. Группы собираются жадным
/// алгоритмом: самый частотный кандидат (при равенстве — более длинное слово, затем лексикографически)
/// забирает свои карточки, следующие кандидаты получают только неразобранные (как used_msg прототипа,
/// suggest.py L129143). Выход — до <see cref="MaxColumns"/> планов, каждый с ≥2 карточками; похожие на
/// существующие доски слова пропускаются (suggest.py _similar_exists L5561). Suggest-keywords — те же
/// частотные слова по выборке карточек (≤60, ≤40 симв.) — прототип suggest_domain_keywords L166193.
/// Все сравнения/порядки — 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 L172176, LIMIT 40).
/// </summary>
public const int KeywordsSampleLimit = 40;
/// <summary>
/// Минимум карточек для suggest-keywords: «нужно хотя бы 3» (suggest.py L178).
/// </summary>
public const int MinKeywordsSample = 3;
// Сколько карточек группы учитывается в note-обосновании (текст note, план L469470).
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 L76163).
/// </summary>
/// <remarks>
/// Анализируется окно из <see cref="MaxText"/> самых свежих карточек (received_at DESC — как SQL
/// L96–99); карточек в окне меньше <see cref="MinInbox"/> → пусто (причину «мало карточек…» называет
/// адаптер). Слова существующих НЕ-suggested досок исключаются из кандидатов (похожесть имени,
/// suggest.py L5561). Каждая карточка попадает не более чем в одну группу (used_msg L129143):
/// кандидаты перебираются от самого частотного, группе достаются только ещё не разобранные карточки.
/// Выход полностью детерминирован: сортировки 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 L129143); группа без ≥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 L166193, эвристика 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 L5561, регистронезависимо).
// 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..];
}