From d95bac7ebc762e6efefce227ee631ea0c81d21da Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 28 Jun 2026 18:10:17 +0300 Subject: [PATCH] feat: AI->operator handoff + bind connection to channel in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handoff (ADR-HUB-0003): the live runtime instructs the model to append a <> token when a human is needed (asks for a person, out-of-scope, discount/invoice/legal, complaint). Ingest strips the token, switches the conversation to PAUSED (expected: operator), posts a system note and notifies operators — verified the token fires on 'счёт на юрлицо' and not on a price question. Binding: integrations now carry a channel; the integration form shows a 'Канал обработки' select for messenger connections and the list shows the bound channel. Backend create/update/serialize/select handle channel_id. Co-Authored-By: Claude Opus 4.8 --- apps/backend/hub_platform/ai/runtime.py | 13 ++++++- .../hub_platform/conversations/ingest.py | 39 +++++++++++++++---- .../hub_platform/integrations/selectors.py | 2 +- .../hub_platform/integrations/serializers.py | 1 + .../hub_platform/integrations/services.py | 14 +++++++ .../hub_platform/integrations/views.py | 3 ++ .../features/integrations/IntegrationForm.tsx | 24 ++++++++++-- .../integrations/IntegrationsPage.tsx | 1 + .../src/features/integrations/model.ts | 7 ++++ 9 files changed, 91 insertions(+), 13 deletions(-) diff --git a/apps/backend/hub_platform/ai/runtime.py b/apps/backend/hub_platform/ai/runtime.py index 8e7a271..a757832 100644 --- a/apps/backend/hub_platform/ai/runtime.py +++ b/apps/backend/hub_platform/ai/runtime.py @@ -21,6 +21,17 @@ MESSENGER_STYLE_GUARD = ( "которых нет в знаниях; если данных нет — честно скажи и предложи оператора." ) +# Протокол передачи оператору: модель добавляет технический токен, система его +# ловит, ставит диалог в очередь и уведомляет операторов (ADR-HUB-0003). +HANDOFF_TOKEN = "<>" +HANDOFF_PROTOCOL = ( + "Если по правилам нужно подключить живого оператора (клиент просит человека; " + "вопрос вне базы знаний; индивидуальные условия, скидка, счёт, оплата от юрлица, " + "документы; жалоба, спор или проблема с оплатой/доступом), в самом конце ответа " + f"добавь отдельной строкой технический токен {HANDOFF_TOKEN}. Не упоминай этот " + "токен в тексте и не показывай его пользователю — просто заверши им сообщение." +) + @dataclass(frozen=True) class TestChatResult: @@ -50,7 +61,7 @@ def run_test_chat(*, release: ChannelAIRelease, message: str, history: list[dict if system_prompt: messages.append(ChatMessage(role="system", content=system_prompt)) if style_guard: - messages.append(ChatMessage(role="system", content=MESSENGER_STYLE_GUARD)) + messages.append(ChatMessage(role="system", content=MESSENGER_STYLE_GUARD + "\n\n" + HANDOFF_PROTOCOL)) if fragments: knowledge = "\n\n".join( f"[{fragment.version.document.code}#{fragment.chunk_index}] {fragment.content}" for fragment in fragments diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index 2734136..583c72e 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -13,6 +13,7 @@ from django.db import IntegrityError, transaction from django.utils import timezone from hub_platform.ai.provider.base import ProviderError +from hub_platform.ai.runtime import HANDOFF_TOKEN from hub_platform.channels.runtime import run_channel_turn from hub_platform.conversations.models import ( ConnectionIdentity, @@ -141,11 +142,35 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: logger.warning("AI turn failed for conversation %s: %s", conversation.id, error) return - Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=result.text) - conversation.last_activity_at = timezone.now() - conversation.expected_responder = ExpectedResponder.CUSTOMER - conversation.save(update_fields=["last_activity_at", "expected_responder"]) + reply = result.text + handoff = HANDOFF_TOKEN in reply + if handoff: + reply = reply.replace(HANDOFF_TOKEN, "").strip() - transports.send_reply( - integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=result.text - ) + Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply) + conversation.last_activity_at = timezone.now() + if handoff: + conversation.control_mode = ControlMode.PAUSED + conversation.expected_responder = ExpectedResponder.OPERATOR + else: + conversation.expected_responder = ExpectedResponder.CUSTOMER + conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"]) + + if handoff: + Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору") + notify( + organization=channel.organization, + type=NotificationType.DIALOG_WAITING, + audience=NotificationAudience.OPERATORS, + title=f"AI передал диалог · {contact.name or 'Гость'}", + body=inbound.text[:120], + target_id=conversation.id, + source_type="Conversation", + source_id=conversation.id, + dedup_key=f"handoff:{conversation.id}", + ) + + if reply: + transports.send_reply( + integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=reply + ) diff --git a/apps/backend/hub_platform/integrations/selectors.py b/apps/backend/hub_platform/integrations/selectors.py index 81e4d85..eca063f 100644 --- a/apps/backend/hub_platform/integrations/selectors.py +++ b/apps/backend/hub_platform/integrations/selectors.py @@ -4,7 +4,7 @@ from hub_platform.integrations.models import Integration def integrations_for_organization(organization_id: int) -> QuerySet[Integration]: - return Integration.objects.filter(organization_id=organization_id).order_by("provider", "name") + return Integration.objects.filter(organization_id=organization_id).select_related("channel").order_by("provider", "name") def integration_for_organization(*, organization_id: int, integration_id: int) -> Integration: diff --git a/apps/backend/hub_platform/integrations/serializers.py b/apps/backend/hub_platform/integrations/serializers.py index 38acbd3..49b7e57 100644 --- a/apps/backend/hub_platform/integrations/serializers.py +++ b/apps/backend/hub_platform/integrations/serializers.py @@ -16,6 +16,7 @@ def integration_payload(integration: Integration) -> dict[str, object]: "botUsername": integration.config.get("bot_username", ""), "botName": integration.config.get("bot_name", ""), }, + "channel": {"id": integration.channel.id, "code": integration.channel.code, "name": integration.channel.name} if integration.channel_id else None, "status": integration.status, "lastCheckedAt": integration.last_checked_at.isoformat() if integration.last_checked_at else None, "lastError": integration.last_error, diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/hub_platform/integrations/services.py index 0aad210..6ccfbab 100644 --- a/apps/backend/hub_platform/integrations/services.py +++ b/apps/backend/hub_platform/integrations/services.py @@ -20,6 +20,18 @@ class IntegrationInput: name: str secret: str | None = None # None = не менять при update config: dict = field(default_factory=dict) + channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019) + + +def _resolve_channel(organization: Organization, channel_id: int | None): + if not channel_id: + return None + from hub_platform.channels.models import Channel + + try: + return Channel.objects.get(organization=organization, id=channel_id) + except Channel.DoesNotExist as error: + raise ValidationError({"channel": "Channel not found"}) from error def _normalized_config(provider: str, config: dict) -> dict: @@ -60,6 +72,7 @@ def create_integration(*, organization: Organization, data: IntegrationInput) -> name=name, secret=(data.secret or "").strip(), config=_normalized_config(provider, data.config), + channel=_resolve_channel(organization, data.channel_id), status=IntegrationStatus.UNCHECKED, ) integration.full_clean(exclude=["secret"]) @@ -71,6 +84,7 @@ def create_integration(*, organization: Organization, data: IntegrationInput) -> def update_integration(*, integration: Integration, data: IntegrationInput) -> Integration: integration.name = data.name.strip() or integration.name integration.config = _normalized_config(integration.provider, data.config) + integration.channel = _resolve_channel(integration.organization, data.channel_id) # Пустой/отсутствующий секрет при обновлении не затирает существующий. if data.secret: integration.secret = data.secret.strip() diff --git a/apps/backend/hub_platform/integrations/views.py b/apps/backend/hub_platform/integrations/views.py index ad6216d..57bbfd2 100644 --- a/apps/backend/hub_platform/integrations/views.py +++ b/apps/backend/hub_platform/integrations/views.py @@ -23,11 +23,14 @@ from hub_platform.integrations.services import ( def _input(body: dict[str, object], *, current: Integration | None = None) -> IntegrationInput: config = body.get("config", current.config if current else {}) + raw_channel = body.get("channelId", current.channel_id if current else None) + channel_id = int(raw_channel) if isinstance(raw_channel, int) or (isinstance(raw_channel, str) and raw_channel.isdigit()) else None return IntegrationInput( provider=str(body.get("provider", current.provider if current else "")), name=str(body.get("name", current.name if current else "")), secret=body.get("secret") if "secret" in body else None, config=config if isinstance(config, dict) else {}, + channel_id=channel_id, ) diff --git a/apps/internal-ui/src/features/integrations/IntegrationForm.tsx b/apps/internal-ui/src/features/integrations/IntegrationForm.tsx index 7bf54cb..d3e2721 100644 --- a/apps/internal-ui/src/features/integrations/IntegrationForm.tsx +++ b/apps/internal-ui/src/features/integrations/IntegrationForm.tsx @@ -1,10 +1,10 @@ import { Modal } from "antd"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { api } from "../../api/client"; import { FormField, SelectField } from "../../shared/form-controls"; import { Button } from "../../shared/ui-controls"; -import { PROVIDERS, type Integration, type IntegrationProvider } from "./model"; +import { fetchChannels, PROVIDERS, type ChannelOption, type Integration, type IntegrationProvider } from "./model"; const PROVIDER_OPTIONS: Array<[string, string]> = (Object.keys(PROVIDERS) as IntegrationProvider[]).map( (key) => [key, PROVIDERS[key].label], @@ -17,10 +17,17 @@ export function IntegrationForm({ initial, onClose, onSaved }: { initial: Integr const [secret, setSecret] = useState(""); const [baseUrl, setBaseUrl] = useState(initial?.config.baseUrl ?? ""); const [defaultModel, setDefaultModel] = useState(initial?.config.defaultModel ?? ""); + const [channelId, setChannelId] = useState(initial?.channel ? String(initial.channel.id) : ""); + const [channels, setChannels] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const meta = PROVIDERS[provider]; + const isMessenger = meta.kind === "MESSENGER"; + + useEffect(() => { + if (isMessenger) fetchChannels().then(setChannels).catch(() => setChannels([])); + }, [isMessenger]); const ready = name.trim().length > 0 && (isEdit || !meta.testable || secret.trim().length > 0); async function submit() { @@ -28,16 +35,17 @@ export function IntegrationForm({ initial, onClose, onSaved }: { initial: Integr setSubmitting(true); setError(null); const config = { baseUrl: baseUrl.trim(), defaultModel: defaultModel.trim() }; + const channel = isMessenger ? { channelId: channelId ? Number(channelId) : null } : {}; try { if (isEdit) { await api(`/api/v1/integrations/${initial.id}/`, { method: "PATCH", - body: JSON.stringify({ name, config, ...(secret.trim() ? { secret } : {}) }), + body: JSON.stringify({ name, config, ...channel, ...(secret.trim() ? { secret } : {}) }), }); } else { await api("/api/v1/integrations/", { method: "POST", - body: JSON.stringify({ provider, name, secret, config }), + body: JSON.stringify({ provider, name, secret, config, ...channel }), }); } onSaved(); @@ -73,6 +81,14 @@ export function IntegrationForm({ initial, onClose, onSaved }: { initial: Integr {isEdit && initial.config.botUsername && ( )} + {isMessenger && ( + [String(c.id), c.name] as [string, string])]} + /> + )} {error &&
{error}
}
diff --git a/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx b/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx index 3aac133..3f31e88 100644 --- a/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx +++ b/apps/internal-ui/src/features/integrations/IntegrationsPage.tsx @@ -122,6 +122,7 @@ export function IntegrationsPage() { {item.config.baseUrl || meta.defaultBaseUrl || "—"} {item.config.defaultModel && {item.config.defaultModel}} + {item.kind === "MESSENGER" && {item.channel ? `Канал: ${item.channel.name}` : "канал не привязан"}}
diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts index ff3972e..db4115c 100644 --- a/apps/internal-ui/src/features/integrations/model.ts +++ b/apps/internal-ui/src/features/integrations/model.ts @@ -1,3 +1,5 @@ +import { api } from "../../api/client"; + export type IntegrationProvider = "OPENROUTER" | "MAX" | "TELEGRAM" | "WEB"; export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER"; export type IntegrationStatus = "UNCHECKED" | "OK" | "ERROR"; @@ -9,6 +11,7 @@ export type Integration = { name: string; hasSecret: boolean; config: { baseUrl: string; defaultModel: string; botId: string; botUsername: string; botName: string }; + channel: { id: number; code: string; name: string } | null; status: IntegrationStatus; lastCheckedAt: string | null; lastError: string; @@ -42,3 +45,7 @@ export const KIND_LABEL: Record = { LLM_PROVIDER: "Провайдеры", MESSENGER: "Подключения", }; + +export type ChannelOption = { id: number; code: string; name: string }; + +export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/channels/").then((r) => r.items);