Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -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 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..];
|
||||
}
|
||||
Reference in New Issue
Block a user