feat: AI->operator handoff + bind connection to channel in UI

Handoff (ADR-HUB-0003): the live runtime instructs the model to append a
<<HANDOFF>> 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 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-06-28 18:10:17 +03:00
1 parent 72bfaa452a
commit d95bac7ebc
9 files changed
+91 -13

No files matched your search

+12 -1
View File
@@ -21,6 +21,17 @@ MESSENGER_STYLE_GUARD = (
"которых нет в знаниях; если данных нет — честно скажи и предложи оператора."
)
# Протокол передачи оператору: модель добавляет технический токен, система его
# ловит, ставит диалог в очередь и уведомляет операторов (ADR-HUB-0003).
HANDOFF_TOKEN = "<<HANDOFF>>"
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
@@ -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
)
@@ -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:
@@ -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,
@@ -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()
@@ -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,
)
@@ -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<ChannelOption[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 && (
<FormField label="Бот" value={`${initial.config.botName || initial.config.botUsername}${initial.config.botUsername ? ` · @${initial.config.botUsername}` : ""}${initial.config.botId ? ` · id ${initial.config.botId}` : ""}`} />
)}
{isMessenger && (
<SelectField
label="Канал обработки"
value={channelId}
onChange={setChannelId}
options={[["", "— не привязан —"], ...channels.map((c) => [String(c.id), c.name] as [string, string])]}
/>
)}
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
@@ -122,6 +122,7 @@ export function IntegrationsPage() {
<td className="integration-config">
<span>{item.config.baseUrl || meta.defaultBaseUrl || "—"}</span>
{item.config.defaultModel && <small>{item.config.defaultModel}</small>}
{item.kind === "MESSENGER" && <small>{item.channel ? `Канал: ${item.channel.name}` : "канал не привязан"}</small>}
</td>
<td>
<div className="integration-status">
@@ -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<IntegrationKind, string> = {
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);