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

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
# Контракты gRPC этапа 6 (`src/contracts`)
Контракты между ядром Deal и сервисами telegram/ml/ai (отдельные процессы).
Источники: дизайн-док §6.2 (L145–152), план «Дейл — Этап 6: Сервисы …»
(Task 1, Rulings 1/3/5/7), api-map §3.3/§3.7/§3.8/§4.8/§4.9/§4.10, прототип
`backend/app/services/*.py` и `mlservice/model.py`.
Контракты — **единственный «язык» между процессами** (Ruling 1): сервисы не
делят с ядром ничего, кроме этих `.proto` и NuGet.
| Файл | Пакет | `csharp_namespace` | Сервисы |
|---|---|---|---|
| `telegram.proto` | `deal.telegram.v1` | `Deal.Grpc.Telegram` | `TelegramService`, `IngressService` |
| `ml.proto` | `deal.ml.v1` | `Deal.Grpc.Ml` | `MlService` |
| `ai.proto` | `deal.ai.v1` | `Deal.Grpc.Ai` | `AiService` |
## Кодогенерация
- `Deal.Proto.csproj` — общий classlib: компилирует все три `.proto` через
`Grpc.Tools` (`<Protobuf Include=... GrpcServices="Both"/>`), т.е. генерирует
и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона
игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и
первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам
`ProjectReference`-ом либо `<Protobuf Include="..\..\contracts\X.proto">`.
- Пакеты: `Grpc.Tools` (PrivateAssets), `Google.Protobuf`, `Grpc.Core.Api`.
Транспорты — по месту: `Grpc.AspNetCore` (серверы), `Grpc.Net.Client`/клиенты.
- Сгенерированные типы: `obj/…/{Telegram,TelegramGrpc,Ml,MlGrpc,Ai,AiGrpc}.cs`,
пространства имён из `option csharp_namespace`.
- Проекты-потребители: core (клиенты ML/AI; telegram-клиент-гейт + сервер
ингресса), telegram-service/ml-service/ai-service — добавляются в Task 2–4+.
## Metadata (все RPC, обязательны)
| Заголовок | Значение | Отказ |
|---|---|---|
| `tenant-id` | id тенанта (строка). **Единственный** источник принадлежности; полю в теле не доверяем | отсутствует → `UNAUTHENTICATED` (для ингресса core: `SetTenant` из metadata) |
| `service-token` | общий токен сервисов (env `DEAL_SERVICE_TOKEN`, общий в compose) | пустой/неверный → `UNAUTHENTICATED` |
Интерцептор `service-token` — общий шаблон в каждом процессе (Ruling 1/2);
каждый сервис дополнительно проверяет принадлежность по своей модели (сессия/
модель тенанта есть, иначе `NOT_FOUND`/`FAILED_PRECONDITION`).
## Коды ошибок (общие)
Ошибки домена — gRPC-статусы, `detail` = текст причины 1:1 с прототипом
(строки «Telegram не подключён», «Неверный код», «Код истёк — запросите новый»,
«Неверный облачный пароль», «ИИ (имя) не ответил корректно — …» и т.д.):
| Статус | Когда |
|---|---|
| `INVALID_ARGUMENT` | невалидный ввод/код/пароль/username, пустой текст |
| `NOT_FOUND` | диалог/сущность/сессия тенанта не найдены |
| `FAILED_PRECONDITION` | операция невозможна в текущей фазе (нет сессии и т.п.) |
| `RESOURCE_EXHAUSTED` | FloodWait Telegram (detail начинается с префикса `flood`) |
| `UNAVAILABLE` | недоступен Telegram/LLM-провайдер/хранилище модели (безопасный повтор/фолбэк) |
| `UNAUTHENTICATED` | неверный/отсутствующий `service-token` |
«Мягкие» сценарии НЕ являются ошибками RPC: Predict неготовой модели («не
уверен»), ReadForEval без истории (`ok=false, error="no_history"`), сброс с
`ok=false,error`, `filter` с решением pass/reason.
## Deadlines (клиент)
| Сфера | Рекомендация | Обоснование |
|---|---|---|
| telegram: GetStatus/SetMonitor/SetMonitorAll/Logout | 10 с | локальная сеть/статус |
| telegram: StartPhone/StartQr/SendCode/SendPassword/Search/GetInfo/ReadRecent/ReadForEval/Join/Leave | 60 с | сетевые операции Telegram (паузы анти-бана 2–4 с поиск) |
| telegram: RefreshDialogs/Backfill | 120 с | iter_dialogs 500; backfill 10 сообщ. × 1.5–3 с + 3–6 с/диалог |
| ingress: PushMessage/SyncDialogs/ReportStatus | 10 с | локальная сеть; упущенное догоняет realtime-sweep |
| ml: Predict | 5 с | локальная модель |
| ml: Status/Reset | 10 с | локально |
| ml: TrainBatch | 30 с | батч ≤100, 1 транзакция |
| ai: все RPC | 120 с | провайдер 90/60 с + ретраи 0.8/2 с |
---
## `telegram.proto`
Два сервиса: команды ядра → сервис (`TelegramService`) и поток сервис → ядро
(`IngressService`, gRPC-сервер в Deal.Api :5082, Ruling 7).
### Словари значений
- `phase`: `idle | phone | code | password | qr | ready` (status() L85).
- `kind`: `channel | group | forum | chat` (канон контракта; 1:1 `_kind_of`
L461466: broadcast → channel, megagroup/gigagroup/group → group, остальное →
chat; `forum` — отдельный флаг `is_forum` в GetInfo, в каталоге форум приходит
как group). Discovery-коды `channel/group/forum` ядро получает из kind+is_forum.
- `error`/`qr_url`/`account``optional` (presence): пустое = нет ошибки/URL.
### Маппинги на HTTP-контракт (заметка для T13/14, код не меняется)
Внутренний канон контракта `kind` — EN (`channel/group/forum/chat`). Граница
HTTP-эндпоинтов (api-map §4.8 L349, замороженный контракт фронта) НЕ 1:1:
- `GET /api/tg/dialogs``item.type` остаётся **русским** («канал»/«группа»/
«чат»), как в прототипе (`refresh_dialogs`/`list_dialogs`): на границе
эндпоинта каналов (T13/14) нужен обратный маппинг EN → RU
(channel→«канал», group/forum→«группа», chat→«чат»; forum в списке диалогов
не встречается — каталог приносит его как group).
- Discovery: `candidate.type` (`kind`) — **EN** (`channel/group/forum`), как в
прототипе (db.py L162166, `_kind_code`); маппинг на границе НЕ нужен.
### TelegramService (ядро — клиент, сервис — сервер)
| RPC | Запрос | Ответ | Ошибки / примечания |
|---|---|---|---|
| `GetStatus` | `GetStatusRequest` (пуст) | `GetStatusReply{phase,connected,listener,account,error?,qr_url?}` | нет сессии → FAILED_PRECONDITION «Telegram не подключён». live-поля для `GET /api/tg/status`; monitored/keysSet ядро считает само (Ruling 8) |
| `StartPhone` | `StartPhoneRequest{phone, api_id, api_hash}` | `StartPhoneReply{phase}` | ключи tgKeys передаёт ядро (Ruling 3); нет ключей → INVALID_ARGUMENT «Сначала сохраните…»; ответ фаза `code` |
| `StartQr` | `StartQrRequest{api_id, api_hash}` | `StartQrReply{phase, qr_url}` | фаза `qr` + url; уже авторизован → `ready`, url пуст |
| `SendCode` | `SendCodeRequest{code}` | `SendCodeReply{phase}` | «Неверный код»/«Код истёк…» → INVALID_ARGUMENT; 2FA → фаза `password` |
| `SendPassword` | `SendPasswordRequest{password}` | `SendPasswordReply{phase}` | «Неверный облачный пароль» → INVALID_ARGUMENT; ответ `ready` |
| `Logout` | `LogoutRequest` (пуст) | `LogoutReply{ok}` | отключение + удаление сессии тенанта |
| `RefreshDialogs` | `RefreshDialogsRequest` (пуст) | `RefreshDialogsReply{entries: DialogEntry[]}` | каталог диалогов; применять ядру через Ingress.SyncDialogs-семантику (`SyncFromTelegram`) |
| `SetMonitor` | `SetMonitorRequest{dialog_id, enabled}` | `SetMonitorReply{ok, enabled}` | зеркало monitored в сервисе; первый backfill запускает ядро |
| `SetMonitorAll` | `SetMonitorAllRequest{enabled}` | `SetMonitorAllReply{ok, count, enabled}` | count = диалогов в каталоге |
| `Backfill` | `BackfillRequest{dialog_id, force}` | `BackfillReply{processed}` | последние ~10 сообщений → поток PushMessage; паузы 1.53 с/сообщ. + mark-as-read; force = «Перечитать» |
| `ReadRecent` | `ReadRecentRequest{dialog_id, limit 1..50}` | `ReadRecentReply{messages: PreviewMessage[]}` | свежие из TG; lead и фолбэк на БД — в ядре |
| `Search` | `SearchRequest{query, limit}` | `SearchReply{results: DialogEntry[]}` | пауза анти-бана внутри сервиса; личные/ботов отсеивает ядро (Ruling 10) |
| `GetInfo` | `GetInfoRequest{dialog_id}` | `GetInfoReply{info: ChannelInfo}` | ChannelInfo{id,name,username,kind,hue,participants?,is_forum}; сбои full_chat не роняют RPC |
| `ReadForEval` | `ReadForEvalRequest{dialog_id, limit}` | `ReadForEvalReply{ok, error?, messages: EvalMessage[]}` | форумы — по темам; история скрыта → ok=false,error="no_history" (НЕ ошибка RPC) |
| `Join` | `JoinRequest{username}` | `JoinReply{ok}` | FloodWait → RESOURCE_EXHAUSTED (`flood`); ручной join вне квот |
| `Leave` | `LeaveRequest{dialog_id}` | `LeaveReply{ok}` | нет членства → NOT_FOUND |
Общие сообщения:
- `DialogEntry{id,name,username,kind,hue}` — каталог/поиск (id подписанный:
каналы `-100…`, группы `-…`, личные `+…`; hue — палитра DIALOG_HUES).
- `ChannelInfo` = DialogEntry + `participants?` + `is_forum`.
- `PreviewMessage{id(text), text, time(ms)}` — превью (api-map §4.8 L351; `id`
строкой: int-сообщения TG и фолбэк `m_<dialog>_<msg>`).
- `EvalMessage{id(int64), text, date_ms, topic_id?, topic_title?}` — выборка
оценки кандидата (`_discovery_message_item`).
### IngressService (сервис — клиент, ядро — сервер в Deal.Api, порт `GRPC_INGRESS_PORT` :5082)
| RPC | Запрос | Ответ | Примечания |
|---|---|---|---|
| `PushMessage` | `PushMessageRequest{dialog_id, channel_name, channel_handle, channel_hue, msg_id?, text, msg_at?}` | `PushMessageReply{accepted, duplicate}` | 1:1 QueuedMessage/demo-ingest: EnqueueAsync + превью в TgMessages; дубль dialog+msgId → duplicate=true, очередь не растёт; нет msg_at → ядро подставит now; hue считает сервис |
| `SyncDialogs` | `SyncDialogsRequest{entries: DialogEntry[]}` | `SyncDialogsReply{monitored_ids[]}` | ядро: SyncFromTelegram (autoMonitorNew/обновление/удаление); ответ — актуальный зеркальный список monitored сервиса |
| `ReportStatus` | `ReportStatusRequest{phase,connected,listener,account,error?,qr_url?}` | `ReportStatusReply{ok}` | ядро: KV tgStatus/tgAccount + SSE system_status/тосты на переходах фаз |
---
## `ml.proto`
`MlService` — пул инкрементальных наивно-байесовских моделей per-tenant
(файл `data/ml/<tenantId>.sqlite`). Поля 1:1 с `MlPredictResultDto`/
`MlServiceStatusDto` и model.py.
| RPC | Запрос | Ответ | Примечания |
|---|---|---|---|
| `Predict` | `PredictRequest{text}` | `PredictReply{take,label?,scores: map<string,double>,hits,ready,margin?,terms[],type?}` | «не уверен» при неготовой модели/пустом тексте — не ошибка; scores ≤5 лучших (round 3); margin адаптивный 0.9/0.7/0.5/0.35; terms ≤8; type — t:hire/t:order |
| `Status` | `StatusRequest` (пуст) | `StatusReply{ready,classes: map<string,double>,learned,eval: ModelEval}` | classes «label → вес» round 2; модель создаётся лениво |
| `Reset` | `ResetRequest` (пуст) | `ResetReply{ok, error?}` | очистка classes/terms/eval_log + пересоздание файла; ok=false — мягкая ошибка (ядро чистит ml_outbox только при ok) |
| `TrainBatch` | `TrainBatchRequest{items: TrainExample[]}` | `TrainBatchReply{learned}` | 1 транзакция + пакетные вставки терминов (= learn_batch); learned = применено примеров |
Сообщения:
- `TypeDecision{take,label("hire"|"order"),value("t:hire"|"t:order"),margin}`
решение о типе заявки.
- `ModelEval{count,correct,accuracy}` — окно самооценки (EVAL_WINDOW последних
подтверждённых решений).
- `TrainExample{text,label,delta}` — строка обучения (ml_outbox): delta 1.0
пользователь / −1.0 снять / 0.4–0.6 ИИ-правила.
Пороги и константы (Ruling 4): MIN_TOTAL 20, MIN_WINNER 6, MIN_WINNER_SPAM 4,
MIN_HITS 2, MARGIN 0.9, типы `t:*` с MIN_TYPE_WINNER 4 — живут в ml-service
(реализация Task 5/6), в контракт не входят.
---
## `ai.proto`
`AiService` — фасад LLM-провайдеров без БД (Ruling 5). Ядро передаёт в теле
каждого запроса заполненные промпты/контекст + конфиг активного провайдера
(`provider_config`); сервис возвращает ответ модели + оценку токенов.
| RPC | Запрос | Ответ | Примечания |
|---|---|---|---|
| `Filter` | `FilterRequest{prompt, text, provider_config}` | `FilterReply{pass, reason?, usage}` | prompt = заполненный aiFilterPrompt (ядро); «фильтр не применялся» обрабатывает ядро до вызова |
| `Classify` | `ClassifyRequest{system_prompt, user_context, provider_config}` | `ClassifyReply{ok, json?, usage}` | system_prompt = aiPrompt+cardPrompt, user_context = «Доски + примеры + Сообщение» (собирает ядро); json — сырой ответ модели строкой; строгий маппинг в карточку — ядро |
| `GenerateKeywords` | `GenerateKeywordsRequest{description, provider_config}` | `GenerateKeywordsReply{keywords[], usage}` | фикс. промпт (routes L3647); очистку `_clean_keywords` и мягкие ошибки делает ядро (Ruling 11) |
| `EvaluateFit` | `EvaluateFitRequest{text, description, keywords[], provider_config}` | `EvaluateFitReply{fit, reason?, usage}` | промпт discovery_eval L5054; ядро зовёт при aiEnabled, сбой → эвристика |
`provider_config` — конфиг активного провайдера на запрос (Ruling 5 «в теле
каждого запроса»): ядро собирает эффективный конфиг (настройка `aiConfigs`
тенанта хранит `{apiKey, baseUrl, model}` в camelCase, ключ шифруется AES-GCM;
`apiStyle` — из каталога `AiProviders`) и передаёт в теле; сервис настроек не
хранит. Форма — сообщение `ProviderConfig`:
| Поле | Обязательность | Описание |
|---|---|---|
| `provider_id` | да | Id провайдера (ключ `aiConfigs`/каталога: deepseek/openai/anthropic/ollama/lmstudio/custom…) |
| `base_url` | да | Эффективный базовый URL API (`aiConfigs.baseUrl` или дефолт каталога) |
| `api_key` | опц. | Ключ открытым текстом (расшифрован ядром); пуст у локальных провайдеров — заголовок не шлётся |
| `model` | да | Активная модель (`aiConfigs.model` или первая из каталога) |
| `api_style` | опц. | Стиль API: пуст — OpenAI-совместимый (`{base}/chat/completions`, Bearer); `"anthropic"` — Messages API (`{base}/v1/messages`, x-api-key + anthropic-version) |
Сообщения:
- `Usage{prompt, completion, total}` — оценка токенов в каждом reply (из usage
API-ответа; при отсутствии ≈chars/4; ядро копит в KV `aiTokenUsage`).
Ошибки: провайдер не ответил корректно после ретраев → `UNAVAILABLE` с detail
«ИИ (имя) не ответил корректно — повторите попытку через несколько секунд».
`ClassifyReply.ok=false` — ответ без разбираемого JSON (не RPC-ошибка); ядро
трактует как «не разобрано» и использует локальный путь.