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,18 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Данные проверки подключения AI-провайдера.
|
||||
/// </summary>
|
||||
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>, ключ в aiConfigs).</param>
|
||||
/// <param name="BaseUrl">Эффективный базовый URL: переопределение aiConfigs или дефолт каталога.</param>
|
||||
/// <param name="Model">Активная модель: переопределение aiConfigs или первая модель каталога.</param>
|
||||
/// <param name="ApiKey">API-ключ открытым текстом (пустая строка — ключ не задан).</param>
|
||||
/// <param name="IsLocal">True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.</param>
|
||||
/// <param name="ApiStyle">Стиль API: null — OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
|
||||
public sealed record AiCheckRequest(
|
||||
string ProviderId,
|
||||
string BaseUrl,
|
||||
string Model,
|
||||
string ApiKey,
|
||||
bool IsLocal,
|
||||
string? ApiStyle);
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check.
|
||||
/// </summary>
|
||||
/// <param name="Ok">True — подключение успешно (включая локальные серверы).</param>
|
||||
/// <param name="Message">Сообщение для UI.</param>
|
||||
/// <param name="Provider">Активный провайдер (id из каталога).</param>
|
||||
/// <param name="Name">Имя провайдера из каталога.</param>
|
||||
/// <param name="Base">Эффективный базовый URL.</param>
|
||||
/// <param name="Model">Активная модель.</param>
|
||||
/// <param name="Local">True — провайдер локальный.</param>
|
||||
/// <param name="KeySet">True — API-ключ задан (расшифрованный непустой).</param>
|
||||
/// <param name="KeyMasked">Маска ключа: пусто, «x…» (len ≤ 8) или «1234…5678».</param>
|
||||
public sealed record AiCheckResultDto(
|
||||
bool Ok,
|
||||
string Message,
|
||||
string Provider,
|
||||
string Name,
|
||||
string Base,
|
||||
string Model,
|
||||
bool Local,
|
||||
bool KeySet,
|
||||
string KeyMasked);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Публичная форма конфигурации AI-провайдера в <c>aiConfigs</c>.
|
||||
/// </summary>
|
||||
/// <param name="BaseUrl">Базовый URL API провайдера.</param>
|
||||
/// <param name="Model">Активная модель.</param>
|
||||
/// <param name="KeySet">True — API-ключ задан (после расшифровки не пустой).</param>
|
||||
/// <param name="KeyMasked">Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».</param>
|
||||
public sealed record AiConfigPublicDto(string BaseUrl, string Model, bool KeySet, string KeyMasked);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Сохранённая конфигурация AI-провайдера в настройке <c>aiConfigs</c>
|
||||
/// </summary>
|
||||
/// <param name="ApiKey">API-ключ: пустая строка или зашифрованное значение с префиксом <c>enc:</c>.</param>
|
||||
/// <param name="BaseUrl">Базовый URL API (переопределение дефолта провайдера).</param>
|
||||
/// <param name="Model">Активная модель.</param>
|
||||
public sealed record AiConfigSetting(string ApiKey, string BaseUrl, string Model);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Описание AI-провайдера — зеркало <c>constants.AI_PROVIDERS</c>.
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор провайдера (совпадает с ключом в настройке <c>aiConfigs</c>).</param>
|
||||
/// <param name="Name">Человекочитаемое имя.</param>
|
||||
/// <param name="Base">Базовый URL API по умолчанию.</param>
|
||||
/// <param name="Local">True — локальный сервер (Ollama/LM Studio), API-ключ не нужен.</param>
|
||||
/// <param name="Models">Доступные модели; может быть пустым (кастомный сервер).</param>
|
||||
/// <param name="ApiStyle">Стиль API: null = OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
|
||||
public sealed record AiProviderDefinition(
|
||||
string Id,
|
||||
string Name,
|
||||
string Base,
|
||||
bool Local,
|
||||
IReadOnlyList<string> Models,
|
||||
string? ApiStyle);
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Статический список AI-провайдеров.
|
||||
/// </summary>
|
||||
public static class AiProviders
|
||||
{
|
||||
/// <summary>
|
||||
/// Все провайдеры в фиксированном порядке.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<AiProviderDefinition> All = new List<AiProviderDefinition>
|
||||
{
|
||||
new(
|
||||
Id: "deepseek",
|
||||
Name: "DeepSeek",
|
||||
Base: "https://api.deepseek.com",
|
||||
Local: false,
|
||||
Models: new[] { "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp" },
|
||||
ApiStyle: null),
|
||||
new(
|
||||
Id: "openai",
|
||||
Name: "OpenAI",
|
||||
Base: "https://api.openai.com/v1",
|
||||
Local: false,
|
||||
Models: new[] { "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna" },
|
||||
ApiStyle: null),
|
||||
new(
|
||||
Id: "openrouter",
|
||||
Name: "OpenRouter",
|
||||
Base: "https://openrouter.ai/api/v1",
|
||||
Local: false,
|
||||
Models: new[]
|
||||
{
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-5.6-luna",
|
||||
"google/gemini-3.8-flash",
|
||||
"qwen/qwen3.8-flash",
|
||||
},
|
||||
ApiStyle: null),
|
||||
new(
|
||||
Id: "anthropic",
|
||||
Name: "Anthropic Claude",
|
||||
Base: "https://api.anthropic.com",
|
||||
Local: false,
|
||||
Models: new[] { "claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5-20251001" },
|
||||
ApiStyle: "anthropic"),
|
||||
new(
|
||||
Id: "ollama",
|
||||
Name: "Ollama (локально)",
|
||||
Base: "http://localhost:11434/v1",
|
||||
Local: true,
|
||||
Models: new[] { "qwen3-coder:30b", "qwen3-coder:480b", "qwen3:32b" },
|
||||
ApiStyle: null),
|
||||
new(
|
||||
Id: "lmstudio",
|
||||
Name: "LM Studio (локально)",
|
||||
Base: "http://localhost:1234/v1",
|
||||
Local: true,
|
||||
Models: new[] { "qwen3-coder-30b", "qwen3-coder-480b", "llama-3.3-70b" },
|
||||
ApiStyle: null),
|
||||
new(
|
||||
Id: "custom",
|
||||
Name: "Другой (OpenAI-совместимый)",
|
||||
Base: "https://",
|
||||
Local: false,
|
||||
Models: Array.Empty<string>(),
|
||||
ApiStyle: null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтные тексты ИИ-промптов.
|
||||
/// </summary>
|
||||
public static class DefaultPrompts
|
||||
{
|
||||
/// <summary>
|
||||
/// Промпт классификатора входящих сообщений.
|
||||
/// </summary>
|
||||
public static readonly string DefaultAiPrompt = Normalize(
|
||||
"""
|
||||
Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА/ОБЪЯВЛЕНИЕ, только если в нём есть конкретный запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека. Направление вашей сферы:
|
||||
{domain}
|
||||
|
||||
Частые слова-маркеры заявок в вашей сфере: {keywords}
|
||||
|
||||
Определи по тексту:
|
||||
1. is_spam — true, если это НЕ заявка: служебные сообщения (коды входа/подтверждения, уведомления), приветствия и поздравления, флуд и обсуждения без задачи, вопросы без конкретики, реклама, скам, фин. пирамиды, резюме соискателей, взаимный пиар, приглашения в чаты, рассылки. Если сомневаешься — ставь true (лучше пропустить сообщение, чем засорить карточками)
|
||||
2. board — id подходящей доски из списка. Выбирай по КРИТЕРИЯМ колонки (в скобках указаны её направление, тематика, ключевые слова, уровень, бюджет, описание), а не только по названию; если ни одна колонка не подходит под текст — верни null (карточка пойдёт в «Неразобранное»)
|
||||
3. is_vacancy — true, если это НАЙМ/постоянная или проектная занятость: ищут человека в команду (признаки: вакансия, грейд/уровень, «в компанию», hr, зарплата за месяц); false — разовая сделка: заказ/услуга/товар (фриланс, подряд, «нужно сделать/купить», цена за работу)
|
||||
4. title — короткий заголовок (4–9 слов, без эмодзи, хэштегов, markdown-разметки, ссылок и знаков препинания в конце)
|
||||
5. company — кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста; не указано — верни ""
|
||||
6. format — формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет — ""
|
||||
7. task — 1–2 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать). Ёмко, без пересказа всего объявления и служебных строк
|
||||
8. requirements — JSON-массив строк ключевых требований к кандидату/исполнителю (пункты «Требования/Обязанности/Нужно»). Нет — []
|
||||
9. plus — JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет — []
|
||||
10. conditions — условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет — ""
|
||||
11. stack — JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки. Названия пиши слитно как в оригинале: ".NET", "C#", "Node.js", "ASP.NET Core" — не разбивай на отдельные буквы (2–8)
|
||||
12. budget — объект { from, to, currency }: одна сумма → from=to=X; «до X» → from=null, to=X; диапазон «от X до Y» → from=X, to=Y. Если суммы нет — null
|
||||
13. contacts — JSON-массив строк контактов ДЛЯ СВЯЗИ: телефон, email, @username (не бот), ссылка на профиль человека (LinkedIn, t.me/…). НЕ включай ботов, каналы/группы и ссылки на вакансию/пост/форму. Если контакта в тексте нет — пустой массив []
|
||||
Поля 5–10 — это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай. Запрещено: markdown-разметка, ссылки в любом виде, эмодзи, хэштеги, «от»/«привет», дословное копирование исходника.
|
||||
Отвечай строго в формате JSON.
|
||||
""");
|
||||
|
||||
/// <summary>
|
||||
/// Промпт структуры карточки — блок «О заявке».
|
||||
/// </summary>
|
||||
public static readonly string DefaultCardPrompt = Normalize(
|
||||
"""
|
||||
Ты также возвращаешь содержимое блока «О заявке» карточки — поля company, format, task, requirements, plus, conditions. Карточка всегда собирается из одних и тех же блоков: одинаковая структура у всех карточек, различается только длина.
|
||||
1. company — кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. Одно предложение, факт из текста.
|
||||
2. format — формат работы: удалённо/офис/гибрид/разъездной, город/страна, график (5/2, full-time, part-time). Коротко.
|
||||
3. task — 1–2 предложения по шаблону: что за задача/роль → для кого → что нужно сделать/какой результат. Пиши ёмко, не пересказывай объявление дословно.
|
||||
4. requirements — вынеси сюда только реальные требования/обязанности из текста (пункты списков «Требования», «Обязанности», «Что предстоит делать», «Нужно»): каждый пункт короткой строкой в JSON-массиве. Не придумывай сверх текста.
|
||||
5. plus — только то, что отмечено как «будет плюсом»/«приветствуется»/«желательно». Нет таких пунктов — пустой массив [].
|
||||
6. conditions — условия одной строкой: оплата/ЗП/вилка/гонорар, сроки, объём, тип занятости. Не дублируй budget-числа в других полях.
|
||||
Для не-IT сфер «стек/требования» означают материалы, услуги, навыки, инструменты — по смыслу заявки. Запрещено во всех полях: markdown-разметка, ссылки в любом виде ([текст](url), голые url), эмодзи, хэштеги, «от»/«привет»/служебные строки канала.
|
||||
""");
|
||||
|
||||
/// <summary>
|
||||
/// Промпт стража входящих
|
||||
/// </summary>
|
||||
public static readonly string DefaultAiFilterPrompt = Normalize(
|
||||
"""
|
||||
Ты — страж входящих сообщений каналов. Пропускай только реальные заявки/объявления по вашей сфере (заказ, услуга, товар, найм — с конкретикой).
|
||||
Ваша сфера и что считать заявкой:
|
||||
{domain}
|
||||
|
||||
Слова-маркеры заявок: {keywords}
|
||||
|
||||
НЕ пропускай:
|
||||
- служебные сообщения: коды входа/подтверждения, уведомления, приветствия, поздравления
|
||||
- просто сообщения без задачи и конкретики: флуд, обсуждения, вопросы «кто работал с …»
|
||||
- рекламу и саморекламу
|
||||
- скам, фин. пирамиды, «заработок»
|
||||
- резюме, поиск работы соискателями
|
||||
- взаимный пиар, приглашения в чаты
|
||||
- рассылки и дайджесты без прямых заявок
|
||||
При сомнении — не пропускай.
|
||||
|
||||
Верни строго JSON: { "pass": true|false, "reason": "причина отказа или null" }
|
||||
""");
|
||||
|
||||
// Приводит текст к переводам строк \n и убирает завершающий перевод (raw-литерал добавляет его).
|
||||
// text: Многострочный текст из raw string literal.
|
||||
// Возвращает: Текст, идентичный строке шаблона data.js (переводы строк — только \n).
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
return text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Каталог ключей глобальных
|
||||
/// </summary>
|
||||
public static class GlobalSettingsKeys
|
||||
{
|
||||
public const string TelegramKeys = "telegramKeys";
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Этап-1 правила фильтра входящих — чистая реализация <c>stage1_plain</c>.
|
||||
/// </summary>
|
||||
public sealed class IncomingRules(ISettingsStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Номер пайплайна для результата правил
|
||||
/// </summary>
|
||||
public const int Stage = 1;
|
||||
|
||||
/// <summary>
|
||||
/// kind: текст короче minLen.
|
||||
/// </summary>
|
||||
public const string KindLength = "length";
|
||||
|
||||
/// <summary>
|
||||
/// kind: найдена стоп-фраза.
|
||||
/// </summary>
|
||||
public const string KindStop = "stop";
|
||||
|
||||
/// <summary>
|
||||
/// kind: текст опознан как резюме соискателя.
|
||||
/// </summary>
|
||||
public const string KindResume = "resume";
|
||||
|
||||
/// <summary>
|
||||
/// kind: тип текста не совпал с wantedType.
|
||||
/// </summary>
|
||||
public const string KindType = "type";
|
||||
|
||||
/// <summary>
|
||||
/// kind на проходе — пустая строка.
|
||||
/// </summary>
|
||||
public const string KindPass = "";
|
||||
|
||||
private const string ResumeMarkerGuardWord = "резюме";
|
||||
|
||||
// wantedType: собираем и вакансии, и разовые заказы (дефолт).
|
||||
private const string WantedTypeBoth = "both";
|
||||
|
||||
// wantedType: только вакансии/занятость.
|
||||
private const string WantedTypeVacancy = "vacancy";
|
||||
|
||||
// wantedType: только разовые заказы/фриланс.
|
||||
private const string WantedTypeFreelance = "freelance";
|
||||
|
||||
private const string ReasonFormatLength = "короче {0} символов";
|
||||
|
||||
private const string ReasonFormatStop = "стоп-фраза «{0}»";
|
||||
|
||||
private const string ReasonFormatResume = "резюме соискателя («{0}»)";
|
||||
|
||||
private const string ReasonTypeFreelanceOnly = "ищете только разовые заявки — сообщение похоже на вакансию/занятость";
|
||||
|
||||
private const string ReasonTypeVacancyOnly = "ищете только занятость — сообщение похоже на разовый заказ/услугу";
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет текст -1 фильтра по настройкам тенанта.
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения; null/пустой трактуется как «короче minLen» (как <c>(text or "").strip</c>).</param>
|
||||
/// <returns>Вердикт: {pass, reason, stage:1, kind, kw}.</returns>
|
||||
public async Task<IncomingRulesResult> CheckAsync(string? text, CancellationToken ct)
|
||||
{
|
||||
RulesSettings settings = ReadSettings(await TenantSettingsSnapshot.LoadAsync(store, ct));
|
||||
return Evaluate(text, settings);
|
||||
}
|
||||
|
||||
private static IncomingRulesResult Evaluate(string? text, RulesSettings settings)
|
||||
{
|
||||
string trimmed = (text ?? string.Empty).Trim();
|
||||
if (trimmed.Length < settings.MinLen)
|
||||
{
|
||||
return Fail(
|
||||
KindLength,
|
||||
string.Format(ReasonFormatLength, settings.MinLen),
|
||||
Kw: string.Empty);
|
||||
}
|
||||
|
||||
string lower = trimmed.ToLowerInvariant();
|
||||
|
||||
foreach (string phrase in settings.StopPhrases)
|
||||
{
|
||||
if (string.IsNullOrEmpty(phrase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower.Contains(phrase.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
return Fail(KindStop, string.Format(ReasonFormatStop, phrase), phrase);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.BlockResumes)
|
||||
{
|
||||
string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers);
|
||||
if (marker is not null)
|
||||
{
|
||||
return Fail(KindResume, string.Format(ReasonFormatResume, marker), marker);
|
||||
}
|
||||
}
|
||||
|
||||
string wantedType = settings.WantedType;
|
||||
if (wantedType == WantedTypeVacancy || wantedType == WantedTypeFreelance)
|
||||
{
|
||||
bool looksVacancy = ContainsAny(lower, settings.HireMarkers);
|
||||
if (wantedType == WantedTypeFreelance && looksVacancy)
|
||||
{
|
||||
return Fail(KindType, ReasonTypeFreelanceOnly, Kw: string.Empty);
|
||||
}
|
||||
|
||||
if (wantedType == WantedTypeVacancy && !looksVacancy)
|
||||
{
|
||||
return Fail(KindType, ReasonTypeVacancyOnly, Kw: string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return new IncomingRulesResult(Pass: true, Reason: null, Stage, KindPass, Kw: string.Empty);
|
||||
}
|
||||
|
||||
private static string? FindResumeMarker(
|
||||
string lower,
|
||||
IReadOnlyList<string> resumeMarkers,
|
||||
IReadOnlyList<string> hireMarkers)
|
||||
{
|
||||
foreach (string marker in resumeMarkers)
|
||||
{
|
||||
int position = lower.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (position < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (marker == ResumeMarkerGuardWord
|
||||
&& ContainsAny(lower.Substring(0, position), hireMarkers))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Содержит ли текст хотя бы один маркер из списка (подстрочное сравнение).
|
||||
// lower: Текст в нижнем регистре.
|
||||
// markers: Нормализованные (нижний регистр) маркеры.
|
||||
// Возвращает: True — найден хотя бы один маркер.
|
||||
private static bool ContainsAny(string lower, IReadOnlyList<string> markers)
|
||||
{
|
||||
foreach (string marker in markers)
|
||||
{
|
||||
if (lower.Contains(marker, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IncomingRulesResult Fail(
|
||||
string kind,
|
||||
string reason,
|
||||
string Kw)
|
||||
{
|
||||
return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw);
|
||||
}
|
||||
|
||||
private static RulesSettings ReadSettings(TenantSettingsSnapshot settings)
|
||||
{
|
||||
return new RulesSettings(
|
||||
MinLen: settings.GetInt(SettingsKeys.MinLen, SettingsDefaults.MinLen),
|
||||
StopPhrases: settings.GetStringList(SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases),
|
||||
BlockResumes: settings.GetBool(SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes),
|
||||
ResumeMarkers: NormalizeMarkers(
|
||||
settings.GetStringList(SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers)),
|
||||
HireMarkers: NormalizeMarkers(
|
||||
settings.GetStringList(SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers)),
|
||||
WantedType: ReadWantedType(settings));
|
||||
}
|
||||
|
||||
// Читает wantedType: строка, trim + lowercase; пустая/отсутствующая/повреждённая → "both".
|
||||
// settings: Снимок настроек тенанта.
|
||||
// Возвращает: Значение wantedType (любая строка; тип-гейт активен только для vacancy/freelance).
|
||||
private static string ReadWantedType(TenantSettingsSnapshot settings)
|
||||
{
|
||||
string wanted = settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType)
|
||||
.Trim().ToLowerInvariant();
|
||||
return wanted.Length > 0 ? wanted : WantedTypeBoth;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeMarkers(IReadOnlyList<string> markers)
|
||||
{
|
||||
var result = new List<string>(markers.Count);
|
||||
foreach (string marker in markers)
|
||||
{
|
||||
string normalized = marker.Trim().ToLowerInvariant();
|
||||
if (normalized.Length > 0 && !result.Contains(normalized, StringComparer.Ordinal))
|
||||
{
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record RulesSettings(
|
||||
int MinLen,
|
||||
IReadOnlyList<string> StopPhrases,
|
||||
bool BlockResumes,
|
||||
IReadOnlyList<string> ResumeMarkers,
|
||||
IReadOnlyList<string> HireMarkers,
|
||||
string WantedType);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат -1 правил фильтра входящих — со <c>stage1_plain</c>.
|
||||
/// </summary>
|
||||
/// <param name="Pass">True — текст прошёл; False — отсечён правилом.</param>
|
||||
/// <param name="Reason">Причина для UI или null на проходе.</param>
|
||||
/// <param name="Stage">Номер пайплайна — всегда 1 (константа <see cref="Deal.Modules.Settings.Application.Models.IncomingRules.Stage"/>).</param>
|
||||
/// <param name="Kind">Какое правило сработало: length|stop|resume|type; на проходе — "".</param>
|
||||
/// <param name="Kw">Конкретная стоп-фраза/маркер резюме (для мониторинга отсева); иначе "".</param>
|
||||
public sealed record IncomingRulesResult(
|
||||
bool Pass,
|
||||
string? Reason,
|
||||
int Stage,
|
||||
string Kind,
|
||||
string Kw);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Элемент «Моих промптов».
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор промпта (начинается с <c>pp_</c>).</param>
|
||||
/// <param name="Name">Название (как показывается пользователю).</param>
|
||||
/// <param name="Description">Короткое описание (необязательное).</param>
|
||||
/// <param name="Prompt">Текст промпта.</param>
|
||||
public sealed record MyPromptDto(string Id, string Name, string Description, string Prompt);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Провайдер ИИ в public-снимке настроек.
|
||||
/// </summary>
|
||||
/// <param name="Id">Идентификатор провайдера.</param>
|
||||
/// <param name="Name">Человекочитаемое имя.</param>
|
||||
/// <param name="Base">Базовый URL API.</param>
|
||||
/// <param name="Local">True — локальный сервер (ключ не нужен).</param>
|
||||
/// <param name="Models">Доступные модели.</param>
|
||||
public sealed record ProviderPublicDto(string Id, string Name, string Base, bool Local, IReadOnlyList<string> Models);
|
||||
@@ -0,0 +1,229 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Публичный снимок настроек тенанта — тело ответа GET/PATCH /api/settings.
|
||||
/// </summary>
|
||||
public sealed record PublicSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Авто-архивация выполненных/отклонённых карточек.
|
||||
/// </summary>
|
||||
public bool AutoArchive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Дней до авто-архивации карточки
|
||||
/// </summary>
|
||||
public int ArchiveAfterDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Дней хранения в архиве до очистки.
|
||||
/// </summary>
|
||||
public int ArchiveClearDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Дней хранения в корзине до очистки.
|
||||
/// </summary>
|
||||
public int TrashClearDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина сообщения для -1 фильтра
|
||||
/// </summary>
|
||||
public int MinLen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Стоп-фразы -1 фильтра.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> StopPhrases { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Локальный ML-слой включён.
|
||||
/// </summary>
|
||||
public bool MlEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Полный выключатель ИИ
|
||||
/// </summary>
|
||||
public bool AiEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ИИ-фильтр (страж) входящих включён.
|
||||
/// </summary>
|
||||
public bool AiFilterEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Промпт классификатора входящих
|
||||
/// </summary>
|
||||
public string AiPrompt { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Промпт ИИ-фильтра входящих.
|
||||
/// </summary>
|
||||
public string AiFilterPrompt { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Промпт структуры карточки
|
||||
/// </summary>
|
||||
public string CardPrompt { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип собираемых заявок
|
||||
/// </summary>
|
||||
public string WantedType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Без указания суммы не создавать карточку найма.
|
||||
/// </summary>
|
||||
public bool BudgetRequiredHire { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Без указания суммы не создавать карточку заказа.
|
||||
/// </summary>
|
||||
public bool BudgetRequiredOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Подпись найма на карточке.
|
||||
/// </summary>
|
||||
public string HireLabel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Подпись разового заказа на карточке.
|
||||
/// </summary>
|
||||
public string OrderLabel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание сферы
|
||||
/// </summary>
|
||||
public string DomainDescription { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Общие слова-маркеры заявок сферы.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DomainKeywords { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Маркеры найма
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> HireMarkers { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Термины грейдов/уровней.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> LevelTerms { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Маркеры резюме соискателей.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ResumeMarkers { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Отсекать резюме соискателей на.
|
||||
/// </summary>
|
||||
public bool BlockResumes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные исключения по ключевым словам/фразам/технологиям
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExcludeKeywords { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные исключения по локации/языку
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExcludeLocations { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные исключения по типу заявки
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExcludeTypes { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница глобального исключения по бюджету
|
||||
/// </summary>
|
||||
public int ExcludeBudgetFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница глобального исключения по бюджету
|
||||
/// </summary>
|
||||
public int ExcludeBudgetTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Личная библиотека промптов пользователя.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MyPromptDto> MyPrompts { get; init; } = Array.Empty<MyPromptDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Общие напоминания включены.
|
||||
/// </summary>
|
||||
public bool RemindersEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Конвертация бюджетов карточек включена.
|
||||
/// </summary>
|
||||
public bool ConversionOn { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Целевая валюта конвертации.
|
||||
/// </summary>
|
||||
public string TargetCurrency { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Источник курсов
|
||||
/// </summary>
|
||||
public string RateSource { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Авто-мониторинг новых чатов/каналов.
|
||||
/// </summary>
|
||||
public bool AutoMonitorNew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Суточный лимит авто-вступлений Discovery
|
||||
/// </summary>
|
||||
public int DiscJoinLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница паузы между авто-вступлениями, сек
|
||||
/// </summary>
|
||||
public int DiscJoinDelayMin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница паузы между авто-вступлениями, сек
|
||||
/// </summary>
|
||||
public int DiscJoinDelayMax { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений при оценке канала
|
||||
/// </summary>
|
||||
public int DiscEvalSample { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Процент подходящих сообщений для оценки канала
|
||||
/// </summary>
|
||||
public int DiscEvalThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Стоп-кран авто-вступлений Discovery.
|
||||
/// </summary>
|
||||
public bool DiscPaused { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Состояние колонок канбана.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object?> ColState { get; init; } =
|
||||
new Dictionary<string, object?>();
|
||||
|
||||
/// <summary>
|
||||
/// Активный AI-провайдер
|
||||
/// </summary>
|
||||
public string AiProvider { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Публичные конфигурации AI-провайдеров
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, AiConfigPublicDto> AiConfigs { get; init; } =
|
||||
new Dictionary<string, AiConfigPublicDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Статический список AI-провайдеров.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProviderPublicDto> Providers { get; init; } = Array.Empty<ProviderPublicDto>();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Значение внутреннего KV-ключа <c>ratesCache</c>.
|
||||
/// </summary>
|
||||
/// <param name="Rates">Курсы к рублю: «код валюты → курс» (включая RUB:1).</param>
|
||||
/// <param name="Source">Источник сохранённых курсов: <c>mock</c> | <c>cbr</c>.</param>
|
||||
/// <param name="UpdatedAtMs">Момент сохранения кэша (Unix-ms, UTC).</param>
|
||||
public sealed record RatesCacheValue(
|
||||
IReadOnlyDictionary<string, double> Rates,
|
||||
string Source,
|
||||
long UpdatedAtMs);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Курсы валют — тело ответа GET /api/rates и поле <c>rates</c> POST /api/rates/refresh.
|
||||
/// </summary>
|
||||
/// <param name="Base">Базовая валюта (RUB).</param>
|
||||
/// <param name="Rates">Курсы к базовой: «код валюты → сколько базовой за 1 единицу».</param>
|
||||
/// <param name="Source">Источник кэша: <c>mock</c> | <c>cbr</c>.</param>
|
||||
/// <param name="UpdatedAtMs">Момент последнего обновления кэша (Unix-ms, UTC) или null, если кэша ещё нет.</param>
|
||||
public sealed record RatesDto(
|
||||
string Base,
|
||||
IReadOnlyDictionary<string, double> Rates,
|
||||
string Source,
|
||||
[property: JsonPropertyName("updatedAt")] long? UpdatedAtMs);
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Категория ключа настроек тенанта
|
||||
/// </summary>
|
||||
public enum SettingKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Целое число (например, <c>archiveAfterDays</c>, <c>minLen</c>).
|
||||
/// </summary>
|
||||
Int,
|
||||
|
||||
/// <summary>
|
||||
/// Булево (например, <c>autoArchive</c>, <c>aiEnabled</c>).
|
||||
/// </summary>
|
||||
Bool,
|
||||
|
||||
/// <summary>
|
||||
/// Строка (например, <c>targetCurrency</c>, <c>aiPrompt</c>).
|
||||
/// </summary>
|
||||
String,
|
||||
|
||||
/// <summary>
|
||||
/// Список строк
|
||||
/// </summary>
|
||||
List,
|
||||
|
||||
/// <summary>
|
||||
/// Произвольный JSON-объект
|
||||
/// </summary>
|
||||
Dict,
|
||||
|
||||
/// <summary>
|
||||
/// Личная библиотека промптов пользователя
|
||||
/// </summary>
|
||||
MyPrompts,
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурации AI-провайдеров с ключами
|
||||
/// </summary>
|
||||
AiConfigs,
|
||||
|
||||
/// <summary>
|
||||
/// Внутренний (непубличный) ключ
|
||||
/// </summary>
|
||||
Internal,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Значение настройки из KV-хранилища
|
||||
/// </summary>
|
||||
/// <param name="Key">Ключ настройки (имя из каталога <c>SettingsKeys</c>).</param>
|
||||
/// <param name="ValueJson">Значение, сериализованное в JSON.</param>
|
||||
/// <param name="UpdatedAt">Момент последней записи (UTC).</param>
|
||||
public sealed record SettingValue(string Key, string ValueJson, DateTimeOffset UpdatedAt);
|
||||
@@ -0,0 +1,270 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтные значения настроек тенанта.
|
||||
/// </summary>
|
||||
public static class SettingsDefaults
|
||||
{
|
||||
// ── Хранилище ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «autoArchive»
|
||||
/// </summary>
|
||||
public const bool AutoArchive = true;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «archiveAfterDays»
|
||||
/// </summary>
|
||||
public const int ArchiveAfterDays = 14;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «archiveClearDays»
|
||||
/// </summary>
|
||||
public const int ArchiveClearDays = 90;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «trashClearDays»
|
||||
/// </summary>
|
||||
public const int TrashClearDays = 7;
|
||||
|
||||
// ── Фильтры входящих ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «minLen»
|
||||
/// </summary>
|
||||
public const int MinLen = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтные стоп-фразы.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> StopPhrases = new[]
|
||||
{
|
||||
"взаимный пиар",
|
||||
"резюме",
|
||||
"ищу работу",
|
||||
"набор в команду",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «mlEnabled»
|
||||
/// </summary>
|
||||
public const bool MlEnabled = true;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «aiEnabled»
|
||||
/// </summary>
|
||||
public const bool AiEnabled = true;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «aiFilterEnabled»
|
||||
/// </summary>
|
||||
public const bool AiFilterEnabled = true;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «aiPrompt» — текст из DefaultPrompts.
|
||||
/// </summary>
|
||||
public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «aiFilterPrompt» — текст из DefaultPrompts.
|
||||
/// </summary>
|
||||
public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт « » — текст из DefaultPrompts.
|
||||
/// </summary>
|
||||
public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt;
|
||||
|
||||
// ── Сфера и ключи ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «domainDescription»
|
||||
/// </summary>
|
||||
public const string DomainDescription = "";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «domainKeywords»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> DomainKeywords = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтные маркеры найма.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> HireMarkers = new[]
|
||||
{
|
||||
"вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time",
|
||||
"на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка",
|
||||
"грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает",
|
||||
"на постоянную основу", "в штат",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтные термины грейдов/уровней.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> LevelTerms = new[]
|
||||
{
|
||||
"junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор",
|
||||
"lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтные маркеры резюме соискателей.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ResumeMarkers = new[]
|
||||
{
|
||||
"резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию",
|
||||
"рассмотрю предложения", "готов к собеседованию", "в поиске работы",
|
||||
"ищу проект", "ищу подработку",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «blockResumes»
|
||||
/// </summary>
|
||||
public const bool BlockResumes = true;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «excludeKeywords»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ExcludeKeywords = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «excludeLocations»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ExcludeLocations = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «excludeTypes»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ExcludeTypes = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «excludeBudgetFrom»
|
||||
/// </summary>
|
||||
public const int ExcludeBudgetFrom = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «excludeBudgetTo»
|
||||
/// </summary>
|
||||
public const int ExcludeBudgetTo = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «wantedType»
|
||||
/// </summary>
|
||||
public const string WantedType = "both";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «budgetRequiredHire»
|
||||
/// </summary>
|
||||
public const bool BudgetRequiredHire = false;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «budgetRequiredOrder»
|
||||
/// </summary>
|
||||
public const bool BudgetRequiredOrder = false;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «hireLabel»
|
||||
/// </summary>
|
||||
public const string HireLabel = "вакансия";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «orderLabel»
|
||||
/// </summary>
|
||||
public const string OrderLabel = "фриланс";
|
||||
|
||||
// ── Мои промпты / напоминания ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «myPrompts»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<MyPromptDto> MyPrompts = Array.Empty<MyPromptDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «remindersEnabled»
|
||||
/// </summary>
|
||||
public const bool RemindersEnabled = true;
|
||||
|
||||
// ── Валюта и курсы ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «conversionOn»
|
||||
/// </summary>
|
||||
public const bool ConversionOn = true;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «targetCurrency»
|
||||
/// </summary>
|
||||
public const string TargetCurrency = "RUB";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «rateSource»
|
||||
/// </summary>
|
||||
public const string RateSource = "cbr";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «colState»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, object?> ColState = new Dictionary<string, object?>();
|
||||
|
||||
// ── ИИ ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «aiProvider»
|
||||
/// </summary>
|
||||
public const string AiProvider = "deepseek";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «aiConfigs»
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, AiConfigSetting> AiConfigs = BuildDefaultAiConfigs();
|
||||
|
||||
// ── Telegram / Discovery ──
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «autoMonitorNew»
|
||||
/// </summary>
|
||||
public const bool AutoMonitorNew = true;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «discJoinLimit»
|
||||
/// </summary>
|
||||
public const int DiscJoinLimit = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «discJoinDelayMin»
|
||||
/// </summary>
|
||||
public const int DiscJoinDelayMin = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «discJoinDelayMax»
|
||||
/// </summary>
|
||||
public const int DiscJoinDelayMax = 70;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «discEvalSample»
|
||||
/// </summary>
|
||||
public const int DiscEvalSample = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «discEvalThreshold»
|
||||
/// </summary>
|
||||
public const int DiscEvalThreshold = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Дефолт «discPaused»
|
||||
/// </summary>
|
||||
public const bool DiscPaused = false;
|
||||
|
||||
private static IReadOnlyDictionary<string, AiConfigSetting> BuildDefaultAiConfigs()
|
||||
{
|
||||
var result = new Dictionary<string, AiConfigSetting>(AiProviders.All.Count);
|
||||
foreach (AiProviderDefinition provider in AiProviders.All)
|
||||
{
|
||||
string firstModel = provider.Models.FirstOrDefault() ?? string.Empty;
|
||||
result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Каталог ключей настроек тенанта.
|
||||
/// </summary>
|
||||
public static class SettingsKeys
|
||||
{
|
||||
// ── Внутренние (непубличные) ключи: в GET/PATCH /settings не участвуют ──
|
||||
|
||||
/// <summary>
|
||||
/// Кэш курсов валют.
|
||||
/// </summary>
|
||||
public const string RatesCache = "ratesCache";
|
||||
|
||||
/// <summary>
|
||||
/// Счётчик решений ML.
|
||||
/// </summary>
|
||||
public const string MlDecisions = "mlDecisions";
|
||||
|
||||
/// <summary>
|
||||
/// Счётчик решений ИИ.
|
||||
/// </summary>
|
||||
public const string AiDecisions = "aiDecisions";
|
||||
|
||||
/// <summary>
|
||||
/// Метка последнего успешного ИИ-предложения колонок, epoch-сек.
|
||||
/// </summary>
|
||||
public const string LastSuggestAt = "lastSuggestAt";
|
||||
|
||||
/// <summary>
|
||||
/// Последний статус Telegram-аккаунта.
|
||||
/// </summary>
|
||||
public const string TgStatus = "tgStatus";
|
||||
|
||||
/// <summary>
|
||||
/// Аккаунт Telegram «@username».
|
||||
/// </summary>
|
||||
public const string TgAccount = "tgAccount";
|
||||
|
||||
/// <summary>
|
||||
/// Накопленный учёт токенов ИИ-вызовов.
|
||||
/// </summary>
|
||||
public const string AiTokenUsage = "aiTokenUsage";
|
||||
|
||||
/// <summary>
|
||||
/// День суточного лимита авто-вступлений Discovery «flood».
|
||||
/// </summary>
|
||||
public const string DiscFloodDay = "discFloodDay";
|
||||
|
||||
// ── Int: целочисленные настройки ──
|
||||
|
||||
public const string ArchiveAfterDays = "archiveAfterDays";
|
||||
|
||||
public const string ArchiveClearDays = "archiveClearDays";
|
||||
|
||||
public const string TrashClearDays = "trashClearDays";
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина сообщения для -1 фильтра
|
||||
/// </summary>
|
||||
public const string MinLen = "minLen";
|
||||
|
||||
public const string DiscJoinLimit = "discJoinLimit";
|
||||
|
||||
public const string DiscJoinDelayMin = "discJoinDelayMin";
|
||||
|
||||
public const string DiscJoinDelayMax = "discJoinDelayMax";
|
||||
|
||||
public const string DiscEvalSample = "discEvalSample";
|
||||
|
||||
public const string DiscEvalThreshold = "discEvalThreshold";
|
||||
|
||||
public const string ExcludeBudgetFrom = "excludeBudgetFrom";
|
||||
|
||||
public const string ExcludeBudgetTo = "excludeBudgetTo";
|
||||
|
||||
// ── Bool: булевы настройки ──
|
||||
|
||||
public const string AutoArchive = "autoArchive";
|
||||
|
||||
public const string AiEnabled = "aiEnabled";
|
||||
|
||||
public const string AiFilterEnabled = "aiFilterEnabled";
|
||||
|
||||
public const string ConversionOn = "conversionOn";
|
||||
|
||||
public const string RemindersEnabled = "remindersEnabled";
|
||||
|
||||
public const string MlEnabled = "mlEnabled";
|
||||
|
||||
public const string BlockResumes = "blockResumes";
|
||||
|
||||
public const string BudgetRequiredHire = "budgetRequiredHire";
|
||||
|
||||
public const string BudgetRequiredOrder = "budgetRequiredOrder";
|
||||
|
||||
public const string AutoMonitorNew = "autoMonitorNew";
|
||||
|
||||
public const string DiscPaused = "discPaused";
|
||||
|
||||
// ── String: строковые настройки ──
|
||||
|
||||
public const string TargetCurrency = "targetCurrency";
|
||||
|
||||
public const string RateSource = "rateSource";
|
||||
|
||||
public const string AiProvider = "aiProvider";
|
||||
|
||||
public const string AiPrompt = "aiPrompt";
|
||||
|
||||
public const string AiFilterPrompt = "aiFilterPrompt";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ « »: промпт структуры карточки
|
||||
/// </summary>
|
||||
public const string CardPrompt = "cardPrompt";
|
||||
|
||||
public const string DomainDescription = "domainDescription";
|
||||
|
||||
public const string WantedType = "wantedType";
|
||||
|
||||
public const string HireLabel = "hireLabel";
|
||||
|
||||
public const string OrderLabel = "orderLabel";
|
||||
|
||||
// ── List: списки строк ──
|
||||
|
||||
public const string StopPhrases = "stopPhrases";
|
||||
|
||||
public const string DomainKeywords = "domainKeywords";
|
||||
|
||||
public const string HireMarkers = "hireMarkers";
|
||||
|
||||
public const string LevelTerms = "levelTerms";
|
||||
|
||||
public const string ResumeMarkers = "resumeMarkers";
|
||||
|
||||
public const string ExcludeKeywords = "excludeKeywords";
|
||||
|
||||
public const string ExcludeLocations = "excludeLocations";
|
||||
|
||||
public const string ExcludeTypes = "excludeTypes";
|
||||
|
||||
// ── Словари / особые ──
|
||||
|
||||
public const string ColState = "colState";
|
||||
|
||||
public const string MyPrompts = "myPrompts";
|
||||
|
||||
public const string AiConfigs = "aiConfigs";
|
||||
|
||||
/// <summary>
|
||||
/// Каталог публичных ключей
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, SettingKind> PublicKeys = new Dictionary<string, SettingKind>
|
||||
{
|
||||
// Int
|
||||
[ArchiveAfterDays] = SettingKind.Int,
|
||||
[ArchiveClearDays] = SettingKind.Int,
|
||||
[TrashClearDays] = SettingKind.Int,
|
||||
[MinLen] = SettingKind.Int,
|
||||
[DiscJoinLimit] = SettingKind.Int,
|
||||
[DiscJoinDelayMin] = SettingKind.Int,
|
||||
[DiscJoinDelayMax] = SettingKind.Int,
|
||||
[DiscEvalSample] = SettingKind.Int,
|
||||
[DiscEvalThreshold] = SettingKind.Int,
|
||||
[ExcludeBudgetFrom] = SettingKind.Int,
|
||||
[ExcludeBudgetTo] = SettingKind.Int,
|
||||
// Bool
|
||||
[AutoArchive] = SettingKind.Bool,
|
||||
[AiEnabled] = SettingKind.Bool,
|
||||
[AiFilterEnabled] = SettingKind.Bool,
|
||||
[ConversionOn] = SettingKind.Bool,
|
||||
[RemindersEnabled] = SettingKind.Bool,
|
||||
[MlEnabled] = SettingKind.Bool,
|
||||
[BlockResumes] = SettingKind.Bool,
|
||||
[BudgetRequiredHire] = SettingKind.Bool,
|
||||
[BudgetRequiredOrder] = SettingKind.Bool,
|
||||
[AutoMonitorNew] = SettingKind.Bool,
|
||||
[DiscPaused] = SettingKind.Bool,
|
||||
// String
|
||||
[TargetCurrency] = SettingKind.String,
|
||||
[RateSource] = SettingKind.String,
|
||||
[AiProvider] = SettingKind.String,
|
||||
[AiPrompt] = SettingKind.String,
|
||||
[AiFilterPrompt] = SettingKind.String,
|
||||
[CardPrompt] = SettingKind.String,
|
||||
[DomainDescription] = SettingKind.String,
|
||||
[WantedType] = SettingKind.String,
|
||||
[HireLabel] = SettingKind.String,
|
||||
[OrderLabel] = SettingKind.String,
|
||||
// List
|
||||
[StopPhrases] = SettingKind.List,
|
||||
[DomainKeywords] = SettingKind.List,
|
||||
[HireMarkers] = SettingKind.List,
|
||||
[LevelTerms] = SettingKind.List,
|
||||
[ResumeMarkers] = SettingKind.List,
|
||||
[ExcludeKeywords] = SettingKind.List,
|
||||
[ExcludeLocations] = SettingKind.List,
|
||||
[ExcludeTypes] = SettingKind.List,
|
||||
// Dict
|
||||
[ColState] = SettingKind.Dict,
|
||||
// special
|
||||
[MyPrompts] = SettingKind.MyPrompts,
|
||||
[AiConfigs] = SettingKind.AiConfigs,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает категорию публичного ключа или null, если ключ не в каталоге.
|
||||
/// </summary>
|
||||
/// <param name="key">Имя ключа.</param>
|
||||
/// <returns>Категория ключа или null для неизвестного/внутреннего ключа.</returns>
|
||||
public static SettingKind? FindPublicKind(string key)
|
||||
{
|
||||
return PublicKeys.TryGetValue(key, out SettingKind kind) ? kind : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
|
||||
namespace Deal.Modules.Settings.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Типизированный снимок настроек тенанта
|
||||
/// </summary>
|
||||
public sealed class TenantSettingsSnapshot
|
||||
{
|
||||
private static readonly JsonSerializerOptions RatesCacheJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Переопределения тенанта: ключ → строка settings (копия на момент загрузки).
|
||||
private readonly IReadOnlyDictionary<string, SettingValue> _overrides;
|
||||
|
||||
// Создаёт снимок из готовых строк (конструктор приватный — вход через LoadAsync/FromRows).
|
||||
// overrides: Переопределения настроек тенанта (ключ → строка).
|
||||
private TenantSettingsSnapshot(IReadOnlyDictionary<string, SettingValue> overrides)
|
||||
{
|
||||
_overrides = overrides;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает ВСЕ переопределения тенанта одним запросом и строит снимок.
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
|
||||
/// <returns>Снимок (значения резолвятся лениво методами Get* с дефолтами SettingsDefaults).</returns>
|
||||
public static async Task<TenantSettingsSnapshot> LoadAsync(ISettingsStore store, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(ct);
|
||||
return FromRows(rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строит снимок из готовых строк — переиспользование одного чтения на операцию/батч.
|
||||
/// </summary>
|
||||
/// <param name="rows">Строки настроек (например, из одного GetAllAsync вызывающего).</param>
|
||||
/// <returns>Снимок над переданными строками.</returns>
|
||||
public static TenantSettingsSnapshot FromRows(IEnumerable<SettingValue> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
var map = new Dictionary<string, SettingValue>(StringComparer.Ordinal);
|
||||
foreach (SettingValue row in rows)
|
||||
{
|
||||
map[row.Key] = row;
|
||||
}
|
||||
|
||||
return new TenantSettingsSnapshot(map);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Булева настройка
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки (bool-категория каталога <see cref="SettingsKeys"/>).</param>
|
||||
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
|
||||
/// <returns>Значение настройки либо дефолт (отсутствие строки/повреждение/другой JSON-вид).</returns>
|
||||
public bool GetBool(string key, bool defaultValue)
|
||||
{
|
||||
JsonElement? value = ReadValue(key);
|
||||
if (value is { ValueKind: JsonValueKind.True })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value is { ValueKind: JsonValueKind.False })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Целочисленная настройка
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки (int-категория каталога <see cref="SettingsKeys"/>).</param>
|
||||
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
|
||||
/// <returns>Значение настройки (диапазон long сжимается до int) либо дефолт.</returns>
|
||||
public int GetInt(string key, int defaultValue)
|
||||
{
|
||||
return TryReadInt64(key, out long wide)
|
||||
? (int)Math.Clamp(wide, int.MinValue, int.MaxValue)
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Длинная целочисленная настройка
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки (числовой JSON).</param>
|
||||
/// <param name="defaultValue">Дефолт (0 для внутренних счётчиков).</param>
|
||||
/// <returns>Значение настройки либо дефолт.</returns>
|
||||
public long GetLong(string key, long defaultValue)
|
||||
{
|
||||
return TryReadInt64(key, out long wide) ? wide : defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строковая настройка
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки (string-категория каталога <see cref="SettingsKeys"/>).</param>
|
||||
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
|
||||
/// <returns>Значение настройки либо дефолт.</returns>
|
||||
public string GetString(string key, string defaultValue)
|
||||
{
|
||||
JsonElement? value = ReadValue(key);
|
||||
if (value is { ValueKind: JsonValueKind.String })
|
||||
{
|
||||
return value.Value.GetString() ?? defaultValue;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Список-настройка строк
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки (list-категория каталога <see cref="SettingsKeys"/>).</param>
|
||||
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
|
||||
/// <returns>Список строк либо дефолт.</returns>
|
||||
public IReadOnlyList<string> GetStringList(string key, IReadOnlyList<string> defaultValue)
|
||||
{
|
||||
JsonElement? value = ReadValue(key);
|
||||
if (value is null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
switch (value.Value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
var items = new List<string>();
|
||||
foreach (JsonElement element in value.Value.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
items.Add(element.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
case JsonValueKind.String:
|
||||
return new[] { value.Value.GetString() ?? string.Empty };
|
||||
default:
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Внутренний кэш курсов ratesCache
|
||||
/// </summary>
|
||||
/// <returns>Кэш {rates, source, updatedAtMs} либо null — строки нет / JSON повреждён / rates не задан (дефолт — мок-курсы, решает вызывающий, как RatesService.LoadCacheAsync).</returns>
|
||||
public RatesCacheValue? TryGetRatesCache()
|
||||
{
|
||||
JsonElement? value = ReadValue(SettingsKeys.RatesCache);
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RatesCacheValue? cache = value.Value.Deserialize<RatesCacheValue>(RatesCacheJsonOptions);
|
||||
return cache is { Rates: not null } ? cache : null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённый кэш трактуем как отсутствующий (мягкая семантика, как в SettingsService).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Читает целочисленное значение настройки (JSON-число или числовая строка).
|
||||
// key: Ключ настройки.
|
||||
// value: Прочитанное число (int64; диапазон шире int32 — сжимает вызывающий).
|
||||
// Возвращает: True — значение прочитано; False — отсутствует/повреждено/нечисловое.
|
||||
private bool TryReadInt64(string key, out long value)
|
||||
{
|
||||
JsonElement? element = ReadValue(key);
|
||||
if (element is null)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.Value.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return element.Value.TryGetInt64(out value);
|
||||
}
|
||||
|
||||
if (element.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return long.TryParse(
|
||||
element.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Распарсенное JSON-значение сохранённой настройки; отсутствие строки/повреждённый JSON → null.
|
||||
// key: Ключ настройки.
|
||||
// Возвращает: Корневой JsonElement (копия, живёт после вызова) или null.
|
||||
private JsonElement? ReadValue(string key)
|
||||
{
|
||||
if (!_overrides.TryGetValue(key, out SettingValue? row))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — трактуем как отсутствующую (мягкая семантика, как в SettingsService).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user