Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,95 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage6-services.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
## Todos
- Task 1: complete (1 fix round: ProviderConfig в ai.proto + kind-маппинг RU/EN зафиксирован в README; Deal.Proto build 0/0). Отчёт: task-1-report.md. Note: способ подключения Deal.Proto (ProjectReference vs Include) решают T2T4; HTTP /api/tg/dialogs type RU на границе эндпоинта.
- Task 2: complete (каркас telegram-service; T3/T4 — каркасы ml/ai-сервисов). Отчёт: task-2-report.md. Решение по подключению: **ProjectReference на src/contracts/Deal.Proto.csproj** (Note T1 закрыт; per-process Protobuf Include не используется — общая сборка Deal.Proto, GrpcServices="Both"); хост-фабрика TelegramServiceHost.Create — seam для in-proc интеграционных тестов.
- Task 3: complete (каркас ml-service; шаблон T2 + явный fail-closed-гард `_expectedToken.Length == 0` — замечание ревью T2 учтено; DEP-запись ml-service добавлена). Отчёт: task-3-report.md.
- Task 4: complete (каркас ai-service; шаблон T3 1:1: AiServiceHost.Create, fail-closed-гард + тест пустого env, заглушки 4 RPC ai.proto, порт 5102, DEP-запись без volume — stateless Ruling 5). Отчёт: task-4-report.md.
- [x] Task 1: .proto-контракты + кодогенерация
- [x] Task 2: каркас telegram-service (sln/csproj/health/service-token/DI/DEP) (review clean)
- [x] Task 3: каркас ml-service (build 0/0; тесты 5/5 PASS; см. task-3-report.md)
- [x] Task 4: каркас ai-service (build 0/0; тесты 5/5 PASS; см. task-4-report.md)
- [x] Task 5 (первая из «5–8»): логика telegram-service — сессии/QR/подключение/статус (build 0/0; тесты 42/42 PASS)
- [ ] Task 58: логика telegram-service (сессии/QR/диалоги/мониторинг/анти-бан) — остаток: Task 68 (диалоги/backfill/мониторинг, discovery)
- [ ] Task 911: ml-service (модель/обучение/сохранение), ai-service (фасад/промпты/учёт)
- [x] Task 1214: core-интеграция (gRPC-клиенты за флагами, входящий PushMessage, MlOutbox-флашер; + ai-адаптеры плана Task 15) — ledger Task 9/10/11: complete
- [ ] Task 1519: Discovery + каналы (таблицы/воркер/эндпоинты/квоты) + /tg/status реальный
- [x] Task 20: Финал/compose dev + сквозная приёмка (review pending)
## Pre-flight scan (краткий)
| Пара | Производит/потребляет | Результат |
|---|---|---|
| T1 → T2T14 | proto → сервисы+core-клиенты | Чисто |
| T2–T4 | каркасы → логика | Чисто |
| T12T14 | gRPC-клиенты за флагом; Local-фолбэк | Local-заглушки сохраняются (default true) |
| T16T19 | Discovery-таблицы (миграция) | Новая tenant-миграция — имя? |
| T19 | /tg/status реальный | BootStub правится (последняя заглушка снимается) |
| — | ml-service SQLite per tenant; сессии-файлы | Вне core (отдельные процессы) |
## Task status
- T1T20: complete (T20 — review pending). 830 unit PASS; сервисы telegram/ml/ai + core-интеграция +
Discovery/каналы реализованы; полный dev-стек в deploy/compose.dev.yml (UseLocal=false — сквозной
gRPC-режим; compose config rc=0), smoke-скрипт scripts/dev-smoke.sh (живой прогон отложен — Docker
Desktop выключен), техдок §11/§13.7, roadmap/STATUS обновлены. Отчёт: task-20-report.md.
- T20 (compose-dev + сквозная интеграция + доки): complete (review pending). Предыдущий запуск оборвался
на spawn_agent — файлы compose.dev.yml/Dockerfile'ов были записаны частично (см. отчёты T2–T4 и
хронологию mtime); текущий запуск перепроверил и доработал: compose.dev.yml приведён к единому файлу
полного стека (UseLocal=false для core; override compose.grpc.yml удалён как избыточный — флаги заданы
в базовом файле), доки/roadmap/STATUS/ledger актуализированы. Живой smoke — Manual после поднятия
Docker: `sh scripts/dev-smoke.sh` (подъём → health → login → /api/tg/status → simulate-lead → trash
(MlOutbox) → флашер → /api/ml/status; trap → docker compose down).
- Task 5 (в нумерации логгера; в плане-файле — секция «Task 9: telegram-service — сессии,
подключение, QR, статус»): complete. Build sln 0/0 (Debug+Release); тесты 42/42 PASS.
Состав: SessionStore/SessionFileCipher (AES-GCM, атомарная запись), TenantSession (фазы
idle|code|password|qr|ready), SessionFarm (1 акк/тенант), auto_resume+heartbeat (30 с),
RPC GetStatus/StartPhone/StartQr/SendCode/SendPassword/Logout (прочие — заглушки).
Отчёт: task-5-report.md.
- Task 7 (в нумерации логгера; в плане-файле — секции «Task 5: ml-service — движок инкрементальной
модели» и «Task 6: ml-service — gRPC-сервис поверх пула», L260289): complete. Build
src/ml-service/Deal.Ml.sln 0/0 (Debug+Release); тесты 36/36 PASS. Состав: OnlineNaiveBayes/
MlTokenizer/ModelConstants/ModelState (1:1 mlservice/model.py predict/learn/status/reset),
MlDb (SQLite data/ml/<tenantId>.sqlite per-tenant, 1 транзакция на батч), TenantModel/ModelPool
(lazy-load, lock на модель), MlServiceImpl Predict/Status/Reset/TrainBatch (tenant-id из metadata),
DEP-запись ml-service дополнена DEAL_ML_DATA_DIR=/data/ml. Численная сверка predict со
сценарием python-модели: scores/hits/margin совпадают. Отчёт: task-7-report.md.
- Task 9 (в нумерации логгера/отчёта; в плане-файле — секция «Task 12: core — gRPC-ингресс telegram
(PushMessage/SyncDialogs/ReportStatus)», L361377): complete. Build Deal.sln 0/0; тесты 630/630 PASS
(новых 10/10 TelegramIngressServiceTests, in-proc gRPC на фейках); живой smoke: Deal.Api на
:5080 (HTTP/1.1) + [::]:5082 (gRPC HTTP/2, GRPC_INGRESS_PORT), health OK. Состав: второй Kestrel-
endpoint в Program.cs (основной биндится из urls-конфигурации — явные Listen заменяют URL-биндинг),
AddGrpc + IngressServiceTokenInterceptor (fail-closed), TelegramIngressService (tenant-id metadata →
реестр → scope SetTenant → EnqueueAsync; ReportStatus → KV tgStatus/tgAccount + SSE system_status/
тосты переходов connected; SyncDialogs — приём+лог, зеркало пусто до Task 13), ключи SettingsKeys
tgStatus/tgAccount. Отчёт: task-9-report.md.
- Task 11 (в нумерации отчёта; в плане-файле — секция «Task 15: core — ai-интеграция: контекст запроса,
GrpcAiClassifier/GrpcAiTools, маппер, usage», L412434): complete. Build Deal.sln 0/0 (Debug+Release);
тесты 681/681 PASS (новых 34/34). Состав: порт IAiTools + DTO (GenerateKeywords/EvaluateFit) + LocalAiTools
(NotSupportedException) и GrpcAiTools; GrpcAiClassifier за флагом Services:Ai:UseLocal=false (Local-
адаптеры — дефолт, воркер Pipeline и контракт IAiClassifier НЕ менялись — отклонение, см. отчёт);
AiClassifyContextBuilder/AiRawLeadMapper в модуле Pipeline (промпты/доски/примеры/маппинг 1:1 с ai.py),
AiProviderConfigBuilder (aiConfigs → ProviderConfig, расшифровка apiKey), AiUsageLedger (usage → KV
aiTokenUsage), AiGrpcConnection/AiServiceOptions; IKanjStore.GetAiMarkupExamplesAsync (few-shot по CardMoves);
DI по флагу + старт-лог. Приёмка плана L433–434: воркер с GrpcAiClassifier против in-proc ai-service
проходит фильтр/классификацию (PipelineWorkerGrpcAiTests), недоступность → локальный разбор (aiFail).
Отчёт: task-15-report.md (переименован из task-11-report.md 07.09.2026: план-файл ждёт этот отчёт в
task-15-report.md — см. Acceptance Task 15 L434; имя task-11-report.md занял отчёт плана Task 11).
- Plan Task 13 «core — модуль Telegram (таблицы, DTO, порт, DialogsService)» (L377395): complete. Build
Deal.sln 0/0 (Debug+Release); тесты 703/703 PASS (новых 22/22). Состав: модуль Deal.Modules.Telegram
(Dialogs/TgMessages — миграция TenantTelegram применена к дефолтной схеме, psql-приёмка), DTO §4.8/§4.9,
ITelegramStore → TelegramStore, ITelegramGateway (Contracts, 16 команд Ruling 7) + LocalTelegramGateway
(dev-заглушка), DialogsService (List/SyncFromTelegram/SetMonitor*/MarkBackfilled/SavePreview/ReadRecent);
ингресс актуализирован (SyncDialogs → зеркало + monitored ids; PushMessage → превью). Фоновый backfill
первого включения — Api-слой Task 14 (модуль отдаёт BackfillNeeded/ids). Отчёт: task-13-report.md.
- Plan Task 14 «core — эндпоинты /api/tg (каналы, статус, QR) + SSE; замена boot-заглушки» (L395412):
complete. Build Deal.sln 0/0; тесты 729/729 PASS (новых 26/26); curl-приёмка :5080 (DEAL_DEMO, Local-гейт)
PASS=20/FAIL=0. Состав: GrpcTelegramClient + TelegramGrpcConnection/TelegramServiceOptions за флагом
Services:Telegram (UseLocal default true → Local); 14 эндпоинтов /api/tg 1:1 api-map §3.3 (TelegramEndpoints +
TelegramQrImageEndpoint: SVG Net.Codecrete, 404 «QR не активен…»), TgStatusService (§4.9: гейт+KV tgAccount+
monitored+keysSet, idle при недоступности), TelegramKeysService (tgKeys enc → расшифровка), boot-заглушка
BootStubEndpoints удалена; DialogsService += BackfillOneAsync/PreviewAsync + ReadRecent per-dialog continue
(ревью T13); фоновые backfill-спуски — TelegramBackfillScheduler. Отчёт: task-14-report.md + скрипт
task-14-curl-acceptance.sh.
@@ -0,0 +1,35 @@
# Task 1 — `.proto`-контракты telegram/ai/ml + спецификация — отчёт
Статус: **DONE** (контракты + README созданы; кодогенерация проверена сборкой `Deal.Proto.csproj` — 0 warnings / 0 errors).
## Файлы (`src/contracts/`, каталог был пуст)
| Файл | Содержание |
|---|---|
| `telegram.proto` | Пакет `deal.telegram.v1``csharp_namespace Deal.Grpc.Telegram`. Два сервиса (Ruling 1/7): `TelegramService` — 16 RPC (GetStatus/StartPhone/StartQr/SendCode/SendPassword/Logout/RefreshDialogs→entries[]/SetMonitor/SetMonitorAll/Backfill/ReadRecent/Search/GetInfo/ReadForEval/Join/Leave) + `IngressService` — PushMessage/SyncDialogs/ReportStatus. Поля 1:1: status() L103119, refresh L516, dialog_messages L583620, discovery_* L624848, QueuedMessage/demo-ingest; PushMessage 1:1 с контрактом EnqueueAsync (dialog_id/channel_name/channel_handle/channel_hue/msg_id?/text/msg_at?) |
| `ml.proto` | Пакет `deal.ml.v1``Deal.Grpc.Ml`. `MlService` Predict/Status/Reset/TrainBatch; поля 1:1 с `MlPredictResultDto`/`MlServiceStatusDto`/`MlEvalDto`/`MlResetResultDto` и model.py: take/label?/scores map<string,double>/hits/ready/margin?/terms/type(TypeDecision t:hire/t:order), status classes map + eval{count,correct,accuracy}, Reset{ok,error?}, TrainBatch{items text/label/delta} → learned |
| `ai.proto` | Пакет `deal.ai.v1``Deal.Grpc.Ai`. `AiService` Filter/Classify/GenerateKeywords/EvaluateFit (Ruling 5); usage{prompt/completion/total} в каждом reply; в каждый запрос включён `provider_config` (сообщение `ProviderConfig{provider_id, base_url, api_key?, model, api_style?}`) — правка по review Finding 1 |
| `Deal.Proto.csproj` | Общий classlib кодогенерации: `Grpc.Tools 2.83.0` (PrivateAssets), `Google.Protobuf 3.35.1`, `Grpc.Core.Api 2.83.0`; `<Protobuf Include=… GrpcServices="Both">` для всех трёх `.proto`; TFM net10.0, `TreatWarningsAsErrors` — контракты валидируются компиляцией уже в Task 1 (план допускал первый прогон только в Task 2–4; по заданию — проект создан и проверен здесь, сервисы подключатся ProjectReference в T2T4) |
| `README.md` | Спецификация: сервисы/RPC/messages/поля (таблицы по каждому RPC), metadata `tenant-id`+`service-token` (обязательны; отказ `UNAUTHENTICATED`), коды ошибок (`INVALID_ARGUMENT`/`NOT_FOUND`/`FAILED_PRECONDITION`/`RESOURCE_EXHAUSTED` c `flood`/`UNAVAILABLE`, detail 1:1), словари phase/kind, deadline-рекомендации (telegram 10/60/120 с; ingress 10 с; ml 5/10/30 с; ai 120 с) |
## Решения (зафиксированы в README)
- `tenant-id`/`service-token` — только в gRPC-metadata, НЕ поля сообщений (Ruling 1). Пустые запросы — собственные `*Request`-сообщения (без google.protobuf.Empty).
- Канон `kind`: `channel|group|forum|chat` + флаг `is_forum` (GetInfo) — 1:1 `_kind_of`/discovery-кодов; отражено в README для Task 10/13.
- `optional` (proto3) для presence-скаляров: label/margin (ml), error/qr_url/account, msg_id/msg_at, reason/json, participants и т.д. — nullable-семантика C# сохранена (проверено: сгенерированы `HasMargin`/`HasMsgId`/`HasQrUrl`).
- GetStatus возвращает live-поля; `account` дублируется, источник истины для ядра — KV по ReportStatus (Ruling 8). ReadRecent без `lead`/фолбэка на БД (у сервиса нет БД тенанта — Ruling 1/7).
- `Classify` = system_prompt (aiPrompt+cardPrompt) + user_context (Доски+примеры+Сообщение), собирает ядро.
- `provider_config` во всех ai-запросах (review Finding 1): форма 1:1 с эффективным конфигом core — aiConfigs хранит {apiKey/baseUrl/model} (camelCase, ключ AES-GCM), api_style из каталога AiProviders; HTTP-клиенту нужны base_url/model/api_key (запрос) + api_style (схема вызова).
## Валидация
- `dotnet build Deal.Proto.csproj` (из `src/contracts`): Предупреждений 0, Ошибок 0 → `bin\Debug\net10.0\Deal.Proto.dll`.
- Кодогенерация на месте: `obj/…/{Telegram,TelegramGrpc,Ml,MlGrpc,Ai,AiGrpc}.cs`; namespace `Deal.Grpc.Telegram/Ai/Ml`; серверные базы `TelegramServiceBase`/`IngressServiceBase`/`MlServiceBase`/`AiServiceBase` сгенерированы (GrpcServices=Both).
- Сервисные проекты НЕ создавались (Task 2–4).
- Повторная сборка после review: `ProviderConfig` сгенерирован и присутствует во всех четырёх ai-запросах (Filter/Classify/GenerateKeywords/EvaluateFit).
## Отклонения и решения
- По плану кодогенерация Task 1 «невозможна без csproj»; по заданию создан общий `Deal.Proto.csproj` — это и есть первый прогон (protoc валидирует `.proto`). Ruling 1 (per-process `<Protobuf Include>` без общего проекта) НЕ нарушен: файлы остаются единственным источником, `Deal.Proto` лишь переиспользуемая сборка; окончательный способ подключения (ProjectReference vs Include) — за Task 24.
- README telegram.proto: зафиксирован маппинг kind на HTTP-контракт (review Finding 2, заметка для T13/14): `/api/tg/dialogs` `item.type` замороженно-русский («канал/группа/чат») → на границе эндпоинта каналов обратный маппинг EN→RU; Discovery `candidates.type` — EN (channel/group/forum), без маппинга. Код не менялся.
- Версии пакетов: Grpc.Tools/Grpc.Core.Api 2.83.0 (latest stable), Google.Protobuf 3.35.1 (совместим; NuGet-доступ был — restore прошёл).
@@ -0,0 +1,113 @@
# Task 10 — Отчёт: core — gRPC-клиенты Infrastructure за флагами + MlOutbox-флашер (план-файл: секция «Task 16», L436449, Ruling 6 L122132)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors (Debug и Release);
тесты **647/647 PASS** (`dotnet test tests/Deal.Tests.Unit`, из них новых **17/17**:
GrpcMlClientTests 10, MlOutboxFlushSchedulerTests 4, IntegrationsDiTests 3). Сеть наружу не
использовалась (gRPC-сценарии — in-proc фейк ml-service на Kestrel HTTP/2, эталон TelegramIngressTestHost).
Нумерация: отчёт пишется как `task-10-report.md` (инструкция). Сверка с планом-файлом: из секции
**core-интеграции** (Rulings 6, задачи 15–16) к этой задаче относится **Task 16 «core — ml-интеграция:
GrpcMlClient + MlOutboxFlushScheduler»** (L436449) — см. «Отклонения» п.1 про ai-часть (Task 15).
## Сверка с заданием (Acceptance плана L449 + брифа)
- **Вопрос брифа про судьбу MlOutbox** (разрешён по Ruling 6 L127130): PushAsync **ВСЕГДА** пишет в MlOutbox —
и в Local-, и в gRPC-режиме (`GrpcMlClient.PushAsync` = та же запись, что у LocalMlClient; общая логика
вынесена в `MlOutboxQueue`). Отправку батчами делает фоновый `MlOutboxFlushScheduler`, регистрируемый
**только при `Services:Ml:UseLocal=false`** (gRPC-режим): Local-режиму (этапы 25) ml-service не нужен —
очередь копится, как в прототипе при недоступном сервисе (python L56–82), и автоматически выгружается
после переключения на UseLocal=false. «При gRPC-режиме Push отправляет сразу» — НЕТ, план: Push остаётся
записью в MlOutbox (Task 16 L438439: «Push остаётся записью в MlOutbox через IMlLearningStore как
LocalMlClient»).
- **GrpcMlClient : IMlClient** (п.1 брифа, п.2): Predict → RPC (deadline 5 с), сбой → фиксированный «не
уверен» (predict-fallback, python L101107); Status → RPC (10 с) + **кэш 15 с** (`MlStatusCache`,
python L3031/127135): сервис недоступен — старые данные кэша + `reachable=false`; Reset → RPC Reset +
`ClearOutboxAsync` **только при успехе** + инвалидация кэша (reset_model L110124); мягкий сбой —
`{ok:false,error}`. Local-статистика тенанта (mlEnabled, ml/ai-счётчики, learning/outbox из
`IMlLearningStore`) — как у LocalMlClient. Metadata tenant-id/service-token на каждый вызов (Ruling 1),
deadline по README контрактов.
- **MlOutboxFlushScheduler** (п.2 брифа; `A/Hosting/`, как «по плану — Api»): hosted-сервис 10 с, per-tenant
цикл (эталон PipelineWorkerScheduler): собственный scope + `SetTenant` на тенанта; порции по 10 строк
(`ORDER BY created_at`), ≤100 за цикл, RPC TrainBatch (deadline 30 с), **удаление строк только после
успеха**; недоступность — строки остаются, ретрай на следующем тике (1:1 flush_outbox L5682).
Регистрация — в Program.cs под флагом `!Services:Ml:UseLocal`.
- **DI за флагами** (п.1 брифа): секция `Services:Ml``MlServiceOptions{UseLocal=true, Endpoint}`;
`AddDealIntegrations(mlOptions)`: UseLocal=true → LocalMlClient (фолбэк); UseLocal=false → GrpcMlClient
(тот же scoped-экземпляр реализует `IMlClient` + `IMlTrainClient` для флашера) + singleton
`MlGrpcConnection` (создаётся сразу — fail-fast при пустом endpoint/`DEAL_SERVICE_TOKEN`) и `MlStatusCache`.
Выбор на старте, рантайм-логики нет (Ruling 6). appsettings.json + секция `Services:{Ml,Ai}` (Ai — для
Task 15).
- **Порт хранилища**: `IMlLearningStore` дополнен `TakeOutboxBatchAsync(limit)` / `DeleteOutboxAsync(ids)`
(+DTO `MlOutboxEntryDto` в Kanban/Models) — выборка и удаление разделены (удаление только после успеха).
- **SettingsKeys**: добавлены внутренние ключи `AiTokenUsage`/`DiscFloodDay` (список Task 16 L443444;
`TgStatus`/`TgAccount` уже были — Task 9).
- **Стиль**: 1 тип = 1 файл, XML-doc на public, комментарии на русском, без регионов, именованные
константы (5/10/30 с, батч 10, ≤100/цикл, TTL 15 с, 6000, 6 байт RNG), camelCase.
## Что сделано (файлы)
- `src/core/Deal.Infrastructure/Integrations/`: `GrpcMlClient.cs` (IMlClient+IMlTrainClient, маппинг
DTO↔ml.proto, metadata/deadline, кэш-статус, predict-фолбэк), `MlServiceOptions.cs` (секция Services:Ml),
`MlGrpcConnection.cs` (общий канал, service-token из env, fail-closed), `MlStatusCache.cs` (TTL 15 с,
инъекция часов для тестов), `IMlTrainClient.cs` (TrainBatch для флашера), `MlOutboxQueue.cs` (общая логика
push: trim/no-op/text[:6000]/id mle_+hex).
- Изменены: `ServiceCollectionExtensions.cs` (AddDealIntegrations(mlOptions), ветка по флагу),
`LocalMlClient.cs` (Push делегирует `MlOutboxQueue`; поведение не изменено — фолбэк сохранён, тесты 255
этапа 3 зелёные), `Persistence/Repositories/MlLearningStore.cs` (Take/Delete порции), `Deal.Infrastructure.csproj`
(+ProjectReference Deal.Proto, +Grpc.Net.Client 2.83.0).
- `src/core/Deal.Modules.Kanban/Application/Models/MlOutboxEntryDto.cs` (новый), `IMlLearningStore.cs`
(+2 метода), `src/core/Deal.Modules.Settings/Application/SettingsKeys.cs` (+AiTokenUsage/DiscFloodDay).
- `src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs` (новый hosted-сервис), `Program.cs` (привязка
Services:Ml, AddDealIntegrations(mlOptions), AddHostedService при UseLocal=false, стартовый лог),
`appsettings.json` (+секция Services).
- Тесты `Deal.Tests.Unit/`: `RecordingMlService.cs` (фейк-сервер ml.proto: ответы/сбои/запись metadata),
`MlGrpcTestHost.cs` + `MlGrpcTestsCollection.cs` (Kestrel HTTP/2 на эфемерном порту, env-токен),
`GrpcMlClientTests.cs` (10), `MlOutboxFlushSchedulerTests.cs` (4), `IntegrationsDiTests.cs` (3);
`FakeMlLearningStore.cs` (+Take/Delete/SeedOutbox).
## Отклонения и решения
1. **Ai-часть (GrpcAiClassifier/GrpcAiTools/IAiTools/LocalAiTools) в эту задачу НЕ включена** — это план
Task 15 (L412434) со своей Acceptance: переход `IAiClassifier` на запросные record'ы
(AiClassifyRequest{Text,SystemPrompt,UserContext} — запросы ai.proto без них не построить) +
`AiClassifyContextBuilder`/`AiRawLeadMapper` + call-site'ы PipelineWorkerService. Адаптер-клиент без этого
слоя реализуем только «на бумаге» (пустые промпты — не 1:1). Секция `Services:Ai` в appsettings заведена
(Ruling 6), но ветка UseLocal=false для Ai регистрируется задачей 15.
2. **`GrpcColumnSuggester` НЕ создавался** (бриф п.1 упоминал замену IColumnSuggester): Self-Review плана
L525527 — IColumnSuggester сознательно НЕ заменяется gRPC (эвристика читает карточки тенанта в ядре;
ai-service участвует только через IAiTools.GenerateKeywords). LocalColumnSuggester остаётся локальным.
3. **Флашер регистрируется только при UseLocal=false** (см. Сверку): Local-режиму некуда слать — ретраи
против мёртвого endpoint были бы шумом; при подъёме ml-service очередь (в т.ч. накопленная в Local-режиме)
выгружается с первого же цикла.
4. **MlGrpcConnection создаётся в AddDealIntegrations сразу** (`new` в момент регистрации): пустой endpoint/
токен останавливают старт (fail-closed Ruling 2/13; тест «без токена → InvalidOperationException»).
5. **Текст мягкой ошибки reset** при недоступности — «ML-сервис недоступен» (для UNAVAILABLE) / «ML-сервис
не ответил — повторите попытку через несколько секунд»; python отдавал бы str(exc) — в .NET фиксируем
стабильную строку без секретов (Ruling 13).
6. Deadline-константы (5/10/30 с) и потолки (10/≤100/15 с) — именованные константы; кэш-часы инъекцией
(`Func<DateTimeOffset>`) — тесты TTL без ожидания 15 с.
7. `LocalMlClient` «не трогаем (фолбэк)» из Files Task 16 L442 понимается как «не заменяем»: внутренний
внутренний вынос Push-логики в общий `MlOutboxQueue` поведение не меняет (тесты LocalMlClient зелёные — полный прогон 647/647).
## Проверка (команды, из `src/core`)
- `dotnet build Deal.sln` и `dotnet build Deal.sln -c Release` — 0 warnings / 0 errors.
- `dotnet test tests/Deal.Tests.Unit`**647/647 PASS** (новых 17/17).
- Новые сценарии: Predict-маппинг полей + metadata tenant-id/service-token; predict-фолбэк (UNAVAILABLE →
«не уверен»); Status fetch+маппинг и merge локальных счётчиков; reachable=false при недоступности и
восстановление после TTL; кэш 15 с (1 fetch на 2 вызова в TTL); Reset: успех → очистка outbox + инвалидация
кэша; мягкая ошибка и UNAVAILABLE → {ok:false,error}, outbox цел; Push → запись mle_-строки без RPC;
TrainBatch → батч/learned; флашер: 25 строк → 3 батча (10/10/5) и удаление; сбой → строки остались + ретрай
на 2-м цикле; 2 тенанта → независимые очереди; 105 строк → ≤100/цикл; DI: UseLocal=true → LocalMlClient без
IMlTrainClient, UseLocal=false → GrpcMlClient под обоими портами (Same), без токена → ошибка старта.
## Concerns
- ⚠ Ai-часть core-интеграции (план Task 15: GrpcAiClassifier/GrpcAiTools + запросные record'ы IAiClassifier +
контекст-билдер/маппер/воркер + usage→KV aiTokenUsage) — следующая задача; ключ `aiTokenUsage` уже добавлен
в SettingsKeys (список Task 16).
- Сквозную проверку с реальным ml-service (UseLocal=false, поднятый процесс, выгрузка накопленной очереди) даст
финал этапа (Task 20 / curl-приёмка); в unit-сценариях ml-service эмулируется in-proc.
- Тесты, меняющие `DEAL_SERVICE_TOKEN`, собраны в коллекцию `MlGrpcTests` (сериализация); с коллекцией
TelegramIngress-тестов (тоже меняют env) возможна теоретическая гонка — как и ранее между её собственными
тестами, риск принят по образцу репозитория.
@@ -0,0 +1,107 @@
# Task 11 — Отчёт: telegram-service — discovery-операции (search/info/read/join) (план-файл: секция «Task 11», L347359, Ruling 3/7/10)
Статус: **complete**. Build `Deal.Telegram.sln` (src/telegram-service) — 0 warnings / 0 errors (Debug и
Release); тесты **114/114 PASS** (`dotnet test Deal.Telegram.sln`, из них новых **26/26**: DiscoveryOpsTests
10, DiscoveryProtoMapperTests 7, DiscoveryRpcTests 9). Сеть Telegram не использовалась (все сценарии — на
фейк-клиенте ISessionClient и in-proc gRPC-хосте; TL-слой не трогается — мапперы/валидация чистыми).
Нумерация: отчёт — `task-11-report.md` (Acceptance плана L359). Бывший файл `task-11-report.md` (отчёт
ledger-«Task 11» = план Task 15, core-ai) переименован в план-каноничный `task-15-report.md` (Acceptance
плана L434), ссылка в `progress.md` поправлена — данные не потеряны.
## Сверка с заданием (Files L349354 + Acceptance L358 + Ruling 3/10)
- **RPC-имена proto сверены** (L104127): `Search/GetInfo/ReadForEval/Join/Leave` (не «SearchDialogs/
GetDialogInfo/ReadHistory/JoinDialog») — реализованы ровно эти пять; лимиты/анти-бан — по комментариям
контракта и Ruling 3 (поиск-пауза 2–4 с внутри сервиса; join — вне квот).
- **Search** (1:1 discovery_search L624664): contacts.search → сущности chats/users, id подписанные,
kind EN-канона (форумы — "forum", как в каталоге), hue считает маппер (Ruling 7). Пауза анти-бана 2–4 с
после успешного поиска (ban_guard.search_pause; фейк-пейсер в тестах). Личные чаты/боты НЕ отсеиваются
(kind=chat) — это делает core-воркер (Ruling 10, L105106); дедуп по id + обрезка до лимита — в службе.
- **GetInfo** (1:1 L666716): участники из GetFullChannel (каналы/супергруппы) / GetFullChat (базовые
группы, только при членстве), is_forum из entity, kind — EN-канон. Сбои определения наружу НЕ бросаются:
недоступная сущность → инфо по умолчанию (name=id, kind="", participants пуст) — 1:1 прототипа.
- **ReadForEval** (1:1 L718800): обычная лента getHistory; форумы — getForumTopics (cap 5 тем) + по теме
getReplies(reply_to=topic.id), per-topic 3..10 (формула L784), плоский список с topic_id/topic_title;
ошибка тем → безопасный фолбэк на ленту; история недоступна → **ok:false + error="no_history"** (это НЕ
ошибка RPC). Только непустые тексты; limit ≤ 0 → пустой ok без сети.
- **Join** (1:1 L818839): по username (нормализация strip + lstrip("@"), пустой → INVALID_ARGUMENT);
FloodWait → **RESOURCE_EXHAUSTED с detail-префиксом "flood"** (флуд-гард сессии; базовый MapRpcException
L399412). **Пауз/квот в join НЕТ** — суточный лимит 50/тенант и паузы 50–70 с (рандом) владеет
core-воркер Discovery (Ruling 10 L9397 и Ruling 3 L92: «внешний анти-бан — владение core»).
- **Leave** (L841848): channels.leaveChannel по подписанному id.
- **ISessionClient-обёртка расширена** (план: «ISessionClient-обёртка расширяется (фейки)»): 5 новых членов
seam'а; фейки тестов (FakeSessionClient) реализуют их данными без сети.
- **Изоляция тенантов**: все операции исполняются на сессии своего тенанта (SessionFarm→TenantSession,
per-tenant gate, Ruling 1); нет сессии → FAILED_PRECONDITION «Telegram не подключён» (тест).
## Что сделано (файлы)
`src/telegram-service/Deal.Telegram/`:
- `Telegram/TelegramSourceInfo.cs`, `Telegram/DiscoveryMessage.cs`, `Telegram/DiscoveryReadResult.cs`
нейтральные DTO discovery (seam от TL; ok=false/no_history — нормальный результат, не исключение).
- `Telegram/TlMessageMapper.cs` — чистые мапперы: сущность поиска → TelegramDialog (ToFoundChat/ToFoundUser),
сообщение выборки → DiscoveryMessage (ToEvalMessage, темы форума), `KindOf` открыт для инфо.
- `Telegram/ISessionClient.cs` — +SearchAsync/GetInfoAsync/ReadForEvalAsync/JoinAsync/LeaveAsync.
- `Telegram/WTelegramSessionClient.cs` — TL-реализация discovery: contacts.search, resolveUsername +
channels.joinChannel/leaveChannel, getFullChannel/getFullChat (участники/forum), getForumTopics+getReplies
(чтение форумов по темам); кэш сущностей расширен (chats/users by raw id — имена/forum-флаг без лишних
RPC); FloodWait → RESOURCE_EXHAUSTED "flood: …".
- `Sessions/TenantSession.cs` (+5 операций под per-tenant gate/ready+auto-connect), `Sessions/SessionFarm.cs`
(+5 passthrough, RequireSession → «не подключён»), `Sessions/SessionErrorMessages.cs`
(+JoinUsernameMissing «Не указан username для вступления», JoinTargetNotChannel).
- `Discovery/DiscoveryOps.cs` — служба discovery-операций (дедуп/кап поиска, пауза 2–4 с, нормализация
username, дефолт-лимиты 30), `Discovery/DiscoveryProtoMapper.cs` — чистый маппер ответов
(ChannelInfo/EvalMessage/ReadForEvalReply; hue сервиса).
- `TelegramServiceImpl.cs` — заглушки заменены реализациями RPC Search/GetInfo/ReadForEval/Join/Leave
(ExecuteAsync/аудит), `TelegramServiceHost.cs` — DI DiscoveryOps, `Program.cs` — актуализирован.
Тесты `Deal.Telegram.Tests/`: `DiscoveryOpsTests.cs` (10: дедуп/кап + пауза 2–4 с, дефолт-лимит 30,
«не подключён», join-нормализация без пауз/пустой username/flood → RESOURCE_EXHAUSTED, изоляция 2 тенантов),
`DiscoveryProtoMapperTests.cs` (7: формат ChannelInfo incl. optional participants/is_forum, EvalMessage
topic-поля, ok:false+no_history), `DiscoveryRpcTests.cs` (9: Search/GetInfo/ReadForEval/Join через gRPC-хост
на фейк-клиенте + фейк-пейсер, включая no_session → FAILED_PRECONDITION, flood-detail, изоляцию тенантов).
`FakeSessionClient.cs` — данные/ошибки discovery для тестов.
## Отклонения и решения
1. **Позиция анти-бан-паузы поиска** — уровень службы (DiscoveryOps), не TL-клиента: python спит сразу
после SearchRequest (L637); здесь пауза стоит после успешного вызова сессии — тот же эффект, но тестируется
фейк-пейсером без реальных задержек (Ruling 3).
2. **GetInfo/ReadForEval «нет сессии»** → FAILED_PRECONDITION (общая конвенция RPC сервиса, как
RefreshDialogs/Backfill), хотя python отдал бы default/no_history при не подключённом клиенте. Внутри
сессии сбои определения/чтения — строго как python (default/no_history).
3. **Кэш сущностей расширен** (chats/users by raw id): WTelegramClient не отдаёт entity-by-id публично, а
discovery_info/discovery_read нужно имя/forum-флаг источника из поиска без лишних RPC (аналог Telethon
process_entities). См. отклонение 4 отчёта Task 6 (task-6-report.md).
4. **kind-канон**: форумы в результатах поиска и инфо приходят "forum" (как в каталоге, отклонение 5
task-6-report) + GetInfo.is_forum=true — core трактует kind как forum (Ruling 10).
5. **Join-флуд**: базовый флуд-гард — перевод FloodWait в RESOURCE_EXHAUSTED "flood" (есть в MapRpcException
с Task 9); суточный стоп-кран/flood-день — за пределами сервиса (нет tenant-БД; Ruling 10, воркер ядра).
## Проверка (команды, из `src/telegram-service`)
- `dotnet build Deal.Telegram.sln` → 0 warnings / 0 errors; `-c Release` → 0/0.
- `dotnet test Deal.Telegram.sln`**114/114 PASS** (было 88; новых 26/26).
- Новые сценарии: поиск (entries id/name/username/kind/hue + пауза 2–4 с фейк-пейсером; дедуп/обрезка;
дефолт-лимит 30; «не подключён»); info (поля ChannelInfo, optional participants, is_forum, hue, default
при недоступности); read (ok:true + сообщения, в т.ч. темы форума topic_id/topic_title; ok:false +
no_history — без ошибки RPC); join (нормализация username, ok:true, пауз нет; пустой → INVALID_ARGUMENT;
FloodWait → RESOURCE_EXHAUSTED "flood: …"); leave; изоляция тенантов (поиск только на своей сессии,
отсутствующий тенант → FAILED_PRECONDITION).
## ⚠ Manual (живая проверка, не выполнялась — нужны реальные креды)
Сценарий плана L358: поиск/инфо/чтение (в т.ч. по форумным темам)/join живого аккаунта — из core
(эндпоинты /api/discovery, план Task 19) либо напрямую gRPC: login аккаунта с кредов → Search ключа задачи
→ GetInfo кандидата → ReadForEval (форум — темы с topic_id/title) → Join по username → Leave.
## Concerns
- TL-путь join/leave/полный чат (resolveUsername/JoinChannel/LeaveChannel/GetFullChannel, кэш access_hash)
проверен только компиляцией и фейками — живая проверка обязательна (Manual выше); точные имена TL-методов
сверены рефлексией WTelegramClient 4.4.8.
- GetInfo участников для каналов без членства работает только при доступном access_hash (публичные из поиска/
каталога); приватные без членства → participants пуст (как python).
- Core-воркер Discovery (Tasks 1719) будет звать эти RPC; контракт ответов готов (ChannelInfo/EvalMessage/
ok+no_history), эндпоинты — следующие задачи.
@@ -0,0 +1,104 @@
# Task 13 — Отчёт: core — модуль Telegram (таблицы, DTO, порт, DialogsService) (план-файл: секция «Task 13», L377395)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors (Debug и Release); тесты
**703/703 PASS** (`dotnet test tests/Deal.Tests.Unit`, из них новых **22/22**: DialogsServiceTests 19,
TelegramGatewayPortTests 2, SyncDialogs-ингресс +1). Миграция `TenantTelegram` применена к дефолтной схеме
(живой smoke Api + psql: `__TenantMigrationsHistory` содержит `20260907141242_TenantTelegram`, таблицы
`Dialogs`/`TgMessages` на месте, health `{"ok":true}`, исключений 0).
## Сверка с заданием (Acceptance плана L393 + брифа)
- **Модуль `Deal.Modules.Telegram`** (чистый, референсы ST + Contracts, как план): маркер, DTO
(`TelegramDialogDto` §4.8, `TelegramMessageDto` §4.11, `TgStatusDto` §4.9, `TelegramMonitorToggleDto`,
`TelegramMonitorAllDto`), порт `ITelegramStore`, `DialogsService` (List / SyncFromTelegram / ListMonitoredIds /
SetMonitor / SetMonitorAll / MarkBackfilled / SavePreview / ReadRecent) и реестр `AddTelegramModule()`
(подключён в `Program.cs`). Таблицы: `I/Persistence/Entities/{DialogEntity,TgMessageEntity}` + конфигурации
(индексы Dialogs PK / TgMessages (DialogId, MsgAt)), DbSet + ApplyConfiguration в `TenantDbContext`.
- **`C/Integrations/ITelegramGateway.cs`** (Ruling 7): 16 команд наружу 1:1 со списком Ruling 7 (Status/
StartPhone/StartQr/SendCode/SendPassword/Logout/RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/ReadRecent/
Search/Info/ReadForEval/Join/Leave) + DTO контракта в `Contracts/Integrations/Models` (зеркала proto
DialogEntry/GetStatusReply/PreviewMessage/ChannelInfo/EvalMessage/ReadForEvalReply). Порт-контракт заморожен
тестом `TelegramGatewayPortTests` (набор методов + async-сигнатуры).
- **DialogsService** — семантика 1:1 python telegram.py: `SyncFromTelegram` (_persist_dialogs L468503: upsert
новых с монитором по `autoMonitorNew` из KV-настроек, обновление имени/типа/handle/hue без троек монитора,
удаление отсутствующих в каталоге), `SetMonitor`/`SetMonitorAll` (флаг в БД + RPC SetMonitor*/SetMonitorAll*
через гейт — зеркало сервиса), `SavePreview` (_on_message L270274: TgMessages-строка `m_<dialog>_<msg>`
≤4000 + last_text/last_at каталога ≤200), `ReadRecent` (backfill_monitored L569581: только включённые,
Backfill(force=true) каждому, backfilled после успеха).
- **gRPC-ингресс актуализирован** (план Task 12 L367 обещал это «до Task 13»): `SyncDialogs` применяет entries
`DialogsService.SyncFromTelegram` и отвечает списком monitored id (Ruling 7); `PushMessage` после enqueue
пишет превью (`SavePreview`), сбой превью не влияет на accepted.
- **Миграция**: `dotnet ef migrations add TenantTelegram --context TenantDbContext --output-dir Migrations/TenantDb
--project Deal.Infrastructure --startup-project Deal.Api` — 2 CreateTable (Dialogs, TgMessages) + индекс
`IX_TgMessages_DialogId_MsgAt`; старт Api (TenantProvisioningService) применяет к схемам тенантов.
- **Стиль**: 1 тип = 1 файл, XML-doc на public, комментарии на русском, без регионов, именованные константы
(200/4000/`#666`), camelCase-JSON, Task/CancellationToken в портах, кодировка времени DateTimeOffset (UTC).
## Что сделано (файлы)
`src/core/Deal.Modules.Telegram/` (новый проект, добавлен в Deal.sln): `Deal.Modules.Telegram.csproj` (ST +
Contracts + SharedKernel + DI.Abstractions), `TelegramModuleMarker.cs`, `Application/Models/` — `TelegramDialogDto`,
`TelegramDialogLastDto`, `TelegramMessageDto`, `TgStatusDto`, `TelegramMonitorToggleDto`, `TelegramMonitorAllDto`;
`Application/ITelegramStore.cs`, `Application/DialogsService.cs`, `Application/TelegramModuleRegistrar.cs`.
`src/core/Deal.Contracts/Integrations/`: `ITelegramGateway.cs`; `Models/` — `TelegramDialogEntryDto`,
`TelegramAccountStatusDto`, `TelegramAuthResultDto`, `TelegramRecentMessageDto`, `TelegramChannelInfoDto`,
`TelegramEvalMessageDto`, `TelegramEvalReadDto`.
`src/core/Deal.Infrastructure/`: `Persistence/Entities/{DialogEntity,TgMessageEntity}.cs`,
`Persistence/{DialogConfiguration,TgMessageConfiguration}.cs`, `Persistence/Repositories/TelegramStore.cs`
(upsert/delete каталога, ExecuteUpdate для monitor/backfilled/last, INSERT OR IGNORE превью),
`Integrations/LocalTelegramGateway.cs` (dev-заглушка: idle-статус/no-op, Ruling 6); изменены
`Persistence/TenantDbContext.cs` (DbSet+конфигурации), `ServiceCollectionExtensions.cs`
(ITelegramStore → TelegramStore в AddDealPersistence; ITelegramGateway → LocalTelegramGateway в
AddDealIntegrations), csproj (+ProjectReference TM); `Migrations/TenantDb/20260907141242_TenantTelegram.*`
(+Designer, snapshot обновлён).
`src/core/Deal.Api/`: `Program.cs` (+AddTelegramModule), `Telegram/TelegramIngressService.cs` (SyncDialogs →
DialogsService + monitored ids; PushMessage → SavePreview-превью), csproj (+ProjectReference TM).
Тесты `Deal.Tests.Unit/`: `FakeTelegramStore.cs` (+`FakeTelegramDialogRow`/`FakeTelegramMessageRow` — семантика
адаптера), `FakeTelegramGateway.cs` (запись SetMonitor/SetMonitorAll/Backfill), `DialogsServiceTests.cs` (19),
`TelegramGatewayPortTests.cs` (2); изменены `TelegramIngressTestHost.cs` (регистрации модуля + фейки по
умолчанию), `TelegramIngressServiceTests.cs` (SyncDialogs: 2 сценария — авто-мониторинг вкл/выкл),
`IntegrationsDiTests.cs` (LocalTelegramGateway по умолчанию), csproj (+ProjectReference TM).
## Отклонения и решения
1. **«Фоновый backfill при первом включении» (Ruling 7) вынесен из модуля в Api-слой (Task 14).** python
спавнит `backfill_dialog` из set_monitor (L546); в ядре модуль scoped (EF-контекст схемы тенанта живёт в
запросе), а Backfill RPC длится секунды (паузы анти-бана). Модуль отдаёт признаки «нужен первый разбор»
(`TelegramMonitorToggleDto.BackfillNeeded` / `TelegramMonitorAllDto.BackfillNeededIds`) и `MarkBackfilled`,
фоновый спуск RPC + mark-backfilled делает Api-слой (Task 14 «с фоновым backfill-спуском», Ruling 8) — как
python-_spawn из роутеров. Документировано в DialogsService.
2. **`ReadRecent` (модуль) выполняет Backfill последовательно** (эквивалент backfill_monitored): вызывать из
фонового скоупа (эндпоинт backfill-all Task 14/воркер), не из HTTP-запроса. В unit-сценариях гейт — фейк.
3. **Kind каталога хранится в EN-каноне** (channel|group|forum|chat, proto DialogEntry): русская форма
(«канал/группа/чат», api-map §4.8) — приведение на границе эндпоинта (Task 14, заметка Task 1).
4. **`ITelegramGateway` реализует пока только LocalTelegramGateway** (dev-заглушка, idle/no-op): gRPC-клиент
GrpcTelegramClient под флагом `Services:Telegram:UseLocal=false` (Ruling 6) — следующая задача
(Task 14/20); Task 14 тестирует эндпоинты фейк-гейтом (не Local). Заглушка гарантирует разрешимость DI.
5. **PushMessage пишет превью-строку в TgMessages** (Ruling 7: «PushMessage … пишет превью в TgMessages») на
каждое принятое сообщение; id `m_<dialog>_<msg>` (без msg_id — только last каталога); дубли превью не
перезаписываются (INSERT OR IGNORE). Сбой превью ловится и не меняет accepted (как python: ошибка после
enqueue не отменяет приём).
6. **`ListMonitoredIds` и `ListNotBackfilledIds` упорядочены по Id** (в адаптере и фейке): детерминированные
ответы SyncDialogs/списка backfill (python-зеркало — set, порядок не контрактен).
## Проверка (команды, из `src/core`)
- `dotnet build Deal.sln` и `dotnet build Deal.sln -c Release` — 0 warnings / 0 errors.
- `dotnet ef migrations add TenantTelegram …` — сгенерирована (2 CreateTable + индекс), build 0/0.
- `dotnet test tests/Deal.Tests.Unit` — **703/703 PASS** (новых 22/22: sync upsert/удаление/autoMonitorNew вкл-выкл,
setMonitor флаг+RPC+BackfillNeeded (первое включение/уже разобран/выключение/нет диалога), setMonitorAll count+
неразобранные+RPC, markBackfilled, readRecent только включённые (force) и без гейт-вызовов при 0, list
«monitor DESC, name»+last, savePreview ≤4000/≤200/дубль/нет msg_id/пустой текст; порт-контракт гейта; ингресс
SyncDialogs: autoMonitorNew=true → monitored ids, false → пусто).
- Живой smoke (dev-профиль, Postgres :5433): Api поднялся (listening :5191/:5098), health `{"ok":true}`,
исключений 0; psql: `__TenantMigrationsHistory` += `20260907141242_TenantTelegram`, таблицы `Dialogs`/
`TgMessages` в схеме дефолтного тенанта. Процесс остановлен, порты свободны.
## Concerns
- gRPC-клиент гейта (GrpcTelegramClient) и секция `Services:Telegram` — следующие задачи (Task 14 curl на
фейк-гейте, финал этапа — Task 20); LocalTelegramGateway — временная dev-заглушка.
- Проверка EF-адаптера (TelegramStore) против реальной БД: сгенерированная миграция применена и DDL проверены;
поведение upsert/удаления покрыто unit-сценариями DialogsService на фейке с семантикой адаптера (паттерн
FakeKanjStore/FakePipelineStore этапов 3–4); сквозную проверку с живым telegram-service даст Task 20.
- Рост TgMessages за счёт превью каждого PushMessage — ожидаемо по Ruling 7; автоочистка не в скоупе этапа 6.
@@ -0,0 +1,169 @@
#!/usr/bin/env sh
# Task 14 curl-приёмка: эндпоинты /api/tg на :5080 (DEAL_DEMO=1, Development) со стаб-гейтом
# (LocalTelegramGateway — UseLocal default true). Сценарий: 401 без куки → login → GET /api/tg/status
# (реальная idle-форма §4.9) → dialogs/refresh/backfill-all/monitor/preview/monitor-ветки → start-phone/start-qr
# без ключей 400 → qr-image 404 → PATCH tgKeys (enc в БД) → status keysSet:true → возврат ключей (удаление
# переопределения) → logout → 401. PASS/FAIL каждого шага; в конце сервер останавливается.
set -u
BASE_URL="http://localhost:5080"
WORK=$(mktemp -d)
JAR="$WORK/cookies.txt"
OUT="$WORK/out.txt"
PASS=0
FAIL=0
FAILED_NAMES=""
check() { # имя, ожидание HTTP-кода, [фрагменты...]
local name="$1" code="$2"
shift 2
if grep -q "\[HTTP:$code\]" "$OUT"; then
for frag in "$@"; do
if ! grep -qF "$frag" "$OUT"; then
echo " [FAIL] $name (нет фрагмента: $frag)"
FAIL=$((FAIL + 1))
FAILED_NAMES="$FAILED_NAMES|$name"
return
fi
done
echo " [PASS] $name"
PASS=$((PASS + 1))
else
echo " [FAIL] $name (ожидался HTTP $code)"
cat "$OUT"
FAIL=$((FAIL + 1))
FAILED_NAMES="$FAILED_NAMES|$name"
fi
}
check_text() { # имя без HTTP-кода, [фрагменты...] (psql-выводы)
local name="$1"
shift
for frag in "$@"; do
if ! grep -qF "$frag" "$OUT"; then
echo " [FAIL] $name (нет фрагмента: $frag)"
cat "$OUT"
FAIL=$((FAIL + 1))
FAILED_NAMES="$FAILED_NAMES|$name"
return
fi
done
echo " [PASS] $name"
PASS=$((PASS + 1))
}
echo "== старт Deal.Api :5080 =="
cd "$(dirname "$0")/../../../src/core/Deal.Api" || exit 1
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS="http://localhost:5080" DEAL_DEMO=1 nohup dotnet bin/Debug/net10.0/Deal.Api.dll > "$WORK/api.log" 2>&1 &
APP_PID=$!
UP=""
i=0
while [ $i -lt 90 ]; do
if curl -s -m 2 -o /dev/null "$BASE_URL/api/health"; then UP=1; break; fi
i=$((i + 1))
sleep 2
done
if [ -z "$UP" ]; then
echo " [FAIL] сервер не поднялся за 180 с"
tail -40 "$WORK/api.log"
exit 1
fi
echo " [PASS] сервер поднят (health 200)"
echo
echo "== 1. 401-гейт без куки =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/tg/status" > "$OUT"
check "GET /tg/status без сессии → 401" 401 '"detail":"Требуется авторизация"'
echo
echo "== 2. login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login → 200 ok:true" 200 '"ok":true'
echo
echo "== 3. GET /api/tg/status — реальная idle-форма §4.9 (Local-гейт, чистая БД) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
check "status idle-форма + все поля §4.9" 200 \
'"phase":"idle"' '"connected":false' '"listener":false' '"account":""' \
'"monitored":0' '"keysSet":false' '"error":null' '"qrUrl":null'
echo
echo "== 4. Каналы: диалоги/refresh/backfill-all/monitor/preview (стаб-гейт) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/dialogs" > "$OUT"
check "GET /dialogs → {items:[]}" 200 '"items":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/dialogs/refresh" > "$OUT"
check "POST /dialogs/refresh → not-connected (мягкая ветка)" 200 '"ok":false' '"reason":"not-connected"' '"count":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/dialogs/backfill-all" > "$OUT"
check "POST /dialogs/backfill-all → {ok:true,count:0}" 200 '"ok":true' '"count":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/dialogs/monitor-all" -H "Content-Type: application/json" -d '{"enabled":true}' > "$OUT"
check "POST /dialogs/monitor-all → {ok:true,count:0,enabled:true}" 200 '"ok":true' '"count":0' '"enabled":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/dialogs/-100999/monitor" -H "Content-Type: application/json" -d '{"enabled":true}' > "$OUT"
check "POST /dialogs/{id}/monitor (нет диалога) → {ok:true,enabled:true}" 200 '"ok":true' '"enabled":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/dialogs/preview" -H "Content-Type: application/json" -d '{"dialogId":"-100999","limit":24}' > "$OUT"
check "POST /dialogs/preview → {items:[]}" 200 '"items":[]'
echo
echo "== 5. Подключение без ключей: 400 «Сначала сохраните …», qr-image 404 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/start-phone" -H "Content-Type: application/json" -d '{"phone":"+70001112233"}' > "$OUT"
check "start-phone без ключей → 400" 400 '"detail":"Сначала сохраните Telegram api_id и api_hash в настройках"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/start-qr" > "$OUT"
check "start-qr без ключей → 400" 400 '"detail":"Сначала сохраните Telegram api_id и api_hash в настройках"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/qr-image?t=1" > "$OUT"
check "qr-image вне фазы qr → 404" 404 '"detail":"QR не активен — начните вход по QR"'
echo
echo "== 6. Ключи приложения: PATCH tgKeys (enc в БД) → status keysSet:true =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" -H "Content-Type: application/json" -d '{"tgKeys":{"apiId":"123456","apiHash":"abcdefghijklmnop"}}' > "$OUT"
check "PATCH tgKeys → 200, apiHashSet:true, apiId без маски" 200 '"tgKeys":{"apiId":"123456","apiHashSet":true}'
check_absent=$(grep -c "enc:" "$OUT" || true)
if [ "$check_absent" = "0" ]; then echo " [PASS] enc: наружу не уходит (GET/PATCH снимок)"; PASS=$((PASS + 1)); else echo " [FAIL] enc: в снимке"; FAIL=$((FAIL + 1)); fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
check "status после PATCH ключей → keysSet:true, idle" 200 '"keysSet":true' '"phase":"idle"'
echo
echo "== 7. psql: tgKeys в БД зашифрованы (enc:) =="
docker exec deal-postgres psql -U deal -d deal -t -A -c "SELECT \"ValueJson\" FROM tenant_00000000000000000000000000000001.settings WHERE \"Key\"='tgKeys';" > "$OUT" 2>/dev/null
check_text "tgKeys.ValueJson содержит enc:" 'enc:'
echo
echo "== 8. Возврат состояния (прямое удаление переопределения tgKeys — PATCH пустыми ключами не очищает,
мягкая семантика SettingsService: пустые значения невалидны) =="
docker exec deal-postgres psql -U deal -d deal -c "DELETE FROM tenant_00000000000000000000000000000001.settings WHERE \"Key\"='tgKeys';" > /dev/null 2>&1
docker exec deal-postgres psql -U deal -d deal -t -A -c "SELECT count(*) FROM tenant_00000000000000000000000000000001.settings WHERE \"Key\"='tgKeys';" > "$OUT" 2>/dev/null
check_text "переопределение tgKeys удалено (0 строк)" '0'
echo
echo "== 9. /api/tg/logout → auth logout → 401 на status =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/tg/logout" > "$OUT"
check "tg logout → {ok:true}" 200 '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "auth logout → ok" 200 '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
check "GET /tg/status после logout → 401" 401 '"detail":"Требуется авторизация"'
echo
echo "== остановка сервера =="
kill "$APP_PID" 2>/dev/null
sleep 1
pkill -f "Deal.Api.dll" 2>/dev/null
echo " лог: $WORK/api.log"
echo
echo "== ИТОГ: PASS=$PASS FAIL=$FAIL =="
if [ "$FAIL" = "0" ]; then
echo "ПРИЁМКА ПРОЙДЕНА"
exit 0
fi
echo "Провалы:$FAILED_NAMES"
exit 1
@@ -0,0 +1,89 @@
# Task 14 — Отчёт: core — эндпоинты /api/tg (каналы, статус, QR) + замена boot-заглушки (план-файл: секция «Task 14», L395412)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors (`scripts/build.sh`); тесты
**729/729 PASS** (`scripts/test.sh`, `dotnet test tests/Deal.Tests.Unit`; было 703, новых **26/26**);
curl-приёмка :5080 (DEAL_DEMO, Local-гейт) — **PASS=20 FAIL=0** (`task-14-curl-acceptance.sh`).
## Сверка с заданием (Acceptance плана L409410 + брифа)
- **GrpcTelegramClient : ITelegramGateway** (I/Integrations, Ruling 6/7): все 16 команд порта 1:1 telegram.proto
(Status/StartPhone/StartQr/SendCode/SendPassword/Logout/RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/
ReadRecent/Search/Info/ReadForEval/Join/Leave); metadata tenant-id/service-token (TelegramGrpcConnection,
fail-closed) + deadline по README (10/60/120 с). Доменные RPC-ошибки пробрасываются с каноническим detail;
транспортные сбои нормализуются в RpcException(Unavailable, «Telegram не подключён»). DI под флагом
`Services:Telegram:UseLocal` (default true → LocalTelegramGateway; false → GrpcTelegramClient) —
`AddDealIntegrations(mlOptions, aiOptions, telegramOptions)`.
- **Эндпоинты /api/tg*** (1:1 api-map §3.3, 14 шт.): status/start-phone/start-qr/send-code/send-password/logout/
qr-image/dialogs/refresh/monitor-all/backfill-all/{id}/monitor/{id}/backfill/preview — TelegramEndpoints +
TelegramQrImageEndpoint; 401-гейт {detail}; ошибки гейта → 400 {detail}; refresh-ветка
`{ok:false,reason:"not-connected",count:0}` (HTTP 200, мягкая); RU-маппинг type на границе
(channel→«канал», group/forum→«группа», chat→«чат»); dialogs — `{items:[§4.8]}` с `last{text,time}`;
preview — свежие из гейта (lead по TgMessages-строке) + фолбэк БД.
- **Замена boot-заглушки**: `BootStubEndpoints.cs` удалён, `MapBootStubEndpoints` убран из Program.cs;
GET /api/tg/status — реальный (TgStatusService: гейт+KV tgAccount+monitored+keysSet; сервис недоступен →
idle-форма §4.9).
- **TgStatusService** (A/Telegram): сборка §4.9; **TgKeysService/TgKeysSnapshot** — чтение tgKeys + расшифровка
apiHash (`enc:`), «Сначала сохраните Telegram api_id и api_hash в настройках» 400; **TelegramBackfillScheduler**
(singleton, fire-and-forget в отдельном scope с захваченным tenant-контекстом) — первый разбор при включении
мониторинга (BackfillNeeded/ids модуля) и «Перечитать» (ReadRecentAsync) как python-_spawn.
- **QR**: TelegramQrImageEndpoint — SVG Net.Codecrete.QrCodeGenerator (Deal.Api.csproj +2.0.6), border=1,
no-store/inline; вне фазы qr — 404 «QR не активен — начните вход по QR».
- **Замечание ревью T13 учтено**: DialogsService.ReadRecent/BackfillOne — per-dialog try/continue при частичном
падении; mark-backfilled строго после успеха RPC.
- **Тесты**: TgStatusService (idle-форма/gateway-недоступен → idle с KV+monitored+keysSet/ready-сборка/qr+qrUrl);
DialogsService (ReadRecent продолжает при падении диалога + mark только успешных; BackfillOne 0 без RPC при
нет-строки/backfilled-без-force; force=true); GrpcTelegramClient in-proc «по проводу» (фейк-сервер telegram.proto:
status/доменный RPC-отказ с detail/start-phone/start-qr/refresh-entries/backfill/read_recent/нет-тенанта);
RU-маппинг + GatewayErrorText; DI-выбор по флагу (+fail-fast Telegram без DEAL_SERVICE_TOKEN).
## Что сделано (файлы)
Созданы: `Deal.Infrastructure/Integrations/{TelegramServiceOptions,TelegramGrpcConnection,GrpcTelegramClient}.cs`;
`Deal.Api/Telegram/{TgKeysSnapshot,TelegramKeysService,TgStatusService}.cs`; `Deal.Api/TelegramBackfillScheduler.cs`;
`Deal.Api/Endpoints/{TelegramEndpoints,TelegramQrImageEndpoint}.cs`; `Deal.Api/Endpoints/RequestModels/`
`{TgStartPhoneRequest,TgSendCodeRequest,TgSendPasswordRequest,TgMonitorBody,TgPreviewBody}.cs`. Тесты:
`{TgStatusServiceTests,TelegramEndpointsMappingTests,RecordingTelegramService,TelegramGrpcTestHost,GrpcTelegramClientTests}.cs`.
Изменены: `ServiceCollectionExtensions.cs` (AddDealIntegrations + telegramOptions/ветка), `Program.cs`
(секция Services:Telegram, регистрации TgStatusService/TelegramKeysService/TelegramBackfillScheduler,
MapTelegramEndpoints+MapTelegramQrImageEndpoint, старт-лог, удалён MapBootStubEndpoints), `appsettings.json`
(+Services:Telegram), `Deal.Api.csproj` (+Net.Codecrete.QrCodeGenerator), `DialogsService.cs`
(ReadRecent per-dialog + BackfillOneAsync + PreviewAsync), тесты: `FakeTelegramGateway.cs` (StatusFailure/
BackfillFailures), `DialogsServiceTests.cs` (+3), `IntegrationsDiTests.cs` (сигнатура + Telegram-ветка).
Удалён: `Deal.Api/Endpoints/BootStubEndpoints.cs`.
## Отклонения и решения
1. **Core-фоновый автосвип каталога диалогов НЕ добавлялся** (бриф-п.3 «sweep в StorageTick / hosted»):
в файловом списке Task 14 его нет, python-аналога в core нет (realtime-свип — в telegram-service, план
Task 10/Ruling 7), актуализация — фронт-флоу (ChannelsView syncList при входе на вкладку → POST
/dialogs/refresh → SyncFromTelegram), плюс ингресс SyncDialogs сервиса. Зафиксировано как решение.
2. **Backfill-спуски — в Api-слое через TelegramBackfillScheduler** (Ruling 8): модуль отдаёт признаки
«нужен первый разбор», RPC+mark делает планировщик в отдельном scope (эквивалент RatesRefreshScheduler);
эндпоинты отвечают сразу (python-_spawn L546/L566/L580).
3. **Превью свежих сообщений не пишет строки TgMessages** (python dialog_messages L601605 пишет при показе):
превью-строки создаёт PushMessage-ингресс (Ruling 7: «признак lead и фолбэк на БД добавляет ядро»), запись
при показе не нужна (lead свежих — по уже сохранённой строке `m_<dialog>_<msg>`).
4. **`/dialogs/{id}/backfill`** (сервер-only, фронт не вызывает): DialogsService.BackfillOneAsync(force=false)
— строка есть и не разобрана → RPC → {ok, processed}; mark после успеха; иначе 0 без RPC.
5. **Локальный dev-гейт (UseLocal=true)** отдаёт refresh→{ok,count:0} только при connected-статусе — refresh
сначала проверяет StatusAsync().Connected (python L119), поэтому в dev — мягкая ветка not-connected.
6. **Очистка tgKeys** через PATCH пустыми ключами невозможна (мягкая семантика SettingsService: пустые значения
невалидны — pre-existing, не менялось); в curl-приёмке состояние возвращалось SQL-удалением строки.
## Проверка (команды)
- `scripts/build.sh` (Debug) — 0 warnings / 0 errors; `dotnet test tests/Deal.Tests.Unit`**729/729 PASS**.
- `task-14-curl-acceptance.sh` (:5080, DEAL_DEMO, Postgres :5433): 401 без куки → login → GET /api/tg/status
(idle-форма §4.9, 8 полей) → dialogs `{items:[]}` → refresh not-connected → backfill-all `{count:0}`
monitor-all `{count:0,enabled:true}` → {id}/monitor → preview `{items:[]}` → start-phone/start-qr без ключей
400 («Сначала сохраните …») → qr-image 404 («QR не активен…») → PATCH tgKeys → psql `enc:` → status
keysSet:true → удаление переопределения (0 строк) → tg-logout {ok:true} → auth-logout → status 401. **PASS=20,
FAIL=0**. Процесс остановлен, DB возвращена (tgKeys-строки нет), порты свободны.
## Concerns
- /qr-image в фазе «qr» и start-phone/qr «happy path» требуют живого telegram-service (UseLocal=false) —
покрыто in-proc-тестами гейта и фейк-статусом; живой QR-скан — ⚠ ручная проверка (план Task 20).
- Local-гейт остаётся дефолтом dev (UseLocal=true): эндпоинты работают в idle-режиме до подключения сервиса.
- RefreshDialogs каталога: изменение происходит по явному refresh (фронт при входе на вкладку) либо SyncDialogs
сервиса; переименования без визита на вкладку подтянутся следующим входом (см. отклонение 1).
@@ -0,0 +1,105 @@
# Task 11 — Отчёт: core — gRPC-ai: GrpcAiClassifier / IAiTools (Filter/Classify/GenerateKeywords) за флагом Services:Ai:UseLocal (план-файл: секция «Task 15», L412434, Ruling 5/6/9)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors (Debug и Release); тесты
**681/681 PASS** (`dotnet test tests/Deal.Tests.Unit`, из них новых **34/34**: AiClassifyContextBuilderTests 6,
AiRawLeadMapperTests 11, GrpcAiClassifierTests 8, GrpcAiToolsTests 4, LocalAiToolsTests 2,
PipelineWorkerGrpcAiTests 2, IntegrationsDiTests 4→4(+1 сценарий флага Ai)). Сеть наружу не использовалась
(gRPC-сценарии — in-proc фейк ai-service на Kestrel HTTP/2, эталон MlGrpcTestHost).
Нумерация: отчёт пишется как `task-11-report.md` (инструкция). По плану-файлу это **Task 15 «core —
ai-интеграция: контекст запроса, GrpcAiClassifier/GrpcAiTools, маппер, usage»** (L412434, Acceptance L433434);
ledger-Task 10 (отчёт task-10-report.md) закрыл план-Task 16 (ml) и явно отложил ai-часть сюда.
## Сверка с заданием (Acceptance плана L433434 + брифа)
- **GrpcAiClassifier : IAiClassifier (Filter/Classify)** (п.1 брифа): RPC Filter/Classify по ai.proto с
заполненными промптами из настроек тенанта и ProviderConfig активного провайдера (aiConfigs → расшифровка
apiKey через ISecretCipher, base/model/api_style из каталога AiProviders — 1:1 с python `ai.py _cfg` L2533);
usage ответов копится в tenant-KV `aiTokenUsage` (Ruling 5 L119121). Маппинг DTO↔proto, deadline 120 с
(README контрактов), metadata tenant-id/service-token (Ruling 1).
- **Недоступность → по плану** (вопрос брифа): порт сигналит **исключением** `AiUnavailableException` — воркер
Pipeline уже отличает aiFail/пропуск catch-ветками (FilterSafelyAsync → `{pass:true,skipped:true}`;
классификация → `parsed=null` → локальный разбор, python L11021114). `ClassifyReply.ok=false` (модель без
JSON после ретраев, контрактная форма) тоже бросается — как RuntimeError python `chat_json`. LocalAiClassifier
детерминирован и не бросает — семантика сохранена.
- **IAiTools** (п.2 брифа): порт реализован целиком — GenerateKeywordsAsync (`{ok,keywords,error}`, мягкая
ошибка Ruling 11) + EvaluateFitAsync (`{fit,reason}`, сбой → исключение → эвристика Discovery Ruling 10).
EvaluateFit НЕ откладывался: ai-service его уже реализует (план Task 8), порт один на обе Discovery-задачи
(1719); LocalAiTools бросает NotSupportedException (Ruling 9 — «исключение/пустой результат»).
- **DI за флагами** (п.3 брифа): `Services:Ai``AiServiceOptions{UseLocal=true, Endpoint}` (env
`SERVICES__AI__USELOCAL=false`, `SERVICES__AI__ENDPOINT`); `AddDealIntegrations(mlOptions, aiOptions)`:
UseLocal=true → LocalAiClassifier/LocalAiTools (фолбэк, default); false → GrpcAiClassifier/GrpcAiTools +
singleton `AiGrpcConnection` (создаётся сразу — fail-fast при пустом endpoint/DEAL_SERVICE_TOKEN, как
MlGrpcConnection). appsettings.json секция Services:Ai уже была (ledger-Task 10). Стартовый лог режима Ai
добавлен в Program.cs. Выбор на старте, рантайм-логики нет (Ruling 6).
- **Порт-адаптер IColumnSuggester не заменялся** — Self-Review плана L525–527 (эвристика читает карточки
тенанта в ядре; ai-service участвует только через IAiTools.GenerateKeywords).
## Что сделано (файлы)
- `Deal.Contracts`: `Integrations/IAiTools.cs` (+`Models/AiGenerateKeywordsResultDto.cs`,
`Models/AiEvaluateFitResultDto.cs`). Контракт `IAiClassifier` и LocalAiClassifier **не менялись** (см.
«Отклонения» п.1).
- `Deal.Modules.Pipeline/Application/`: `AiClassifyContextBuilder.cs` (fill_prompt 1:1 ai.py L6377 + системный
промпт aiPrompt+cardPrompt + user-контекст «Доски (критерии правил RulesDescriber/ключи ≤8/описание ≤160) +
примеры разметки ≤8 + Сообщение ≤5000» — python L226251), `AiRawLeadMapper.cs` (json-ответ → AiParsedLeadDto
1:1 python `_store_lead`/clean_budget/build_contacts/normalize_stack; «2к», алиасы валют, contacts-объекты,
заголовок ≤140 с fallback); регистрация билдера в `PipelineModuleRegistrar` (scoped).
- `Deal.Modules.Kanban`: `Application/Models/AiMarkupExampleDto.cs`, `IKanjStore.GetAiMarkupExamplesAsync`
(+EF в `KanbanStore`: join CardMoves(actions move/restore, ToCol не trash/archive) + Cards(SourceMsg≠''),
ORDER BY created_at DESC — python `_learning_examples` L201215).
- `Deal.Infrastructure/Integrations/`: `AiServiceOptions.cs`, `AiGrpcConnection.cs` (транспорт, эталон
MlGrpcConnection), `AiProviderConfigBuilder.cs` (эффективный ProviderConfig из настроек), `AiUsageLedger.cs`
(read-modify-write `aiTokenUsage` {prompt,completion,total}), `AiUnavailableException.cs`, `GrpcAiClassifier.cs`,
`GrpcAiTools.cs`, `LocalAiTools.cs`. Изменён `ServiceCollectionExtensions.cs` (сигнатура
`AddDealIntegrations(mlOptions, aiOptions)` + ветка Ai по флагу).
- `Deal.Api/Program.cs`: привязка `Services:Ai`, передача aiOptions, стартовый лог. Комментарии поправлены.
- `Deal.Tests.Unit`: `RecordingAiService.cs` + `AiGrpcTestHost.cs` (in-proc Kestrel HTTP/2 фейк, эталон
MlGrpcTestHost), `GrpcAiClassifierTests.cs`, `GrpcAiToolsTests.cs`, `LocalAiToolsTests.cs`,
`AiClassifyContextBuilderTests.cs`, `AiRawLeadMapperTests.cs`, `PipelineWorkerGrpcAiTests.cs` (приёмка
Acceptance), `IntegrationsDiTests.cs` (+сценарий флага Ai); `FakeKanjStore.cs` (+GetAiMarkupExamplesAsync).
## Отклонения и решения
1. **Воркер Pipeline и контракт IAiClassifier НЕ менялись** (инструкция брифа п.3 «воркер не меняется, порт
тот же» — отклонение от Files плана L414419/L424, где call-site'ы переходят на запросные record'ы через
билдер). Контекст/маппер помещены в модуль Pipeline как «ядро владельца» (план: `PL/Application/…`) и
потребляются gRPC-адаптером `GrpcAiClassifier` (Infrastructure → Pipeline-модуль, зависимость уже была у
LocalAiClassifier) — python-структура сохранена 1:1 (классификатор сам собирает промпты/доски/примеры по
тексту, `ai.py classify L218258`). Кандидатура на будущее: при Discovery-задачах/этапе 7 порт можно
перевести на запросные record'ы без изменения адаптеров.
2. **IAiTools реализован полностью** (GenerateKeywords + EvaluateFit): EvaluateFit откладывать не стали —
серверная сторона ai-service готова (план Task 8), отложенная реализация оставила бы порт «на бумаге».
3. **Ключ `aiTokenUsage`** уже добавлен в SettingsKeys (ledger-Task 10, список плана Task 16 L443444) — форма
значения {prompt,completion,total} зафиксирована здесь (этап 7 добавит лимиты/бюджеты).
4. **Стиль**: 1 тип = 1 файл, XML-doc на public, комментарии на русском, именованные константы (120 с, 4000,
5000, 500, 160, ≤8, ≤6, ≤30), без регионов; DTO-рекорды в Contracts — как AiParsedLeadDto.
5. **Классификация в тестах against in-proc**: RecordingAiService не проверяет токен (как RecordingMlService) —
проверяется, что клиент его шлёт; токен-интерцептор сервисов покрыт тестами Tasks 2–4.
## Проверка (команды, из `src/core`)
- `dotnet build Deal.sln` и `dotnet build Deal.sln -c Release` — 0 warnings / 0 errors.
- `dotnet test tests/Deal.Tests.Unit`**681/681 PASS** (новых 34/34).
- Новые сценарии: фильтр (маппинг pass/reason/skipped=false, заполненный промпт, ProviderConfig, обрезка 4000,
UNAVAILABLE → AiUnavailableException, usage→KV); классификация (маппинг JSON→DTO со всеми полями, контекст
«Доски+сообщение», ok=false/UNAVAILABLE → исключение, usage копится и при ok=false, расшифрованный apiKey/
api_style anthropic, обрезка 5000); контекст-билдер (fill_prompt, склейка cardPrompt, доски с критериями/
ключами/описанием, suggested-исключение, примеры свежими первыми, фраза «колонок пока нет», обрезка 5000);
маппер (полный ответ, «2к»/₽/«до X», contacts-объекты/дубли/боты, стек строкой, spam/board="", fallback
заголовка, не-JSON → JsonException); GrpcAiTools (ключи, мягкая ошибка, fit/ключи-запроса, UNAVAILABLE);
LocalAiTools (NotSupportedException); DI (UseLocal=true → Local-адаптеры без транспортов, UseLocal=false →
Grpc-адаптеры + оба транспорта, без токена → InvalidOperationException по каждому флагу); **Acceptance
L433434**: PumpOnce воркера с GrpcAiClassifier против in-proc ai-service — фильтр+классификация прошли,
карточка создана (IsVacancyKnown=true), usage накоплен; ai-service недоступен → локальный разбор (aiFail)
без падения pump (фолбэк Task 20).
## Concerns
- Потребители IAiTools (воркер/эндпоинты Discovery, план Tasks 17–19) — следующие задачи; сейчас порт
проверен напрямую и в DI. Полная сквозная проверка с реальным ai-service (без ключа → UNAVAILABLE → фолбэк;
затем подъём состава) — финал этапа (Task 20).
- Учёт `aiTokenUsage` накоплением без лимитов — осознанно: лимиты/бюджеты токенов — этап 7 (Self-Review
L536–537), форма значения готова.
- Тесты, меняющие `DEAL_SERVICE_TOKEN`, добавлены в коллекцию `MlGrpcTests` (сериализация с TelegramIngress/
Ml-тестами — как раньше, риск принят по образцу репозитория).
@@ -0,0 +1,99 @@
# Task 17 — Отчёт: core — Discovery: таблицы, порт, сервисы задач/кандидатов/чёрного списка/лога (план-файл L451465, Ruling 9)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors; тесты **777/777 PASS**
(`dotnet test Deal.sln`, из них новых **48/48**: DiscoveryTasksServiceTests 18, DiscoveryCandidatesServiceTests 20,
DiscoveryBlacklistServiceTests 4, DiscoveryLogServiceTests 4, FakeDiscoveryStore — инфраструктура). Миграция
`TenantDiscovery` **применяется** — проверено `dotnet ef database update` на dev-Postgres :5433 в песочной схеме
`tenant_disc_verify` (все 4 таблицы созданы, схема удалена). Сеть наружу не использовалась.
## Сверка с заданием (Acceptance L465)
- **Модуль чистый, паттерн портов** (п.1–2 брифа): DTO §4.8 (`DiscoveryTaskDto` L353, `DiscoveryCandidateDto`
L355 + `DiscoveryTopicDto`, `DiscoveryBlacklistDto`, `DiscoveryLogDto`) + write/patch-типы
(`DiscoveryTaskRow/DiscoveryCandidateRow/DiscoveryTaskDraft/DiscoveryTaskPatch/DiscoveryCandidatePatch`);
`DiscoveryIdPrefixes` (`dt_`/`dl_` + генератор 12-hex, модуль зависит только от ST + Contracts);
порт `IDiscoveryStore` (23 метода, xml-doc со ссылками на discovery.py/db.py); сервисы
`DiscoveryTasksService`/`DiscoveryCandidatesService`/`DiscoveryBlacklistService`/`DiscoveryLogService` +
`DiscoveryPlanGuard` (Ruling 9) + `DiscoveryModuleRegistrar`. Каталоги значений: `DiscoveryTaskStatuses`,
`DiscoveryCandidateStatuses`, `DiscoveryCandidateKinds`, `DiscoveryLogEvents`, `DiscoveryCounterField`.
`Deal.Modules.Discovery.csproj` → SharedKernel + Contracts + **ST** (Settings).
- **Бюджет и квоты 1:1 с прототипом/планом**: суточный лимит `discJoinLimit` (дефолт 50), занятое =
`SUM(plan_joins)` задач со статусом NOT IN (done, failed); создание/рост плана — `used + new ≤ limit`
(DiscoveryPlanGuard, python L80111). Итог: задача на 50 занимает весь бюджет (другая не создаётся); две по 25
допустимы (25+25=50), третья — нет; текст 400 — python (L97110).
- **Ключевые слова ИИ-генерации — НЕ в Task 17** (вопрос брифа «создание с генерацией?»): сверено с планом и
прототипом — create_task не генерирует ключи (discovery.py L234282 принимает keywords из payload);
генерация — отдельный endpoint `generate-keywords` (discovery_routes L189211) за IAiTools, эндпоинты — Task 19.
- **Задачи**: create/patch (рост плана с бюджетом, клампы `_validate_task_values` L213231)/delete (каскад:
кандидаты + лог, чёрный список общий)/start (пустые ключи → 400 «Нет ключевых слов для поиска — добавьте их
в задачу»; done/failed → сброс прогресса L332339)/pause/advance_search/bump_counter — 1:1 L234381.
- **Кандидаты**: add с исключениями (мониторится/чёрный список/уже new|review|joined → null + лог skip;
stale rejected → перезапись новой записью L431433; found+1), set_candidate (пустое имя/kind/hue не затирают
L480482; marks/topics JSON), set_candidate_status (new/review + лог review), mark_joined (joined/autoJoined +
счётчик joined + лог join_auto/join_manual; идемпотентен), mark_rejected (rejected + счётчик + лог reject +
чёрный список через upsert; joined → 400 «Нельзя отклонить источник, в который уже вступили»; идемпотентен),
delete — 1:1 L385563. 404-семантика — null (текст «Задача/Кандидат не найден» у эндпоинта Task 19);
400 — `DiscoveryValidationException` с текстами python.
- **Инфраструктура**: сущности + EF-конфигурации (таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog,
индексы TaskId+Status и TaskId+CreatedAt — python L177/196), DbSet'ы + ApplyConfiguration в TenantDbContext,
EF-адаптер `DiscoveryStore` (регистрация IDiscoveryStore в AddDealPersistence), миграция `TenantDiscovery`.
## Что сделано (файлы)
- `Deal.Modules.Discovery`: csproj (+ST + DI Abstractions); `Application/DiscoveryIdPrefixes.cs`,
`DiscoveryTaskStatuses.cs`, `DiscoveryCandidateStatuses.cs`, `DiscoveryCandidateKinds.cs`, `DiscoveryLogEvents.cs`,
`DiscoveryCounterField.cs`, `DiscoveryValidationException.cs`, `IDiscoveryStore.cs`, `DiscoveryPlanGuard.cs`,
`DiscoveryTasksService.cs`, `DiscoveryCandidatesService.cs`, `DiscoveryBlacklistService.cs`,
`DiscoveryLogService.cs`, `DiscoveryModuleRegistrar.cs`; `Application/Models/` — 7 DTO-типов (§4.8/запросы).
- `Deal.Infrastructure`: `Persistence/Entities/{DiscTaskEntity,DiscCandidateEntity,DiscBlacklistEntity,DiscLogEntity}.cs`,
`Persistence/{DiscTask,DiscCandidate,DiscBlacklist,DiscLog}Configuration.cs`, `Persistence/Repositories/DiscoveryStore.cs`
(JSON camelCase, epoch-ms наружу, upsert с сохранением CreatedAt), `Persistence/TenantDbContext.cs` (DbSet + Apply),
`ServiceCollectionExtensions.cs` (IDiscoveryStore → DiscoveryStore), csproj (+Discovery),
`Migrations/TenantDb/20260907155333_TenantDiscovery.cs` (+Designer/snapshot).
- `Deal.Tests.Unit`: `FakeDiscoveryStore.cs`, `DiscoveryTasksServiceTests.cs`, `DiscoveryCandidatesServiceTests.cs`,
`DiscoveryBlacklistServiceTests.cs`, `DiscoveryLogServiceTests.cs`.
## Отклонения и решения
1. **QuotaService как отдельного класса нет** (бриф упоминал): по плану/Ruling 9 бюджет вынесен в
`DiscoveryPlanGuard` (план-бюджет задач), квоты авто-вступлений/флуд-день — Task 18 (бан-гард воркера).
2. **DiscoveryModuleRegistrar создан, но не подключён в Program.cs Api** — по плану Files Task 17 Api не меняет
(эндпоинты Task 19 добавят `AddDiscoveryModule()`); сервисы/регистратор готовы и протестированы напрямую.
3. **Id-генерация** (dt_/dl_) реализована в `DiscoveryIdPrefixes` (модуль зависит только от ST + Contracts —
общий PrefixId живёт в Kanban, ссылаться нельзя; 12-hex CSPRNG, как PrefixId).
4. **`DiscoveryValidationException`** — новый тип 400-семантики модуля (python ValueError): тексты 1:1 с
прототипом; 404 — null-результаты (конвенция этапов 1–5), тексты «Задача не найдена»/«Кандидат не найден» —
у эндпоинтов Task 19.
5. **participants:null в патче кандидата** — «не менять» (не очистка): очистка не нужна — participants всегда
присылает discovery_info либо поле не трогается (отклонение задокументировано в DiscoveryCandidatePatch).
6. **Стиль**: 1 тип = 1 файл, XML-doc на public, комментарии на русском, именованные константы/тексты 400,
без регионов, времена DateTimeOffset (UTC) → наружу epoch-ms, JSON-колонки camelCase text (эталон ProjectStore).
## Проверка (команды, из `src/core`)
- `dotnet build Deal.sln` — 0 warnings / 0 errors.
- `dotnet test Deal.sln`**777/777 PASS** (новых 48/48). Сценарии: валидации create (имя/план 0/план 51/бюджет
50-из-50/остаток 25+25=50 и отказ третьей), нормализация дефолтов (draft, threshold/sample из настроек),
patch (клампы/Trim, рост плана 20→30 ок/20→31 бюджет-400, null-патч без записи), start (без ключей 400,
paused → прогресс сохранён, done → сброс), delete-каскад (кандидаты/лог удалены, чёрный список цел),
advance (idx+1, конец → searchDone), bump; add_candidate (успех/found+1, дефолты имени/kind/hue, skip:
мониторится/чёрный список/уже new|review, stale rejected → перезапись, задачи нет → null без лога),
mark_joined auto/manual (joined/autoJoined/счётчик/лог, идемпотентность, missing → null), mark_rejected
(rejected/счётчик/лог/чёрный список, идемпотентность, joined → 400, missing → null), set_candidate (пустое имя
не затирает, marks/topics/participants/fit), set_candidate_status (review + лог, joined → 400), delete;
blacklist (имя-дефолт, upsert-перезапись с сохранением CreatedAt, remove, список новые сверху); лог (dl_-id,
новые сверху, limit, пусто).
- Миграция: `dotnet tool run dotnet-ef migrations add TenantDiscovery --context TenantDbContext --output-dir
Migrations/TenantDb --project Deal.Infrastructure --startup-project Deal.Api` (готова);
`dotnet ef database update` применён к песочной схеме `tenant_disc_verify` dev-Postgres :5433 → 4 таблицы +
история созданы; схема удалена (`DROP SCHEMA ... CASCADE`).
## Concerns
- Воркер (Task 18) получит кандидатные/задачные сервисы и порт; потребуются дополнительные методы хранилища
(список running-задач, счётчик join_auto за UTC-сутки, инкремент join_failures) — расширение IDiscoveryStore/
DiscoveryStore/FakeDiscoveryStore в Task 18 (сейчас — ровно объём Task 17, YAGNI).
- Мягкая семантика части операций (add_candidate/set_candidate при исчезнувшей задаче → null вместо исключения
python KeyError) осознана: воркеру нужен «тихий» skip; 404-тексты эндпоинтов фиксируются в Task 19.
- Стиль/согласование с фронтом (DiscoveryView/store.js) не проверялось сквозным curl — эндпоинты Task 19
(curl-приёмка discovery по плану там же).
@@ -0,0 +1,102 @@
# Task 18 — Отчёт: core — Discovery-воркер (5 с): поиск/оценка/авто-join, бан-гард (план-файл L467480, Ruling 10)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors; тесты **821/821 PASS**
(`dotnet test Deal.sln`, из них новых **44/44**: DiscoveryBanGuardTests 7, DiscoveryLangDetectorTests 5,
DiscoveryEvaluatorTests 10, DiscoveryWorkerServiceTests 20, DiscoveryWorkerSchedulerTests 2 + фейки
FakeDiscoveryGateway/FakeAiTools/FakeDiscoveryPacer). Сеть наружу не использовалась (воркер тестируется
фейковым гейтом 1:1 с контрактом ITelegramGateway; Local-режим dev — нейтральный no-op).
## Сверка с заданием (Acceptance L480)
- **`DC/Application/DiscoveryWorkerService.cs`** — чистый оркестратор, `TickOnceAsync` = ОДНО действие за тик,
порядок шагов 1:1 discovery_worker.tick L444484: стоп-краны (paused/flood-день) → план достигнут (joined ≥
planJoins) → done+лог → поиск (следующая задача с !searchDone, один ключ, gateway.Search) → оценка первого
`new` (info → minSubscribers → ReadForEval → язык ru → <3 сообщений → содержание) → авто-join первого `review`
задачи с autoJoin. Действия-результаты: `DiscoveryWorkerOutcome{Action, TaskId}` (search/review/skip/join/
reject/flood/error/done/none). Логи 1:1 (search «поиск завершён: N кандидатов», skip «…: личный чат/бот»/
«мало участников (X < Y)»/«язык не русский»/«мало подходящих (X из N)»/«не удалось вступить (3 попытки)»,
flood «…: flood — стоп до конца суток», error/done), метки кандидата 1:1 (участники не подтверждены / язык не
подтверждён / канал: история недоступна / закрытая группа (история скрыта) — вступите сами / мало сообщений).
- **Оценка** — `DiscoveryEvaluator` (1:1 discovery_eval.py): каскад «короткое (<10 симв.) → ML-спам (mlEnabled,
IMlClient.Predict: take+label=spam) → ИИ (aiEnabled, IAiTools.EvaluateFit; любая ошибка, включая
NotSupportedException Local-режима и RPC-сбой → эвристика по ключам) → эвристика»; `GroupByTopic` (topic_id →
«main», сортировка по размеру, сниппеты-заголовки ≤60) и `Passed` (total≥3 && ratio·100≥threshold).
Форум оценивается по темам (есть проходная тема → подходит; topics кандидата: topicId/title/fitCount/total/
fitRatio/passed), не-форум — одним прогоном выборки. `DiscoveryLangDetector` — доля кириллицы (0.15/0.03).
- **Бан-гард/квоты/паузы** — `DiscoveryBanGuard` (1:1 ban_guard.py): суточный лимит по DiscLog `join_auto` за
UTC-сутки (`IDiscoveryStore.CountLogEventAsync`, ключ DiscFloodDay внутренний KV, discPaused стоп-кран,
`NoteFloodAsync` → блок до конца суток). Пауза 5070 с (discJoinDelayMin..Max) — за портом `IDiscoveryPacer`
(`DiscoveryPacer`: рандом из настроек, инверсия min>max, обе ≤0 → нет паузы); воркер в тестах — фейк.
- **Авто-join** (шаг 4): повторная проверка «не состоим» (Dialogs/чёрный список → mark_rejected с причиной 1:1) →
пауза → повторная перепроверка (кандидат review/задача running+autoJoin/не состоим/CanAutoJoin) → gateway.Join;
FloodWait → NoteFlood+лог flood (кандидат остаётся review); прочая ошибка → join_failures+1 (порт
`IncrementJoinFailuresAsync`, только review-строка), после 3 → delete+лог skip; успех → mark_joined(auto:true) →
SetMonitorAsync(true) (монитор-зеркало, 1:1 add_dialog_monitored) → BackfillAsync (сбой не роняет шаг) →
remove_blacklist. Задача с планом → done (`SetTaskDoneAsync`), лог done.
- **`A/Hosting/DiscoveryWorkerScheduler.cs`** — IHostedService, период 5 с, первый проход сразу, per-tenant цикл
(ITenantRepository → вложенный scope + ITenantContext.SetTenant → TickOnceAsync; эталон StorageTickScheduler/
PipelineWorkerScheduler), in-flight Interlocked-guard, ошибки логируются (тик одного тенанта не валит проход),
graceful stop. Зарегистрирован в Program.cs (`AddHostedService<DiscoveryWorkerScheduler>`).
- **DI**: DiscoveryModuleRegistrar расширен (DiscoveryEvaluator scoped, IDiscoveryPacer→DiscoveryPacer scoped,
DiscoveryBanGuard через фабрику с дефолтными UTC-часами, IDiscoverySearchErrorCounter singleton + impl,
DiscoveryWorkerService scoped); `builder.Services.AddDiscoveryModule()` подключён в Program.cs.
- **Тесты**: бан-гард (лимит по UTC-суткам/только join_auto/флуд-день и его сброс на следующий день/стоп-кран/
кастомный лимит), оценка (язык-пороги, короткое, ML-спам, ИИ-вердикт, сбой ИИ и NotSupported Local → эвристика,
регистронезависимый фит по ключу, агрегат fit X из N, passed по порогу и объёму, группировка форумов),
воркер-шаги (search→кандидат+лог done/skip чата; 3 ошибки ключа → пропуск; flood поиска → стоп; eval→review с
fitRatio/метками, участники/язык/мало сообщений/нет истории/мало подходящих; форум → topics; план → done +
идемпотентность «после done тик пуст»; join с паузой → mark_joined+монитор+backfill; уже состоим → reject;
flood join → стоп; 3 неудачи join → delete; изменение состояния за паузу → none без join; квота дня → none без
паузы; глобальная пауза → none), изоляция тенантов (два независимых набора стор/гейт/настройки — действия и
логи не пересекаются; DiscoveryWorkerSchedulerTests: RunCycle тикает оба тенанта в собственных scope, контекст
AsyncLocal сброшен, пустой цикл — тихий no-op).
## Что сделано (файлы)
- `Deal.Modules.Discovery/Application/`: `DiscoveryLangDetector.cs`, `DiscoveryBanGuard.cs`, `IDiscoveryPacer.cs`,
`DiscoveryPacer.cs`, `IDiscoverySearchErrorCounter.cs`, `DiscoverySearchErrorCounter.cs`, `DiscoveryEvaluator.cs`,
`DiscoveryEvalSample.cs`, `DiscoveryMessageFit.cs`, `DiscoveryTopicGroup.cs`, `DiscoveryWorkerService.cs`,
`DiscoveryWorkerOutcome.cs`; modify: `IDiscoveryStore.cs` (+SetTaskDoneAsync/CountLogEventAsync/
IncrementJoinFailuresAsync), `DiscoveryModuleRegistrar.cs` (регистрации Task 18).
- `Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs` — реализация трёх новых методов порта
(status=done; счёт DiscLog Event+CreatedAt≥sinceUtc; join_failures+1 только у review-строки).
- `Deal.Api/Hosting/DiscoveryWorkerScheduler.cs` (5 с); `Deal.Api/Program.cs``AddDiscoveryModule()` +
`AddHostedService<DiscoveryWorkerScheduler>()`.
- `Deal.Tests.Unit`: `FakeDiscoveryGateway.cs`, `FakeAiTools.cs`, `FakeDiscoveryPacer.cs`,
`DiscoveryBanGuardTests.cs`, `DiscoveryLangDetectorTests.cs`, `DiscoveryEvaluatorTests.cs`,
`DiscoveryWorkerServiceTests.cs`, `DiscoveryWorkerSchedulerTests.cs`; modify: `FakeDiscoveryStore.cs`
(+SeedLog и 3 метода порта).
## Отклонения и решения
1. **«+в Dialogs (монитор on)» после авто-join — на уровне зеркала telegram-service, локальную строку каталога
воркер не пишет.** Модуль DC зависит только от ST + Contracts (запись таблицы Dialogs — владелец модуль TM,
IDiscoveryStore сознательно без записи каталога, решение Task 17). После mark_joined(auto) воркер включает
монитор-зеркало сервиса (gateway.SetMonitorAsync(id, true), Ruling 7) и запускает Backfill; локальная строка
Dialogs появится ближайшей SyncDialogs-синхронизацией/refresh каталога (в python `add_dialog_monitored` писал
ту же строку сразу — в ядре это ответственность модуля TM/Api, не чистого воркера). Эндпоинт ручного join
(Task 19) добавит строку каталога из Api-слоя, где модули доступны.
2. **FloodWait детектится без ссылки модуля на Grpc.Core**: контракт — RpcException RESOURCE_EXHAUSTED с
detail-префиксом «flood:»; `RpcException.Message` кодирует Status как
`Status(StatusCode="ResourceExhausted", Detail="flood: …")` (проверено тестом) — воркер ищет маркер «flood:»
в тексте исключения (чистый модуль; обычные ошибки маркера не несут). Тесты бросают настоящий RpcException.
3. **Счётчик «3 ошибки поиска ключа подряд»** (python: глобальный dict процесса) вынесен в singleton
`IDiscoverySearchErrorCounter` (ключ — id задачи; ids глобально уникальны, тенанты не коллизятся): воркер
scoped (разрешается на каждый тик), состояние должно переживать тики — иначе битый ключ зацикливает поиск.
4. **Пауза join (50–70 с) выполняется синхронно внутри тика тенанта** (1:1 с прототипом: ban_guard.wait_join_delay
в шаге 4). DiscoveryWorkerScheduler — последовательный цикл тенантов (эталон PipelineWorkerScheduler): пока
один тенант держит паузу join, тики других тенантов ждут (в прототипе аккаунт один). Для мульти-тенантности
альтернатива — параллельные тики тенантов (Task.WhenAll) или перенос отложенного join в очередь; оставлено как
есть 1:1 с Ruling 10, кандидат на ревью в Task 20.
5. **Program.cs зовёт `AddDiscoveryModule()` уже в Task 18** (воркер/цикл должны резолвиться в tenant-scope);
в Task 17 регистратор был создан без подключения. Когда Task 19 будет добавлять модуль повторно — дубль
регистрации безвреден (контейнер берёт последнюю).
6. **Пауза-«спейсинг» search (24 с ban_guard.search_pause) в ядре не воспроизводится**: в этапе 6 она живёт в
telegram-service (DiscoveryOps, анти-бан сервиса, Task 10); тик ядра и так один поиск за 5 с.
7. **Стиль**: 1 тип = 1 файл, XML-doc на public, русские комментарии, именованные константы/тексты 1:1,
времена UTC/epoch-ms, enum/каталоги — как в модуле Discovery (Task 17).
## Проверка (команды, из `src/core`)
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors, AnalysisLevel latest).
- `dotnet test Deal.sln`**821/821 PASS** (новых 44/44, см. выше). Сценарии: см. «Тесты» сверки.
@@ -0,0 +1,238 @@
#!/usr/bin/env sh
# Task 19 curl-приёмка: эндпоинты /api/discovery на :5080 (DEAL_DEMO=1, Development). Сценарий (Acceptance L488489):
# 401 без куки → login → создать задачу (без ключей) → start 400 «Нет ключевых слов…» → generate-keywords
# (мягкая ошибка HTTP 200 keywords:[] error — LocalAiTools, Services:Ai:UseLocal=true) → ветка aiEnabled=false →
# PATCH keywords → list → квоты (GET /api/settings → discJoinLimit/discPaused) → start (running; Local-гейт:
# поиск пуст, кандидатов воркер не найдёт) → кандидаты симуляцией (psql-вставка строк DiscCandidates задачи) →
# reject → чёрный список → снятие чёрного списка → join (Local JoinAsync — no-op, строка каталога Dialogs пишется
# из Api-слоя, монитор on) → повторный join 400 «Уже вступили…» → кандидаты по статусам → лог → 404/400-ветки →
# delete задачи → logout → 401. В конце сервер останавливается, данные Discovery сценария удаляются.
set -u
BASE_URL="http://localhost:5080"
TENANT="tenant_00000000000000000000000000000001"
WORK=$(mktemp -d)
JAR="$WORK/cookies.txt"
OUT="$WORK/out.txt"
PASS=0
FAIL=0
FAILED_NAMES=""
TASK_ID=""
check() { # имя, ожидание HTTP-кода, [фрагменты...]
local name="$1" code="$2"
shift 2
if grep -q "\[HTTP:$code\]" "$OUT"; then
for frag in "$@"; do
if ! grep -qF "$frag" "$OUT"; then
echo " [FAIL] $name (нет фрагмента: $frag)"
FAIL=$((FAIL + 1))
FAILED_NAMES="$FAILED_NAMES|$name"
return
fi
done
echo " [PASS] $name"
PASS=$((PASS + 1))
else
echo " [FAIL] $name (ожидался HTTP $code)"
cat "$OUT"
FAIL=$((FAIL + 1))
FAILED_NAMES="$FAILED_NAMES|$name"
fi
}
cleanup() {
[ -z "$TASK_ID" ] || curl -s -m 5 -b "$JAR" -X DELETE "$BASE_URL/api/discovery/tasks/$TASK_ID" > /dev/null 2>&1
curl -s -m 5 -b "$JAR" -X DELETE "$BASE_URL/api/discovery/blacklist/-1009002" > /dev/null 2>&1
docker exec deal-postgres psql -U deal -d deal -c "DELETE FROM $TENANT.\"Dialogs\" WHERE \"Id\" IN ('-1009001','-1009002');" > /dev/null 2>&1
docker exec deal-postgres psql -U deal -d deal -c "DELETE FROM $TENANT.settings WHERE \"Key\"='aiEnabled';" > /dev/null 2>&1
}
reset_discovery() { # чистит строки Discovery прошлых прогонов (dev-БД deal; таблицы только этого сценария)
docker exec deal-postgres psql -U deal -d deal -c "DELETE FROM $TENANT.\"DiscCandidates\"; DELETE FROM $TENANT.\"DiscLog\"; DELETE FROM $TENANT.\"DiscTasks\"; DELETE FROM $TENANT.\"DiscBlacklist\"; DELETE FROM $TENANT.\"Dialogs\" WHERE \"Id\" IN ('-1009001','-1009002');" > /dev/null 2>&1
}
json_file() { # тело JSON в UTF-8-файл (Windows-curl иначе шлёт тело в кодовой странице консоли)
local name="$1"
shift
printf '%s' "$*" > "$WORK/$name.json"
}
echo "== старт Deal.Api :5080 =="
cd "$(dirname "$0")/../../../src/core/Deal.Api" || exit 1
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS="http://localhost:5080" DEAL_DEMO=1 nohup dotnet bin/Debug/net10.0/Deal.Api.dll > "$WORK/api.log" 2>&1 &
APP_PID=$!
UP=""
i=0
while [ $i -lt 90 ]; do
if curl -s -m 2 -o /dev/null "$BASE_URL/api/health"; then UP=1; break; fi
i=$((i + 1))
sleep 2
done
if [ -z "$UP" ]; then
echo " [FAIL] сервер не поднялся за 180 с"
tail -40 "$WORK/api.log"
exit 1
fi
echo " [PASS] сервер поднят (health 200)"
trap cleanup EXIT
echo
echo "== 1. 401-гейт без куки =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/discovery/tasks" > "$OUT"
check "GET /discovery/tasks без сессии → 401" 401 '"detail":"Требуется авторизация"'
echo
echo "== 2. login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login → 200 ok:true" 200 '"ok":true'
echo
json_file create '{"name":"Поиск фриланс-каналов","description":"Каналы и группы о фрилансе и удалёнке","planJoins":1,"autoJoin":false}'
echo "== 3. Создание задачи без ключей: дефолты сервиса (status draft, план 1) =="
reset_discovery
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks" -H "Content-Type: application/json" --data-binary "@$WORK/create.json" > "$OUT"
check "POST /tasks → задача draft" 200 '"id":"dt_' '"status":"draft"' '"name":"Поиск фриланс-каналов"'
TASK_ID=$(sed -n 's/.*"id":"\([^"]*\)".*/\1/p' "$OUT")
echo " задача: $TASK_ID"
echo
echo "== 4. start без ключей → 400 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/$TASK_ID/start" > "$OUT"
check "start без keywords → 400" 400 '"detail":"Нет ключевых слов для поиска — добавьте их в задачу"'
echo
echo "== 5. generate-keywords: мягкая ошибка HTTP 200 (Local-режим, ai-service не подключён) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/$TASK_ID/generate-keywords" > "$OUT"
check "generate-keywords → 200 keywords:[] error (LocalAiTools)" 200 '"keywords":[]' '"error":"ИИ-инструменты доступны'
echo
echo "== 6. generate-keywords при aiEnabled=false: ветка выключателя (Ruling 10/11) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" -H "Content-Type: application/json" -d '{"aiEnabled":false}' > "$OUT"
check "PATCH aiEnabled=false → 200" 200 '"aiEnabled":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/$TASK_ID/generate-keywords" > "$OUT"
check "generate-keywords при выключенном ИИ → 200 error" 200 '"keywords":[]' '"error":"ИИ выключен в настройках (aiEnabled)"'
docker exec deal-postgres psql -U deal -d deal -c "DELETE FROM $TENANT.settings WHERE \"Key\"='aiEnabled';" > /dev/null 2>&1
echo
json_file keywords '{"keywords":["фриланс","удалённая работа","freelance"]}'
echo "== 7. PATCH keywords → список/поля обновлены =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/discovery/tasks/$TASK_ID" -H "Content-Type: application/json" --data-binary "@$WORK/keywords.json" > "$OUT"
check "PATCH keywords → 200 keywords" 200 '"keywords":["фриланс","удалённая работа","freelance"]'
echo
echo "== 8. GET /tasks — задача в списке =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks" > "$OUT"
check "list задач → items с задачей" 200 '"items":[' 'Поиск фриланс-каналов'
echo
echo "== 9. Квоты Discovery из /api/settings (фронт loadDiscQuota) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
check "GET /api/settings → discJoinLimit/discJoinDelayMin/Max/discPaused" 200 '"discJoinLimit":50' '"discJoinDelayMin"' '"discJoinDelayMax"' '"discPaused":false'
echo
echo "== 10. start (ключи есть) → running =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/$TASK_ID/start" > "$OUT"
check "start → 200 status running" 200 '"status":"running"'
echo
echo "== 11. pause → paused =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/$TASK_ID/pause" > "$OUT"
check "pause → 200 status paused" 200 '"status":"paused"'
echo
echo "== 12. Кандидаты пусты (Local-поиск ничего не находит) — симуляция строк кандидатов через psql =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/$TASK_ID/candidates" > "$OUT"
check "candidates до симуляции → items:[]" 200 '"items":[]'
docker exec deal-postgres psql -U deal -d deal -c "INSERT INTO $TENANT.\"DiscCandidates\" (\"DialogId\",\"TaskId\",\"Name\",\"Username\",\"Kind\",\"Hue\",\"Participants\",\"LangRu\",\"MarksJson\",\"TopicsJson\",\"FitRatio\",\"Status\",\"AutoJoined\",\"JoinFailures\",\"CreatedAt\",\"UpdatedAt\") VALUES ('-1009001','$TASK_ID','Канал фриланса','join_ch','channel','#a11',120,true,'[]','[]',0.5,'new',false,0,now(),now()),('-1009002','$TASK_ID','Группа удалёнки','reject_ch','group','#c21',null,false,'[]','[]',null,'review',false,0,now(),now());" > /dev/null 2>&1
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/$TASK_ID/candidates" > "$OUT"
check "candidates после вставки → 2 кандидата" 200 '"dialogId":"-1009001"' '"dialogId":"-1009002"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/$TASK_ID/candidates?status=review" > "$OUT"
check "candidates?status=review → только -1009002" 200 '"dialogId":"-1009002"' '"status":"review"'
echo
echo "== 13. reject кандидата → rejected + чёрный список =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/candidates/-1009002/reject" > "$OUT"
check "reject → 200 status rejected" 200 '"dialogId":"-1009002"' '"status":"rejected"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/blacklist" > "$OUT"
check "blacklist → запись с причиной «отклонено вручную»" 200 '"dialogId":"-1009002"' 'отклонено вручную'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/candidates/-1009002/reject" > "$OUT"
check "повторный reject (rejected идемпотентен) → 200" 200 '"status":"rejected"'
echo
echo "== 14. Снятие чёрного списка =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/discovery/blacklist/-1009002" > "$OUT"
check "DELETE blacklist → {ok:true}" 200 '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/blacklist" > "$OUT"
check "blacklist после удаления → items:[]" 200 '"items":[]'
echo
echo "== 15. join кандидата (ручное вступление; Local JoinAsync — no-op) → joined(auto:false) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/candidates/-1009001/join" > "$OUT"
check "join → 200 status joined, autoJoined:false" 200 '"dialogId":"-1009001"' '"status":"joined"' '"autoJoined":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/dialogs" > "$OUT"
check "join добавил строку каталога Dialogs (монитор on) — /api/tg/dialogs" 200 '"-1009001"' '"on":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/candidates/-1009001/join" > "$OUT"
check "повторный join → 400 «Уже вступили…»" 400 '"detail":"Уже вступили в этот источник"'
echo
echo "== 16. Кандидаты по статусам после действий =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/$TASK_ID/candidates?status=joined" > "$OUT"
check "candidates?status=joined → -1009001" 200 '"dialogId":"-1009001"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/$TASK_ID/candidates?status=rejected" > "$OUT"
check "candidates?status=rejected → -1009002" 200 '"dialogId":"-1009002"'
echo
echo "== 17. Лог задачи: события join_manual и reject =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/$TASK_ID/log" > "$OUT"
check "log → события" 200 '"event":"join_manual"' '"event":"reject"'
echo
echo "== 18. 404/400-ветки =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/discovery/tasks/dt_missing" -H "Content-Type: application/json" -d '{"name":"x"}' > "$OUT"
check "PATCH неизвестной задачи → 404" 404 '"detail":"Задача не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/dt_missing/start" > "$OUT"
check "start неизвестной задачи → 404" 404 '"detail":"Задача не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/dt_missing/candidates" > "$OUT"
check "candidates неизвестной задачи → 404" 404 '"detail":"Задача не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks/dt_missing/log" > "$OUT"
check "log неизвестной задачи → 404" 404 '"detail":"Задача не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/candidates/-1009999/join" > "$OUT"
check "join неизвестного кандидата → 404" 404 '"detail":"Кандидат не найден"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/candidates/-1009999/reject" > "$OUT"
check "reject неизвестного кандидата → 404" 404 '"detail":"Кандидат не найден"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/discovery/tasks/dt_missing/generate-keywords" > "$OUT"
check "generate-keywords неизвестной задачи → 404" 404 '"detail":"Задача не найдена"'
echo
echo "== 19. Удаление задачи (с кандидатами и логом) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/discovery/tasks/$TASK_ID" > "$OUT"
check "DELETE задачи → {ok:true}" 200 '"ok":true'
TASK_ID=""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks" > "$OUT"
check "list после удаления → items:[]" 200 '"items":[]'
echo
echo "== 20. logout → 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "auth logout → ok" 200 '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/discovery/tasks" > "$OUT"
check "GET /discovery/tasks после logout → 401" 401 '"detail":"Требуется авторизация"'
echo
echo "== остановка сервера =="
kill "$APP_PID" 2>/dev/null
sleep 1
pkill -f "Deal.Api.dll" 2>/dev/null
echo " лог: $WORK/api.log"
echo
echo "== ИТОГ: PASS=$PASS FAIL=$FAIL =="
if [ "$FAIL" = "0" ]; then
echo "ПРИЁМКА ПРОЙДЕНА"
exit 0
fi
echo "Провалы:$FAILED_NAMES"
exit 1
@@ -0,0 +1,87 @@
# Task 19 — Отчёт: core — эндпоинты /api/discovery + generate-keywords; curl-приёмка
**План:** `docs/superpowers/plans/2026-09-05-deal-stage6-services.md` L482493 (Ruling 11).
**Источники:** `backend/app/routers/discovery_routes.py` (целиком), `store.js` L21482430, api-map §3.8/§4.8/§5.
## Сверка с заданием (Acceptance L493)
- **13 эндпоинтов /api/discovery 1:1 (api-map §3.8)**: `Deal.Api/Endpoints/DiscoveryEndpoints.cs`
tasks list/create/patch/delete/start/pause, generate-keywords, candidates (query `status`), join/reject,
blacklist list/delete, log. Роут-шаблоны/формы ответов — DTO модуля Discovery (camelCase §4.8 L353355)
и `{items: [...]}`; 404 «Задача не найдена»/«Кандидат не найден», 400-тексты python 1:1
(`DiscoveryValidationException` → 400 {detail}); 401-гейт — `EndpointResults.Unauthorized` (паттерн эндпоинтов
этапа). `app.MapDiscoveryEndpoints()` в `Program.cs` (после /api/tg).
- **generate-keywords**: POST `/tasks/{id}/generate-keywords` без тела → `IAiTools.GenerateKeywordsAsync`;
мягкие ошибки HTTP 200 `{keywords: [], error}` (Ruling 11): выключатель `aiEnabled` читает эндпоинт
(«ИИ выключен в настройках (aiEnabled)»), пустое описание («У задачи нет описания…»), недоступность —
`Ok:false` порта (GrpcAiTools, текст причины ai-service) либо `NotSupportedException` LocalAiTools (dev,
UseLocal=true). Очистка ключей — `DiscoveryEndpoints.CleanKeywords` (public helper, python `_clean_keywords`
L111128: ≤30, ≤60 симв., дедуп регистронезависимый).
- **join (ручное, вне квот)**: RPC `ITelegramGateway.JoinAsync``DialogsService.AddDiscoveredMonitoredAsync`
(строка каталога Dialogs: monitor on, backfilled=false + зеркало SetMonitor(true)) → фоновый первый разбор
`TelegramBackfillScheduler.ScheduleFirstBackfill` (python-_spawn `_backfill_quiet`) → снятие чёрного списка →
`MarkJoinedAsync(auto:false)`. Уже joined → 400 «Уже вступили в этот источник»; ошибка Telegram → 400
«Не удалось вступить в @username: причина»; 404 — кандидата нет.
- **reject**: предпроверка joined → 400 «Уже вступили — удалите источник из каналов» (текст роутера python;
сервисный guard `MarkRejectedAsync` — defense-in-depth), иначе `MarkRejectedAsync(reason «отклонено вручную»)`
→ чёрный список + лог reject; повтор rejected идемпотентен.
- **blacklist list/delete, log**: как discovery_routes L269285.
- **Стиль**: 1 тип = 1 файл, XML-doc, именованные константы, RU-комментарии; запросные тела — отдельные
wire-модели `Endpoints/RequestModels/DiscoveryTaskCreateBody.cs`/`DiscoveryTaskPatchBody.cs`.
## Файлы
| Файл | Тип | Содержание |
|---|---|---|
| `Deal.Api/Endpoints/DiscoveryEndpoints.cs` | create | 13 эндпоинтов + `CleanKeywords` (public), `ReadAiEnabledAsync`, маппинги тела→сервис. |
| `Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs` | create | Тело POST /tasks (TaskCreate L5060). |
| `Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs` | create | Тело PATCH (TaskPatch L6271, все optional). |
| `Deal.Api/Program.cs` | modify | `app.MapDiscoveryEndpoints();` (комментарий Task 19). |
| `Deal.Modules.Telegram/Application/DialogsService.cs` | modify | `AddDiscoveredMonitoredAsync` (upsert каталога + зеркало; python add_dialog_monitored L850873). |
| `Deal.Modules.Telegram/Application/ITelegramStore.cs` | modify | Порт `UpsertDiscoveredMonitoredAsync` (upsert ON CONFLICT L858872). |
| `Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs` | modify | EF-реализация upsert (новая строка monitor=true/backfilled=false либо обновление существующей). |
| `tests/…/DiscoveryEndpointsHelpersTests.cs` | create | 5 тестов `CleanKeywords` (null/пусто, trim+пустые, >60, дедуп casefold, потолок 30). |
| `tests/…/DialogsServiceTests.cs`, `FakeTelegramStore.cs`, `FakeTelegramGateway.cs` | modify | 4 теста `AddDiscoveredMonitored` (новая строка, upsert существующей, нормализация name/hue, сбой зеркала) + фейки. |
| `.superpowers/sdd/deal-stage6-services/task-19-curl-acceptance.sh` | create | curl-приёмка :5080 (37 шагов, PASS/FAIL). |
## Валидация
- `dotnet build Deal.sln`**0 предупреждений / 0 ошибок**.
- `dotnet test Deal.sln` (без build) — **830 PASS / 0 fail** (все тесты этапа; новые: CleanKeywords 5 + DialogsService 4).
- **Curl-приёмка PASS 37/37** (лог: `task-19-curl-run.log`): 401-гейт → login → create (без ключей) →
start 400 «Нет ключевых слов…» → generate-keywords 200 `keywords:[] error` (LocalAiTools) → ветка
`aiEnabled=false` → PATCH keywords → list → квоты `discJoinLimit`/`discJoinDelayMin/Max`/`discPaused` из
/api/settings → start `running` → pause → кандидаты симуляцией (psql-вставка строк задачи, Local-поиск пуст) →
reject → чёрный список («отклонено вручную») → снятие blacklist → join → `joined(auto:false)` + строка
каталога Dialogs on:true видна в `/api/tg/dialogs` → повторный join 400 → фильтры статусов → лог
(join_manual/reject) → 404-ветки (7 шт.) → delete задачи → logout → 401.
## Отклонения и решения
1. **Ручной join добавляет локальную строку каталога из Api-слоя** (решение T18-ревью, п.1): воркер-авто-join
пишет только зеркало; эндпоинт (где модули доступны) через TM `DialogsService.AddDiscoveredMonitoredAsync`
делает upsert строки Dialogs (монитор on, backfilled=false) + зеркало SetMonitor(true) — иначе фоновый
первый разбор (DialogsService.BackfillOneAsync) для не-каталогового источника был бы no-op (Backfilled=null).
Сбой зеркала не роняет вступление (как в worker-шаге).
2. **generate-keywords: веток «статус ИИ (local/keySet)» python в ядре нет** — ключи провайдера читает ai-service;
порт `IAiTools` возвращает мягкий `Ok:false + error` (GrpcAiTools) или кидает NotSupported (LocalAiTools);
эндпоинт ловит/пробрасывает обе формы в HTTP 200 {keywords: [], error}. Выключатель `aiEnabled` эндпоинт
проверяет сам (порт его не читает, Ruling 10/11). В dev-режиме наружу уходит технический текст LocalAiTools
(«…только при подключённом ai-service»).
3. **reject**: 400-текст для вступившего источника — python-роутера «Уже вступили — удалите источник из
каналов» (роутер проверяет статус до mark_rejected); сервисный guard (`RejectJoinedDetail`) остаётся как
защита от прямых вызовов сервиса. Повторный reject для rejected — идемпотентный 200 (python L552553).
4. **Невалидный query `status` у candidates** возвращает пустой список (в python FastAPI-Literal дал бы 422).
UI шлёт только new/review/joined/rejected; 422-конверт не копировался.
5. **curl-приёмка**: кандидаты создаются прямой psql-вставкой строк DiscCandidates задачи — реальный поиск
даёт пусто (Local-гейт, telegram-service не поднят; реальные данные — Manual T20). Приёмка эндпоинтов —
на формах: join успешен (Local JoinAsync no-op), строка Dialogs проверена через GET /api/tg/dialogs.
Кириллица тел передаётся `--data-binary @file` (UTF-8): Windows-curl иначе шлёт тело в кодовой странице
консоли (сервер: «invalid UTF-8 JSON», 400). Скрипт сам чистит строки Discovery dev-БД до/после прогона.
## Concerns
- `DiscoveryEndpointsHelpersTests`/DialogsService-тесты добавлялись на чистые хелперы и TM-метод; тонкие
HTTP-ветки покрыты curl-приёмкой (WebApplicationFactory в проекте не используется — конвенция этапа).
- Расширение порта `ITelegramStore` — изменение TM-модуля (Task 13) в задаче DC: обосновано решением
T18-ревью (join пишет каталог из Api-слоя); все реализации порта (EF-адаптер + Fake) обновлены.
@@ -0,0 +1,50 @@
# Task 2 — Каркас telegram-service (sln, gRPC-хост, health, service-token, DI) — отчёт
Статус: **DONE** (каркас создан; build 0/0; тесты 6/6 PASS после fix-ревью fail-closed; compose-запись добавлена и валидна).
## Файлы
| Файл | Содержание |
|---|---|
| `src/telegram-service/Directory.Build.props` | Код-стайл этапа (как `src/core`): net10.0, Nullable, ImplicitUsings, `TreatWarningsAsErrors`, `AnalysisLevel=latest`, `EnforceCodeStyleInBuild`. |
| `src/telegram-service/Deal.Telegram.sln` | Решение сервиса: `Deal.Telegram` + `Deal.Telegram.Tests`; `Deal.Proto` подтянут автоматически (`dotnet sln add` добавляет ProjectReference-проекты) — сборка sln = прогон кодогенерации. |
| `Deal.Telegram/Deal.Telegram.csproj` | Web SDK; `Grpc.AspNetCore`/`Grpc.AspNetCore.HealthChecks` 2.83.0 (одна версия с Grpc.Tools Deal.Proto); **ProjectReference** на `src/contracts/Deal.Proto.csproj` (Note T1 закрыт — решение T2: общая сборка кодогенерации вместо per-process `<Protobuf Include>`). |
| `Deal.Telegram/Program.cs` | Точка входа: порт из env `GRPC_PORT``PORT` → 5101; стартовый лог; делегирует сборку `TelegramServiceHost.Create`. |
| `Deal.Telegram/TelegramServiceHost.cs` | Фабрика хоста (Kestrel `IPAddress.Any:port` HTTP/2 без TLS — Ruling 2; `AddGrpc` + интерцептор; `AddGrpcHealthChecks().AddCheck("ready", …)`; `MapGrpcService<TelegramServiceImpl>` + `MapGrpcHealthChecksService`). Seam для интеграционных тестов (in-proc) и DI-хук `configureServices` для фейков задач 9–11. |
| `Deal.Telegram/ServiceTokenInterceptor.cs` | Проверка gRPC-metadata `service-token` против env `DEAL_SERVICE_TOKEN` (Ruling 1); отказ `UNAUTHENTICATED`; `grpc.health.v1.Health` освобождён от токена (liveness инфраструктуры, Ruling 12); fail-closed при пустом токене. |
| `Deal.Telegram/TelegramServiceImpl.cs` | Явные заглушки `UNIMPLEMENTED` всех 16 RPC `TelegramService` (сигнатуры 1:1 с telegram.proto — компиляция доказывает кодогенерацию); в XML-doc размечено, какой задачей (9/10/11) реализуется каждый метод. `IngressService` здесь сервером не выставляется (в telegram-service это клиент ядра — Ruling 7). |
| `Deal.Telegram/Dockerfile` | Мультистейдж sdk→aspnet:10.0 + `grpc_health_probe` (healthcheck Ruling 12). Контекст сборки — корень репозитория: csproj ссылается на `src/contracts` вне каталога сервиса. |
| `Deal.Telegram.Tests/` | `Deal.Telegram.Tests.csproj` (стек как `Deal.Tests.Unit` + `Grpc.Net.Client`/`Grpc.HealthCheck` 2.83.0, `FrameworkReference Microsoft.AspNetCore.App`) + `TelegramServiceHostTests.cs` — 6 интеграционных тестов (5 каркаса + 1 fix fail-closed). |
| `deploy/compose.dev.yml` | Запись `telegram-service` (Ruling 12): build из корня, порт `5101:5101`, env `GRPC_PORT`/`DEAL_SERVICE_TOKEN` (default `deal_dev_service_token`), volume `deal_tg_sessions:/data/sessions`, healthcheck `grpc_health_probe -addr=localhost:5101`. |
## Валидация
- `dotnet build Deal.Telegram.sln` (из `src/telegram-service`): **0 warnings / 0 errors** (Deal.Proto + Deal.Telegram + Deal.Telegram.Tests).
- `dotnet test Deal.Telegram.sln`: **6/6 PASS** — health `SERVING`; GetStatus без токена → `UNAUTHENTICATED`; неверный токен → `UNAUTHENTICATED`; верный токен проходит к методу → `UNIMPLEMENTED` (заглушка); при незаданном `DEAL_SERVICE_TOKEN` Deal-RPC fail-closed, health при этом `SERVING`; fix: пустой metadata-токен при незаданном env → `UNAUTHENTICATED` (health жив).
- Smoke реального бинарника: `GRPC_PORT=5199 ./Deal.Telegram/bin/Debug/net10.0/Deal.Telegram.exe` → лог «…0.0.0.0:5199…», процесс погашен.
- `docker compose -f deploy/compose.dev.yml config --quiet` — OK.
- Запуск вручную: `dotnet run --project src/telegram-service/Deal.Telegram` (порт 5101 либо env `GRPC_PORT`); health — `grpc_health_probe -addr=localhost:5101`; Deal-RPC требуют metadata `service-token` = `DEAL_SERVICE_TOKEN`.
## Решения и отклонения
1. **Подключение контрактов — ProjectReference на `Deal.Proto`** (не per-process `<Protobuf Include>` из Ruling 1). Это закрывает Note task-1-report («способ решают T2–T4»); требование «сборка доказывает кодогенерацию telegram.proto» выполнено — Deal.Proto входит в sln и собирается с ним. T3/T4 повторяют шаблон.
2. **Хост-фабрика `TelegramServiceHost.Create`** — дополнительный файл сверх списка плана: gRPC не работает через TestServer, поэтому интеграционные тесты поднимают настоящий Kestrel-хост в своём процессе на эфемерном порту; заодно появляется DI-хук для фейков задач 9–11. Program.cs остаётся тонкой продакшн-обёрткой (порт из env).
3. **Health освобождён от service-token** — стандартный `grpc.health.v1.Health` это liveness инфраструктуры (docker healthcheck, Ruling 12), данных тенантов не отдаёт. Deal-RPC — fail-closed при пустом `DEAL_SERVICE_TOKEN`.
4. **Явная health-проверка `ready`** (`AddCheck`): без зарегистрированных проверок gRPC-health отвечает `UNKNOWN`, а не `SERVING` (выявлено тестами, исправлено).
5. Docker-образ **не собирался** (тянет `sdk/aspnet:10.0` и probe-образ по сети) — проверен только `docker compose config`; сборка образа и запуск в compose — в Task 20. Для ускорения context-загрузки на этапе сборки стоит добавить корневой `.dockerignore` (bin/obj) — сейчас не добавлял, чтобы не задеть корневой docker-compose проекта.
## Fix-ревью: fail-closed-гард в ServiceTokenInterceptor
Замечание: XML-doc интерцептора обещал fail-closed при незаданном `DEAL_SERVICE_TOKEN`, но при
`_expectedToken == ""` запрос с ПУСТЫМ metadata `service-token` проходил (`«» == «»`).
- `Deal.Telegram/ServiceTokenInterceptor.cs`: до сравнения добавлен гард `if (_expectedToken.Length == 0) → UNAUTHENTICATED` (health по-прежнему пропускается раньше и остаётся живым); общий отказ вынесен в `Rejection()`, чтобы не дублировать конструкцию RpcException. Логика — как в ml-service (T3).
- `Deal.Telegram.Tests/TelegramServiceHostTests.cs`: новый тест `EmptyServiceToken_WithUnsetEnvToken_IsUnauthenticated_HealthServing` — env не задан + пустой токен в metadata → `UNAUTHENTICATED`, health при этом `SERVING` (регрессия на гард).
Перепроверка после fix: build 0 warnings / 0 errors; `dotnet test Deal.Telegram.sln`**6/6 PASS**.
## Concerns
- Host-тесты меняют процессный env `DEAL_SERVICE_TOKEN`: все сценарии — в одном классе (xunit исполняет методы класса последовательно); будущим классам, поднимающим хост, нужно учитывать (collection или свой процесс env).
- Русский detail в логах Kestrel выглядит кракозябрами из-за кодовой страницы консоли (по gRPC-каналу — корректный UTF-8); косметика, кода не касается.
- Состав sln включает `Deal.Proto` (внешний путь `..\contracts`) — осознанно: sln сервиса должен собираться 0/0 вместе с контрактами.
@@ -0,0 +1,87 @@
# Task 20 — compose-dev, сквозная интеграция и финал этапа 6 — отчёт
Статус: **DONE (review pending)**. Docker Desktop на машине выключен (НЕ запускался — ресурсы); живой
smoke-прогон стека отложен (Manual), композ-файл доведён до полного и валидирован без движка, подготовлен
скрипт smoke-проверки для запуска одной командой.
## Контекст: наследие оборванного запуска
`compose.dev.yml`, `compose.grpc.yml` и четыре Dockerfile'а были записаны предыдущим (оборванным на
spawn_agent) запуском T20 (mtime 20:1220:27, до записи progress.md 20:32). Текущий запуск перепроверил
всё по фактическим файлам и доработал до требований задачи. Dockerfile'ы (T2T4) корректны: контекст
сборки — корень репозитория (`context: ..` в compose), csproj ссылаются на `src/contracts/Deal.Proto.csproj`
вне каталога сервиса; в образах — `grpc_health_probe` для healthcheck (Ruling 12).
## Что сделано
### 1. `deploy/compose.dev.yml` — полный dev-стек (единый файл)
- Состав: `postgres` (:5433), `minio` (:9000/:9001), `telegram-service` (:5101), `ai-service` (:5102),
`ml-service` (:5103), `core`/Deal.Api (HTTP :5080 + gRPC-ингресс :5082, `GRPC_INGRESS_PORT`).
- Dev-секреты: единый `DEAL_SERVICE_TOKEN` (core + все сервисы, default `deal_dev_service_token`),
`DEAL_TELEGRAM_SESSION_KEY` (32 Б base64; telegram-service fail-closed без него),
`DEAL_ENCRYPTION_KEY` (секреты настроек core), creds БД/минио. Все — `${VAR:-default}`.
- Env core: `ConnectionStrings__DealPostgres`, `Storage__Minio__{Endpoint,AccessKey,SecretKey,Bucket,Secure}`
(ключи сверены с `FileStorageRegistrar`/`MinioStorageOptions`), эндпоинты сервисов именами compose-сети,
**`Services__{Ml,Ai,Telegram}__UseLocal: "false"`** — полный стек «по-настоящему» (задание; дефолт кода
Local в appsettings не тронут). Незакрытые env telegram/ml/ai из замечаний прошлых задач закрыты:
у telegram-записи есть `DEAL_TELEGRAM_SESSION_KEY`+`DEAL_TELEGRAM_SESSION_DIR=/data/sessions`
(volume `deal_tg_sessions`) и `SERVICES__CORE__INGRESS` (`http://core:5082`, перекрытие
`DEAL_CORE_INGRESS`); у ml — `DEAL_ML_DATA_DIR=/data/ml` (volume `deal_ml_data`); ai stateless без volume.
- Volumes/healthcheck/depends_on: все 5 сервисов с healthcheck (gRHC `grpc_health_probe`), core
`depends_on: postgres: service_healthy`.
- `deploy/compose.grpc.yml` **удалён** как избыточный: UseLocal=false теперь заданы в базовом файле
(файл-override ссылался только на самого себя и заголовок compose.dev.yml; внешних ссылок не было).
- Валидация без движка: `docker compose -f deploy/compose.dev.yml config`**rc=0** (не требует daemon).
### 2. `scripts/dev-smoke.sh` — сквозной smoke (одна команда, trap-очистка)
- Предусловие-проверка движка (`docker info`); подъём `up -d --build`; ожидание health всех контейнеров
(10 мин таймаут; minio — running, т.к. healthcheck нет).
- Проверки: login admin/admin → `GET /api/tg/status` (поля §4.9; структурно — зависит от состояния
volume БД) → `POST /api/demo/simulate-lead` (карточка `l_…`, inbox) → `POST /api/leads/{id}/trash`
(обучающий сигнал spam → строка `MlOutbox`) → поллинг `GET /api/ml/status` до
`reachable:true` + `stats.outbox:0` + класс `"spam"` в модели ml-service — **доказывает живой gRPC-путь
флашера `MlOutboxFlushScheduler` → TrainBatch**.
- `trap EXIT INT TERM``docker compose down` (без `-v`, данные dev сохраняются) + удаление temp; в фоне
ничего не остаётся. Без движка скрипт падает сразу с понятным сообщением (rc=1, очистка отрабатывает).
- `sh -n` — синтаксис OK.
### 3. Доки
- `docs/technical/Техническая-документация-Дейл.md`: §13 → «актуально для этапа 6», §13.1 — подъём только
хранилищ для host-режима (+ отсылка на полный стек), §13.6 — 830 PASS и сборка четырёх sln 0/0; новый
**§13.7 «Этап 6 — сервисы telegram/ai/ml + Discovery + каналы»**: порты/процессы, gRPC-контракты и
безопасность (service-token, без mTLS — этап 7), флаги UseLocal, env каждого сервиса, полный стек и
smoke, каналы-вкладка /api/tg, Discovery, ml-модель и веса (MIN_TOTAL 20, сигналы 1.0/0.4/0.6, флашер),
ai-фасад, ручные проверки с кредами. §11 — блок «Выполнено на этапе 6» + обновлённый «Остаётся TODO»
(Manual-smoke, живые Telegram/LLM, этап 7).
- `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: этап 6 — «Выполнено» (счётчики, curl-приёмки,
Manual-пункты с кредами), этап 7 — следующий, ограничения этапа 6 перечислены в буллете этапа 6.
- `docs/superpowers/STATUS.md`: этап 6 — ✅ готов 20/20 (830; приёмки 20/20 + 37/37), итог ~88%; пометка
«живой smoke отложен до поднятия Docker»; «Что увидеть глазами» — команда полного стека/smoke.
### 4. Ledger
- `.superpowers/sdd/deal-stage6-services/progress.md`: Task 20 → `[x]` complete (review pending); блок
«НЕ выполнен — среда …» заменён на фактический статус с пояснением наследия.
## Валидация (выполнено в этом запуске)
- `docker compose -f deploy/compose.dev.yml config`**OK** (rc=0; движок не нужен).
- Build всех четырёх sln Debug+Release — **0 warnings / 0 errors**: `src/core/Deal.sln`,
`src/telegram-service/Deal.Telegram.sln`, `src/ml-service/Deal.Ml.sln`, `src/ai-service/Deal.Ai.sln`.
- `dotnet test src/core/tests/Deal.Tests.Unit` (Debug, --no-build) — **830/830 PASS**.
- `sh -n scripts/dev-smoke.sh` — OK; прогон без движка — корректный ранний выход rc=1 + очистка.
- Ничего не поднималось и не осталось: docker daemon выключен (`docker ps` — connection refused),
dotnet-процессы после build/test завершены.
## Manual (осталось на пользователя)
1. Поднять Docker Desktop → `sh scripts/dev-smoke.sh` — живой smoke полного стека (одна команда).
2. Реальный Telegram-вход (tgKeys api_id/api_hash → start-qr → QR) и реальные LLM-вызовы (aiConfigs/
aiProvider, `/api/ai/check`) — с кредами; без ключа LLM ai-service недоступен — воркеры падают в
локальные пути (фолбэк по замыслу).
## Concerns
- Docker-образы не собирались (нет движка): сборка/запуск контейнеров проверены только `compose config`
+ сверкой Dockerfile/context/env с кодом (имена env-ключей подтверждены по Program.cs/Options каждого
процесса). Первый `up -d --build` соберёт 4 образа — займёт время; health-ожидание в smoke — 10 мин.
- `docker compose config` печатает `name: deploy` (имя каталога deploy) — как на этапах 1–5, не менял.
- Порт 5080/5082/51015103 должны быть свободны на хосте (в smoke-сценарии стек один).
- Удаление `compose.grpc.yml` — осознанное упрощение (флаги в базовом файле); если понадобится «core в
docker на Local», достаточно переопределить env `Services__*__UseLocal=true` в своём override.
@@ -0,0 +1,41 @@
# Task 3 — Каркас ml-service (sln, gRPC-хост, health, service-token, DI) — отчёт
Статус: **DONE** (каркас создан; build 0/0; тесты 5/5 PASS; compose-запись добавлена и валидна).
## Файлы
| Файл | Содержание |
|---|---|
| `src/ml-service/Directory.Build.props` | Код-стайл этапа (шаблон T2): net10.0, Nullable, ImplicitUsings, `TreatWarningsAsErrors`, `AnalysisLevel=latest`, `EnforceCodeStyleInBuild`. |
| `src/ml-service/Deal.Ml.sln` | Решение сервиса: `Deal.Ml` + `Deal.Ml.Tests` + `Deal.Proto` (ProjectReference-проект подтянут в sln явно, как T2; `--format sln` — .NET 10 по умолчанию создаёт `.slnx`). |
| `Deal.Ml/Deal.Ml.csproj` | Web SDK; `Grpc.AspNetCore`/`Grpc.AspNetCore.HealthChecks` 2.83.0; ProjectReference на `src/contracts/Deal.Proto.csproj` (кодогенерация ml.proto — общий проект, решение T2). |
| `Deal.Ml/Program.cs` | Точка входа: порт из env `GRPC_PORT``PORT` → 5103; стартовый лог; делегирует сборку `MlServiceHost.Create`. |
| `Deal.Ml/MlServiceHost.cs` | Фабрика хоста (Kestrel `IPAddress.Any:port` HTTP/2 без TLS — Ruling 2; `AddGrpc` + интерцептор; `AddGrpcHealthChecks().AddCheck("ready", …)`; `MapGrpcService<MlServiceImpl>` + `MapGrpcHealthChecksService`). Seam для in-proc тестов; в XML-doc размечено место DI-регистраций модель-менеджера (Task 5 — движок/пул/SQLite; Task 6 — gRPC поверх пула). |
| `Deal.Ml/ServiceTokenInterceptor.cs` | Проверка gRPC-metadata `service-token` против env `DEAL_SERVICE_TOKEN` (Ruling 1); отказ `UNAUTHENTICATED`; health освобождён от токена. **Fail-closed с явным гардом `_expectedToken.Length == 0` → всегда отказ** (замечание ревью T2 учтено: без гарда пустое значение metadata сравнилось бы с пустым env-токеном как равное). |
| `Deal.Ml/MlServiceImpl.cs` | Явные заглушки `UNIMPLEMENTED` всех 4 RPC `MlService` (Predict/Status/Reset/TrainBatch — сигнатуры 1:1 с ml.proto, компиляция доказывает кодогенерацию); в XML-doc размечено, что реализуется в Task 6 поверх движка Task 5 (Ruling 4). |
| `Deal.Ml/Dockerfile` | Мультистейдж sdk→aspnet:10.0 + `grpc_health_probe`; EXPOSE 5103; контекст сборки — корень репозитория (csproj ссылается на `src/contracts`). |
| `Deal.Ml.Tests/` | `Deal.Ml.Tests.csproj` (стек как T2) + `MlServiceHostTests.cs` — 5 интеграционных тестов. |
| `deploy/compose.dev.yml` | Запись `ml-service` (Ruling 12): build из корня, порт `5103:5103`, env `GRPC_PORT`/`DEAL_SERVICE_TOKEN` (default `deal_dev_service_token`), volume `deal_ml_data:/data/ml`, healthcheck `grpc_health_probe -addr=localhost:5103`. |
## Валидация
- `dotnet build Deal.Ml.sln` (из `src/ml-service`): **0 warnings / 0 errors** (Deal.Proto + Deal.Ml + Deal.Ml.Tests).
- `dotnet test Deal.Ml.sln`: **5/5 PASS** — health `SERVING`; Status без токена → `UNAUTHENTICATED`; неверный токен → `UNAUTHENTICATED`; верный токен проходит к методу → `UNIMPLEMENTED` (заглушка); при незаданном `DEAL_SERVICE_TOKEN` — fail-closed (запрос и с пустым значением metadata `service-token`, и с «верным-на-вид» токеном отклонён; health при этом `SERVING`).
- Smoke реального бинарника: `GRPC_PORT=5201 ./Deal.Ml/bin/Debug/net10.0/Deal.Ml.exe` → «ml-service стартует: gRPC plaintext 0.0.0.0:5201…» + «Now listening on: http://0.0.0.0:5201»; процесс погашен, остатков в `tasklist` нет.
- `docker compose -f deploy/compose.dev.yml config --quiet` — OK.
- Запуск вручную: `dotnet run --project src/ml-service/Deal.Ml` (порт 5103 либо env `GRPC_PORT`); health — `grpc_health_probe -addr=localhost:5103`; Deal-RPC требуют metadata `service-token` = `DEAL_SERVICE_TOKEN`.
## Решения и отклонения
1. **Подключение контрактов — ProjectReference на `Deal.Proto`** (шаблон T2, Note task-1-report закрыт); ml.proto входит в sln и кодогенерация доказывается сборкой.
2. **Хост-фабрика `MlServiceHost.Create`** — сверх списка плана (как T2): gRPC не работает через TestServer, тесты поднимают настоящий Kestrel на эфемерном порту; заодно DI-хук `configureServices` для фейков задач 5–6 и зафиксировано место регистрации модель-менеджера.
3. **Fail-closed гард `_expectedToken.Length == 0`** — замечание ревью T2 учтено в интерцепторе ml-service (в T2 такого гарда нет — см. Concerns). Тест на «пустое значение metadata при пустом env» подтверждает, что лазейка `«» == «»` закрыта (grpc-dotnet такие заголовки отправляет — сценарий достижим).
4. Health освобождён от service-token (шаблон T2); Deal-RPC — fail-closed.
5. `.slnx` не используется: `dotnet new sln` в .NET 10 создаёт XML-решение, а не классическое — пересоздано с `--format sln` (формат совпадает с T2, включая x64/x86-конфигурации).
6. Docker-образ **не собирался** (тянет образы по сети, как T2) — проверен только `docker compose config`; сборка образа и запуск в compose — Task 20.
## Concerns
- **T2 ServiceTokenInterceptor содержит ту же лазейку**: при незаданном `DEAL_SERVICE_TOKEN` запрос с пустым значением metadata `service-token` прошёл бы проверку (`«» == «»`). Гард добавлен только в ml-сервис; T4 повторит шаблон T3 с гардом. Рекомендация: вернуться и допатчить T2 (тот же трёхстрочный гард + при желании тест), чтобы три сервиса этапа не расходились.
- Русский detail в логах Kestrel выглядит кракозябрами из-за кодовой страницы консоли (по gRPC-каналу — корректный UTF-8); косметика, кода не касается (как T2).
- Состав sln включает `Deal.Proto` (внешний путь `..\contracts`) — осознанно (как T2): sln сервиса собирается 0/0 вместе с контрактами.
@@ -0,0 +1,43 @@
# Task 4 — Каркас ai-service (sln, gRPC-хост, health, service-token, DI) — отчёт
Статус: **DONE** (каркас создан; build 0/0; тесты 5/5 PASS; compose-запись добавлена и валидна).
## Файлы
| Файл | Содержание |
|---|---|
| `src/ai-service/Directory.Build.props` | Код-стайл этапа (шаблон T3): net10.0, Nullable, ImplicitUsings, `TreatWarningsAsErrors`, `AnalysisLevel=latest`, `EnforceCodeStyleInBuild`. |
| `src/ai-service/Deal.Ai.sln` | Решение сервиса: `Deal.Ai` + `Deal.Ai.Tests` + `Deal.Proto` (ProjectReference-проект подтянут в sln явно, как T2/T3; формат классический `.sln`). |
| `Deal.Ai/Deal.Ai.csproj` | Web SDK; `Grpc.AspNetCore`/`Grpc.AspNetCore.HealthChecks` 2.83.0; ProjectReference на `src/contracts/Deal.Proto.csproj` (кодогенерация ai.proto — общий проект, решение T2). |
| `Deal.Ai/Program.cs` | Точка входа: порт из env `GRPC_PORT``PORT` → 5102; стартовый лог; делегирует сборку `AiServiceHost.Create`. |
| `Deal.Ai/AiServiceHost.cs` | Фабрика хоста (Kestrel `IPAddress.Any:port` HTTP/2 без TLS — Ruling 2; `AddGrpc` + интерцептор; `AddGrpcHealthChecks().AddCheck("ready", …)`; `MapGrpcService<AiServiceImpl>` + `MapGrpcHealthChecksService`). Seam для in-proc тестов; в XML-doc размечено место DI-регистраций LLM-фасада (Task 7 — OpenAI-совместимые/Anthropic-клиенты, таймауты 90/60 с, retry 0.8/2 с, extract_json, usage; Task 8 — AiService поверх фасада). |
| `Deal.Ai/ServiceTokenInterceptor.cs` | Проверка gRPC-metadata `service-token` против env `DEAL_SERVICE_TOKEN` (Ruling 1); отказ `UNAUTHENTICATED`; health освобождён от токена. **Fail-closed с явным гардом `_expectedToken.Length == 0` → всегда отказ** (шаблон T3; лазейка «пустое значение metadata == пустой env-токен» закрыта). |
| `Deal.Ai/AiServiceImpl.cs` | Явные заглушки `UNIMPLEMENTED` всех 4 RPC `AiService` (Filter/Classify/GenerateKeywords/EvaluateFit — сигнатуры 1:1 с ai.proto, компиляция доказывает кодогенерацию); в XML-doc размечено, что реализуется в Task 8 поверх LLM-фасада Task 7 (Ruling 5). |
| `Deal.Ai/Dockerfile` | Мультистейдж sdk→aspnet:10.0 + `grpc_health_probe`; EXPOSE 5102; контекст сборки — корень репозитория (csproj ссылается на `src/contracts`). Volume-ов нет — ai-service без БД и без персистентных файлов (Ruling 5). |
| `Deal.Ai.Tests/` | `Deal.Ai.Tests.csproj` (стек как T3) + `AiServiceHostTests.cs` — 5 интеграционных тестов. |
| `deploy/compose.dev.yml` | Запись `ai-service` (Ruling 12): build из корня, порт `5102:5102`, env `GRPC_PORT`/`DEAL_SERVICE_TOKEN` (default `deal_dev_service_token`), healthcheck `grpc_health_probe -addr=localhost:5102`; volume не нужен (stateless). |
## Валидация
- `dotnet build Deal.Ai.sln` (из `src/ai-service`): **0 warnings / 0 errors** (Deal.Proto + Deal.Ai + Deal.Ai.Tests).
- `dotnet test Deal.Ai.sln`: **5/5 PASS** — health `SERVING`; Classify без токена → `UNAUTHENTICATED`; неверный токен → `UNAUTHENTICATED`; верный токен проходит к методу → `UNIMPLEMENTED` (заглушка); при незаданном `DEAL_SERVICE_TOKEN` — fail-closed (запрос и с пустым значением metadata `service-token`, и с «верным-на-вид» токеном отклонён; health при этом `SERVING`).
- Smoke реального бинарника: `GRPC_PORT=5302 ./Deal.Ai.exe` → «ai-service стартует: gRPC plaintext 0.0.0.0:5302…» + «Now listening on: http://0.0.0.0:5302»; процесс погашен, остатков в `tasklist` нет.
- `docker compose -f deploy/compose.dev.yml config --quiet` — OK.
- Запуск вручную: `dotnet run --project src/ai-service/Deal.Ai` (порт 5102 либо env `GRPC_PORT`); health — `grpc_health_probe -addr=localhost:5102`; Deal-RPC требуют metadata `service-token` = `DEAL_SERVICE_TOKEN`.
## Решения и отклонения
1. **Порт 5102** — сверено с планом (Ruling 12: telegram 5101 / ai 5102 / ml 5103) и дефолтом задачи (Task 4, «Kestrel :5102»); гипотеза «5301» из брифа не подтвердилась.
2. **Подключение контрактов — ProjectReference на `Deal.Proto`** (решение T2, Note task-1-report закрыт); ai.proto входит в sln и кодогенерация доказывается сборкой.
3. **Хост-фабрика `AiServiceHost.Create`** — сверх списка плана (шаблон T2/T3): gRPC не работает через TestServer, тесты поднимают настоящий Kestrel на эфемерном порту; заодно DI-хук `configureServices` для фейков задач 7–8 и зафиксировано место регистрации LLM-фасада.
4. **Fail-closed гард `_expectedToken.Length == 0`** — шаблон T3 перенесён 1:1 (включая тест «пустое значение metadata при пустом env»); в T2 гарда нет (см. Concerns task-3-report).
5. Health освобождён от service-token (шаблон T2/T3); Deal-RPC — fail-closed.
6. Тест-пробник RPC — `Classify` (репрезентативный для ai-контракта; в T3 был `Status`).
7. DEP-запись ai-service — без volume (Ruling 5: без БД и персистентных файлов), в отличие от telegram/ml; healthcheck/токен — как у соседей.
8. Docker-образ **не собирался** (тянет образы по сети, как T2/T3) — проверен только `docker compose config`; сборка образа и запуск в compose — Task 20.
## Concerns
- Косметика (как T2/T3): русский detail в логах Kestrel выглядит кракозябрами из-за кодовой страницы консоли; по gRPC-каналу текст корректный UTF-8, кода не касается.
- Три сервиса этапа по-прежнему расходятся по fail-closed-гарду: T2 (telegram) без гарда, T3/T4 (ml/ai) с гардом. Рекомендация task-3-report — допатчить T2 до единого шаблона — остаётся открытой.
- Состав sln включает `Deal.Proto` (внешний путь `..\contracts`) — осознанно (как T2/T3).
@@ -0,0 +1,104 @@
# Task 5 — Отчёт: telegram-service — сессии и QR-подключение (план-файл: секция «Task 9»)
Статус: **complete**. Build `src/telegram-service/Deal.Telegram.sln` — 0 warnings / 0 errors (Debug и Release);
тесты 42/42 PASS (`dotnet test Deal.Telegram.sln`). Сеть Telegram не использовалась: unit — фейки,
RPC-ветки — in-proc gRPC-хост с фейковой фабрикой клиентов.
Нумерация: логгер `.superpowers/sdd/deal-stage6-services/progress.md` ведёт telegram-логику как
«Task 5–8»; задача «сессии, подключение, QR, статус» в плане-файле
`docs/superpowers/plans/2026-09-05-deal-stage6-services.md` — секция **Task 9** (L314330). Отчёт по
инструкции исполнителя — `task-5-report.md`.
## Сверка с заданием (вопросы из брифа)
- **Имена RPC (ConnectAccount/CheckConnect — не существуют).** Сверено по `src/contracts/telegram.proto`:
подключение — `StartQr/StartPhone/SendCode/SendPassword` (+`GetStatus/Logout`); каталог/мониторинг/
discovery (`RefreshDialogs…Leave`) и Ingress — другие задачи. Реализованы 6 RPC подключения/статуса.
- **Ключ сессий — `DEAL_TELEGRAM_SESSION_KEY`** (не `DEAL_SESSION_KEY`): подтверждено Ruling 3 (L83).
32 байта base64, только env (Ruling 13). Отсутствует/некорректен → хост не стартует (fail-closed,
аналогично service-token ml/ai-каркасов) — тесты окружения обновлены.
- **Ключи приложения (api_id/api_hash) сервис получает в теле запросов** (Ruling 3; core расшифровывает
`tgKeys` сам) — реализовано: `StartQrRequest/StartPhoneRequest` несут ключи, проверка в сервисе.
- **AES-GCM-утилита:** core сервису недоступен (общее — только `.proto`/NuGet), поэтому маленький
`SessionFileCipher` написан в сервисе как локальное дублирование паттерна `AesGcmSecretCipher`
этапа 2 (тот же формат `enc:` + Base64(nonce‖cipher‖tag), nonce 12, tag 16, ключ 32).
## Что сделано (файлы)
`src/telegram-service/Deal.Telegram/`:
- `Sessions/TgOptions.cs` — env `DEAL_TELEGRAM_SESSION_KEY` + `DEAL_TELEGRAM_SESSION_DIR` (default
`data/sessions` под ContentRoot; absolute env — как есть, для volume `/data/sessions`).
- `Sessions/SessionFileCipher.cs` — AES-256-GCM-обёртка файла сессии.
- `Sessions/SessionStore.cs` — файлы `data/sessions/<tenant>.session`, атомарная запись (tmp+move,
единый семафор записи), Load (битый/чужой ключ → «нет сессии» + warning, файл сохраняется),
Save/Delete/ListTenantIds; изоляция тенантов 1:1; валидация tenant-id.
- `Sessions/StoredSession.cs` — открытое содержимое файла: версия формата + api_id/api_hash +
байты сессии WTelegramClient (см. «Отклонения», п.1).
- `Sessions/TenantSession.cs` — состояние 1 аккаунта тенанта: фазы `idle|code|password|qr|ready`
(канон proto), error/account/qrUrl, per-tenant семафор; StartPhone/SendCode/SendPassword/
StartQr (фоновая QR-задача: RPC возвращается после первого URL, ротация токена и сканирование
финализируют в фоне), Logout, GetSnapshot, TryResume (auto_resume L209222), TryReconnect
(heartbeat L318327), FlushAndDispose. Тексты ошибок — `SessionErrorMessages` 1:1 со списком плана
(«Сначала сохраните…», «Неверный код», «Код истёк…», «Неверный облачный пароль», «Telegram не
подключён»; фаза-гарды/новые — помечены в классе).
- `Sessions/SessionFarm.cs` — пул `tenantId → TenantSession` (объект переиспользуется, Logout
сбрасывает — гонок удаления/создания нет), auto_resume по файлам на старте, heartbeat, shutdown.
- `Hosting/SessionHeartbeatService.cs` — auto_resume при старте + цикл 30 с (reconnect ready-сессий),
при остановке — перешифровка/сохранение живых сессий.
- `Telegram/ISessionClient.cs` / `Telegram/ITelegramClientFactory.cs` / `Telegram/ClientFactory.cs`
абстракция клиента (seam для фейков) и реальная фабрика.
- `Telegram/WTelegramSessionClient.cs` — реальный адаптер WTelegramClient (пакет **4.4.8** добавлен в
csproj). Сессия библиотеки — **только в памяти**: ctor `Client(config, startSession, saveSession)`
(байты → колбэк при каждом сохранении; расшифрованного файла на диске нет). Вход шагами 1:1 с
прототипом: `Auth_SendCode` (AUTH_RESTART-retry) → `Auth_SignIn` (PHONE_CODE_INVALID/EXPIRED →
тексты; SESSION_PASSWORD_NEEDED → 2FA) → `Account_GetPassword`+`InputCheckPassword`+
`Auth_CheckPassword`; QR — `LoginWithQRCode(qrDisplay, logoutFirst:false)`; `Auth_LogOut`; self —
`Users_GetUsers(InputUser.Self)`. RpcException → `SessionException` (FloodWait →
`RESOURCE_EXHAUSTED` detail c префиксом `flood`; 400 → INVALID_ARGUMENT; прочее → UNAVAILABLE).
- `TelegramServiceImpl.cs` — GetStatus/StartPhone/StartQr/SendCode/SendPassword/Logout поверх
SessionFarm; tenant-id из metadata (отсутствует → UNAUTHENTICATED); SessionException → RPC-статус
контракта; остальные 10 RPC — заглушки UNIMPLEMENTED (задачи каталога/discovery).
- `TelegramServiceHost.cs` — DI (options/cipher/store/factory/farm/heartbeat), fail-closed-проверка
ключа сессий.
Тесты (`Deal.Telegram.Tests/`): `SessionStorageTests` (cipher roundtrip/enc-формат/чужой ключ;
store roundtrip/шифрование-без-plaintext/перезапись/нет-файла/чужой ключ/битый файл/изоляция
тенантов/ListTenantIds/удаление/некорректный tenant-id), `TenantSessionTests` (ветки: нет ключей →
INVALID_ARGUMENT; без сессии → «Telegram не подключён»; фаза-гарды; неверный/истёкший код остаётся в
фазе; 2FA → ready + файл сессии; QR url→сканирование→ready; повторный StartQr; resume; StartQr на
авторизованной; Logout; переключение QR→phone), `TelegramSessionRpcTests` (in-proc: StartPhone без
ключей; GetStatus без tenant-id/без сессии; StartQr → qr+url; статус qr; сканирование → ready;
SendPassword вне фазы; Logout). Общий харнесс `TelegramTestHost` + фейки `FakeSessionClient`/
`FakeClientFactory`; тесты сериализованы (`AssemblyInfo`: env-харнесс).
## Отклонения и решения
1. **В файл сессии кладутся api_id/api_hash приложения** (поверх байт сессии WTelegramClient, всё под
единой AES-GCM-обёрткой). Иначе auto_resume на старте невозможен: ядро передаёт ключи только в теле
StartQr/StartPhone (Ruling 3), после рестарта контейнера сервису их взять неоткуда, а авторизованная
сессия → ready (L209–222) — обязательная семантика. At-rest — только зашифрованный файл.
2. **«Temp-файл под личным каталогом процесса» заменён байтовым session-store**: WTelegramClient
получает байты и колбэк сохранения — расшифрованная сессия не пишется на диск вообще (строже
Ruling 3 «только в памяти процесса»), нет гонок чтения файла с FileShare.None и синхронизации tmp.
3. **Фаза «phone»** в каноне proto не выставляется (прототип после запроса кода сразу «code»); enum
содержит её для полноты канона.
4. **GetStatus без сессии тенанта** (не начат вход / после Logout) → FAILED_PRECONDITION «Telegram не
подключён» (README telegram.proto L107). QR/phone-флоу создают сессию до авторизации — статус виден.
5. QR-вход с 2FA: `LoginWithQRCode` внутри требует пароль через config; наш config возвращает null →
ошибка → фаза idle с текстом (как python `_wait_qr` generic-ветка). Телефонный вход с 2FA — полный.
6. Хост теперь требует `DEAL_TELEGRAM_SESSION_KEY` (иначе не стартует). Тесты-харнесс выставляет
тестовый ключ и temp-каталог; старые host-тесты обновлены (GetStatus с валидным токеном теперь
FAILED_PRECONDITION вместо UNIMPLEMENTED).
## Проверка (команды)
- `dotnet build Deal.Telegram.sln` → 0 warnings / 0 errors.
- `dotnet build Deal.Telegram.sln -c Release` → 0/0.
- `dotnet test Deal.Telegram.sln` → 42/42 PASS.
## ⚠ Manual (живая проверка, не выполнялась — нужны реальные креды)
Реальный QR-вход / SMS-код / 2FA и `auto_resume` после рестарта контейнера с настоящим аккаунтом:
api_id/api_hash (настройки tgKeys тенанта), env `DEAL_TELEGRAM_SESSION_KEY` (32 б base64) и каталог
`/data/sessions` (compose volume). Порядок: поднять сервис → StartQr → отсканировать → готово;
рестарт → GetStatus без команд должен вернуть phase=ready.
@@ -0,0 +1,156 @@
# Task 6 — Отчёт: telegram-service — диалоги, мониторинг, backfill, поток в core (план-файл: секция «Task 10», L330347)
Статус: **complete**. Build `src/telegram-service/Deal.Telegram.sln` — 0 warnings / 0 errors (Debug и Release);
тесты 88/88 PASS (`dotnet test Deal.Telegram.sln`). Сеть Telegram не использовалась: unit — фейк-клиенты и
фейк-канал в ядро, RPC-ветки — in-proc gRPC-хост, клиент ингресса — против in-proc фейк-сервера IngressService,
реалтайм-ветки — фейковые TL-объекты обновлений (без сети).
Нумерация: логгер `.superpowers/sdd/deal-stage6-services/progress.md` ведёт telegram-логику как «Task 5–8»;
задача «диалоги, мониторинг, backfill, поток в core» в плане-файле — секция **Task 10** (L330347). Отчёт по
инструкции исполнителя — `task-6-report.md`.
## Сверка с заданием (вопросы из брифа)
- **Имён RPC «ListDialogs/SetMonitor/Subscribe/ReadRecent мониторинга» в proto нет** — сверено с
`src/contracts/telegram.proto` и планом: команды core → сервис — `RefreshDialogs` (актуальный список
диалогов аккаунта, entries), `SetMonitor`/`SetMonitorAll`, `Backfill` («Перечитать»: последние ~10 с
паузами), `ReadRecent` (превью, свежие из TG); исходящий поток сервис → core — Ingress `PushMessage`/
`SyncDialogs` (+`ReportStatus` — сервер ядра, Task 12). Типы EN-канона (`channel|group|forum|chat`) — на
границе gRPC (`DialogKinds`), python-русские значения нигде в сервисе не хранятся.
- **Кто владелец списка мониторинга — решено по Ruling 7**: владелец — core (БД `Dialogs.Monitor`);
telegram-service держит **зеркало в памяти** (`DialogCatalog`, per-tenant: полный каталог + monitored-набор),
актуализируемое тремя путями: командой SetMonitor/SetMonitorAll, **ответом SyncDialogs** (ядро применило
entries с autoMonitorNew и вернуло monitored ids) и очисткой на Logout. Потерю зеркала при рестарте
догоняет realtime_sweep (первый проход сразу, затем 30 с; `SyncDialogs` восстанавливает monitored из БД
ядра). Realtime/догон фильтруются строго по этому зеркалу.
- **Обратный канал в core**: `CoreIngressClient` (gRPC-клиент IngressService), адрес — env
`SERVICES__CORE__INGRESS` (в конфиге ключ `Services:Core:Ingress`; default `http://localhost:5082`),
каждый RPC несёт metadata `tenant-id` + `service-token` (Ruling 1). Буфер — **без диска**: PushMessage
после сбоя не подтверждается read-ack (сообщение остаётся «новым») и догоняется sweep; дубли в ядре
гасятся дубль-гвардом dialog+msgId (Ruling 7). Сбой канала → `SessionException` UNAVAILABLE «Ядро
недоступно — повторите попытку позже» + лог аудита.
- **Mark-as-read (ТЗ «сразу прочитанными»)**: realtime-сообщение → фильтр зеркала → PushMessage в core →
только при успехе read-ack диалога (send_read_acknowledge эквивалент — generic `Client.ReadHistory`,
channels/messages). Анти-бан-паузы между сетевыми операциями сессии — Ruling 3: backfill 1.53 с/сообщение
и 36 с/диалог (random.uniform эквивалент), sweep — без пауз внутри (период 30 с, как python L392456).
## Что сделано (файлы)
`src/telegram-service/Deal.Telegram/`:
- `Telegram/TelegramDialog.cs`, `Telegram/TelegramMessage.cs` — нейтральные DTO каталога/сообщений (seam от TL).
- `Telegram/DialogKinds.cs` — EN-канон типов контракта.
- `Telegram/ISessionClient.cs` (+ члены): `GetDialogsAsync`/`GetMessagesAsync`/`MarkReadAsync` и событие
`MessageReceived` (входящие текстовые сообщения; подписчиков изолирует TenantSession).
- `Telegram/WTelegramSessionClient.cs` — TL-реализация Task 10: getDialogs (первая страница, как iter_dialogs
limit=500), getHistory (от новых к старым, непустые тексты), readHistory (ReadHistory-хелпер — канал/чат
сам), realtime — **штатный `UpdateManager`** библиотеки (единый нормализованный колбэк: UpdateNewMessage,
каналы — UpdateNewChannelMessage-подкласс, короткие UpdateShort* — синтез в UpdateList; порядок/pts и
восстановление пропусков getDifference), кэш access_hash сущностей + коллектор UpdateManager/страница
диалогов для InputPeer (канал/группа/личный по подписанному id).
- `Telegram/TlMessageMapper.cs` — чистый маппер TL→нейтральные типы (ToDialog/ToMessage/SignedIdOf,
классификатор NewMessageFrom) — используется клиентом и unit-тестами на фейковых TL-объектах.
- `Sessions/TenantSession.cs` — проброс событий клиента (подписка при создании каждого клиента, отписка перед
Dispose), операции каталога под per-tenant gate (фаза ready + авто-connect), `Phase`, `SetListenerActive`
(GetStatus.listener); `Sessions/SessionFarm.cs``Sessions`, passthrough диалоговых операций.
- `Sessions/SessionErrorMessages.cs` — «Ядро недоступно…», «Источник не найден в аккаунте…», «Некорректный id источника».
- `Dialogs/DialogCatalog.cs` — зеркало каталога/мониторинга (replace/set/all/reset, изоляция тенантов).
- `Dialogs/DialogHue.cs` — палитра DIALOG_HUES 1:1 + dialog_hue (по Unicode code points — parity с python).
- `Dialogs/DialogProtoMapper.cs` — TelegramDialog/TelegramMessage → DialogEntry/PushMessageRequest/PreviewMessage.
- `Dialogs/IBackfillPacer.cs`, `Dialogs/RandomBackfillPacer.cs` — seam анти-бан-пауз (фейк в тестах).
- `Dialogs/BackfillService.cs` — последние 10 с паузами 1.5–3 с/сообщение, 3–6 с/диалог (между диалогами
тенанта), старые→новые, read-ack в конце; повторный вход диалога → 0; сбой push → без read-ack; `force`
пробрасывается (флаг backfilled живёт в БД ядра — сервис исполняет всегда, дубли гасит ядро).
- `Dialogs/RealtimeListener.cs` — подписка сессии ready: фильтр зеркала → PushMessage → read-ack.
- `Dialogs/RealtimeSweep.cs` — 30 с: диалоги → SyncDialogs (зеркало от ответа) → непрочитанные monitored
(min(unread+2,10)) от старых к новым → push → read-ack (1:1 L392456).
- `Core/CoreIngressOptions.cs`, `Core/CoreIngressClient.cs` (+ `Core/ICoreIngressClient.cs`) — канал в ядро.
- `Hosting/RealtimeSweepService.cs` (первый проход сразу + 30 с), `Hosting/RealtimeMonitorService.cs`
(reconcile 2 с: listener на ready-сессию, отписка при выходе из ready).
- `TelegramServiceImpl.cs` — реализованы RefreshDialogs (entries + best-effort SyncDialogs: сбой ядра не
роняет ответ), SetMonitor/SetMonitorAll (зеркало; ответ ok/enabled/count), Backfill, ReadRecent (превью
свежих из TG, лимит 1..50/default 24, read-ack); Logout чистит зеркало; discovery (Search…Leave) — стubs
Task 11. `TelegramServiceHost.cs` — DI Task 10 (catalog/ingress/pacer/backfill/sweep + hosted-циклы).
`Program.cs`/доки — актуализированы.
Тесты (`Deal.Telegram.Tests/`): `DialogCatalogTests` (7), `DialogHueTests` (паритет с python, эталоны посчитаны
прототипом), `BackfillServiceTests` (порядок/паузы 1.5–3 и 3–6 с фейк-пейсером, processed, read-ack, сбой push
без ack, «не подключён»), `RealtimeSweepTests` (синк+зеркало, фильтр unread/monitored, сбой синка),
`RealtimeListenerTests` (фильтр мониторинга, push+mark-read, сбой без ack, stop-отписка), `CoreIngressClientTests`
(PushMessage/SyncDialogs на in-proc фейк-сервере IngressService — metadata tenant/service-token; недоступность
ядра → UNAVAILABLE), `DialogRpcTests` (RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/ReadRecent через gRPC-хост
с фейк-фабрикой клиентов, фейк-пейсером и in-proc ингрессом). Харнессы: `FarmHarness`/`SessionHarness`, фейки
`FakeIngress`/`RecordingPacer`/`FakeIngressServer`; `TelegramTestHost.RunAsync` — опция extraEnv (адрес ингресса).
## Отклонения и решения
1. **Список мониторинга и «backfilled» — в БД ядра, не в сервисе** (Ruling 7). Следствия: `SetMonitor` не
запускает первый backfill (это делает ядро отдельным RPC Backfill — комментарий proto); `force` в Backfill
RPC для сервиса не фильтрует (не знает флага) — дубли не растут за счёт дубль-гварда ядра.
2. **Realtime-события поднимаются на все входящие текстовые сообщения**, фильтр по зеркалу — в службе
(каталог). Это повторяет python (Telethon-хендлер + `_monitored`-проверка), но через seam событий
ISessionClient → TenantSession → RealtimeListener.
3. **«listener» статуса** (GetStatus.listener/ReportStatus.listener): RealtimeMonitorService вешает listener
на каждую ready-сессию (2 с) и выставляет `SetListenerActive`; python-эквивалент `_start_listener` на
`_finalize`. Окно до подписки не теряет сообщения: без read-ack они остаются unread и догоняются sweep.
4. **Кэш access_hash сущностей** в WTelegramSessionClient (словари chats/users ответов и обновлений) +
доливка страницей getDialogs (500) — WTelegramClient не отдаёт entity-by-id публично; для InputPeer
каналов/пользователей access_hash обязателен. Источник вне первой страницы диалогов для сервиса
недостижим — это согласовано с refresh-каталогом (он тоже limit=500).
5. **hue и kind**: hue считает сервис (Ruling 7) по 1:1 палитре/хэшу python (code points, EnumerateRunes);
форумы в списке диалогов помечаются `kind="forum"` (канон proto, отдельного is_forum в DialogEntry нет).
6. **Отправка ReportStatus в ядро (клиентская часть) в Task 10 не входит** (в плане это сервер core Task 12 и
сквозная эмуляция Task 20): реализованы GetStatus (серверная сторона) и listener-признак; периодический
репорт статуса в Ingress остаётся следующей интеграционной задаче (сервис уже имеет всё для этого).
7. Найдено и исправлено по ходу: чтение `SERVICES__CORE__INGRESS` через `configuration[IngressEndpointEnvVarName]`
не работает — env `__` провайдер превращает в `:`; читается `Services:Core:Ingress`.
## Проверка (команды)
- `dotnet build Deal.Telegram.sln` → 0 warnings / 0 errors; `-c Release` → 0/0.
- `dotnet test Deal.Telegram.sln` → 88/88 PASS (было 42/42 из Task 5 и 79/79 до ревью-фикса; +9 тестов
маппера/веток обновлений).
## Ревью-фикс (после первой сдачи): типы realtime-обновлений
Замечание ревью: разбор ловил только `UpdateNewMessage`, а каналы/супергруппы шлют `UpdateNewChannelMessage`
(и короткие варианты), что грозило молчанием realtime каналов (работал бы только 30-с sweep).
Факт по библиотеке 4.4.8: `UpdateNewChannelMessage`**подкласс** `UpdateNewMessage`, а `UpdateShortMessage`/
`UpdateShortChatMessage` **синтезируются** библиотекой в `UpdateNewMessage` в списке `UpdateList`
(проверено по IL и исходникам TL.Xtended/UpdateManager). Несмотря на это, realtime переведён на **штатный
`UpdateManager`** (рекомендация ревью):
- единый колбэк на каждое нормализованное обновление (`UpdateNewMessage` для всех типов новых сообщений);
- гарантированные порядок и отсутствие пропусков/дублей (pts + автоматический getDifference/getChannelDifference
при разрывах и на новом соединении) — надёжнее сырого OnUpdates;
- downstream не менялся: зеркало DialogCatalog → PushMessage в core → read-ack (единый путь).
- доработки: коллектор UpdateManager (Users/Chats) как источник имён/username и access_hash для read-ack
(импорт в кэш до фолбэка-страницы диалогов).
- юниты новых веток на фейковых TL-объектах без сети: `TlMessageMapperTests` (UpdateNewMessage,
UpdateNewChannelMessage, синтез UpdateShortMessage/UpdateShortChatMessage, игнор edit/delete/исходящих
и служебных, подписанные id/канальные поля). ⚠ Живая проверка приёма сообщений канала/супергруппы
(UpdateNewChannelMessage/короткие) на реальном аккаунте — Manual (см. ниже).
## ⚠ Замечание на будущее (не фикс этой задачи): backlog > 10 сообщений диалога
`BackfillService` и `RealtimeSweep` читают за цикл не более ~10 сообщений диалога (лимит python L371/L435),
но read-ack снимает «новое» со **всего** диалога (ReadHistory без границы, как python send_read_acknowledge).
Если ядро недоступно дольше, чем накопится > 10 сообщений, часть «новых» сообщений будет помечена
прочитанной без доставки в core (догон потеряет их: unread_count обнулится) — риск унаследован от
python-прототипа; для продуктового контура стоит добавить read-ack по фактически прочитанному max_id либо
страничный догон по msg_id (этап 7/доработка).
## ⚠ Manual (живая проверка, не выполнялась — нужны реальные креды)
Реальный аккаунт (api_id/api_hash из настроек tgKeys, env `DEAL_TELEGRAM_SESSION_KEY`, `/data/sessions`):
QR-вход → `RefreshDialogs` (entries/kind/forum) → `SetMonitor`/монитор → входящее сообщение в реальном времени
уходит PushMessage в core-ингресс и снимает «новое»; `Backfill`/«Перечитать» с реальными паузами; `ReadRecent`
превью; догон после рестарта контейнера (окно до первого SyncDialogs ≤30 с) и поведение WTelegram updates
(UpdateNewMessage на канал/группу/личный) на живой сети.
## Concerns
- WTelegram-слой каталога (getDialogs/getHistory/readHistory/OnUpdates-разбор) unit-тестами не покрыт (нет
сети) — только нейтральные фейки; проверка TL-разбора — ручная (см. выше).
- Догон полагается на `unread_count` диалогов (как python L431); окно realtime-потерь после рестарта — до
первого успешного SyncDialogs (≤30 с).
- Зеркало-каталог сервиса может временно отставать от БД ядра (entry появляется после join в ядре, но не в
refresh/sweep сервиса) — count в `SetMonitorAll` и фильтрация сходятся циклом sweep.
@@ -0,0 +1,84 @@
# Task 7 — Отчёт: ml-service — движок инкрементальной модели + gRPC поверх пула (план-файл: секции «Task 5», L260277 и «Task 6», L277289)
Статус: **complete**. Build `src/ml-service/Deal.Ml.sln` — 0 warnings / 0 errors (Debug и Release);
тесты **36/36 PASS** (`dotnet test Deal.Ml.sln`, Debug и Release). Сеть не использовалась; численная
сверка predict со сценарием python `mlservice/model.py` (временный контрольный прогон): scores/hits/
margin совпадают 1:1 (Dev 37.333/21, t:hire 33.6; Order 30.857/18; spam 29.143/17; «ищу» → 1.714).
Нумерация: логгер `.superpowers/sdd/deal-stage6-services/progress.md` ведёт ml-логику как «Task 7»;
в плане-файле это секции **Task 5** (движок/хранилище) и **Task 6** (gRPC) — отчёт по инструкции —
`task-7-report.md`.
## Сверка с заданием (Acceptance плана + брифа)
- **Модель 1:1 с model.py (Ruling 4, НЕ ONNX/ML.NET)**: tokenize L7887 (снятие ссылок + токены
[a-zа-яё0-9@+.#]+, len≥3, «~»+w[:4] при len≥6); upsert/learn_batch L105173 (одна транзакция на
батч, per-пример семантика с удалением строк count≤0 при delta<0); predict L184293 (score термина
w<1→1.0 иначе 1+(w1)/(w+1); prior; best=score+3·prior; адаптивный margin 0.9/0.7/0.5/0.35 после
0/60/150/400; type-решение t:hire/t:order с MIN_TYPE_WINNER=4; terms ≤8 без «~»); eval L296322
(delta=1, не t:*, после ready); status L325345 (EVAL_WINDOW 50); reset L348354. Пороги 20/6/4/2.
- **SQLite per-tenant (data/ml/<tenantId>.sqlite)**: Microsoft.Data.Sqlite 10.0.11; таблицы
classes(label,n,updated_at)/terms(label,term,count)/eval_log(created_at,expected,predicted,correct)
— схема 1:1 L64–75; пул соединений per-tenant (долгоживущее соединение на модель, MlDb;
Pooling=False — чтобы Reset мог удалить файл); lazy-load по первому обращению; запись транзакциями.
- **RPC Predict/Status/Reset/TrainBatch**: tenantId только из metadata (нет → UNAUTHENTICATED,
некорректный путь → INVALID_ARGUMENT); Predict неготовой/пустой модели — «не уверен» 1:1
(take:false, label пуст, scores пуст, hits=0, ready:false, margin/terms/type пусты), не ошибка;
TrainBatch = learn_batch → число применённых; Reset — ok:true/мягкая ok:false+error; сбой хранилища
→ UNAVAILABLE. Аудит каждого RPC структурированным логом (Ruling 13).
- **Unit/интеграционные тесты (без сети)**: обучение→predict (спам/колонка/тип «нужен middle python…»/
«резюме…»), ready-пороги, адаптивный margin, delta ± «разучивание», eval-окно (50/200), перезапуск
пула на том же файле (веса сохраняются), RPC-ветки in-proc (train→predict, батч 3, reset обнуляет,
неверный токен → UNAUTHENTICATED — каркас Task 3 сохранён).
## Что сделано (файлы)
`src/ml-service/Deal.Ml/`: `Model/ModelConstants.cs`, `Model/MlOptions.cs` (env `DEAL_ML_DATA_DIR`,
default `data/ml` под ContentRoot), `Model/MlTokenizer.cs`, `Model/ModelState.cs` (+ records `EvalEntry`,
`LearnItem`, `MlTypeDecision`, `MlPredictResult`, `MlEvalInfo`, `MlStatusResult`), `Model/OnlineNaiveBayes.cs`
(математика predict/status/ready/margin/type), `Model/TenantModel.cs` (lock на модель; lazy-load;
learn-семантика per-пример 1:1), `Model/ModelPool.cs` (ConcurrentDictionary, валидация tenant-id как
имени файла), `Storage/MlDb.cs` (схема/load/batch-apply/prune/DeleteFile), `MlServiceImpl.cs` (RPC),
`MlServiceHost.cs` (DI: MlOptions+ModelPool), `Program.cs`/доки актуализированы. `Deal.Ml.csproj`:
+Microsoft.Data.Sqlite 10.0.11. `deploy/compose.dev.yml`: ml-service + `DEAL_ML_DATA_DIR=/data/ml`
(volume deal_ml_data, Ruling 12). Тесты `Deal.Ml.Tests/`: `AssemblyInfo.cs` (сериализация env-харнессов),
`MlTokenizerTests` (6), `TenantModelLearningTests` (11), `ModelPersistenceTests` (3 — reload/prune 200/окно 50),
`ModelPoolTests` (3), `MlRpcTests` (8), `MlTestHost.cs` (харнесс), `LearningData.cs` (фикстуры канонического
батча); `MlServiceHostTests` — тест «верный токен → UNIMPLEMENTED» заменён на «→ Status ready=false»
(Status реализован; каталог моделей теста во временной папке).
## Отклонения и решения
1. **Состояние в памяти + write-through в SQLite** (python читает DuckDB на каждый вызов): эквивалентно,
lazy-load из файла при первом обращении тенанта; перезапуск пула сохраняет веса (тест).
2. **Прунинг eval_log детерминирован по rowid** (python по created_at и при равных мс оставляет >200):
хранится ровно EVAL_KEEP=200, память и файл не расходятся после перезапуска; окно статуса не меняется.
3. **TrainBatch.learned = число применённых** примеров (пустые text/label пропускаются тихо — no-op,
1:1 `_upsert_one` L112114); core шлёт только непустые строки outbox.
4. **gRPC Predict с пустым/пробельным text** — «не уверен», не ошибка (ml.proto L5862; python-HTTP 400
к gRPC-контракту не переносится).
5. **Порт-интерфейс (IModelStore) не вводился**: план (Files Task 5/6) задаёт конкретные типы
MlDb/TenantModel/ModelPool, потребитель один (MlServiceImpl через ModelPool), тесты на реальном SQLite;
интерфейс добавил бы индирекцию без выгоды. Seam для фейков — configureServices-хук хоста.
6. **Расширение файла — `.sqlite`** (Ruling 4/README контрактов), не `.db`.
7. `Pooling=False` у соединения (см. «пул соединений» выше): один долгоживущий connection на модель —
иначе Reset не смог бы удалить файл на Windows (ADO.NET-пул держит handle).
8. Самооценка батча оценивает состояние **до** применения батча (python-семантика learn_batch L147173).
## Проверка (команды)
- `dotnet build Deal.Ml.sln -c Debug` и `-c Release` (из `src/ml-service`): 0 warnings / 0 errors.
- `dotnet test Deal.Ml.sln` (Debug и Release): **36/36 PASS** (host/health/token 5, tokenizer 6,
learning 11, persistence 3, pool 3, RPC in-proc 8).
- `docker compose -f deploy/compose.dev.yml config --quiet` — OK (после добавления DEAL_ML_DATA_DIR).
- Численная сверка с python `mlservice/model.py` на каноническом сценарии (временный скрипт, удалён):
статус/классы/learned/predict scores·hits·margin совпадают.
## Concerns
- **Состав eval-строк при обучении батчами**: сигналы внутри одного батча не дают самооценку, пока батч
не применён (python-семантика); при флашере ≤100 строк эффект минимален и 1:1 с прототипом.
- В compose.dev.yml у telegram-записи по-прежнему нет обязательных env (DEAL_TELEGRAM_SESSION_KEY/DIR) —
известная незакрытая интеграция (Task 20, финал этапа); для ml-service env добавлен здесь.
- Reset мягко-ошибается (ok=false+error), если файл модели занят другим процессом (Windows) —
контрактная семантика ResetReply.
@@ -0,0 +1,97 @@
# Task 8 — Отчёт: ai-service — LLM-фасад провайдеров + gRPC AiService (план-файл: секции «Task 7», L289303 и «Task 8», L303314)
Статус: **complete**. Build `src/ai-service/Deal.Ai.sln` — 0 warnings / 0 errors (Debug и Release);
тесты **50/50 PASS** (`dotnet test Deal.Ai.sln`, Debug и Release). Сеть не использовалась
(HTTP-тесты — заглушка HttpMessageHandler, RPC-тесты — фейк-провайдер); живые проверки с реальным
ключом LLM — ⚠ ручные, не выполнялись (глобальное ограничение этапа: без сети; нужен ключ тенанта).
Нумерация: логгер `.superpowers/sdd/deal-stage6-services/progress.md` ведёт ai-логику следующим
пунктом после ml (ml-логика = «Task 7»); в плане-файле это секции **Task 7** (LLM-фасад) и
**Task 8** (gRPC AiService) — отчёт по инструкции — `task-8-report.md`.
## Сверка с заданием (Acceptance плана L301/312 + брифа)
- **IProviderClient + реализации OpenAI-совместимых и Anthropic** (п.1 брифа; Task 7):
`Llm/IProviderClient.cs` (одна HTTP-попытка → {text, usage}); `Llm/LlmHttpClient.cs` — по
`api_style` конфига: OpenAI `POST {base}/chat/completions` (Bearer при ключе; temperature 0.2,
max_tokens 8000; ответ choices[0].message.content + usage), Anthropic `POST {base}/v1/messages`
(x-api-key + anthropic-version 2023-06-01; system отдельным полем; склейка text-блоков content[];
usage input/output → total=сумма). Таймауты попытки 90/60 с (CancelAfter на попытку);
reasoning-only/пустой ответ, HTTP≠2xx, не-JSON-тело, сеть/таймаут → `LlmHttpException`
(повод для ретрая). Ключи API ни в логи, ни в исключения не попадают (Ruling 13): логи аудита —
только method/tenant/provider/kind/токены; тексты HTTP-ошибок — код статуса, без тела.
- **Ретраи/JSON/usage**: `Llm/LlmRetryPolicy.cs` (2 ретрая: паузы 0.8/2 с → 3 попытки),
`Llm/JsonExtractor.cs` (1:1 extract_json ai.py L175183: обёртка ```json…``` → срез {…}),
`Llm/TokenEstimator.cs` (usage API-ответа как есть, total «как есть»; иначе ≈ceil(chars/4)),
`Llm/ProviderCaller.cs` (chat_json L80117: попытка = вызов + извлечение; исчерпание →
`LlmCallException` с Kind=ProviderUnavailable | AnswerNotJson; текст 1:1 Ruling 5 / L115117).
- **RPC Filter/Classify/GenerateKeywords/EvaluateFit** (Task 8; п.2 брифа): поверх фасада, каждый
ответ + usage. Filter → {pass, reason} (pass по умолчанию true — ai.py L195); Classify →
{ok:true, json=извлечённый ответ строкой} либо {ok:false} при «ответе без JSON после ретраев»
(НЕ RPC-ошибка — README ai.proto L201204; usage в ответе); GenerateKeywords — фикс. промпт
routes L36–47 + «Описание ниши/задачи:\n…» → keywords (чистку делает ядро, Ruling 11);
EvaluateFit — промпт discovery_eval L5054 с подстановкой description/keywords (сервисом,
как «Ключи: a, b.») → {fit, reason}; причина по умолчанию «подходит»/«не подходит», потолок 200
(_ai_reason L167171); строковые «нет»-значения fit — ложь (1:1 _ai_fit L158164). Недоступность
провайдера после ретраев → UNAVAILABLE с detail «ИИ (имя) не ответил корректно — повторите
попытку через несколько секунд» (ядро → aiFail/локальный путь). Конфиг-валидация: пустой
base_url/model → INVALID_ARGUMENT. tenant-id обязателен (UNAUTHENTICATED) — шаблон MlServiceImpl.
- **Тесты без реальных LLM** (п.3 брифа): unit (JsonExtractor 8, TokenEstimator 4, LlmHttpClient 7
на заглушке HttpMessageHandler — формы обоих API/usage/HTTP-ошибка/таймаут, ProviderCaller 7 —
ретраи/различение сбоев/usage/отмена) + in-proc gRPC AiRpcTests 19 с фейк-IProviderClient
(4 RPC, UNAVAILABLE-ветки, ok=false, валидация, tenant) + хост-тесты 5 (health/token; тест
«достижение заглушки» переведён на реализацию Task 8). Стиль: 1 тип = 1 файл, XML-doc,
именованные константы (0.2/8000/90 с/60 с/0.82 с/4/200 — без магических чисел), без регионов.
## Что сделано (файлы)
`src/ai-service/Deal.Ai/Llm/`: `IProviderClient.cs`, `LlmConfig.cs` (конфиг вызова + DisplayName по
каталогу AiProviders), `LlmHttpClient.cs`, `LlmHttpException.cs`, `LlmRetryPolicy.cs`,
`JsonExtractor.cs`, `TokenEstimator.cs`, `ProviderCaller.cs`, `ProviderChatResult.cs`,
`ProviderUsage.cs`, `LlmUsage.cs`, `LlmCallResult.cs`, `LlmCallFailureKind.cs`, `LlmCallException.cs`;
`AssemblyInfo.cs` (InternalsVisibleTo Deal.Ai.Tests — внутренний ctor таймаутов клиента).
Изменены: `AiServiceImpl.cs` (4 RPC поверх `ProviderCaller`, конфиг-валидация, tenant-id, аудит),
`AiServiceHost.cs` (DI: AddHttpClient<LlmHttpClient> без общего таймаута → IProviderClient;
функция паузы ретраев; ProviderCaller; configureServices — seam фейков), `Program.cs`/`Deal.Ai.csproj`
(комментарии актуализированы). Тесты `Deal.Ai.Tests/`: `AssemblyInfo.cs` (сериализация env-харнессов),
`AiTestHost.cs`, `FakeProviderClient.cs`, `StubHttpMessageHandler.cs`, `JsonExtractorTests.cs`,
`TokenEstimatorTests.cs`, `LlmHttpClientTests.cs`, `ProviderCallerTests.cs`, `AiRpcTests.cs`;
`AiServiceHostTests.cs` — тест заглушки Task 4 заменён на проверку реализации.
## Отклонения и решения
1. **Различение «ИИ недоступен» и «ответ без JSON»** (вопрос брифа): по README контрактов — ответ
без разбираемого JSON после ретраев даёт `ClassifyReply.ok=false` (не RPC-ошибка, «не разобрано»),
транспортная недоступность провайдера — UNAVAILABLE (aiFail). У Filter/GenerateKeywords/EvaluateFit
ok-поля нет — оба исхода → UNAVAILABLE (README «провайдер не ответил корректно после ретраев»).
2. **Имя провайдера в тексте ошибки**: ai.proto несёт только `provider_id`, каталог имён — в ядре.
Сервис держит малую карту id→DisplayName (1:1 `C.AI_PROVIDERS`; неизвестный id — как есть) для
текста «ИИ (имя)…». Заметка для Task 15: тексты совпадут с python при известных провайдерах.
3. **Anthropic без temperature**: python `_call_anthropic` (L162–167) параметр не шлёт — повторено
1:1; temperature 0.2 — только в OpenAI-совместимом теле (как python).
4. **Usage Anthropic**: total = input+output (API возвращает только их); OpenAI total — как отдал
API (может отличаться от суммы — proto допускает). Оценка по символам — ceil(chars/4), запрос =
system+user.
5. **Паузы ретраев инъекцией**: `Func<TimeSpan, CancellationToken, Task>` в DI (prod — Task.Delay;
тесты — мгновенно). Без этого RPC-сценарии сбоя ждали бы 2.8 с на тест.
6. **Пустые text/prompt не валидируются** (кроме конфига): python нигде пустой текст не режет до
вызова, ядро само ограничивает 4000/5000 и держит ветки aiEnabled; сервис повторяет это 1:1.
7. Таймаут одной попытки — CancelAfter(90/60 с) поверх клиента без общего таймаута (общий 100 с
помешал бы Anthropic-лимиту 60 с).
## Проверка (команды)
- `dotnet build Deal.Ai.sln -c Debug` и `-c Release` (из `src/ai-service`): 0 warnings / 0 errors.
- `dotnet test Deal.Ai.sln` (Debug и Release): **50/50 PASS** (host/health/token 5, RPC in-proc 19,
extractor 8, estimator 4, HTTP-клиент 7, оркестратор 7).
- Сеть не использовалась: HTTP-клиент — заглушка `StubHttpMessageHandler`; RPC — `FakeProviderClient`.
## Concerns
- ⚠ **Ручная проверка**: реальный вызов OpenAI-совместимого/Anthropic-провайдера с ключом
(в т.ч. ответы deepseek в ```json```-обёртке и usage) — не выполнялась (нет ключа/сети); на
финале этапа (Task 20) или вручную через поднятый сервис + `SERVICES__AI__USELOCAL=false`.
- DisplayName-карта (п.2 отклонений) расходится с каталогом ядра только при кастомных `provider_id`;
если ядро начнёт слать человекочитаемое имя, поле стоит добавить в ai.proto (этап 7+).
- Логи консоли тестов показывают «кракозябры» кириллицы (кодировка консоли Windows) — косметика,
к коду не относится.
@@ -0,0 +1,99 @@
# Task 9 — Отчёт: core — gRPC-ингресс telegram (PushMessage/SyncDialogs/ReportStatus) (план-файл: секция «Task 12», L361377)
Статус: **complete**. Build `Deal.sln` (src/core) — 0 warnings / 0 errors; тесты **630/630 PASS**
(`dotnet test tests/Deal.Tests.Unit`; из них новых — 10/10 `TelegramIngressServiceTests`). Живой
smoke-тест хоста: Deal.Api поднялся с двумя Kestrel-листенерами (`:5080` HTTP/1.1 + `[::]:5082` HTTP/2),
`GET /api/health` на :5080 отвечает, процесс остановлен, порты свободны. Сеть/Telegram/LLM не использовались.
Нумерация: отчёт пишется как `task-9-report.md` (инструкция); в плане-файле задача — **Task 12**
«core — gRPC-ингресс telegram (PushMessage/SyncDialogs/ReportStatus)», L361377, Acceptance L375.
## Сверка с заданием (Acceptance плана L361377 + брифа)
- **gRPC в Deal.Api: AddGrpc + второй Kestrel-endpoint + MapGrpcService** (п.1 брифа): `Program.cs`
`ConfigureKestrel`: основной HTTP/1.1-эндпоинт пере-биндится из конфигурации `urls`
(`--urls`/`ASPNETCORE_URLS`/launchSettings; без URL — фолбэк `http://localhost:5000`, дефолт ASP.NET
Core) + `Listen(IPAddress.Any, ingressPort, Http2)`, порт из env `GRPC_INGRESS_PORT`, дефолт 5082
(Ruling 7/12: compose-telegram ходит через `host.docker.internal:5082`). Явные `Listen` заменяют
URL-биндинг Kestrel — поэтому основной эндпоинт биндится в коде теми же адресами (комментарий в
Program.cs). `AddGrpc` + интерцептор `IngressServiceTokenInterceptor` (fail-closed, шаблон T2T4),
`MapGrpcService<TelegramIngressService>()`. Пользовательская сессия/`AddAuthentication` не нужны
(Ruling 1: tenant-id из metadata → собственный scope с `ITenantContext.SetTenant`).
- **IngressServiceImpl** (п.2 брифа): `A/Telegram/TelegramIngressService.cs`
(наследует `Deal.Grpc.Telegram.IngressServiceBase`, ProjectReference на `src/contracts/Deal.Proto.csproj` +
`Grpc.AspNetCore` 2.83.0 в Deal.Api.csproj). Каждый RPC: metadata `tenant-id` (пусто → UNAUTHENTICATED
«tenant-id отсутствует в metadata», README контрактов) → проверка реестра `public.tenants`
(`ITenantRepository.FindByIdAsync`; tenant-scoped registry-scope) → собственный scope с
`SetTenant(tenant.Id.ToString("N"))` (эталон PipelineWorkerScheduler L169213) → tenant-scoped адаптеры.
- `PushMessage``PipelineIngestService.EnqueueAsync` (QueuedMessage 1:1: dialogId/канальные поля/
msgId/msgAt/текст) → reply `{accepted, duplicate}`. Дубль dialog+msgId — duplicate=true, очередь не
растёт (гвард EnqueueAsync). Неизвестный тенант/сбой схемы-БД — RPC **не падает**, reply
not-accepted (план Task 12), лог аудита (Ruling 13).
- `SyncDialogs` → приём каталога (entries) + лог аудита; ответ `monitored_ids` пуст: таблица Dialogs/
`DialogsService.SyncFromTelegram` появляются с модулем Deal.Modules.Telegram (**Task 13**) — до него
ядро зеркала мониторинга не хранит (Ruling 7: сервис фильтрует realtime по своему зеркалу).
- `ReportStatus` → KV `tgStatus` (JSON-снимок phase/connected/listener/error/qrUrl — новый тип
`TgReportedStatus`) + KV `tgAccount` (JSON-строка) через `ISettingsStore` схемы тенанта (новые
внутренние ключи в `SettingsKeys`), SSE `system_status` на каждый репорт + тосты только на переходах
connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён»
(1:1 тексты контракта; гард переходов по предыдущему снимку KV — heartbeat 30 с не дублирует тосты).
- **Тесты in-proc gRPC** (п.3 брифа; без БД/сети): `TelegramIngressTestHost` (Kestrel HTTP/2 на
эфемерном порту, регистрации как Program.cs, tenant-фейки) + `TelegramIngressServiceTests`
(10 тестов): ok-ack с проверкой строки очереди; дубль dialog+msgId; неизвестный тенант → not-accepted
без RpcException; нет токена/неверный токен/нет env-токена (fail-closed)/нет tenant-id →
UNAUTHENTICATED; SyncDialogs → пустое зеркало; ReportStatus → KV + system_status + тосты переходов +
повторный connected без тоста; ReportStatus неизвестного тенанта → ok=false. `FakeTenantRegistry`
(FindByIdAsync) — новый фейк реестра (существующий FakeTenantRepository FindById не поддерживает).
- **Стиль**: 1 тип = 1 файл, XML-doc на public, комментарии на русском, без регионов, именованные
константы, camelCase-JSON (конвенция value_json), `{detail}`/лог-шаблоны аудита без секретов.
## Что сделано (файлы)
Создан `A/Telegram/`: `TelegramIngressService.cs`, `IngressServiceTokenInterceptor.cs` (шаблон
ServiceTokenInterceptor T2T4, fail-closed), `TgReportedStatus.cs` (снимок KV/SSE).
Изменены: `A/Program.cs` (Kestrel-эндпоинты + AddGrpc/интерцептор + MapGrpcService), `A/Deal.Api.csproj`
(ProjectReference Deal.Proto + Grpc.AspNetCore), `ST/Application/SettingsKeys.cs` (+`tgStatus`/`tgAccount`).
Тесты `Deal.Tests.Unit/`: `TelegramIngressTestHost.cs`, `TelegramIngressServiceTests.cs`,
`FakeTenantRegistry.cs`; `Deal.Tests.Unit.csproj` (+Grpc.Net.Client, +FrameworkReference Microsoft.AspNetCore.App).
## Отклонения и решения
1. **Kestrel-эндпоинты в коде, а не «второй Listen поверх URL»**: любой явный `Listen` отключает
URL-биндинг Kestrel целиком, поэтому основной HTTP/1.1-эндпоинт пере-биндится из `urls`-конфигурации
в том же `ConfigureKestrel` (loopback для localhost, AnyIP для 0.0.0.0/+/…; https — `UseHttps()`).
Проверено живым smoke-тестом: оба листенера подняты, health :5080 отвечает (см. Проверка).
2. **Неизвестный тенант = мягкий отказ reply-флагом, не gRPC-статус**: «PushMessage для несуществующего
тенанта не падает… reply not-accepted» (Acceptance L371). Принадлежность подтверждается реестром
(детерминированно, без расчёта на исключения БД); сбой схемы/БД существующего тенанта ловится
тем же мягким ответом. Это «+1 SELECT public.tenants на RPC» — приемлемо для масштаба этапа
(оптимизация возможна, когда Task 13 даст постоянное зеркало диалогов тенанта).
3. **SyncDialogs не сохраняет entries** (приём + лог + пустой monitored_ids): план предписывает
применять их `DialogsService.SyncFromTelegram` (Task 13 создаёт таблицы Dialogs/TgMessages и модуль
Telegram); зеркало ядра пусто, пока мониторить нечего. Task 13 заменит заглушку одним вызовом.
4. **KV tgStatus хранит снимок без account** (account — отдельный ключ tgAccount, Ruling 7/8: GET
/api/tg/status читает account из KV). Снимок-тип `TgReportedStatus` переиспользуется SSE-публикацией.
5. **Иконки тостов «send»/«logout»** — допущение: набор из Icon.vue фронта (в репо нет python-прототипа
telegram.py L178207, чтобы сверить имена 1:1); тексты тостов — точные строки контракта этапа.
6. Interceptor сохранил освобождение `grpc.health.v1.Health` от токена (общий шаблон T2–T4), хотя в
Deal.Api health-сервис появится позже (несуществующие методы до интерцептора не доходят).
## Проверка (команды, из `src/core`)
- `dotnet build Deal.sln` — 0 warnings / 0 errors.
- `dotnet test tests/Deal.Tests.Unit`**630/630 PASS** (новых 10/10).
- Живой smoke (без git): `ASPNETCORE_URLS=http://127.0.0.1:5080 DEAL_SERVICE_TOKEN=… Deal.Api.exe &`
лог: `Now listening on: http://localhost:5080` и `Now listening on: http://[::]:5082`;
`curl http://127.0.0.1:5080/api/health``{"ok":true,"service":"deal"}`; ошибок в логе нет;
`taskkill` → порты :5080/:5082 свободны.
## Concerns
- ⚠ Ветка «схема тенанта есть в реестре, но не провижинена» (catch PushMessage/ReportStatus) в тестах
напрямую не воспроизводится (нет БД) — покрыта логикой catch + soft-ответом; сквозную проверку с БД
даст финал этапа (Task 20 / curl-приёмка с реальным telegram-service на :5082).
- SyncDialogs (п.3) — временное поведение до Task 13; после модуля Telegram ответ будет реальным
списком monitored id (важно: сейчас ответ-пусто затирает зеркало сервиса только на старте/refresh —
до появления каналов в ядре мониторить действительно нечего).
- Иконки тостов (п.5) — сверить с python-прототипом при наличии исходников (тексты уже 1:1).
- Логи консоли тестов показывают «кракозябры» кириллицы (кодировка консоли Windows) — косметика,
к коду не относится.