Files
Deal/src/contracts
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00
..

Контракты 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/accountoptional (presence): пустое = нет ошибки/URL.

Маппинги на HTTP-контракт (заметка для T13/14, код не меняется)

Внутренний канон контракта kind — EN (channel/group/forum/chat). Граница HTTP-эндпоинтов (api-map §4.8 L349, замороженный контракт фронта) НЕ 1:1:

  • GET /api/tg/dialogsitem.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-ошибка); ядро трактует как «не разобрано» и использует локальный путь.