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
+48
View File
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Deal.Proto — общий проект кодогенерации .proto-контрактов этапа 6 (Ruling 1).
Компилирует все три контракта (telegram/ml/ai.proto) через Grpc.Tools с
генерацией и клиентской, и серверной стороны (GrpcServices="Both"): сборка
этого проекта — первый прогон кодогенерации и валидации .proto до создания
сервисных проектов (Task 2–4). Сгенерированные типы живут в пространствах
имён из option csharp_namespace (Deal.Grpc.Telegram/Deal.Grpc.Ml/Deal.Grpc.Ai)
и переиспользуются core и сервисами через ProjectReference.
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors), как остальные sln.
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AssemblyName>Deal.Proto</AssemblyName>
<RootNamespace>Deal.Proto</RootNamespace>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- Кодогенерация: protoc + плагин gRPC C# (только для сборки). -->
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="All" />
<!-- Рантайм сгенерированных сообщений (MessageParser/ByteString и т.п.). -->
<PackageReference Include="Google.Protobuf" Version="3.35.1" />
<!-- Типы gRPC C#-стабов (Grpc.Core.*): нужны сгенерированному коду при
компиляции; транспорты (Grpc.Net.Client/Grpc.AspNetCore) подключают
проекты-потребители по месту (Ruling 1, список NuGet этапа). -->
<PackageReference Include="Grpc.Core.Api" Version="2.83.0" />
</ItemGroup>
<ItemGroup>
<!-- Контракты этапа 6; файлы лежат рядом с проектом (src/contracts). -->
<Protobuf Include="telegram.proto" GrpcServices="Both" />
<Protobuf Include="sources.proto" GrpcServices="Both" />
<Protobuf Include="ml.proto" GrpcServices="Both" />
<Protobuf Include="ai.proto" GrpcServices="Both" />
<Protobuf Include="storage.proto" GrpcServices="Both" />
</ItemGroup>
</Project>
+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-ошибка); ядро
трактует как «не разобрано» и использует локальный путь.
+138
View File
@@ -0,0 +1,138 @@
//
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
// запроса — сервис настроек тенанта не знает и не хранит.
//
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
// «ИИ (имя) не ответил корректно — повторите попытку через
// несколько секунд» (ядро падает в локальный разбор).
//
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
//
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
syntax = "proto3";
package deal.ai.v1;
option csharp_namespace = "Deal.Grpc.Ai";
service AiService {
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
rpc Filter(FilterRequest) returns (FilterReply);
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
// clean_budget/build_contacts).
rpc Classify(ClassifyRequest) returns (ClassifyReply);
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
}
// --- Запросы/ответы AiService ---
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
message ProviderConfig {
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
// anthropic, ollama, lmstudio, custom…).
string provider_id = 1;
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
string base_url = 2;
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
optional string api_key = 3;
// Активная модель (aiConfigs.model или первая из каталога провайдера).
string model = 4;
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
// x-api-key + anthropic-version).
optional string api_style = 5;
}
message FilterRequest {
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
string prompt = 1;
string text = 2;
ProviderConfig provider_config = 3;
}
message FilterReply {
// True — сообщение проходит фильтр (не спам/реклама/служебное).
bool pass = 1;
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
optional string reason = 2;
Usage usage = 3;
}
message ClassifyRequest {
string system_prompt = 1;
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
string user_context = 2;
ProviderConfig provider_config = 3;
}
message ClassifyReply {
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
bool ok = 1;
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
optional string json = 2;
Usage usage = 3;
}
message GenerateKeywordsRequest {
string description = 1;
ProviderConfig provider_config = 2;
}
message GenerateKeywordsReply {
// Сгенерированные ключи (пустой список — модель не выделила ключи;
repeated string keywords = 1;
Usage usage = 2;
}
message EvaluateFitRequest {
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
string text = 1;
string description = 2;
repeated string keywords = 3;
ProviderConfig provider_config = 4;
}
message EvaluateFitReply {
// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
bool fit = 1;
// Краткая причина решения модели (пуст, если модель её не дала).
optional string reason = 2;
Usage usage = 3;
}
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
message Usage {
// Токены запроса (system + user).
uint32 prompt = 1;
// Токены ответа модели.
uint32 completion = 2;
// Суммарно (prompt + completion; может отличаться от суммы при подсчёте
// провайдером — берём как есть).
uint32 total = 3;
}
+127
View File
@@ -0,0 +1,127 @@
//
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
//
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
//
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
//
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
// (батч ≤100 примеров, одна транзакция).
syntax = "proto3";
package deal.ml.v1;
option csharp_namespace = "Deal.Grpc.Ml";
service MlService {
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
rpc Predict(PredictRequest) returns (PredictReply);
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
rpc Status(StatusRequest) returns (StatusReply);
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
rpc Reset(ResetRequest) returns (ResetReply);
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
// не t:*) до применения. Ответ — число применённых примеров.
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
}
message PredictRequest {
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
string text = 1;
}
message PredictReply {
// True — модель уверена (take) и решение можно использовать без ИИ.
bool take = 1;
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
optional string label = 2;
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
map<string, double> scores = 3;
// Сколько терминов класса-победителя модель узнала в тексте.
int32 hits = 4;
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
// принимать решения.
bool ready = 5;
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
optional double margin = 6;
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
repeated string terms = 7;
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
TypeDecision type = 8;
}
message TypeDecision {
// True — модель уверена в типе.
bool take = 1;
// Тип: "hire" | "order".
string label = 2;
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
string value = 3;
// Запас уверенности (margin, 2 знака).
double margin = 4;
}
message StatusRequest {}
message StatusReply {
// Модель готова принимать решения.
bool ready = 1;
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
map<string, double> classes = 2;
// Всего примеров, на которых модель обучалась (сумма по классам).
int32 learned = 3;
// Самооценка модели по последним подтверждённым решениям.
ModelEval eval = 4;
}
message ModelEval {
// Решений в окне самооценки (последние EVAL_WINDOW).
int32 count = 1;
// Из них совпавших с действием пользователя.
int32 correct = 2;
// Доля верных (correct/count, 0..1; 0 при пустом окне).
double accuracy = 3;
}
message ResetRequest {}
message ResetReply {
// True — модель сброшена (и ядро очищает свою очередь обучения).
bool ok = 1;
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
optional string error = 2;
}
message TrainBatchRequest {
repeated TrainExample items = 1;
}
// Один обучающий пример (строка ml_outbox ядра: text/label/delta).
message TrainExample {
// Текст примера (source_msg карточки или title).
string text = 1;
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
string label = 2;
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
double delta = 3;
}
message TrainBatchReply {
// Число применённых примеров (= len(items) при успехе).
int32 learned = 1;
}
+91
View File
@@ -0,0 +1,91 @@
//
// Контракт входящего потока источников: сервис-источник → ядро.
//
// Любой источник (telegram, whatsapp, avito, сайт, файл, excel) приводит свои
// данные к единому контракту SourceItem и шлёт их одним вызовом PushSource.
// Ядро не знает о природе источника: вид задаётся полем source.kind.
//
// tenant-id — id тенанта (metadata; единственный источник принадлежности);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Файлы вложений источник сам выгружает в сервис данных (storage.proto) и
// передаёт здесь ссылкой в content.data.
syntax = "proto3";
package deal.sources.v1;
option csharp_namespace = "Deal.Grpc.Sources";
service SourceIngressService {
// Запись источника → очередь конвейера ядра. Дубль (вид+оригинал+внешний id)
// уже в очереди — очередь не растёт (duplicate=true).
rpc PushSource(PushSourceRequest) returns (PushSourceReply);
}
message PushSourceRequest {
SourceRefProto source = 1;
SourceContentProto content = 2;
// True — вернуть из отсева: правила/устарелость/ИИ-фильтр пропускаются.
bool force = 3;
}
message SourceRefProto {
// Дискриминатор источника, задаёт владелец (telegram/file/local/...).
string kind = 1;
// Идентификатор записи в источнике (сообщение/строка/файл).
optional string external_id = 2;
// Подпись источника для интерфейса.
optional string display_name = 3;
// Ссылка на оригинал (url, deep-link, путь).
optional string origin_ref = 4;
optional string author = 5;
// Время получения записи, epoch-ms.
int64 received_at = 6;
// Прочие метаданные источника (в т.ч. цвет интерфейса — hue).
map<string, string> extra = 7;
}
message SourceContentProto {
optional string text = 1;
optional string html = 2;
optional string author = 3;
optional string subject = 4;
// Вложения: ссылки на объекты сервиса данных.
repeated DataRefProto data = 5;
// Ссылки, не являющиеся файлами.
repeated string links = 6;
repeated ContactRefProto contacts = 7;
map<string, string> extra = 8;
}
message DataRefProto {
string id = 1;
string ref = 2;
optional string kind = 3;
optional string mime_type = 4;
optional string file_name = 5;
optional int64 size = 6;
optional int32 width = 7;
optional int32 height = 8;
optional double duration_sec = 9;
optional string preview_ref = 10;
optional string caption = 11;
optional int32 order = 12;
map<string, string> meta = 13;
}
message ContactRefProto {
optional string kind = 1;
optional string name = 2;
optional string phone = 3;
optional string email = 4;
optional string url = 5;
}
message PushSourceReply {
// True — запись принята (пустой текст и пустой контент — accepted=false).
bool accepted = 1;
// True — дубль уже в очереди (очередь не выросла).
bool duplicate = 2;
}
+78
View File
@@ -0,0 +1,78 @@
//
// Контракт общего сервиса данных: загрузка/выгрузка/стат/удаление объектов вложений.
//
// tenant-id — id тенанта (metadata); объект хранится в пространстве тенанта;
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/пустой →
// UNAUTHENTICATED (интерцептор хоста).
//
// Тип объекта (image/video/audio/document/archive/other) определяет сервис —
// контракт типы вложений не задаёт.
syntax = "proto3";
package deal.storage.v1;
option csharp_namespace = "Deal.Grpc.Storage";
service StorageService {
// Поток: первое сообщение несёт meta, далее — data.
rpc Upload(stream UploadRequest) returns (UploadReply);
// Поток содержимого объекта частями.
rpc Download(DownloadRequest) returns (stream DownloadChunk);
// Дескриптор объекта (без содержимого).
rpc Stat(StatRequest) returns (StatReply);
// Удаление объекта (отсутствующий — успех).
rpc Delete(DeleteRequest) returns (DeleteReply);
}
message UploadRequest {
UploadMeta meta = 1;
bytes data = 2;
}
message UploadMeta {
string tenant_id = 1;
string file_name = 2;
string content_type = 3;
}
message UploadReply {
// Идентификатор объекта — ключ в хранилище.
string id = 1;
// Ссылка на скачивание/отображение.
string ref = 2;
// Тип, определённый сервисом (image/video/audio/document/archive/other).
string kind = 3;
string mime_type = 4;
string file_name = 5;
int64 size = 6;
optional int32 width = 7;
optional int32 height = 8;
optional double duration_sec = 9;
optional string preview_ref = 10;
}
message DownloadRequest {
string id = 1;
}
message DownloadChunk {
bytes data = 1;
}
message StatRequest {
string id = 1;
}
message StatReply {
bool found = 1;
UploadReply info = 2;
}
message DeleteRequest {
string id = 1;
}
message DeleteReply {}
+390
View File
@@ -0,0 +1,390 @@
//
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
// * IngressService — исходящий поток telegram-service → ядро: синхронизация
// каталога (SyncDialogs), статус аккаунта (ReportStatus).
// * Сообщения мониторящихся диалогов приходят в ядро generic-контрактом
// sources.proto (PushSource) — ядро не привязано к природе источника.
//
//
// tenant-id — id тенанта (строка; единственный источник принадлежности,
// полю в теле не доверяем);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
//
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
// channel, megagroup/gigagroup/group → group, остальное → chat.
// Forum выставляется отдельным флагом is_forum (GetInfo); в
// каталоге (RefreshDialogs) форум приходит как group.
//
// * быстрые команды статуса/мониторинга — 10 с;
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
syntax = "proto3";
package deal.telegram.v1;
option csharp_namespace = "Deal.Grpc.Telegram";
// ---------------------------------------------------------------------------
// TelegramService — команды ядра → telegram-service (клиентская сторона в core)
// ---------------------------------------------------------------------------
service TelegramService {
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
rpc StartQr(StartQrRequest) returns (StartQrReply);
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
rpc Logout(LogoutRequest) returns (LogoutReply);
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
// отдельным RPC Backfill. Ответ: ok/enabled.
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
// Ответ: сколько сообщений отправлено (processed).
rpc Backfill(BackfillRequest) returns (BackfillReply);
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Исходное сообщение источника по id (remote-просмотр исходника карточки).
// found=false — сообщение не найдено/удалено (не ошибка RPC).
rpc ReadSource(ReadSourceRequest) returns (ReadSourceReply);
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
rpc Search(SearchRequest) returns (SearchReply);
// hue + participants и is_forum (полный чат). Сбои определения не роняют
// RPC: participants пуст, остальные поля — из entity/каталога.
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
// Выборка последних сообщений источника для оценки кандидата
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
rpc Join(JoinRequest) returns (JoinReply);
// диалога/членства.
rpc Leave(LeaveRequest) returns (LeaveReply);
}
// --- Запросы/ответы TelegramService ---
message GetStatusRequest {}
message GetStatusReply {
// Фаза входа: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент Telegram подключён и авторизован.
bool connected = 2;
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
bool listener = 3;
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
string account = 4;
// Текст последней ошибки (null, если ошибки нет).
optional string error = 5;
// URL QR-входа (заполнен только при phase == "qr").
optional string qr_url = 6;
}
message StartPhoneRequest {
// Номер телефона в международном формате (как ввёл пользователь).
string phone = 1;
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
int32 api_id = 2;
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
string api_hash = 3;
}
message StartPhoneReply {
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
string phase = 1;
}
message StartQrRequest {
// api_id/api_hash приложения Telegram (см. StartPhoneRequest).
int32 api_id = 1;
string api_hash = 2;
}
message StartQrReply {
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
string phase = 1;
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
string qr_url = 2;
}
message SendCodeRequest {
// Код из SMS/Telegram-сообщения.
string code = 1;
}
message SendCodeReply {
// Фаза после проверки кода: "password" (нужен 2FA) или "ready".
string phase = 1;
}
message SendPasswordRequest {
// Облачный пароль 2FA.
string password = 1;
}
message SendPasswordReply {
// Фаза после входа ("ready").
string phase = 1;
}
message LogoutRequest {}
message LogoutReply {
// True — аккаунт отключён, сессия тенанта удалена.
bool ok = 1;
}
message RefreshDialogsRequest {}
message RefreshDialogsReply {
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
repeated DialogEntry entries = 1;
}
message DialogEntry {
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
string id = 1;
// Отображаемое имя (title/first_name) или id, если имени нет.
string name = 2;
// Username (handle) источника; пуст, если нет публичного username.
string username = 3;
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
string kind = 4;
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
string hue = 5;
}
message SetMonitorRequest {
// Id диалога каталога.
string dialog_id = 1;
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
bool enabled = 2;
}
message SetMonitorReply {
bool ok = 1;
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
bool enabled = 2;
}
message SetMonitorAllRequest {
// True — мониторить все диалоги каталога, false — снять мониторинг со всех.
bool enabled = 1;
}
message SetMonitorAllReply {
bool ok = 1;
int32 count = 2;
bool enabled = 3;
}
message BackfillRequest {
// Id диалога для перечитывания.
string dialog_id = 1;
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
bool force = 2;
}
message BackfillReply {
// Сколько сообщений отправлено в ядро потоком PushMessage.
int32 processed = 1;
}
message ReadRecentRequest {
// Id диалога.
string dialog_id = 1;
int32 limit = 2;
}
message ReadRecentReply {
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
repeated PreviewMessage messages = 1;
}
message ReadSourceRequest {
// Id диалога-источника.
string dialog_id = 1;
// Id исходного сообщения в Telegram.
int64 msg_id = 2;
}
message ReadSourceReply {
// True — сообщение найдено и передано; false — нет (поля пусты).
bool found = 1;
// Текст исходного сообщения.
optional string text = 2;
// Время сообщения, epoch-ms.
optional int64 time = 3;
}
message PreviewMessage {
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
string id = 1;
// Текст сообщения.
string text = 2;
// Время сообщения, epoch-ms.
int64 time = 3;
}
message SearchRequest {
// Поисковый запрос (ключ задачи discovery).
string query = 1;
int32 limit = 2;
}
message SearchReply {
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
repeated DialogEntry results = 1;
}
message GetInfoRequest {
// Id источника (подписанный; из каталога или результата поиска).
string dialog_id = 1;
}
message ChannelInfo {
string id = 1;
string name = 2;
string username = 3;
// Тип: channel|group|forum|chat.
string kind = 4;
string hue = 5;
// Число участников (full_chat); пусто — определить не удалось.
optional int32 participants = 6;
bool is_forum = 7;
}
message GetInfoReply {
ChannelInfo info = 1;
}
message ReadForEvalRequest {
// Id источника.
string dialog_id = 1;
int32 limit = 2;
}
message ReadForEvalReply {
// True — выборка получена; false — история недоступна без членства.
bool ok = 1;
// Код причины при ok=false: "no_history" (остальные поля пусты).
optional string error = 2;
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title
// заполнены; для обычных источников — null).
repeated EvalMessage messages = 3;
}
message EvalMessage {
// Id сообщения в Telegram.
int64 id = 1;
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
string text = 2;
// Время сообщения, epoch-ms.
int64 date_ms = 3;
// Id темы форума (для обычных источников пусто).
optional int64 topic_id = 4;
// Название темы форума (для обычных источников пусто).
optional string topic_title = 5;
}
message JoinRequest {
// @username источника (без "@"; пусто → INVALID_ARGUMENT).
string username = 1;
}
message JoinReply {
bool ok = 1;
}
message LeaveRequest {
// Id диалога для выхода.
string dialog_id = 1;
}
message LeaveReply {
bool ok = 1;
}
// ---------------------------------------------------------------------------
// IngressService — исходящий поток telegram-service → ядро
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
// ---------------------------------------------------------------------------
service IngressService {
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
// отвечает актуальным списком monitored id — сервис держит зеркало
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
}
message SyncDialogsRequest {
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
repeated DialogEntry entries = 1;
}
message SyncDialogsReply {
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
repeated string monitored_ids = 1;
}
message ReportStatusRequest {
// Фаза: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент подключён и авторизован.
bool connected = 2;
// Realtime-listener жив.
bool listener = 3;
// Аккаунт "@username" (пуст после выхода) → KV tgAccount.
string account = 4;
// Текст ошибки (пуст, если нет) → KV tgStatus.error.
optional string error = 5;
// URL QR-входа при phase == "qr".
optional string qr_url = 6;
}
message ReportStatusReply {
// True — статус принят и сохранён ядром.
bool ok = 1;
}