Compare commits

..
2 Commits
Author SHA1 Message Date
Andrey 089dd6ddc2 🔖 release: 1.10.0 2026-09-15 08:45:54 +03:00
AndreyandClaude Opus 5 ae46508d22 ✨ feat(ai): у агента своя модель для текста и своя для расшифровки
Ключ провайдера один на организацию, а агентов на нём несколько, и модель им нужна разная. Раньше модель принадлежала интеграции и копировалась на агента при каждом сохранении — выбрать её агенту было нельзя. Теперь на карточке агента две пары «провайдер + модель»: чем отвечать и чем расшифровывать голосовые. Пустое поле модели означает «как в интеграции», и подсказка в нём показывает её значение, поэтому агент, которому модель не выбирали, по-прежнему следует за настройкой ключа.

Миграция освобождает поле у тех агентов, чья модель совпадает с моделью их интеграции: такие агенты продолжают следовать за ней, а не фиксируют старое значение.

Проверено: тесты маршрутизации моделей и провайдеров, карточки агента, расшифровки, списка агентов и каталога переводов; ruff и проверка типов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:40:04 +03:00
15 changed files with 246 additions and 24 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.9.1
1.10.0
+21
View File
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
)
def _integration_model(integration, key: str) -> str:
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
if integration is None:
return ""
return str((integration.config or {}).get(key) or "")
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
@@ -125,7 +132,15 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
"groupColor": channel.group.color if channel.group_id else "",
"aiStatus": agent.status,
# Модели агента: пустая строка означает «как в интеграции», и тогда
# карточка показывает модель интеграции подсказкой в поле.
"model": agent.model,
"transcriptionModel": agent.transcription_model,
"providerModel": _integration_model(agent.provider_integration, "default_model"),
"transcriptionProviderModel": _integration_model(
agent.transcription_integration or agent.provider_integration,
"transcription_model",
),
"providerIntegrationId": agent.provider_integration_id,
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
"transcriptionIntegrationId": agent.transcription_integration_id,
@@ -233,6 +248,8 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"transcriptionIntegrationId",
"model",
"transcriptionModel",
"modelParams",
"persona",
"tone",
@@ -276,6 +293,10 @@ def update_agent_card(
name=channel.name,
provider_integration_id=provider_integration_id,
transcription_integration_id=transcription_integration_id,
model=str(body.get("model", agent.model)),
transcription_model=str(
body.get("transcriptionModel", agent.transcription_model)
),
model_params=model_params,
allowed_tools=agent.allowed_tools,
persona=str(body.get("persona", agent.persona)),
@@ -0,0 +1,23 @@
# Generated by Django 5.2.16 on 2026-09-15 05:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0020_aiagent_transcription_integration'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_model',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AlterField(
model_name='aiagent',
name='model',
field=models.CharField(blank=True, default='', max_length=128),
),
]
@@ -0,0 +1,34 @@
"""Агент перестаёт дублировать модель интеграции.
Раньше модель копировалась на агента при каждом сохранении, и поле означало
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
агент отвечает именно этой моделью, даже если у интеграции другая по
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
продолжают следовать за интеграцией.
"""
from django.db import migrations
def release_copied_models(apps, schema_editor):
AIAgent = apps.get_model("ai", "AIAgent")
updated = []
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
integration = agent.provider_integration
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
if agent.model == default_model:
agent.model = ""
updated.append(agent)
AIAgent.objects.bulk_update(updated, ["model"])
class Migration(migrations.Migration):
dependencies = [
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
]
operations = [
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
]
+7 -1
View File
@@ -384,7 +384,13 @@ class AIAgent(TenantRelationModel):
lifecycle_version = models.PositiveIntegerField(default=0)
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
# ключе живут разные агенты, и дорогая модель нужна не каждому.
model = models.CharField(max_length=128, blank=True, default="")
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
# расшифровывает, а если и там пусто — whisper-1.
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
+17 -2
View File
@@ -57,6 +57,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
def resolve_model(channel, *, fallback_model: str) -> str:
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
означает «как у интеграции», поэтому агент, которому модель не назначали,
продолжает следовать за настройкой провайдера.
"""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "model", "") or "").strip()
if chosen:
return chosen
integration = _channel_integration(channel)
return str(integration.config.get("default_model") or "").strip() or fallback_model
@@ -85,8 +96,12 @@ def resolve_transcription_provider(channel) -> LLMProvider:
def resolve_transcription_model(channel) -> str:
"""Модель расшифровки из настроек той интеграции, которая расшифровывает
(поле «Модель расшифровки голосовых»); по умолчанию whisper-1."""
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
интеграции, которая расшифровывает, иначе whisper-1."""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
if chosen:
return chosen
integration = _transcription_integration(channel)
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
+12 -4
View File
@@ -24,6 +24,9 @@ class AgentInput:
provider_integration_id: int | None
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
transcription_integration_id: int | None
# Модели агента; пустая строка — «как в интеграции».
model: str
transcription_model: str
model_params: dict
allowed_tools: list
persona: str
@@ -95,7 +98,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
channel=channel,
name=f"{channel.name} Agent",
status=AIAgentStatus.DRAFT,
model=selection.model,
# Модель новой карточки не фиксируется: агент следует за интеграцией,
# пока человек не выберет свою.
model="",
provider_integration=selection.integration,
persona=data.persona,
tone=data.tone,
@@ -129,9 +134,11 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
context=context,
integration_id=data.provider_integration_id,
)
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
# чтобы PATCH инструкций не стирал её у черновика.
locked.model = selection.model if selection.integration else locked.model
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
# и разрешается в момент вызова (ai.provider.routing).
locked.model = data.model.strip()[:128]
locked.transcription_model = data.transcription_model.strip()[:128]
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
locked.provider_integration = selection.integration
locked.transcription_integration = configure_agent_transcription(
@@ -148,6 +155,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
update_fields=[
"name",
"model",
"transcription_model",
"provider_integration",
"transcription_integration",
"model_params",
@@ -341,8 +341,10 @@ class AgentCardActivationTests(AgentCardTestCase):
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
# Своей модели у агента нет — он следует за интеграцией, и карточка
# показывает её модель подсказкой.
self.assertEqual(patched.json()["agent"]["model"], "")
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(activated.status_code, 200)
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
)
agent = self.channel.ai_agent
agent.provider_integration = integration
agent.save(update_fields=["provider_integration"])
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
# агент, которому модель не выбирали на карточке.
agent.model = ""
agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
return integration
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
self.channel.provider_integration = integration
self.channel.save(update_fields=["provider_integration"])
self.agent.provider_integration = None
self.agent.save(update_fields=["provider_integration"])
# Агент следует за интеграцией: своей модели у него нет.
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
),
)
self.agent.provider_integration = current
self.agent.save(update_fields=["provider_integration"])
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
)
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
# Ключ провайдера один на организацию, агентов на нём несколько: модель
# выбирают агенту, и она не должна теряться при смене настройки ключа.
integration = self._integration(default_model="integration-model")
self.agent.provider_integration = integration
self.agent.model = "own-model"
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
)
@@ -15,6 +15,7 @@ from django.test import TestCase
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.routing import (
resolve_model,
resolve_transcription_model,
resolve_transcription_provider,
)
@@ -100,6 +101,62 @@ class TranscriptionRoutingTests(TestCase):
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
class TranscriptionModelTests(TranscriptionRoutingTests):
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
def test_model_from_the_card_wins(self) -> None:
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = whisper
self.agent.transcription_integration = whisper
self.agent.transcription_model = "gpt-4o-mini-transcribe"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
)
def test_text_and_voice_models_are_independent(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.model = "yandexgpt/rc"
self.agent.transcription_model = "whisper-large-v3-turbo"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"model",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
self.assertEqual(
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
)
self.assertEqual(
resolve_transcription_provider(self.channel).base_url,
"https://whisper.example.test/v1",
)
class TranscriptionErrorTextTests(TestCase):
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
@@ -5,7 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { FormField, SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -535,6 +535,12 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
const transcriptionName = providers.find((item) => item.id === card.transcriptionIntegrationId)?.name ?? "";
// Поля моделей редактируются свободно и уходят на сервер по потере фокуса:
// сохранять каждую букву — это запрос на символ.
const [modelDraft, setModelDraft] = useState(card.model);
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
useEffect(() => { setModelDraft(card.model); }, [card.model]);
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
return (
<section className="agent-card is-side">
@@ -551,13 +557,18 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
{/* Ключ провайдера один на организацию, а агентов на нём несколько:
модель принадлежит агенту. Пустое поле — «как в интеграции», и
подсказкой в нём стоит её модель. */}
<FormField
disabled={busy || !canManage}
label={t("common.model")}
mono
placeholder={missingProvider ? t("ai.pick_provider") : card.providerModel || t("ai.model_of_integration")}
value={modelDraft}
onChange={setModelDraft}
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
/>
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
<SelectField
@@ -569,6 +580,15 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={(next) => void apply({ transcriptionIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.same_as_answers")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<FormField
disabled={busy || !canManage}
label={t("ai.transcription_model")}
mono
placeholder={card.transcriptionProviderModel || t("ai.model_of_integration")}
value={transcriptionDraft}
onChange={setTranscriptionDraft}
onBlur={() => { if (transcriptionDraft !== card.transcriptionModel) void apply({ transcriptionModel: transcriptionDraft }); }}
/>
</div>
</section>
);
+11 -2
View File
@@ -35,6 +35,11 @@ export type AgentCard = {
groupColor: string;
aiStatus: AgentAiStatus;
model: string;
/** Модель ответов; пусто — как в интеграции. */
transcriptionModel: string;
/** Модели, заданные в интеграциях: подсказка в пустом поле. */
providerModel: string;
transcriptionProviderModel: string;
providerIntegrationId: number | null;
/** Чем расшифровывать голосовые; null — тем же провайдером, что отвечает. */
transcriptionIntegrationId: number | null;
@@ -60,6 +65,8 @@ export type AgentPatch = Partial<{
isActive: boolean;
providerIntegrationId: number | null;
transcriptionIntegrationId: number | null;
model: string;
transcriptionModel: string;
answerLanguage: string;
persona: string;
tone: string;
@@ -158,9 +165,11 @@ export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: st
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
}
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
/** Модель в списке: своя модель агента, иначе та, что стоит в интеграции.
* Пока провайдер не выбран, показывать нечего (кадр G1). */
export function agentModelLabel(card: AgentCard): string {
return card.providerIntegrationId ? card.model : "—";
if (!card.providerIntegrationId) return "—";
return card.model || card.providerModel || "—";
}
export function agentTint(provider: string): { color: string; bg: string; full: string } {
+2
View File
@@ -384,6 +384,7 @@ export const en: Record<MessageKey, Message> = {
"ai.manage": "Manage",
"ai.material_import": "Material import",
"ai.move": "Move",
"ai.model_of_integration": "Same as in the integration",
"ai.move_category": "Move to a category",
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
"ai.name_unique_within_its_parent": "The name is unique within its parent.",
@@ -491,6 +492,7 @@ export const en: Record<MessageKey, Message> = {
"ai.up_25_mb": "· up to 25 MB",
"ai.update": "update",
"ai.updated": "· Updated:",
"ai.transcription_model": "Transcription model",
"ai.transcription_provider": "Voice transcription",
"ai.updated_2": "UPDATED",
"ai.updated_by_at": "updated by {name} · {date}",
+2
View File
@@ -385,6 +385,7 @@ export const ru = {
"ai.manage": "Управлять",
"ai.material_import": "Импорт материалов",
"ai.move": "Переместить",
"ai.model_of_integration": "Как в интеграции",
"ai.move_category": "Переместить в категорию",
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
"ai.name_unique_within_its_parent": "Имя уникально внутри родителя.",
@@ -492,6 +493,7 @@ export const ru = {
"ai.up_25_mb": "· до 25 МБ",
"ai.update": "обновить",
"ai.updated": "· Обновлено:",
"ai.transcription_model": "Модель расшифровки",
"ai.transcription_provider": "Расшифровка голосовых",
"ai.updated_2": "ОБНОВЛЕНО",
"ai.updated_by_at": "обновил {name} · {date}",
@@ -9,6 +9,9 @@ type FormFieldProps = {
label: string;
mono?: boolean;
onChange?: (value: string) => void;
/** Значение уходит на сервер по потере фокуса: запрос на каждую букву — это
* запрос на каждую букву. */
onBlur?: () => void;
placeholder?: string;
type?: "password" | "text";
value: string;
@@ -21,6 +24,7 @@ export function FormField({
label,
mono = false,
onChange,
onBlur,
placeholder = "",
type = "text",
value,
@@ -39,7 +43,7 @@ export function FormField({
return (
<label className={className}>
<span>{label}</span>
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} />
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} onBlur={onBlur} />
{error && <small className="form-field-error" role="alert">{error}</small>}
</label>
);