1
План stage2 settings
stepan edited this page 2026-09-13 00:17:00 +03:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Перенесено из репозитория (docs/superpowers/plans/2026-09-05-deal-stage2-settings.md). Актуальная версия — здесь, в вики.

Дейл (Deal) — Этап 2: Настройки тенанта (Settings) Implementation Plan

Исторический документ этапа 2. Актуальное состояние — docs/superpowers/STATUS.md и docs/technical/Техническая-документация-Дейл.md.

Goal: Реализовать в модульном монолите src/core модуль Settings с 1:1-контрактом /api, который потребляет экран «Настройки» Vue-фронта (src/frontend/src/views/SettingsView.vue, components/MLPanel.vue, PromptLibraryModal.vue): чтение/сохранение дерева настроек тенанта (таблица settings уже есть), шифрование секретов (ключи AI/Telegram), проверка подключения AI-провайдера, курсы валют, ML-панель на детерминированной локальной заглушке, тестер фильтров входящих. К концу этапа Settings-экран обслуживается бэкендом полностью (кроме зон, помеченных зависимостями этапов 3–6); приёмка — curl/psql/unit-тесты (Vue-фронт полностью оживает только с этапом 3: его boot() требует /api/boards, /api/leads, /api/projects, /api/tg/status — см. Ruling 11).

Architecture: новый модуль Deal.Modules.Settings (чистый, без EF): константы/дефолты, типизированный каталог ключей, порты ISettingsStore/ISecretCipher/IRatesSource/ IAiConnectionChecker, сервисы SettingsService (public-снимок + частичный PATCH), RatesService, IncomingRules (этап-1 правила тестера). Адаптеры — в Deal.Infrastructure: KV SettingsStore (таблица settings, JSON в value_json), AesGcmSecretCipher, CbrRateSource, HTTP-проверка AI. Интеграционный порт IMlClient + record-DTO — в Deal.Contracts/Integrations, заглушка LocalMlClient — в Deal.Infrastructure/Integrations. HTTP-эндпоинты — в Deal.Api/Endpoints/ (MapSettingsEndpoints, MapMlEndpoints, MapFilterTesterEndpoints). Внешние сервисы (реальные ml/ai/telegram) на этапе 6 заменят заглушки gRPC-адаптерами без правки эндпоинтов.

Spec: docs/api/api-map.md §3.4 (L142152), §3.7 (L187199), §4.6 (L315341), §4.7 (L343346), §4.10 (L363365), правила L7–24, п.9 «экономия» (L399); docs/spec/ТЗ-дейл-новая-архитектура.md §8 (L165179), §5 (L89121, фильтры), §7 (L150–161 — только пересечения), §9 (лимиты — НЕ в этап); docs/architecture/2026-09-05-deal-architecture-design.md §5 (границы модулей), §8 (секреты L207); референс-семантика: backend/app/routers/settings_routes.py, backend/app/services/rates.py, backend/app/services/ai.py (L3677, L188198), backend/app/routers/ml_routes.py, backend/app/services/ml_client.py, backend/app/routers/dashboard_routes.py (admin/check-message L267284), backend/app/services/pipeline.py (stage1_plain L94124), backend/app/constants.py (L3050, L54245), backend/app/crypto.py, backend/app/config.py (L4851); фронт: src/frontend/src/store.js (boot L565628, applySettings L343397, applyMlStatus L487502, schedulePersist L17371766, refreshRates L18441848), src/frontend/src/data.js (L6–141 дефолты промптов; AI_PROVIDERS L1780; PROMPT_LIBRARY L180–200 — библиотека по сферам живёт ТОЛЬКО во фронте, бэкенд её не отдаёт), views/SettingsView.vue (вкладки L3949), components/MLPanel.vue, components/PromptLibraryModal.vue.

Global Constraints

  • Проект НЕ git; фиксация — отчёты задач task-N-report.md и progress.md. Рабочая папка плана: .superpowers/sdd/deal-stage2-settings/.
  • .NET 10 SDK, решение собирается с 0 warnings / 0 errors (TreatWarningsAsErrors).
  • Код-стайл этапа 1: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; явные модификаторы; настройки через IOptions<T>; без регионов.
  • namespace Deal.*. Секретов в коде нет; ключи шифрования — env/файл (Ruling 2). tenantId — только из сессии.
  • Таблица settings уже в TenantDbContext (миграция InitialTenant) — новые EF-таблицы в этапе 2 НЕ создаются.
  • LeadRadar-контейнеры, backend/, mlservice/, src/frontend/ не трогаем. Dev-Postgres deal-postgres (:5433).
  • Ответы: camelCase JSON; ошибки — HTTP + {"detail"}; «мягкие» ошибки (ml/reset) — HTTP 200 с полем error.
  • Дефолтные значения настроек/промптов — из констант прототипа constants.py и data.js (фронт — высший авторитет форм; тексты промптов копируются из data.js L94141).

Зафиксированные решения (Rulings этапа)

  • Ruling 1 (модель настроек): типизированные ключи в существующей таблице settings (key text PK, value_json — JSON-сериализованное значение любого типа, updated_at). Модуль хранит только переопределения; дефолты — в коде (SettingsDefaults), при чтении снимок = дефолты, перекрытые сохранёнными значениями. Каталог публичных ключей — статический словарь «ключ → категория» (Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys). Внутренние (непубличные) ключи — ratesCache, mlDecisions, aiDecisions — хранятся в той же таблице через ISettingsStore, но в GET/PATCH /settings не участвуют. Неизвестные ключи в PATCH игнорируются (семантика settings_routes.py L110185).
  • Ruling 2 (шифрование секретов): AES-256-GCM (System.Security.Cryptography.AesGcm), nonce 12 байт, tag 16 байт. Ключ — env DEAL_ENCRYPTION_KEY (32 байта, urlsafe-Base64); при отсутствии в dev — файл <ContentRoot>/data/encryption.key (генерируется при первом старте, лог-warning; путь переопределяется env DEAL_ENCRYPTION_KEY_FILE). Формат значения в БД: enc: + Base64(nonce‖ct‖tag). Расшифровка повреждённого/чужого значения → пустая строка
    • warning (совместимость crypto.decrypt_text, crypto.py L5261). Порт ISecretCipher — в модуле Settings, адаптер AesGcmSecretCipher — в Infrastructure.
  • Ruling 3 (маски и публичная форма): маска mask(v): пусто → "", len≤8 → как есть, иначе v[:4]+"…"+v[-4:] (settings_routes.py L2832). aiConfigs наружу — {id: {baseUrl, model, keySet, keyMasked}}; tgKeys{apiId: <маска>, apiHashSet: bool}. Список providers — статический из модуля (id,name,base,local,models; зеркало constants.AI_PROVIDERS L170186; api_style — внутреннее поле, наружу не отдаётся).
  • Ruling 4 (границы интеграционных портов): порты будущих внешних сервисов (ML/AI/telegram) объявляются в Deal.Contracts/Integrations (интерфейс + record-DTO) — их потребляют несколько модулей и Api. Заглушки этапа — детерминированные адаптеры в Deal.Infrastructure/Integrations; на этапе 6 заменяются gRPC-клиентами с тем же контрактом. IAiFacade на этапе 2 не заводится: классификация/фильтр ИИ — этап 6, проверка соединения — модульный порт IAiConnectionChecker.
  • Ruling 5 (ML-заглушка): IMlClient (Contracts): StatusAsync/PredictAsync/ResetAsync (+ PushAsync добавится этапом 3). LocalMlClient — детерминированная: reachable=true, ready=false, classes={}, learned=0, eval={count:0,correct:0,accuracy:0} (обучение на действиях появится с Kanban-этапом 3); PredictAsync неготовой модели → {take:false,label:null,scores:{},hits:0,ready:false,margin:null,terms:[],type:null}; ResetAsync{ok:true}. Таблиц ml_outbox/learning_log в этапе 2 нет (владельцы — этапы 3/4); счётчики mlDecisions/aiDecisions — KV-настройки.
  • Ruling 6 (курсы валют): кэш — tenant-настройка ratesCache {rates, source, updatedAtMs}. Источник по rateSource (cbr|mock); интервал обновления 6 часов (≤4 запроса/сутки, rates.py L20); USDT=USD (rates.py L8691). Mock-курсы — константа MockRates (constants.py L41–50). Обновление: лениво на GET при протухании/смене источника, синхронно на POST /rates/refresh, фоново-запуск на PATCH rateSource (settings_routes.py L186–192). Массовый пересчёт карточек (recompute_conversions) — этап 3 (таблицы leads нет); в этапе 2 — только чистый ConvertAmount.
  • Ruling 7 (проверка AI): реальный HTTP, без LLM-вызовов, 1:1 settings_routes.py L195–219: нет ключа → {ok:false, message:"Не задан API-ключ"}; локальный провайдер → {ok:true, message:"Локальный сервер «<name>» (ping в проде)"}; облачный → GET {base}/models (Anthropic: {base}/v1/models, заголовок x-api-key); HTTP<400 → ok, 401/403 → «Ключ не принят (HTTP n)…», иначе «HTTP n — проверьте Base URL и модель»; сетевой сбой → «Ошибка соединения: …». Ответ — {ok, message} + статус провайдера (provider,name,base,model,local, keySet,keyMasked, ai.py L3658).
  • Ruling 8 (эндпоинты этапа и границы): файлы Deal.Api/Endpoints/*, группы MapSettingsEndpoints (GET/PATCH /settings), MapRatesEndpoints (GET /rates, POST /rates/refresh), MapAiCheckEndpoint (POST /ai/check), MapMlEndpoints (/ml/*), MapFilterTesterEndpoints (POST /admin/check-message). «Только для Settings-экрана»: GET/PATCH /settings, POST /ai/check, GET/POST /rates*, POST /admin/check-message, ML-статус/сброс/проверка. «Переиспользуются этапами 3+»: GET/PATCH /settings — общий источник настроек для pipeline/kanban/projects/discovery; /api/ml/* — счётчики и обучение (этап 3), предсказания (этап 4), кандидаты/apply оживают с telegram-данными (этап 6); правила IncomingRules — этап-1 пайплайна (этап 4). НЕ входят в этап 2 (зависимости): /api/tg/* (этап 6), admin/tick, admin/fts/rebuild (кнопки «Хранение и очистка» — этапы 3/4), /api/ai/suggest-keywords и suggest-columns (этапы 3/6), /api/columns/*, /api/leads/*, /api/boards/*, /api/projects/*, /api/pipeline/*, /api/discovery/*, /api/meta/constants (фронт не вызывает — api-map п.9 L399), ml/learn, ml/flush (там же), события SSE, лимиты ТЗ §9 (этап 7).
  • Ruling 9 (колонки/colState): колонки и их правила — этап 3 (Kanban). В этапе 2 colState — обычный dict-ключ (passthrough в PATCH, дефолт {}), отдельные /api/columns/* НЕ делаются.
  • Ruling 10 (звук/вид/напоминания): soundOn/volume и тема — локальное состояние фронта (store.js L106111, в PATCH не шлются) — бэкенд не нужен. Общие напоминания — ключ remindersEnabled (passthrough); отложенные напоминания и reminder_due — этап 5 (Projects).
  • Ruling 11 (приёмка и фронт): Vue boot() (store.js L571–581) требует отсутствующие до этапа 3 группы (/boards, /leads, /leads/counts, /projects, /tg/status, /columns/state) — полная работа фронта восстанавливается этапом 3; поэтому приёмка этапа 2 — unit-тесты + curl + psql. Строки ошибок/сообщений — фиксированные из прототипа (см. задачи).

Задачи

Сокращения путей: S= src/core/Deal.Modules.Settings/, I= src/core/Deal.Infrastructure/, A= src/core/Deal.Api/, C= src/core/Deal.Contracts/, T= src/core/tests/Deal.Tests.Unit/.

Task 1: Шифрование секретов (AES-GCM) — фундамент хранения ключей AI/Telegram

Files:

  • Create: S/Application/ISecretCipher.csEncrypt(string)→string (префикс enc:), Decrypt(string)→string (без префикса — вернуть как есть; сбой → ""), MaybeEncrypt.
  • Create: I/Security/AesGcmSecretCipher.cs — AES-256-GCM, nonce 12/tag 16, формат enc: + Base64(nonce‖ct‖tag) (Ruling 2).
  • Create: I/Security/EncryptionKeyProvider.cs — ключ из IConfiguration (DEAL_ENCRYPTION_KEY, Base64 32 байта); fallback: файл data/encryption.key (env DEAL_ENCRYPTION_KEY_FILE), генерация при первом старте + warning; невалидный env-ключ → исключение при старте (семантика crypto._get_fernet, crypto.py L2242).
  • Create: A/Configuration/EncryptionOptions.cs (IOptions: секция Encryption: KeyFilePath, дефолт data/encryption.key).
  • Modify: I/ServiceCollectionExtensions.cs — регистрация ISecretCipher→AesGcmSecretCipher (singleton, ключ из provider).
  • Test: T/SecretCipherTests.cs (roundtrip; префикс enc:; незашифрованная строка проходит как есть; повреждённый токен → ""; MaybeEncrypt("")"").

Источники: backend/app/crypto.py L170; backend/app/config.py L4851.

Acceptance: build 0/0; dotnet test — SecretCipherTests PASS. Отчёт: task-1-report.md.

Task 2: Модуль Settings — каталог ключей, дефолты, DTO, порт хранилища

Files:

  • Create: S/Application/SettingKind.cs (enum: Int/Bool/String/List/Dict/MyPrompts/AiConfigs/TgKeys/Internal).
  • Create: S/Application/SettingsKeys.cs — статический каталог публичных ключей (категория каждого ключа, 1:1 список §4.6 и PATCH-список L340): Int — archiveAfterDays, archiveClearDays, trashClearDays, minLen, discJoinLimit, discJoinDelayMin/Max, discEvalSample, discEvalThreshold; Bool — autoArchive, aiEnabled, aiFilterEnabled, conversionOn, remindersEnabled, mlEnabled, blockResumes, budgetRequiredHire/Order, autoMonitorNew, discPaused; String — targetCurrency, rateSource, aiProvider, aiPrompt, aiFilterPrompt, cardPrompt, domainDescription, wantedType, hireLabel, orderLabel; List — stopPhrases, domainKeywords, hireMarkers, levelTerms, resumeMarkers; Dict — colState; + special: myPrompts, aiConfigs, tgKeys; Internal: ratesCache, mlDecisions, aiDecisions (в PATCH/GET не участвуют, Ruling 1).
  • Create: S/Application/SettingsDefaults.cs — значения по умолчанию из constants.py L189–245 (включая дефолтные стоп-фразы L55, minLen=24, hire/level/resume-маркеры L144167, aiConfigs для каждого провайдера с первым model, tgKeys={apiId:"",apiHash:""}).
  • Create: S/Application/DefaultPrompts.cs — константы DefaultAiPrompt, DefaultCardPrompt, DefaultAiFilterPrompt — тексты КОПИРУЮТСЯ из src/frontend/src/data.js L94–141 (фронт — источник; в constants.py L63–141 те же тексты для сверки).
  • Create: S/Application/AiProviderDefinition.cs (record: Id, Name, Base, Local, Models, ApiStyle? null=OpenAI-совместимый, "anthropic"), S/Application/AiProviders.cs (статический список 7 провайдеров: deepseek/openai/openrouter/anthropic/ollama/lmstudio/custom — constants.py L170186).
  • Create: S/Application/MockRates.cs (константа, constants.py L4150) + RatesFetchInterval = 6h.
  • Create: S/Application/ISettingsStore.cs — порт: Task<object?> GetAsync(string key, ct), Task<Dictionary<string,object?>> GetAllAsync(ct), Task SetAsync(string key, object? value, ct) (значения JSON-сериализуемые; список/словарь/строка/число/булево).
  • Test: T/SettingsCatalogTests.cs (все ключи §4.6 присутствуют с корректной категорией; внутренние ключи не в каталоге публичных; провайдеры: 7 шт., id/base соответствуют списку; MockRates содержит RUB/USD/EUR/USDT).

Источники: api-map §4.6 L315341; constants.py; data.js L6141.

Acceptance: build 0/0; SettingsCatalogTests PASS. Отчёт: task-2-report.md.

Task 3: SettingsService — public-снимок и частичное обновление (PATCH-семантика 1:1)

Files:

  • Create: S/Application/Models/PublicSettingsDto.cs — record со всеми полями §4.6 (вложенные: MyPromptDto{Id,Name,Description,Prompt}, AiConfigPublicDto{BaseUrl,Model,KeySet, KeyMasked}, TgKeysPublicDto{ApiId,ApiHashSet}, ProviderPublicDto{Id,Name,Base,Local,Models}).
  • Create: S/Application/SettingsService.csGetPublicAsync(ct) (дефолты+сохранённые, маскирование, Ruling 3; для apiHashSetSecretCipher.Decrypt(apiHash) != "", для каждого провайдера — расшифровка ключа + keySet/keyMasked); ApplyPatchAsync( Dictionary<string,JsonElement> body, ct) с клампами и валидацией (см. ниже), ответ — полный public-снимок (фронт затирает локальный state ответом — api-map L147, L341).
  • Create: T/…/FakeSettingsStore.cs (in-memory Dictionary), T/SettingsServiceTests.cs.

Семантика PATCH (референс settings_routes.py L75192):

  • Int: нечисловое → пропуск ключа; клампы: archiveAfterDays 1..30, minLen 10..500, discJoinLimit 1..200, discJoinDelayMin/Max 5..600, discEvalSample 3..30, discEvalThreshold 1..100; интервалы задержек: при паре — клампы+swap при min>max; при одном конце — кламп относительно сохранённого другого конца (L80–109).
  • Bool: JSON-булево (строки не «питон-булеватся»). String: targetCurrency → Upper; aiProvider вне списка провайдеров → пропуск; остальные — строка как есть.
  • List: только список → строки, срез 200. Dict: colState — как есть (Ruling 9).
  • myPrompts: ≤100; name≤80, prompt≤8000, description≤300 (trim); пустые name/prompt — дроп; id ≤40 или генерация pp_ + 8 hex (Ruling дефолта, референс L143–160).
  • aiConfigs: только существующие провайдеры; baseUrl/model — строки; apiKey непустой, ≥8 симв., без префикса enc: → шифруется (L161–175).
  • tgKeys: apiId — только цифры, длина 6..9 (5<len<10); apiHash ≥16 симв. → шифруется (L176–185).
  • Побочные эффекты PATCH: при rateSource — запуск RatesService.RefreshAsync (fire-and-forget); при targetCurrency/conversionOn — в этапе 2 ничего (нет leads; этап 3) (L186192).

Источники: api-map §4.6 L147, L340341; settings_routes.py целиком; crypto.py.

Acceptance: dotnet test — SettingsServiceTests PASS: снимок дефолтов; маскирование ключа; каждый кламп; swap интервалов; myPrompts clean+id; шифрование aiConfigs/tgKeys (в БД enc:); неизвестный ключ игнорируется. Отчёт: task-3-report.md.

Task 4: KV-адаптер SettingsStore (EF) и DI

Files:

  • Create: I/Persistence/Repositories/SettingsStore.cs — реализует ISettingsStore на TenantDbContext.Settings (сущность TenantSettingEntity уже есть): чтение всех строк, сериализация/десериализация значений в JSON, updated_at — UTC-now.
  • Modify: I/ServiceCollectionExtensions.csAddScoped<ISettingsStore, SettingsStore>(); регистрация ISecretCipher из Task 1, RatesService-зависимостей из Tasks 6–8.
  • Modify: S/SettingsModuleRegistrar.cs (Create) — AddSettingsModule(): SettingsService, RatesService, IncomingRules (scoped); вызывается в A/Program.cs (Task 5).
  • Modify: A/Deal.Api.csproj — ProjectReference на Deal.Modules.Settings.

Источники: эталон: I/Persistence/Repositories/AuthStore.cs, TenantModuleRegistrar.cs, ServiceCollectionExtensions.cs (этап 1).

Acceptance: build 0/0; psql-проверка: GET через сервис на пустой схеме тенанта возвращает дефолты, SetAsync создаёт строку с value_json. Отчёт: task-4-report.md.

Task 5: Эндпоинты GET/PATCH /api/settings + DI + curl-приёмка

Files:

  • Create: A/Endpoints/SettingsEndpoints.cs (MapSettingsEndpoints): GET /api/settings → PublicSettingsDto; PATCH /api/settings — тело произвольный JSON-объект → полный снимок после применения. Авторизация — через SessionMiddleware/CurrentUser (эталон AuthEndpoints.cs), 401 {"detail":"Требуется авторизация"}.
  • Modify: A/Program.csAddSettingsModule(), map групп эндпоинтов.
  • Модификации предыдущих задач собираются здесь же (порядок исполнения: T1→T4 затем T5).

Контракт (api-map §3.4 L146147, §4.6): GET — все ключи §4.6 (camelCase, дефолты, маски, providers список); PATCH — те же поля-группы, что шлёт фронт (L340), ответ — полный снимок. Ошибок-исключений нет (мягкая семантика: невалидное поле просто не применяется).

Acceptance (curl, cookie-сессия admin/admin):

  1. GET /api/settings → дефолты: aiEnabled:true, mlEnabled:true, minLen:24, archiveAfterDays:14, stopPhrases:[4 дефолтные], wantedType:"both", rateSource:"cbr", aiProvider:"deepseek", tgKeys:{apiId:"", apiHashSet:false}, colState:{}, providers — 7.
  2. PATCH {"archiveAfterDays":99,"minLen":3,"discJoinDelayMin":700,"discJoinDelayMax":5} → в ответе archiveAfterDays:30, minLen:10, discJoinDelayMin:5, discJoinDelayMax:700 (swap).
  3. PATCH {"myPrompts":[{name:"x",prompt:"y"},{name:"",prompt:""}]} → 1 элемент, id начинается pp_.
  4. PATCH {"aiConfigs":{"deepseek":{"apiKey":"sk-1234567890ab"}}} → ответ keySet:true, keyMasked:"sk-1…90ab"; psql: value_json содержит enc: (см. Task 7-контракт psql).
  5. PATCH {"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}apiHashSet:true.
  6. Неизвестный ключ {"foo":1} — без ошибки, снимок без foo. Отчёт: task-5-report.md.

Task 6: ИИ-провайдеры и POST /api/ai/check (проверка подключения)

Files:

  • Create: S/Application/IAiConnectionChecker.csTask<AiCheckResultDto> CheckAsync( AiCheckRequest request, ct), S/Application/Models/AiCheckResultDto.cs (Ok, Message, Provider, Name, Base, Model, Local, KeySet, KeyMasked), AiCheckRequest (ProviderId, BaseUrl, Model, ApiKey, IsLocal, ApiStyle).
  • Create: I/Integrations/AiConnectionChecker.cs — HTTP-реализация (Ruling 7) через IHttpClientFactory (таймаут 12 с), переиспользует формат сообщений прототипа.
  • Create: A/Endpoints/AiCheckEndpoint.cs (MapAiCheckEndpoint) — читает активную конфигурацию провайдера из ISettingsStore (расшифровка ключа через ISecretCipher), вызывает checker, отдаёт {ok,message,provider,name,base,model,local,keySet,keyMasked} (api-map §4.10 L365).
  • Test: T/AiConnectionCheckerTests.cs (fake HttpMessageHandler): без ключа; local; 200; 401; 403; HTTP 500; сетевая ошибка.

Источники: settings_routes.py L195219; ai.py L3658 (provider_status + mask_key).

Acceptance: build 0/0; тесты PASS. curl: без ключа → {"ok":false,"message":"Не задан API-ключ",...}; провайдер ollama → ok:true «Локальный сервер…»; deepseek с неверным ключом и недоступным хостом → "Ошибка соединения: …" (сеть недоступна — допустимо). Отчёт: task-6-report.md.

Task 7: Промпты и «Мои промпты» — интеграционная проверка границы с фронтом

Бэкенд-логика уже в Tasks 2–3 (DefaultPrompts, валидация myPrompts). Задача — контроль 1:1 границы и приёмочные проверки (библиотека по сферам — фронтовая, data.js PROMPT_LIBRARY L180200; PromptLibraryModal.vue не ходит в API; наружу идут только промпты-строки и myPrompts).

Files:

  • Test: T/PromptDefaultsTests.cs — дефолтные тексты начинаются/содержат маркеры из data.js (например aiPrompt содержит «Ты — классификатор входящих сообщений» и плейсхолдеры {domain}/{keywords}; cardPrompt — «О заявке»; aiFilterPrompt — «страж входящих»); fill_prompt-подстановка (аналог ai.fill_prompt L6377): пустой domain → фраза-фолбэк, keywords склейка, ≤60 ключей.
  • Create: S/Application/PromptFiller.cs — подстановка {domain}/{keywords} (чистая функция, используется этапом 6 для ИИ-вызовов).

Acceptance (curl): 1) PATCH aiPrompt с плейсхолдерами → GET возвращает тот же текст; 2) PATCH myPrompts 3 записи → GET отдаёт их (camelCase id/name/description/prompt); 3) «Применить из библиотеки» фронта = локальная операция — API не вызывается. dotnet test PromptDefaultsTests PASS. Отчёт: task-7-report.md.

Task 8: Курсы валют — сервис, кэш, эндпоинты /api/rates*

Files:

  • Create: S/Application/IRatesSource.cs — порт: Task<Dictionary<string,double>?> FetchAsync(ct) (курсы к RUB). S/Application/Models/RatesDto.cs — record {Base, Rates, Source, UpdatedAtMs?}.
  • Create: S/Application/RatesService.csGetAsync(ct) (кэш ratesCache; нет кэша → дефолт MockRates/source "mock"/updatedAt null); RefreshAsync(ct) (source из настройки: mock → сохранить MockRates; cbr → IRatesSource; неуспех → false, кэш не трогаем); ShouldFetch(ct) (нет кэша / смена источника / ≥6 ч, rates.py L7783); ConvertAmount(amount, fromCur, toCur) — USDT→USD (L86103). Ленивое обновление на GET при ShouldFetch — фоновый запуск RefreshAsync, ответ — текущий кэш.
  • Create: I/Integrations/CbrRateSource.cs — HTTP GET https://www.cbr-xml-daily.ru/daily_json.js (JSON), Valute[code].Value/Nominal, RUB:1; сбой → null (лог) (rates.py L4359).
  • Create: A/Endpoints/RatesEndpoints.cs (MapRatesEndpoints): GET /api/rates → RatesDto; POST /api/rates/refresh{ok, rates: RatesDto} (ok=false при сбое cbr; при mock — true).
  • Modify: A/Program.cs — map; DI: IRatesSource→CbrRateSource (scoped), AddHttpClient.
  • Test: T/RatesServiceTests.cs (fake store+source): mock-режим; cbr успех/сбой; ShouldFetch (интервал 6 ч, смена источника); ConvertAmount USDT=USD, отсутствующая валюта → null.

Источники: services/rates.py целиком; api-map §3.4 L149150; settings_routes.py L224232.

Acceptance: build 0/0; тесты PASS. curl: PATCH {"rateSource":"mock"} затем POST /api/rates/refresh{ok:true, rates:{base:"RUB", rates:{RUB:1,USD:92.5,…}, source:"mock", updatedAt:<ms>}}; GET /api/rates — тот же кэш. Отчёт: task-8-report.md.

Task 9: ML-панель — порт IMlClient, детерминированная заглушка, эндпоинты /api/ml

Files:

  • Create: C/Integrations/IMlClient.cs + C/Integrations/Models/*.cs — record-DTO: MlServiceStatusDto {Ready, Classes(Dictionary<string,double>), Learned, Eval{MlEvalDto}}, MlEvalDto {Count, Correct, Accuracy}, MlPredictResultDto {Take, Label?, Scores, Hits, Ready, Margin?, Terms[], Type?}, MlStatusResponseDto {Enabled, Service, Reachable, Stats{ MlStatsDto}}, MlStatsDto {Ml, Ai, Learning, Ready, Classes, Learned, Reachable, Outbox} (поля/типы 1:1 ml_routes.py L7075 + ml_client.snapshot() L138150).
  • Create: I/Integrations/LocalMlClient.cs — заглушка Ruling 5 (детерминированная; обучение недоступно до этапа 3 — модель всегда «не готова»; счётчики mlDecisions/aiDecisions — из KV settings, Ruling 1).
  • Create: A/Endpoints/MlEndpoints.cs (MapMlEndpoints):
    • GET /api/ml/statusMlStatusResponseDto (enabled = mlEnabled !== false);
    • POST /api/ml/reset{ok:true} (мягкая ошибка {ok:false,error} — зарезервирована);
    • POST /api/ml/predict {text}: trim <2 симв. → 400 «Введите текст»; ответ {text:<первые 200>, take, label, scores, hits, ready, margin, terms, type};
    • POST /api/ml/candidates {dialogId, limit=10 (clamp 1..60)}{items: []} (данных telegram нет — этап 6; контракт §3.7 L196);
    • POST /api/ml/apply {dialogId, msgId, action} → 404 «Исходное сообщение не найдено» (нет сообщений до этапов 3/6; ветка skip — этап 6; контракт §3.7 L197).
    • НЕ реализуем: ml/learn, ml/flush (фронт не вызывает, api-map п.9).
  • Modify: A/Program.cs — DI IMlClient→LocalMlClient (scoped), map.
  • Test: T/LocalMlClientTests.cs (status-форма; predict неготовой модели — все поля; reset → ok).

Источники: api-map §3.7, §4.10 L363; ml_routes.py L6691, L112171; ml_client.py L127150; mlservice/model.py (predict L184293, status L325345 — эталон полей для этапа 6).

Acceptance: build 0/0; тесты PASS. curl: login → GET /api/ml/status (все поля, reachable: true, ready:false, stats.outbox:0); POST /api/ml/predict {"text":"x"} → 400; POST /api/ml/predict {"text":"Python backend на fastapi, бот в телеграм"}take:false, label:null, scores:{}, ready:false; POST /api/ml/reset{ok:true}; POST /api/ml/candidates{"items":[]}. Отчёт: task-9-report.md.

Task 10: Тестер фильтров — этап-1 правила и POST /api/admin/check-message

Files:

  • Create: S/Application/IncomingRules.cs — чистая реализация stage1_plain (pipeline.py L94124) поверх ISettingsStore: минимальная длина (minLen), стоп-фразы (casefold, ответ — конкретная фраза), блокировка резюме (blockResumes + resumeMarkers с контекстным guard «вакансия… присылайте резюме» — не режем, pipeline._resume_reason L644654), тип заявки (wantedType + маркеры найма hireMarkers); результат {pass, reason, stage:1, kind:length|stop|resume|type, kw}.
  • Create: A/Endpoints/FilterTesterEndpoints.cs (MapFilterTesterEndpoints): POST /api/admin/check-message {text}{stage1:{pass,reason}, stage2, passed} (1:1 dashboard_routes.py L267284): если этап-1 не прошёл → stage2:{pass:false,reason:null, skipped:true}, passed:false; иначе stage2:{pass:true,reason:null,skipped:true} — ИИ-фильтр на этапе 2 всегда skipped (Ruling 4/8; реальный ИИ-фильтр — этап 6).
  • Test: T/IncomingRulesTests.cs: короткий текст; стоп-фраза из настроек; резюме (маркер); guard «…вакансия… присылайте резюме» → pass; wantedType:"freelance" с вакансионным маркером; wantedType:"vacancy" с разовым заказом.

Источники: api-map §3.2 L109, §4.10 L364; dashboard_routes.py L267284; pipeline.py L94124; фронт: SettingsView.vue L142155 (тестер), store.js L17231725.

Acceptance: build 0/0; тесты PASS. curl: с дефолтами текст «Заработок на крипте…» (длина ≥24, без стоп-фраз) → stage1.pass:true, stage2.skipped:true, passed:true; текст «Ищу работу python» → stage1.pass:false, kind:"resume" (если ≥minLen); «взаимный пиар» внутри → kind: "stop". Отчёт: task-10-report.md.

Task 11: Финал этапа — интеграция и сквозная приёмка

  • scripts/build.sh/scripts/test.sh — успешны; dotnet build Deal.sln 0/0; все unit-тесты PASS.
  • Сквозной curl-сценарий Settings-экрана: login admin/admin → GET /settings → PATCH-группы из Tasks 5/7 (обработка, ИИ-промпты, myPrompts, aiConfigs, tgKeys, валюта, хранение/уведомления: autoArchive/archiveAfterDays/remindersEnabled, colState) → POST /ai/check → GET /rates + POST /rates/refresh (mock) → GET /api/ml/status + predict + reset → POST /api/admin/check-message (pass и отсев).
  • psql-проверка схемы дефолтного тенанта (SET search_path TO tenant_00000000000000000000000000000001;): строки settings созданы, value_json для aiConfigs/tgKeys содержит enc: и не содержит открытого ключа; внутренние ключи (ratesCache, mlDecisions) не появляются в GET /settings.
  • Известные ограничения этапа (зафиксировать в отчёте): Telegram-вкладка, кнопки «Проверить правила сейчас»/«Пересобрать индекс» (admin/tick, admin/fts), «Предложить ключи» (ai/suggest-keywords) и весь канбан-фронт не работают до этапов 3–6 (Ruling 8/11).
  • Обновить docs/technical/Техническая-документация-Дейл.md (раздел настроек: env DEAL_ENCRYPTION_KEY, поведение GET/PATCH /settings, креды).
  • Отчёт task-11-report.md + финальная строка в progress.md.

Self-Review

  1. Spec coverage: ТЗ §8 (настройки тенанта) — Tasks 1–10; §5 (этап-1 фильтры/тип/резюме — только настройки+тестер) — Task 10, (ML/ИИ-слои пайплайна — этапы 4/6, вне); §7 (обработка) — вне (этап 4); §9 (лимиты) — вне; api-map §3.4 — Tasks 5/7/8; §3.7 — Task 9; admin/check-message — Task 10; §4.6/4.7 — Tasks 2/3/5/7; tgKeys-часть §4.6 — Task 3/5; шифрование §8 архитектуры — Task 1.
  2. Placeholder scan: конкретные адаптеры и контракты; «заглушки» только там, где разрешено решением владельца (п.5): LocalMlClient (Task 9), ИИ-фильтр в тестере = skipped (Task 10); референсы на строки файлов точные. FIXME/TODO нет.
  3. Type consistency: один модуль Settings владеет каталогом ключей/дефолтами — Kanban/Pipeline (этапы 3/4) читают те же ключи через ISettingsStore; IMlClient-контракт (Contracts) един для панели (этап 2), счётчиков (этап 3) и предсказаний (этап 4); сущность TenantSettingEntity не меняется; схемы/миграции не добавляются.
  4. Вне scope этапа 2: канбан-колонки/карточки/архив-корзина и их эндпоинты (этап 3), pipeline/очередь/отсев/дедуп (этап 4), projects/напоминания-отложенные/файлы (этап 5), реальные ml/ai/telegram-сервисы и /api/tg/* (этап 6), discovery, оператор/инвайты/лимиты/ аудит (этап 7); colState-эндпоинты; библиотека промптов (фронтовая); звук/вид (фронт).