Files
chatballs/apps/backend/hub_platform/ai/runtime.py
T
AndreyandClaude Fable 5 a086675283 refactor(ai): упрощение агента и знаний (ADR-HUB-0023)
Знания: плоская сущность Knowledge (заголовок, описание, MD) + файловые
вложения с оригинальными именами (retrieval из md/txt/pdf/docx, публичная
ссылка для клиента). Агент: без релизов, инструкции из трёх частей
(персонализация/тон/инструкции) + выбор знаний из библиотеки; лимит
диалогов упразднён (остался dailyCostUsd). channel.system_prompt/model
удалены — AI-поведение только на агенте. Продукт — техническая запись
без summary/sales_description. Data-миграция переносит опубликованные
документы и активные релизы в новую модель. Internal-UI: раздел Знания,
новая карточка агента, упрощённые продукты, release-страницы удалены.
ADR-0005/0007/0017 → superseded; SPEC-HUB-0012 переписан.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:08:50 +03:00

91 lines
5.2 KiB
Python

from dataclasses import dataclass
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.models import AIAgent, KnowledgeFragment
from hub_platform.ai.provider.base import ChatMessage, ChatResult
from hub_platform.ai.retrieval import KnowledgeRetriever
# Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от
# того, что написано в авторских инструкциях.
MESSENGER_STYLE_GUARD = (
"Пиши ответ простым текстом для мессенджера: без markdown-разметки — "
"никаких **, ##, маркированных списков с -, таблиц, ссылок вида [текст](url). "
"Короткие абзацы. Не придумывай факты, контакты, ссылки, цены и условия, "
"которых нет в знаниях; если данных нет — честно скажи и предложи оператора."
)
# Протокол передачи оператору: модель добавляет технический токен, система его
# ловит, ставит диалог в очередь и уведомляет операторов (ADR-HUB-0003).
HANDOFF_TOKEN = "<<HANDOFF>>"
HANDOFF_PROTOCOL = (
"Если по правилам нужно подключить живого оператора (клиент просит человека; "
"вопрос вне базы знаний; индивидуальные условия, скидка, счёт, оплата от юрлица, "
"документы; жалоба, спор или проблема с оплатой/доступом), в самом конце ответа "
f"добавь отдельной строкой технический токен {HANDOFF_TOKEN}. Не упоминай этот "
"токен в тексте и не показывай его пользователю — просто заверши им сообщение."
)
@dataclass(frozen=True)
class AgentTurnResult:
result: ChatResult
fragments: list[KnowledgeFragment]
handoff_suggested: bool
def agent_system_prompt(agent: AIAgent) -> str:
# Порядок частей фиксирован (ADR-HUB-0023): Персонализация -> Тон -> Инструкции.
parts = [part.strip() for part in (agent.persona, agent.tone, agent.instructions) if part.strip()]
return "\n\n".join(parts)
def knowledge_catalog(agent: AIAgent) -> str:
"""Каталог выбранных знаний для системного промпта: заголовок, краткое
описание и публичные ссылки вложений (агент может отдать ссылку клиенту)."""
lines: list[str] = []
items = agent.knowledge_items.filter(is_enabled=True).prefetch_related("attachments")
for knowledge in items:
line = f"- {knowledge.title}"
if knowledge.description.strip():
line += f" — {knowledge.description.strip()}"
lines.append(line)
for attachment in knowledge.attachments.all():
lines.append(f" файл: {attachment.original_name} — {attachment.public_url()}")
if not lines:
return ""
return "Тебе доступны следующие знания (детали подтягиваются автоматически по запросу). Ссылки на файлы можно давать клиенту:\n" + "\n".join(lines)
def run_agent_turn(*, agent: AIAgent, message: str, history: list[dict] | None = None, style_guard: bool = True) -> AgentTurnResult:
fragments = KnowledgeRetriever().retrieve(agent=agent, query=message, limit=5)
messages: list[ChatMessage] = []
system_prompt = agent_system_prompt(agent)
if system_prompt:
messages.append(ChatMessage(role="system", content=system_prompt))
if style_guard:
messages.append(ChatMessage(role="system", content=MESSENGER_STYLE_GUARD + "\n\n" + HANDOFF_PROTOCOL))
catalog = knowledge_catalog(agent)
if catalog:
messages.append(ChatMessage(role="system", content=catalog))
if fragments:
knowledge = "\n\n".join(
f"[{fragment.knowledge.title}#{fragment.chunk_index}] {fragment.content}" for fragment in fragments
)
messages.append(ChatMessage(role="system", content="Отвечай только на основе этих знаний:\n" + knowledge))
for item in history or []:
messages.append(ChatMessage(role=str(item.get("role", "user")), content=str(item.get("content", ""))))
messages.append(ChatMessage(role="user", content=message))
result = invoke_chat(
channel=agent.channel,
messages=messages,
purpose="agent_chat",
model=agent.model,
params=agent.model_params or None,
used_fragment_ids=[fragment.id for fragment in fragments],
)
# Нет основания в знаниях -> кандидат на передачу оператору (ADR-HUB-0003).
return AgentTurnResult(result=result, fragments=fragments, handoff_suggested=not fragments)