SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,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) решают T2–T4; 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 5–8: логика telegram-service (сессии/QR/диалоги/мониторинг/анти-бан) — остаток: Task 6–8 (диалоги/backfill/мониторинг, discovery)
|
||||
- [ ] Task 9–11: ml-service (модель/обучение/сохранение), ai-service (фасад/промпты/учёт)
|
||||
- [x] Task 12–14: core-интеграция (gRPC-клиенты за флагами, входящий PushMessage, MlOutbox-флашер; + ai-адаптеры плана Task 15) — ledger Task 9/10/11: complete
|
||||
- [ ] Task 15–19: Discovery + каналы (таблицы/воркер/эндпоинты/квоты) + /tg/status реальный
|
||||
- [x] Task 20: Финал/compose dev + сквозная приёмка (review pending)
|
||||
|
||||
## Pre-flight scan (краткий)
|
||||
| Пара | Производит/потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T1 → T2–T14 | proto → сервисы+core-клиенты | Чисто |
|
||||
| T2–T4 | каркасы → логика | Чисто |
|
||||
| T12–T14 | gRPC-клиенты за флагом; Local-фолбэк | Local-заглушки сохраняются (default true) |
|
||||
| T16–T19 | Discovery-таблицы (миграция) | Новая tenant-миграция — имя? |
|
||||
| T19 | /tg/status реальный | BootStub правится (последняя заглушка снимается) |
|
||||
| — | ml-service SQLite per tenant; сессии-файлы | Вне core (отдельные процессы) |
|
||||
|
||||
## Task status
|
||||
- T1–T20: 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-сервис поверх пула», L260–289): 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)», L361–377): 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», L412–434): 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)» (L377–395): 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-заглушки» (L395–412):
|
||||
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() L103–119, refresh L516, dialog_messages L583–620, discovery_* L624–848, 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 в T2–T4) |
|
||||
| `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 2–4.
|
||||
- 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», L436–449, Ruling 6 L122–132)
|
||||
|
||||
Статус: **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»** (L436–449) — см. «Отклонения» п.1 про ai-часть (Task 15).
|
||||
|
||||
## Сверка с заданием (Acceptance плана L449 + брифа)
|
||||
|
||||
- **Вопрос брифа про судьбу MlOutbox** (разрешён по Ruling 6 L127–130): PushAsync **ВСЕГДА** пишет в MlOutbox —
|
||||
и в Local-, и в gRPC-режиме (`GrpcMlClient.PushAsync` = та же запись, что у LocalMlClient; общая логика
|
||||
вынесена в `MlOutboxQueue`). Отправку батчами делает фоновый `MlOutboxFlushScheduler`, регистрируемый
|
||||
**только при `Services:Ml:UseLocal=false`** (gRPC-режим): Local-режиму (этапы 2–5) ml-service не нужен —
|
||||
очередь копится, как в прототипе при недоступном сервисе (python L56–82), и автоматически выгружается
|
||||
после переключения на UseLocal=false. «При gRPC-режиме Push отправляет сразу» — НЕТ, план: Push остаётся
|
||||
записью в MlOutbox (Task 16 L438–439: «Push остаётся записью в MlOutbox через IMlLearningStore как
|
||||
LocalMlClient»).
|
||||
- **GrpcMlClient : IMlClient** (п.1 брифа, п.2): Predict → RPC (deadline 5 с), сбой → фиксированный «не
|
||||
уверен» (predict-fallback, python L101–107); Status → RPC (10 с) + **кэш 15 с** (`MlStatusCache`,
|
||||
python L30–31/127–135): сервис недоступен — старые данные кэша + `reachable=false`; Reset → RPC Reset +
|
||||
`ClearOutboxAsync` **только при успехе** + инвалидация кэша (reset_model L110–124); мягкий сбой —
|
||||
`{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 L56–82).
|
||||
Регистрация — в 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 L443–444;
|
||||
`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 (L412–434) со своей 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 плана
|
||||
L525–527 — 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», L347–359, 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 L349–354 + Acceptance L358 + Ruling 3/10)
|
||||
|
||||
- **RPC-имена proto сверены** (L104–127): `Search/GetInfo/ReadForEval/Join/Leave` (не «SearchDialogs/
|
||||
GetDialogInfo/ReadHistory/JoinDialog») — реализованы ровно эти пять; лимиты/анти-бан — по комментариям
|
||||
контракта и Ruling 3 (поиск-пауза 2–4 с внутри сервиса; join — вне квот).
|
||||
- **Search** (1:1 discovery_search L624–664): contacts.search → сущности chats/users, id подписанные,
|
||||
kind EN-канона (форумы — "forum", как в каталоге), hue считает маппер (Ruling 7). Пауза анти-бана 2–4 с
|
||||
после успешного поиска (ban_guard.search_pause; фейк-пейсер в тестах). Личные чаты/боты НЕ отсеиваются
|
||||
(kind=chat) — это делает core-воркер (Ruling 10, L105–106); дедуп по id + обрезка до лимита — в службе.
|
||||
- **GetInfo** (1:1 L666–716): участники из GetFullChannel (каналы/супергруппы) / GetFullChat (базовые
|
||||
группы, только при членстве), is_forum из entity, kind — EN-канон. Сбои определения наружу НЕ бросаются:
|
||||
недоступная сущность → инфо по умолчанию (name=id, kind="", participants пуст) — 1:1 прототипа.
|
||||
- **ReadForEval** (1:1 L718–800): обычная лента 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 L818–839): по username (нормализация strip + lstrip("@"), пустой → INVALID_ARGUMENT);
|
||||
FloodWait → **RESOURCE_EXHAUSTED с detail-префиксом "flood"** (флуд-гард сессии; базовый MapRpcException
|
||||
L399–412). **Пауз/квот в join НЕТ** — суточный лимит 50/тенант и паузы 50–70 с (рандом) владеет
|
||||
core-воркер Discovery (Ruling 10 L93–97 и Ruling 3 L92: «внешний анти-бан — владение core»).
|
||||
- **Leave** (L841–848): 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 17–19) будет звать эти RPC; контракт ответов готов (ChannelInfo/EvalMessage/
|
||||
ok+no_history), эндпоинты — следующие задачи.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Task 13 — Отчёт: core — модуль Telegram (таблицы, DTO, порт, DialogsService) (план-файл: секция «Task 13», L377–395)
|
||||
|
||||
Статус: **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 L468–503: upsert
|
||||
новых с монитором по `autoMonitorNew` из KV-настроек, обновление имени/типа/handle/hue без троек монитора,
|
||||
удаление отсутствующих в каталоге), `SetMonitor`/`SetMonitorAll` (флаг в БД + RPC SetMonitor*/SetMonitorAll*
|
||||
через гейт — зеркало сервиса), `SavePreview` (_on_message L270–274: TgMessages-строка `m_<dialog>_<msg>`
|
||||
≤4000 + last_text/last_at каталога ≤200), `ReadRecent` (backfill_monitored L569–581: только включённые,
|
||||
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», L395–412)
|
||||
|
||||
Статус: **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 плана L409–410 + брифа)
|
||||
|
||||
- **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 L601–605 пишет при показе):
|
||||
превью-строки создаёт 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», L412–434, 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»** (L412–434, Acceptance L433–434);
|
||||
ledger-Task 10 (отчёт task-10-report.md) закрыл план-Task 16 (ml) и явно отложил ai-часть сюда.
|
||||
|
||||
## Сверка с заданием (Acceptance плана L433–434 + брифа)
|
||||
|
||||
- **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` L25–33);
|
||||
usage ответов копится в tenant-KV `aiTokenUsage` (Ruling 5 L119–121). Маппинг DTO↔proto, deadline 120 с
|
||||
(README контрактов), metadata tenant-id/service-token (Ruling 1).
|
||||
- **Недоступность → по плану** (вопрос брифа): порт сигналит **исключением** `AiUnavailableException` — воркер
|
||||
Pipeline уже отличает aiFail/пропуск catch-ветками (FilterSafelyAsync → `{pass:true,skipped:true}`;
|
||||
классификация → `parsed=null` → локальный разбор, python L1102–1114). `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-задачи
|
||||
(17–19); 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 L63–77 + системный
|
||||
промпт aiPrompt+cardPrompt + user-контекст «Доски (критерии правил RulesDescriber/ключи ≤8/описание ≤160) +
|
||||
примеры разметки ≤8 + Сообщение ≤5000» — python L226–251), `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` L201–215).
|
||||
- `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 плана L414–419/L424, где call-site'ы переходят на запросные record'ы через
|
||||
билдер). Контекст/маппер помещены в модуль Pipeline как «ядро владельца» (план: `PL/Application/…`) и
|
||||
потребляются gRPC-адаптером `GrpcAiClassifier` (Infrastructure → Pipeline-модуль, зависимость уже была у
|
||||
LocalAiClassifier) — python-структура сохранена 1:1 (классификатор сам собирает промпты/доски/примеры по
|
||||
тексту, `ai.py classify L218–258`). Кандидатура на будущее: при Discovery-задачах/этапе 7 порт можно
|
||||
перевести на запросные record'ы без изменения адаптеров.
|
||||
2. **IAiTools реализован полностью** (GenerateKeywords + EvaluateFit): EvaluateFit откладывать не стали —
|
||||
серверная сторона ai-service готова (план Task 8), отложенная реализация оставила бы порт «на бумаге».
|
||||
3. **Ключ `aiTokenUsage`** уже добавлен в SettingsKeys (ledger-Task 10, список плана Task 16 L443–444) — форма
|
||||
значения {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
|
||||
L433–434**: 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: таблицы, порт, сервисы задач/кандидатов/чёрного списка/лога (план-файл L451–465, 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 L80–111). Итог: задача на 50 занимает весь бюджет (другая не создаётся); две по 25
|
||||
допустимы (25+25=50), третья — нет; текст 400 — python (L97–110).
|
||||
- **Ключевые слова ИИ-генерации — НЕ в Task 17** (вопрос брифа «создание с генерацией?»): сверено с планом и
|
||||
прототипом — create_task не генерирует ключи (discovery.py L234–282 принимает keywords из payload);
|
||||
генерация — отдельный endpoint `generate-keywords` (discovery_routes L189–211) за IAiTools, эндпоинты — Task 19.
|
||||
- **Задачи**: create/patch (рост плана с бюджетом, клампы `_validate_task_values` L213–231)/delete (каскад:
|
||||
кандидаты + лог, чёрный список общий)/start (пустые ключи → 400 «Нет ключевых слов для поиска — добавьте их
|
||||
в задачу»; done/failed → сброс прогресса L332–339)/pause/advance_search/bump_counter — 1:1 L234–381.
|
||||
- **Кандидаты**: add с исключениями (мониторится/чёрный список/уже new|review|joined → null + лог skip;
|
||||
stale rejected → перезапись новой записью L431–433; found+1), set_candidate (пустое имя/kind/hue не затирают
|
||||
L480–482; 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 L385–563. 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, бан-гард (план-файл L467–480, 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 L444–484: стоп-краны (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` → блок до конца суток). Пауза 50–70 с (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 (2–4 с 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 L488–489):
|
||||
# 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` L482–493 (Ruling 11).
|
||||
**Источники:** `backend/app/routers/discovery_routes.py` (целиком), `store.js` L2148–2430, 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 L353–355)
|
||||
и `{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`
|
||||
L111–128: ≤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 L269–285.
|
||||
- **Стиль**: 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 L50–60). |
|
||||
| `Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs` | create | Тело PATCH (TaskPatch L62–71, все optional). |
|
||||
| `Deal.Api/Program.cs` | modify | `app.MapDiscoveryEndpoints();` (комментарий Task 19). |
|
||||
| `Deal.Modules.Telegram/Application/DialogsService.cs` | modify | `AddDiscoveredMonitoredAsync` (upsert каталога + зеркало; python add_dialog_monitored L850–873). |
|
||||
| `Deal.Modules.Telegram/Application/ITelegramStore.cs` | modify | Порт `UpsertDiscoveredMonitoredAsync` (upsert ON CONFLICT L858–872). |
|
||||
| `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 L552–553).
|
||||
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:12–20:27, до записи progress.md 20:32). Текущий запуск перепроверил
|
||||
всё по фактическим файлам и доработал до требований задачи. Dockerfile'ы (T2–T4) корректны: контекст
|
||||
сборки — корень репозитория (`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/5101–5103 должны быть свободны на хосте (в 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** (L314–330). Отчёт по
|
||||
инструкции исполнителя — `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 L209–222), TryReconnect
|
||||
(heartbeat L318–327), 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», L330–347)
|
||||
|
||||
Статус: **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** (L330–347). Отчёт по
|
||||
инструкции исполнителя — `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.5–3 с/сообщение
|
||||
и 3–6 с/диалог (random.uniform эквивалент), sweep — без пауз внутри (период 30 с, как python L392–456).
|
||||
|
||||
## Что сделано (файлы)
|
||||
|
||||
`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 L392–456).
|
||||
- `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», L260–277 и «Task 6», L277–289)
|
||||
|
||||
Статус: **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 L78–87 (снятие ссылок + токены
|
||||
[a-zа-яё0-9@+.#]+, len≥3, «~»+w[:4] при len≥6); upsert/learn_batch L105–173 (одна транзакция на
|
||||
батч, per-пример семантика с удалением строк count≤0 при delta<0); predict L184–293 (score термина
|
||||
w<1→1.0 иначе 1+(w−1)/(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 L296–322
|
||||
(delta=1, не t:*, после ready); status L325–345 (EVAL_WINDOW 50); reset L348–354. Пороги 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` L112–114); core шлёт только непустые строки outbox.
|
||||
4. **gRPC Predict с пустым/пробельным text** — «не уверен», не ошибка (ml.proto L58–62; 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 L147–173).
|
||||
|
||||
## Проверка (команды)
|
||||
|
||||
- `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», L289–303 и «Task 8», L303–314)
|
||||
|
||||
Статус: **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 L175–183: обёртка ```json…``` → срез {…}),
|
||||
`Llm/TokenEstimator.cs` (usage API-ответа как есть, total «как есть»; иначе ≈ceil(chars/4)),
|
||||
`Llm/ProviderCaller.cs` (chat_json L80–117: попытка = вызов + извлечение; исчерпание →
|
||||
`LlmCallException` с Kind=ProviderUnavailable | AnswerNotJson; текст 1:1 Ruling 5 / L115–117).
|
||||
- **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 L201–204; usage в ответе); GenerateKeywords — фикс. промпт
|
||||
routes L36–47 + «Описание ниши/задачи:\n…» → keywords (чистку делает ядро, Ruling 11);
|
||||
EvaluateFit — промпт discovery_eval L50–54 с подстановкой description/keywords (сервисом,
|
||||
как «Ключи: a, b.») → {fit, reason}; причина по умолчанию «подходит»/«не подходит», потолок 200
|
||||
(_ai_reason L167–171); строковые «нет»-значения fit — ложь (1:1 _ai_fit L158–164). Недоступность
|
||||
провайдера после ретраев → 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.8–2 с/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», L361–377)
|
||||
|
||||
Статус: **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)», L361–377, Acceptance L375.
|
||||
|
||||
## Сверка с заданием (Acceptance плана L361–377 + брифа)
|
||||
|
||||
- **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, шаблон T2–T4),
|
||||
`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 L169–213) → 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 T2–T4, 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 L178–207, чтобы сверить имена 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) — косметика,
|
||||
к коду не относится.
|
||||
Reference in New Issue
Block a user