mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
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:
1 parent
72bfaa452a
commit
d95bac7ebc
9 files changed
+91
-13
No files matched your search
@@ -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);
|
||||
Reference in new issue
Block a user