# Контракты 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` (``), т.е. генерирует и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам `ProjectReference`-ом либо ``. - Пакеты: `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` L461–466: 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 L162–166, `_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.5–3 с/сообщ. + 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__`). - `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/.sqlite`). Поля 1:1 с `MlPredictResultDto`/ `MlServiceStatusDto` и model.py. | RPC | Запрос | Ответ | Примечания | |---|---|---|---| | `Predict` | `PredictRequest{text}` | `PredictReply{take,label?,scores: map,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,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 L36–47); очистку `_clean_keywords` и мягкие ошибки делает ядро (Ruling 11) | | `EvaluateFit` | `EvaluateFitRequest{text, description, keywords[], provider_config}` | `EvaluateFitReply{fit, reason?, usage}` | промпт discovery_eval L50–54; ядро зовёт при 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-ошибка); ядро трактует как «не разобрано» и использует локальный путь.