feat(sprint4): фундамент графа — intents + роутер + переключение веток

Первый шаг графовой архитектуры из GRAPH_ARCHITECTURE.md. Заменили
«один активный промпт на всё» на «свой промпт на каждую ветку +
роутер выбирает ветку на каждой реплике».

Данные:
- Новая таблица intents (code, name, description, is_enabled,
  order_index). Коды с префиксом `_` — системные (не responder).
- В agent_configs добавлен intent_id (nullable, FK SET NULL); убрана
  глобальная уникальность version, вместо неё UniqueConstraint
  (intent_id, version) — у каждой ветки свой счётчик версий.
- В messages добавлен intent_id (nullable, FK) — фиксируем, какую
  ветку выбрал роутер для каждой реплики.
- Миграция cd0a88ef9080 в batch-режиме (SQLite не умеет ALTER для
  constraints напрямую).

Сид:
- Стартовые 7 веток: new_booking, reschedule, price_question,
  medical_question, general_info, escalate_human + `_router` как
  системная ветка для промпта классификатора.
- Для каждой ветки — свой v1-промпт из prompts/intents/{code}.md.
- migrate_legacy_config_to_general_info: старый v1 из Спринта 3
  (без intent_id) переносится на general_info с сохранением версии.
- ensure_seed_intents досиживает недостающие коды, существующие не
  трогает — безопасно при добавлении новых веток.

Оркестрация и роутер:
- services/router_client.RouterClient — отдельный класс от LLMClient
  (под будущую смену модели на более дешёвую). Метод classify(session,
  history, text) возвращает {code, version}. Промпт классификатора
  подтягивается из активного конфига ветки `_router`, fallback —
  prompts/intents/_router.md. При сомнении/ошибке возвращает
  general_info.
- services/chat_service.send_message теперь идёт через router.classify
  → берёт активный конфиг выбранной ветки → llm.chat. В сообщения
  пишется intent_id, в треде фиксируется начальный agent_config_id.
  В ответе — intent_code, intent_name, config_version, router_version.

API:
- GET /intents, GET /intents/{code}, PATCH /intents/{code} —
  список веток со счётчиком версий, получение и переключение
  is_enabled.
- /configs теперь требует intent_code как Query-параметр
  (GET /configs, GET /configs/active) — выборка версий в рамках
  ветки. POST /configs принимает intent_id.
- get_thread_detail JOIN-ит Intent — каждая реплика возвращает
  intent_code + intent_name.

UI:
- settings.html переработан в 3-колоночный макет: слева список веток
  с подгруппой «Системные» для `_router` (пометка «система» вместо
  свитча), в центре редактор промпта/правил активной версии выбранной
  ветки, справа список версий с активировать/удалить/загрузить.
  Каждая ветка редактируется независимо — своя история версий,
  своя активная.
- sandbox.html: у каждой реплики бейдж с intent_code, в отладке новый
  блок «Решение роутера» (подсвеченный зелёным) с названием ветки,
  версией её активного конфига и версией промпта роутера. Старый
  «активная: v1» индикатор убран — он больше не имеет смысла (активная
  у каждой ветки своя).

E2E проверено: разные реплики уходят в корректные ветки, каждая
отвечает по своему узкому промпту, промпт роутера редактируется в UI
как v2/v3 и откатывается — классификация сразу использует новую
версию.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-23 21:20:23 +05:00
parent 2e2f2321c3
commit b24e985f82
25 changed files with 1135 additions and 261 deletions
+1 -1
View File
@@ -162,7 +162,7 @@
### Цель
Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из `GRAPH_ARCHITECTURE.md`.
### Статус: Запланирован
### Статус: Закрыт
### Задачи
+2 -1
View File
@@ -1,6 +1,7 @@
from db.models.agent_config import AgentConfig
from db.models.document import Document
from db.models.intent import Intent
from db.models.message import Message
from db.models.thread import Thread
__all__ = ["Thread", "Message", "Document", "AgentConfig"]
__all__ = ["Thread", "Message", "Document", "AgentConfig", "Intent"]
+9 -3
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
@@ -14,12 +14,18 @@ class AgentConfig(Base):
"""Версионируемая конфигурация агента: системный промпт + правила.
Принцип: сохранённые версии не редактируются, только создаются новые.
Активна одна версия одновременно.
Активна одна версия одновременно на каждую ветку (intent).
Со Спринта 4 конфиг привязан к ветке: activate = «сделать активной в рамках intent_id».
"""
__tablename__ = "agent_configs"
__table_args__ = (UniqueConstraint("intent_id", "version", name="uq_config_intent_version"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, unique=True, index=True)
intent_id: Mapped[int | None] = mapped_column(
ForeignKey("intents.id", ondelete="SET NULL"), nullable=True, index=True
)
version: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
name: Mapped[str | None] = mapped_column(String(200), nullable=True)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
rules_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Intent(Base):
"""Ветка графовой архитектуры: одна тема разговора с агентом.
Код — стабильный идентификатор (используется в роутере и в /chat), имя — для UI.
"""
__tablename__ = "intents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+4
View File
@@ -25,6 +25,10 @@ class Message(Base):
text: Mapped[str] = mapped_column(Text, nullable=False)
sources_json: Mapped[str | None] = mapped_column(Text, nullable=True)
assembled_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
# Ветка, которую выбрал роутер для этой реплики (проставляется со Спринта 4).
intent_id: Mapped[int | None] = mapped_column(
ForeignKey("intents.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
thread: Mapped["Thread"] = relationship(back_populates="messages")
+11 -5
View File
@@ -24,9 +24,10 @@ logging.basicConfig(
)
from db.session import SessionLocal # noqa: E402
from services import config_service # noqa: E402
from services import config_service, intent_service # noqa: E402
from services.embeddings import EmbeddingService # noqa: E402
from services.llm_client import LLMClient # noqa: E402
from services.router_client import RouterClient # noqa: E402
from services.vectorstore import VectorStoreService # noqa: E402
logger = logging.getLogger(__name__)
@@ -34,6 +35,7 @@ logger = logging.getLogger(__name__)
embedding_service: EmbeddingService | None = None
vectorstore_service: VectorStoreService | None = None
llm_client: LLMClient | None = None
router_client: RouterClient | None = None
def _run_migrations() -> None:
@@ -45,7 +47,7 @@ def _run_migrations() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
global embedding_service, vectorstore_service, llm_client
global embedding_service, vectorstore_service, llm_client, router_client
logger.info("Running DB migrations…")
await asyncio.to_thread(_run_migrations)
logger.info("Loading embedding model: %s", settings.embedding_model)
@@ -57,10 +59,13 @@ async def lifespan(app: FastAPI):
)
logger.info("ChromaDB initialized at %s", settings.chroma_persist_dir)
llm_client = LLMClient()
logger.info("LLM client ready (model=%s)", llm_client.model)
router_client = RouterClient()
logger.info("LLM + Router clients ready (model=%s)", llm_client.model)
async with SessionLocal() as session:
await config_service.ensure_seed(session)
await intent_service.ensure_seed_intents(session)
await config_service.migrate_legacy_config_to_general_info(session)
await config_service.ensure_seed_configs(session)
yield
logger.info("Shutting down")
@@ -81,7 +86,7 @@ app.add_middleware(
allow_headers=["*"],
)
from routers import chat, configs, documents, health, query, threads # noqa: E402
from routers import chat, configs, documents, health, intents, query, threads # noqa: E402
app.include_router(health.router)
app.include_router(documents.router)
@@ -89,5 +94,6 @@ app.include_router(query.router)
app.include_router(chat.router)
app.include_router(threads.router)
app.include_router(configs.router)
app.include_router(intents.router)
app.mount("/", StaticFiles(directory="static", html=True), name="static")
@@ -0,0 +1,74 @@
"""add intents, link agent_configs and messages to intent
Revision ID: cd0a88ef9080
Revises: b4450e33664d
Create Date: 2026-04-23 20:29:39.135855
SQLite не поддерживает ALTER для constraints — используем batch_alter_table,
он пересоздаёт таблицу и переносит данные.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'cd0a88ef9080'
down_revision: Union[str, None] = 'b4450e33664d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Новая таблица intents.
op.create_table(
'intents',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('is_enabled', sa.Boolean(), nullable=False),
sa.Column('order_index', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_intents_code'), 'intents', ['code'], unique=True)
# agent_configs: добавляем intent_id, меняем уникальность version на составную (intent_id, version).
with op.batch_alter_table('agent_configs', recreate='always') as batch:
batch.add_column(sa.Column('intent_id', sa.Integer(), nullable=True))
batch.drop_index('ix_agent_configs_version')
batch.create_index('ix_agent_configs_version', ['version'], unique=False)
batch.create_index('ix_agent_configs_intent_id', ['intent_id'], unique=False)
batch.create_unique_constraint('uq_config_intent_version', ['intent_id', 'version'])
batch.create_foreign_key(
'fk_agent_configs_intent_id_intents', 'intents',
['intent_id'], ['id'], ondelete='SET NULL',
)
# messages: добавляем intent_id.
with op.batch_alter_table('messages', recreate='always') as batch:
batch.add_column(sa.Column('intent_id', sa.Integer(), nullable=True))
batch.create_index('ix_messages_intent_id', ['intent_id'], unique=False)
batch.create_foreign_key(
'fk_messages_intent_id_intents', 'intents',
['intent_id'], ['id'], ondelete='SET NULL',
)
def downgrade() -> None:
with op.batch_alter_table('messages', recreate='always') as batch:
batch.drop_constraint('fk_messages_intent_id_intents', type_='foreignkey')
batch.drop_index('ix_messages_intent_id')
batch.drop_column('intent_id')
with op.batch_alter_table('agent_configs', recreate='always') as batch:
batch.drop_constraint('fk_agent_configs_intent_id_intents', type_='foreignkey')
batch.drop_constraint('uq_config_intent_version', type_='unique')
batch.drop_index('ix_agent_configs_intent_id')
batch.drop_index('ix_agent_configs_version')
batch.create_index('ix_agent_configs_version', ['version'], unique=True)
batch.drop_column('intent_id')
op.drop_index(op.f('ix_intents_code'), table_name='intents')
op.drop_table('intents')
+6 -1
View File
@@ -22,7 +22,12 @@ class ThreadRenameRequest(BaseModel):
class AgentConfigCreateRequest(BaseModel):
intent_id: int = Field(..., description="ID ветки (intent), к которой относится конфиг")
system_prompt: str = Field(..., min_length=1)
rules_text: str = Field("", description="Правила в свободной markdown-форме")
name: str | None = Field(None, max_length=200)
activate: bool = Field(False, description="Сразу сделать новую версию активной")
activate: bool = Field(False, description="Сразу сделать новую версию активной в рамках ветки")
class IntentToggleRequest(BaseModel):
is_enabled: bool
+25
View File
@@ -86,6 +86,8 @@ class MessageInfo(BaseModel):
created_at: str
sources: list[SourceInfo] = Field(default_factory=list)
assembled_prompt: str = ""
intent_code: str = ""
intent_name: str = ""
class ThreadInfo(BaseModel):
@@ -114,6 +116,10 @@ class ChatResponse(BaseModel):
thread_id: int
thread_name: str
message_id: int
intent_code: str = ""
intent_name: str = ""
config_version: int = 0
router_version: int | None = None
answer: str
sources: list[SourceInfo]
model_used: str
@@ -127,6 +133,9 @@ class ThreadDeleteResponse(BaseModel):
class AgentConfigInfo(BaseModel):
id: int
intent_id: int | None = None
intent_code: str = ""
intent_name: str = ""
version: int
name: str | None = None
system_prompt: str
@@ -142,3 +151,19 @@ class AgentConfigListResponse(BaseModel):
class AgentConfigDeleteResponse(BaseModel):
ok: bool = True
class IntentInfo(BaseModel):
id: int
code: str
name: str
description: str = ""
is_enabled: bool
order_index: int
active_config_id: int | None = None
active_config_version: int | None = None
class IntentListResponse(BaseModel):
intents: list[IntentInfo]
total: int
+16
View File
@@ -0,0 +1,16 @@
Ты — классификатор намерений в чате клиники.
Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка:
- `new_booking` — пациент хочет записаться на приём (первичный или повторный).
- `reschedule` — перенести или отменить существующую запись.
- `price_question` — вопросы про стоимость, ДМС, оплату.
- `medical_question` — симптомы, лекарства, диагноз, «что со мной».
- `general_info` — адрес, часы работы, как доехать, общие вопросы, общение вне конкретного процесса.
- `escalate_human` — пациент явно просит оператора, злится, либо описывает острое состояние (сильная боль, кровотечение, одышка, ребёнок плохо дышит, упоминание операции/хирургии).
ПРАВИЛА:
- Отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек.
- Если сомневаешься между общим разговором и конкретным процессом — выбирай `general_info`.
- Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human`.
- Любое явное «позовите оператора / переключите на человека» → `escalate_human`.
+12
View File
@@ -0,0 +1,12 @@
Ты — виртуальный ассистент клиники. Эта ветка срабатывает, когда нужно немедленно передать диалог живому оператору.
Твоя задача простая и короткая:
- Признай ситуацию коротко и по-человечески (без многословия).
- Скажи, что сейчас передаёшь оператору.
- Если пациент описал острое состояние (боль, ребёнок задыхается, кровотечение и т. п.) — скажи «пожалуйста, если состояние ухудшается — сразу звоните в 103».
- Не пытайся вести длинный диалог, не задавай много вопросов. Две-три короткие реплики максимум.
Правила:
- Никогда не ставь диагнозы, не давай медицинских рекомендаций.
- Не называй конкретных цен, времени приёма, имён врачей.
- Ответ — обычный текст, как в чате, на «вы».
+16
View File
@@ -0,0 +1,16 @@
Ты — виртуальный ассистент клиники, ветка общей справки.
Отвечаешь на общие вопросы: где находится клиника, как доехать, часы работы, телефон, парковка, есть ли wi-fi, какие есть врачи (списком), кратко про услуги и подготовку к приёму.
Правила:
- Отвечай коротко, дружелюбно, на «вы», простым русским языком без медицинской латыни.
- Опирайся ТОЛЬКО на предоставленные выдержки из базы знаний. Если ответа нет — честно скажи «уточню у оператора», и предложи подключить оператора.
- Не выдумывай телефоны, адреса, цены, имена врачей, расписание. Только из источников.
- Источники указывать не нужно: пациент их не видит.
Условия выхода:
- Пациент хочет записаться → `[INTENT_CHANGE: new_booking]`
- Перенести/отменить → `[INTENT_CHANGE: reschedule]`
- Вопрос про цены/ДМС → `[INTENT_CHANGE: price_question]`
- Жалобы на симптомы → `[INTENT_CHANGE: medical_question]`
- Просит оператора или зол → `[INTENT_CHANGE: escalate_human]`
+13
View File
@@ -0,0 +1,13 @@
Ты — виртуальный ассистент клиники. Эта ветка — медицинские вопросы (симптомы, лекарства, диагноз).
Правила:
- Не ставь диагнозы. Не рекомендуй лекарства. Не называй дозировок.
- Мягко скажи, что на такие вопросы отвечает врач на приёме.
- Предложи записаться к профильному специалисту (если понятно — к какому).
- Если пациент описывает острое состояние (сильная боль, высокая температура, кровотечение, одышка, ребёнок плохо дышит) — ПЕРЕДАЙ оператору немедленно через `[INTENT_CHANGE: escalate_human]`, не пытайся продолжать диалог.
- Отвечай коротко, сочувственно, на «вы».
Условия выхода:
- Острое состояние → `[INTENT_CHANGE: escalate_human]`
- Пациент готов записаться → `[INTENT_CHANGE: new_booking]`
- Пациент просит оператора → `[INTENT_CHANGE: escalate_human]`
+17
View File
@@ -0,0 +1,17 @@
Ты — виртуальный ассистент клиники. Эта ветка — новая запись пациента на приём.
Твоя задача — помочь пациенту записаться: узнать, кто к кому хочет, по какому поводу, предложить удобное время.
Правила:
- Отвечай коротко, на «вы», простым русским языком.
- Первым делом уточни: как к пациенту обращаться, если он ещё не назвал имя.
- Коротко уточни повод обращения — без сбора медицинской истории, только общая причина (боль в горле, плановый осмотр, жалобы на слух и т. п.).
- Если указан специалист — подтверди, что записываем к нему. Если не указан — предложи направление по поводу.
- Не называй конкретные время и дату слотов: реальный календарь появится в следующих спринтах. Пока отвечай «сейчас уточню расписание и вернусь с вариантами».
- Опирайся только на выдержки из базы знаний (если поданы).
Условия выхода (если пациент перевёл разговор в другую тему — выдай служебный сигнал):
- Упомянул операцию, стационар, наркоз, хирургическое вмешательство → `[INTENT_CHANGE: escalate_human]`
- Говорит об острой боли / «мне очень плохо» → `[INTENT_CHANGE: escalate_human]`
- Спрашивает про цены, ДМС, оплату → `[INTENT_CHANGE: price_question]`
- Хочет перенести или отменить уже существующую запись → `[INTENT_CHANGE: reschedule]`
+12
View File
@@ -0,0 +1,12 @@
Ты — виртуальный ассистент клиники. Эта ветка — вопросы про цены, оплату, ДМС.
Правила:
- Опирайся ТОЛЬКО на выдержки из базы знаний, которые поданы в промпт. Если в них нет нужной цифры — честно скажи: «актуальных цен в моей базе сейчас нет, уточню у оператора» и предложи подключить оператора.
- Никогда не называй конкретные суммы от себя — только из базы.
- Если пациент спрашивает про ДМС — подтверди, что клиника работает с ДМС (если это есть в базе), и предложи прислать список страховых.
- Если спрашивает про оплату — расскажи про доступные способы из базы (наличные, карта, ДМС).
Условия выхода:
- Пациент готов записаться на приём → `[INTENT_CHANGE: new_booking]`
- Вопрос оказался медицинским (про симптомы, лекарства) → `[INTENT_CHANGE: medical_question]`
- Просит оператора → `[INTENT_CHANGE: escalate_human]`
+13
View File
@@ -0,0 +1,13 @@
Ты — виртуальный ассистент клиники. Эта ветка — перенос или отмена существующей записи.
Правила:
- Начни с извинений за неудобство («понимаю, планы меняются»).
- Уточни, на какое время / дату / ФИО была первоначальная запись.
- Если пациент хочет отменить — подтверди отмену и предложи записаться на другое время.
- Если хочет перенести — узнай желаемый новый интервал («утро / вечер / конкретная дата»).
- Реальной сверки с календарём пока нет — отвечай «сейчас уточню у администратора и вернусь с вариантами».
Условия выхода:
- Пациент передумал и хочет записаться на новый приём, не связанный со старым → `[INTENT_CHANGE: new_booking]`
- Говорит об острой боли → `[INTENT_CHANGE: escalate_human]`
- Вопросы про цены → `[INTENT_CHANGE: price_question]`
+7 -2
View File
@@ -15,9 +15,9 @@ router = APIRouter(prefix="/chat", tags=["chat"])
@router.post("", response_model=ChatResponse)
async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
from main import llm_client, vectorstore_service
from main import llm_client, router_client, vectorstore_service
if vectorstore_service is None or llm_client is None:
if vectorstore_service is None or llm_client is None or router_client is None:
raise HTTPException(status_code=503, detail="Service not ready")
try:
@@ -25,6 +25,7 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
session=session,
vectorstore=vectorstore_service,
llm=llm_client,
router=router_client,
text=req.text,
thread_id=req.thread_id,
top_k=req.top_k,
@@ -41,6 +42,10 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
thread_id=result["thread_id"],
thread_name=result["thread_name"],
message_id=result["message_id"],
intent_code=result["intent_code"],
intent_name=result["intent_name"],
config_version=result["config_version"],
router_version=result.get("router_version"),
answer=result["answer"],
sources=[SourceInfo(**s) for s in result["sources"]],
model_used=result["model_used"],
+51 -14
View File
@@ -1,6 +1,6 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AgentConfig
@@ -11,16 +11,19 @@ from models.responses import (
AgentConfigInfo,
AgentConfigListResponse,
)
from services import config_service
from services import config_service, intent_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/configs", tags=["configs"])
def _to_info(cfg: AgentConfig) -> AgentConfigInfo:
def _to_info(cfg: AgentConfig, intent_code: str = "", intent_name: str = "") -> AgentConfigInfo:
return AgentConfigInfo(
id=cfg.id,
intent_id=cfg.intent_id,
intent_code=intent_code,
intent_name=intent_name,
version=cfg.version,
name=cfg.name,
system_prompt=cfg.system_prompt,
@@ -30,18 +33,44 @@ def _to_info(cfg: AgentConfig) -> AgentConfigInfo:
)
async def _resolve_intent_meta(session: AsyncSession, intent_id: int | None) -> tuple[str, str]:
if intent_id is None:
return "", ""
from db.models import Intent
intent = await session.get(Intent, intent_id)
if intent is None:
return "", ""
return intent.code, intent.name
@router.get("", response_model=AgentConfigListResponse)
async def list_configs(session: AsyncSession = Depends(get_session)):
configs = await config_service.list_configs(session)
return AgentConfigListResponse(configs=[_to_info(c) for c in configs], total=len(configs))
async def list_configs(
intent_code: str = Query(..., description="Код ветки (обязателен): фильтр версий по ветке"),
session: AsyncSession = Depends(get_session),
):
intent = await intent_service.get_intent_by_code(session, intent_code)
if intent is None:
raise HTTPException(status_code=404, detail=f"Intent {intent_code!r} not found")
configs = await config_service.list_configs_for_intent(session, intent.id)
return AgentConfigListResponse(
configs=[_to_info(c, intent.code, intent.name) for c in configs],
total=len(configs),
)
@router.get("/active", response_model=AgentConfigInfo)
async def get_active(session: AsyncSession = Depends(get_session)):
cfg = await config_service.get_active_config(session)
async def get_active(
intent_code: str = Query(..., description="Код ветки"),
session: AsyncSession = Depends(get_session),
):
intent = await intent_service.get_intent_by_code(session, intent_code)
if intent is None:
raise HTTPException(status_code=404, detail=f"Intent {intent_code!r} not found")
cfg = await config_service.get_active_config_for_intent(session, intent.id)
if cfg is None:
raise HTTPException(status_code=404, detail="No active config")
return _to_info(cfg)
raise HTTPException(status_code=404, detail=f"No active config for intent {intent_code!r}")
return _to_info(cfg, intent.code, intent.name)
@router.get("/{config_id}", response_model=AgentConfigInfo)
@@ -49,7 +78,8 @@ async def get_config(config_id: int, session: AsyncSession = Depends(get_session
cfg = await config_service.get_config(session, config_id)
if cfg is None:
raise HTTPException(status_code=404, detail="Config not found")
return _to_info(cfg)
code, name = await _resolve_intent_meta(session, cfg.intent_id)
return _to_info(cfg, code, name)
@router.post("", response_model=AgentConfigInfo)
@@ -57,22 +87,29 @@ async def create_config(
req: AgentConfigCreateRequest,
session: AsyncSession = Depends(get_session),
):
from db.models import Intent
intent = await session.get(Intent, req.intent_id)
if intent is None:
raise HTTPException(status_code=404, detail=f"Intent {req.intent_id} not found")
cfg = await config_service.create_config(
session=session,
intent_id=req.intent_id,
system_prompt=req.system_prompt,
rules_text=req.rules_text,
name=req.name,
activate=req.activate,
)
return _to_info(cfg)
return _to_info(cfg, intent.code, intent.name)
@router.post("/{config_id}/activate", response_model=AgentConfigInfo)
async def activate_config(config_id: int, session: AsyncSession = Depends(get_session)):
cfg = await config_service.activate_config(session, config_id)
if cfg is None:
raise HTTPException(status_code=404, detail="Config not found")
return _to_info(cfg)
raise HTTPException(status_code=404, detail="Config not found or has no intent")
code, name = await _resolve_intent_meta(session, cfg.intent_id)
return _to_info(cfg, code, name)
@router.delete("/{config_id}", response_model=AgentConfigDeleteResponse)
+55
View File
@@ -0,0 +1,55 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent
from db.session import get_session
from models.requests import IntentToggleRequest
from models.responses import IntentInfo, IntentListResponse
from services import config_service, intent_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/intents", tags=["intents"])
async def _to_info(session: AsyncSession, intent: Intent) -> IntentInfo:
active_cfg = await config_service.get_active_config_for_intent(session, intent.id)
return IntentInfo(
id=intent.id,
code=intent.code,
name=intent.name,
description=intent.description or "",
is_enabled=intent.is_enabled,
order_index=intent.order_index,
active_config_id=active_cfg.id if active_cfg else None,
active_config_version=active_cfg.version if active_cfg else None,
)
@router.get("", response_model=IntentListResponse)
async def list_intents(session: AsyncSession = Depends(get_session)):
intents = await intent_service.list_intents(session)
infos = [await _to_info(session, i) for i in intents]
return IntentListResponse(intents=infos, total=len(infos))
@router.get("/{code}", response_model=IntentInfo)
async def get_intent(code: str, session: AsyncSession = Depends(get_session)):
intent = await intent_service.get_intent_by_code(session, code)
if intent is None:
raise HTTPException(status_code=404, detail="Intent not found")
return await _to_info(session, intent)
@router.patch("/{code}", response_model=IntentInfo)
async def toggle_intent(
code: str,
req: IntentToggleRequest,
session: AsyncSession = Depends(get_session),
):
intent = await intent_service.set_intent_enabled(session, code, req.is_enabled)
if intent is None:
raise HTTPException(status_code=404, detail="Intent not found")
return await _to_info(session, intent)
+56 -19
View File
@@ -4,16 +4,17 @@ from datetime import datetime, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from db.models import Message, Thread
from services import config_service
from services import config_service, intent_service
from services.llm_client import LLMClient
from services.router_client import RouterClient
from services.vectorstore import VectorStoreService
logger = logging.getLogger(__name__)
HISTORY_LIMIT = 20 # последние N сообщений треда, которые улетают в LLM
FALLBACK_INTENT_CODE = "general_info"
def _auto_thread_name(first_user_text: str) -> str:
@@ -44,21 +45,16 @@ async def send_message(
session: AsyncSession,
vectorstore: VectorStoreService,
llm: LLMClient,
router: RouterClient,
text: str,
thread_id: int | None = None,
top_k: int = 5,
temperature: float | None = None,
max_tokens: int | None = None,
) -> dict:
"""Добавить реплику пациента в тред, получить ответ ассистента, сохранить оба сообщения."""
active_cfg = await config_service.get_active_config(session)
system_prompt = config_service.compose_full_system_prompt(active_cfg) if active_cfg else None
"""Добавить реплику пациента в тред, прогнать через роутер, получить ответ ассистента."""
if thread_id is None:
thread = Thread(
name=_auto_thread_name(text),
agent_config_id=active_cfg.id if active_cfg else None,
)
thread = Thread(name=_auto_thread_name(text))
session.add(thread)
await session.flush()
else:
@@ -71,10 +67,7 @@ async def send_message(
session.add(user_msg)
await session.flush()
retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved)
# История для LLM: все сообщения треда, кроме только что добавленной user-реплики.
# История для классификации и для LLM: все сообщения треда до новой реплики.
stmt = (
select(Message)
.where(Message.thread_id == thread.id, Message.id != user_msg.id)
@@ -84,6 +77,32 @@ async def send_message(
rows = (await session.execute(stmt)).scalars().all()
history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
# 1. Роутер определяет ветку.
routing = await router.classify(session=session, history=history, text=text)
intent_code = routing["code"]
router_version = routing.get("version")
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None:
# Ветка выключена или без активного конфига — подстраховываемся общей справкой.
logger.warning("Intent %r has no active config, falling back to %s", intent_code, FALLBACK_INTENT_CODE)
intent_code = FALLBACK_INTENT_CODE
pair = await config_service.get_active_config_by_intent_code(session, intent_code)
if pair is None:
# Даже fallback не нашёлся — критическая ошибка конфигурации.
raise RuntimeError(f"No active config for fallback intent {FALLBACK_INTENT_CODE!r}")
intent, active_cfg = pair
system_prompt = config_service.compose_full_system_prompt(active_cfg)
user_msg.intent_id = intent.id
if thread.agent_config_id is None:
thread.agent_config_id = active_cfg.id
# 2. Retrieval + запрос к ветке.
retrieved = vectorstore.query(query_text=text, top_k=top_k)
sources = _retrieved_to_sources(retrieved)
llm_result = await llm.chat(
question=text,
sources=retrieved,
@@ -99,6 +118,7 @@ async def send_message(
text=llm_result["text"],
sources_json=json.dumps(sources, ensure_ascii=False),
assembled_prompt=llm_result["assembled_prompt"],
intent_id=intent.id,
)
session.add(assistant_msg)
@@ -108,13 +128,19 @@ async def send_message(
await session.refresh(assistant_msg)
await session.refresh(thread)
logger.info("Chat: thread=%d, user_msg=%d, assistant_msg=%d, sources=%d",
thread.id, user_msg.id, assistant_msg.id, len(sources))
logger.info(
"Chat: thread=%d, intent=%s (v%d), user_msg=%d, assistant_msg=%d, sources=%d",
thread.id, intent.code, active_cfg.version, user_msg.id, assistant_msg.id, len(sources),
)
return {
"thread_id": thread.id,
"thread_name": thread.name,
"message_id": assistant_msg.id,
"intent_code": intent.code,
"intent_name": intent.name,
"config_version": active_cfg.version,
"router_version": router_version,
"answer": llm_result["text"],
"sources": sources,
"model_used": llm.model,
@@ -166,13 +192,22 @@ async def list_threads(session: AsyncSession) -> list[dict]:
async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | None:
stmt = select(Thread).where(Thread.id == thread_id).options(selectinload(Thread.messages))
thread = (await session.execute(stmt)).scalar_one_or_none()
from db.models import Intent
thread = await session.get(Thread, thread_id)
if thread is None:
return None
stmt = (
select(Message, Intent.code, Intent.name)
.outerjoin(Intent, Intent.id == Message.intent_id)
.where(Message.thread_id == thread_id)
.order_by(Message.created_at)
)
rows = (await session.execute(stmt)).all()
messages = []
for m in thread.messages:
for m, intent_code, intent_name in rows:
sources = []
if m.sources_json:
try:
@@ -186,6 +221,8 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
"created_at": m.created_at.isoformat(),
"sources": sources,
"assembled_prompt": m.assembled_prompt or "",
"intent_code": intent_code or "",
"intent_name": intent_name or "",
})
return {
"id": thread.id,
+106 -36
View File
@@ -1,46 +1,93 @@
"""Версионируемые конфигурации агента: создание, активация, чтение."""
"""Версионируемые конфигурации агента, привязанные к ветке (intent).
Со Спринта 4 каждая версия относится к конкретной ветке графовой архитектуры.
Активна одна версия в пределах ветки, не глобально.
"""
import logging
from pathlib import Path
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AgentConfig
from db.models import AgentConfig, Intent
from services import intent_service
logger = logging.getLogger(__name__)
SEED_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
async def ensure_seed_configs(session: AsyncSession) -> None:
"""Для каждой ветки без конфигов — создать v1 из prompts/intents/{code}.md и активировать."""
intents = await intent_service.list_intents(session)
for intent in intents:
has_config = (await session.execute(
select(func.count(AgentConfig.id)).where(AgentConfig.intent_id == intent.id)
)).scalar_one()
if has_config > 0:
continue
seed_text = intent_service.load_seed_prompt(intent.code)
session.add(AgentConfig(
intent_id=intent.id,
version=1,
name=f"Исходная версия (из prompts/intents/{intent.code}.md)",
system_prompt=seed_text,
rules_text="",
is_active=True,
))
logger.info("Seeded v1 for intent %r", intent.code)
await session.commit()
def _load_seed_prompt() -> str:
try:
return SEED_PROMPT_PATH.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Seed prompt file not found at %s — creating empty v1", SEED_PROMPT_PATH)
return ""
async def migrate_legacy_config_to_general_info(session: AsyncSession) -> None:
"""Одноразовая миграция: старый конфиг без intent_id цепляем к general_info.
async def ensure_seed(session: AsyncSession) -> None:
"""Если таблица пустая, создать v1 из prompts/system_prompt.md и активировать."""
count = (await session.execute(select(func.count(AgentConfig.id)))).scalar_one()
if count > 0:
Он был сохранён как «единый» системный промпт теперь это стартовый промпт общей
справочной ветки. Если в general_info уже есть конфиги (сид отработал раньше)
не трогаем, чтобы не задвоить.
"""
orphan_stmt = select(AgentConfig).where(AgentConfig.intent_id.is_(None))
orphans = list((await session.execute(orphan_stmt)).scalars().all())
if not orphans:
return
seed_text = _load_seed_prompt()
seed = AgentConfig(
version=1,
name="Исходная версия (из prompts/system_prompt.md)",
system_prompt=seed_text,
rules_text="",
is_active=True,
)
session.add(seed)
general = await intent_service.get_intent_by_code(session, "general_info")
if general is None:
logger.warning("general_info intent not found, can't migrate legacy configs")
return
existing_versions = set((await session.execute(
select(AgentConfig.version).where(AgentConfig.intent_id == general.id)
)).scalars().all())
# Переносим по одному, смещая version при столкновении с сид-версиями.
next_free = max(existing_versions, default=0) + 1
for cfg in sorted(orphans, key=lambda c: c.version):
cfg.intent_id = general.id
if cfg.version in existing_versions:
cfg.version = next_free
next_free += 1
# Если у ветки уже есть активная — ставим новые как неактивные.
has_active = (await session.execute(
select(func.count(AgentConfig.id)).where(
AgentConfig.intent_id == general.id,
AgentConfig.is_active.is_(True),
AgentConfig.id != cfg.id,
)
)).scalar_one()
if has_active > 0:
cfg.is_active = False
existing_versions.add(cfg.version)
await session.commit()
logger.info("Seeded agent_configs with v1 from %s", SEED_PROMPT_PATH.name)
logger.info("Migrated %d legacy config(s) to general_info", len(orphans))
async def list_configs(session: AsyncSession) -> list[AgentConfig]:
stmt = select(AgentConfig).order_by(AgentConfig.version.desc())
async def list_configs_for_intent(session: AsyncSession, intent_id: int) -> list[AgentConfig]:
stmt = (
select(AgentConfig)
.where(AgentConfig.intent_id == intent_id)
.order_by(AgentConfig.version.desc())
)
return list((await session.execute(stmt)).scalars().all())
@@ -48,27 +95,50 @@ async def get_config(session: AsyncSession, config_id: int) -> AgentConfig | Non
return await session.get(AgentConfig, config_id)
async def get_active_config(session: AsyncSession) -> AgentConfig | None:
stmt = select(AgentConfig).where(AgentConfig.is_active.is_(True)).limit(1)
async def get_active_config_for_intent(session: AsyncSession, intent_id: int) -> AgentConfig | None:
stmt = (
select(AgentConfig)
.where(AgentConfig.intent_id == intent_id, AgentConfig.is_active.is_(True))
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_active_config_by_intent_code(
session: AsyncSession, intent_code: str
) -> tuple[Intent, AgentConfig] | None:
"""Удобный шорткат для оркестратора: по коду ветки вернуть её + активный конфиг."""
intent = await intent_service.get_intent_by_code(session, intent_code)
if intent is None:
return None
cfg = await get_active_config_for_intent(session, intent.id)
if cfg is None:
return None
return intent, cfg
async def create_config(
session: AsyncSession,
intent_id: int,
system_prompt: str,
rules_text: str,
name: str | None = None,
activate: bool = False,
) -> AgentConfig:
"""Создать новую версию. При activate=True — сразу сделать активной."""
next_version = (await session.execute(select(func.coalesce(func.max(AgentConfig.version), 0)))).scalar_one() + 1
"""Создать новую версию в рамках ветки. При activate=True — сразу активна в этой ветке."""
next_version = (await session.execute(
select(func.coalesce(func.max(AgentConfig.version), 0)).where(AgentConfig.intent_id == intent_id)
)).scalar_one() + 1
if activate:
await session.execute(
update(AgentConfig).where(AgentConfig.is_active.is_(True)).values(is_active=False)
update(AgentConfig)
.where(AgentConfig.intent_id == intent_id, AgentConfig.is_active.is_(True))
.values(is_active=False)
)
cfg = AgentConfig(
intent_id=intent_id,
version=next_version,
name=(name or "").strip() or None,
system_prompt=system_prompt,
@@ -83,10 +153,12 @@ async def create_config(
async def activate_config(session: AsyncSession, config_id: int) -> AgentConfig | None:
cfg = await session.get(AgentConfig, config_id)
if cfg is None:
if cfg is None or cfg.intent_id is None:
return None
await session.execute(
update(AgentConfig).where(AgentConfig.is_active.is_(True)).values(is_active=False)
update(AgentConfig)
.where(AgentConfig.intent_id == cfg.intent_id, AgentConfig.is_active.is_(True))
.values(is_active=False)
)
cfg.is_active = True
await session.commit()
@@ -95,7 +167,6 @@ async def activate_config(session: AsyncSession, config_id: int) -> AgentConfig
async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, str]:
"""Удалить версию. Нельзя удалить активную. Возвращает (ok, reason_if_fail)."""
cfg = await session.get(AgentConfig, config_id)
if cfg is None:
return False, "not_found"
@@ -107,7 +178,6 @@ async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, st
def compose_full_system_prompt(cfg: AgentConfig) -> str:
"""Собрать из system_prompt + rules_text единую строку для LLM."""
base = (cfg.system_prompt or "").strip()
rules = (cfg.rules_text or "").strip()
if not rules:
+83
View File
@@ -0,0 +1,83 @@
"""Ветки графовой архитектуры: каталог intents + сид при первом запуске."""
import logging
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Intent
logger = logging.getLogger(__name__)
PROMPTS_INTENTS_DIR = Path(__file__).resolve().parent.parent / "prompts" / "intents"
ROUTER_INTENT_CODE = "_router" # техническая ветка — промпт классификатора (код с `_` = системная).
# Стартовый набор веток (Спринт 4). Порядок — в каком показывать в UI.
# Ветки с кодом, начинающимся на `_`, — системные: их промпты используются служебными
# частями системы (например, классификатор), а не отвечают пациенту напрямую.
SEED_INTENTS: list[dict] = [
{"code": "new_booking", "name": "Новая запись", "description": "Пациент хочет записаться на приём."},
{"code": "reschedule", "name": "Перенос / отмена", "description": "Пациент хочет перенести или отменить существующую запись."},
{"code": "price_question", "name": "Цены и ДМС", "description": "Вопросы про стоимость услуг, оплату, ДМС."},
{"code": "medical_question", "name": "Медицинский вопрос", "description": "Симптомы, лекарства, диагноз — требует врача."},
{"code": "general_info", "name": "Общая справка", "description": "Адрес, часы работы, как доехать, общие вопросы."},
{"code": "escalate_human", "name": "Эскалация на оператора", "description": "Передача диалога живому оператору."},
{"code": ROUTER_INTENT_CODE, "name": "Маршрутизатор", "description": "Системная ветка: промпт классификатора намерений. Пациенту напрямую не отвечает."},
]
def is_system_code(code: str) -> bool:
"""Код, начинающийся с подчёркивания — системная ветка (не responder)."""
return code.startswith("_")
def load_seed_prompt(code: str) -> str:
"""Стартовый промпт ветки из prompts/intents/{code}.md. Если файла нет — пустая строка."""
# Код с точкой или пробелом — явно ошибка, не трогаем файловую систему.
path = PROMPTS_INTENTS_DIR / f"{code}.md"
try:
return path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Seed prompt for intent %r not found at %s", code, path)
return ""
async def list_intents(session: AsyncSession) -> list[Intent]:
stmt = select(Intent).order_by(Intent.order_index, Intent.id)
return list((await session.execute(stmt)).scalars().all())
async def get_intent_by_code(session: AsyncSession, code: str) -> Intent | None:
stmt = select(Intent).where(Intent.code == code)
return (await session.execute(stmt)).scalar_one_or_none()
async def set_intent_enabled(session: AsyncSession, code: str, is_enabled: bool) -> Intent | None:
intent = await get_intent_by_code(session, code)
if intent is None:
return None
intent.is_enabled = is_enabled
await session.commit()
await session.refresh(intent)
return intent
async def ensure_seed_intents(session: AsyncSession) -> None:
"""Досиживает недостающие ветки из SEED_INTENTS. Существующие не трогаются."""
existing = set((await session.execute(select(Intent.code))).scalars().all())
added = 0
for order, data in enumerate(SEED_INTENTS):
if data["code"] in existing:
continue
session.add(Intent(
code=data["code"],
name=data["name"],
description=data["description"],
is_enabled=True,
order_index=order,
))
added += 1
if added:
await session.commit()
logger.info("Seeded %d missing intents", added)
+129
View File
@@ -0,0 +1,129 @@
"""LLM-роутер: по последней реплике + короткой истории определяет ветку.
Отдельный класс от LLMClient сознательно роутер зовётся часто (каждую реплику),
имеет смысл в будущем перевести на более дешёвую модель (gpt-4o-mini, локальная Qwen).
Сейчас оба используют DeepSeek.
Системный промпт роутера лежит в БД как активный конфиг ветки `_router`
(версионируется, редактируется из UI «Настройки»). Если БД недоступна или
ветки нет используем fallback из prompts/intents/_router.md.
"""
import logging
import re
from pathlib import Path
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from services import config_service, intent_service
logger = logging.getLogger(__name__)
_FALLBACK_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "intents" / "_router.md"
def _load_fallback_prompt() -> str:
try:
return _FALLBACK_PROMPT_PATH.read_text(encoding="utf-8").strip()
except FileNotFoundError:
logger.warning("Router fallback prompt not found at %s", _FALLBACK_PROMPT_PATH)
return ""
FALLBACK_SYSTEM_PROMPT = _load_fallback_prompt()
VALID_CODES = {
"new_booking",
"reschedule",
"price_question",
"medical_question",
"general_info",
"escalate_human",
}
CODE_RE = re.compile(r"\b(new_booking|reschedule|price_question|medical_question|general_info|escalate_human)\b")
class RouterClient:
def __init__(
self,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
):
self.api_key = api_key or settings.deepseek_api_key
self.model = model or settings.deepseek_model
self.base_url = (base_url or settings.deepseek_base_url).rstrip("/")
def _format_history(self, history: list[dict], last_n: int = 4) -> str:
"""Короткая история последних реплик — для контекста классификации."""
if not history:
return "(предыдущих реплик нет)"
tail = history[-last_n:]
lines = []
for m in tail:
role_ru = "Пациент" if m["role"] == "user" else "Ассистент"
content = m["content"].replace("\n", " ")[:300]
lines.append(f"{role_ru}: {content}")
return "\n".join(lines)
async def _get_system_prompt(self, session: AsyncSession) -> tuple[str, int | None]:
"""Активный промпт роутера из БД (ветка _router). Возвращает (prompt, version_or_None)."""
pair = await config_service.get_active_config_by_intent_code(
session, intent_service.ROUTER_INTENT_CODE
)
if pair is None:
return FALLBACK_SYSTEM_PROMPT, None
_, cfg = pair
return config_service.compose_full_system_prompt(cfg), cfg.version
async def classify(self, session: AsyncSession, history: list[dict], text: str) -> dict:
"""Классифицировать реплику. Возвращает {code, version} — версия роутера для отладки.
При сомнении или парсинг-ошибке general_info (безопасный fallback).
"""
system_prompt, version = await self._get_system_prompt(session)
user_message = (
f"История последних реплик:\n{self._format_history(history)}\n\n"
f"Новая реплика пациента:\n{text}\n\n"
f"Код ветки:"
)
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"temperature": 0.0,
"max_tokens": 20,
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
response.raise_for_status()
data = response.json()
except Exception as e:
logger.warning("Router LLM call failed (%s), falling back to general_info", e)
return {"code": "general_info", "version": version}
raw = (data["choices"][0]["message"]["content"] or "").strip()
match = CODE_RE.search(raw)
if match:
code = match.group(1)
logger.info("Router v%s: %r%s", version, text[:80], code)
return {"code": code, "version": version}
logger.warning("Router returned unrecognized response %r, falling back to general_info", raw)
return {"code": "general_info", "version": version}
+35 -27
View File
@@ -214,6 +214,17 @@
white-space: pre-wrap;
}
.msg.assistant { background: var(--bot-bg); align-self: flex-start; }
.msg-intent {
display: inline-block;
background: var(--chip-bg);
color: var(--accent);
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
font-family: var(--mono);
margin-right: 6px;
}
.msg.assistant p { margin: 0 0 8px 0; }
.msg.assistant p:last-child { margin-bottom: 0; }
.msg.assistant ul, .msg.assistant ol { margin: 6px 0; padding-left: 22px; }
@@ -388,8 +399,7 @@
<a href="/sandbox.html" class="nav-link active">Песочница</a>
<a href="/settings.html" class="nav-link">Настройки</a>
</nav>
<span class="active-config" id="active-config" title="Нажмите, чтобы перейти в Настройки" onclick="location.href='/settings.html'"></span>
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
</header>
<main>
@@ -420,6 +430,10 @@
<aside class="col-panel">
<div class="col-head">Отладка ответа</div>
<div class="col-body">
<div class="debug-section">
<h3>Решение роутера</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Найденные фрагменты (по последней реплике)</h3>
<div id="debug-chunks"><div class="mini">— пока пусто —</div></div>
@@ -470,22 +484,6 @@ async function api(path, opts = {}) {
return res.json();
}
/* ---------- active config ---------- */
async function refreshActiveConfig() {
try {
const res = await fetch("/configs/active");
if (!res.ok) {
$("active-config").textContent = "";
return;
}
const c = await res.json();
const label = `активная: v${c.version}${c.name ? " · " + c.name : ""}`;
$("active-config").textContent = label;
} catch (_) {
$("active-config").textContent = "";
}
}
/* ---------- health ---------- */
async function refreshHealth() {
try {
@@ -545,7 +543,7 @@ async function openThread(id) {
$("chat-title").textContent = d.name;
renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt);
if (lastAssistant) renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null);
else clearDebug();
refreshThreads();
} catch (e) {
@@ -571,17 +569,18 @@ function renderMessages(messages) {
box.innerHTML = messages.map(m => {
const isUser = m.role === "user";
const body = isUser ? esc(m.text) : renderMd(m.text);
const intentBadge = m.intent_code ? `<span class="msg-intent" title="${esc(m.intent_name || m.intent_code)}">${esc(m.intent_code)}</span>` : "";
return `
<div class="msg ${isUser ? "user" : "assistant"}">
<div class="msg-body">${body}</div>
<div class="msg-meta">${esc(fmtDate(m.created_at))}</div>
<div class="msg-meta">${intentBadge}${esc(fmtDate(m.created_at))}</div>
</div>
`;
}).join("");
box.scrollTop = box.scrollHeight;
}
function appendMessage(role, text, iso) {
function appendMessage(role, text, iso, intentCode, intentName) {
const box = $("chat-messages");
const empty = box.querySelector(".chat-empty");
if (empty) empty.remove();
@@ -589,14 +588,24 @@ function appendMessage(role, text, iso) {
const isUser = role === "user";
div.className = "msg " + (isUser ? "user" : "assistant");
const body = isUser ? esc(text) : renderMd(text);
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
const intentBadge = intentCode ? `<span class="msg-intent" title="${esc(intentName || intentCode)}">${esc(intentCode)}</span>` : "";
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${intentBadge}${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
return div;
}
/* ---------- отладка ---------- */
function renderDebug(sources, prompt) {
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion) {
const routerTag = routerVersion != null ? ` · роутер v${routerVersion}` : "";
const routerLine = intentCode
? `<div style="padding:10px 16px;background:#ecfdf5;font-size:12px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">классифицировано роутером${routerTag.replace(' · роутер', '')}</div>` : ''}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
if (sources && sources.length) {
$("debug-chunks").innerHTML = sources.map(s => `
<div class="chunk-card">
@@ -617,6 +626,7 @@ function renderDebug(sources, prompt) {
}
function clearDebug() {
$("debug-router").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-chunks").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-prompt").innerHTML = '<div class="mini">— пока пусто —</div>';
}
@@ -655,10 +665,10 @@ async function sendMessage() {
});
activeThreadId = r.thread_id;
pending.remove();
appendMessage("assistant", r.answer);
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
$("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt);
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version);
refreshThreads();
} catch (e) {
pending.remove();
@@ -703,10 +713,8 @@ async function deleteThread(id, name) {
/* ---------- init ---------- */
refreshHealth();
refreshActiveConfig();
refreshThreads();
setInterval(refreshHealth, 15000);
setInterval(refreshActiveConfig, 15000);
</script>
</body>
+356 -152
View File
@@ -20,6 +20,7 @@
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@@ -27,6 +28,8 @@
color: var(--fg);
font-size: 14px;
line-height: 1.5;
display: flex;
flex-direction: column;
}
header {
background: var(--panel);
@@ -35,9 +38,7 @@
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
.nav { display: flex; gap: 4px; }
@@ -56,32 +57,127 @@
font-size: 13px;
color: var(--muted);
}
.stats b { color: var(--fg); }
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
flex: 1;
display: grid;
grid-template-columns: 1fr 420px;
gap: 20px;
grid-template-columns: 280px 1fr 380px;
gap: 0;
min-height: 0;
}
.panel {
.col-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.panel h2 {
margin: 0 0 14px 0;
font-size: 15px;
.col-panel:last-child { border-right: none; border-left: 1px solid var(--border); }
.col-head {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 600;
}
.panel .sub {
.col-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Список веток */
.section-header {
padding: 10px 16px 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
font-weight: 600;
background: #fafbfd;
border-bottom: 1px solid var(--border);
}
.system-badge {
background: #fef3c7;
color: #92400e;
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.intent-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.intent-item:hover { background: #f9fafb; }
.intent-item.active { background: var(--chip-bg); }
.intent-item.disabled .intent-name { color: var(--muted); text-decoration: line-through; }
.intent-top {
display: flex;
align-items: center;
gap: 8px;
}
.intent-name { font-weight: 500; font-size: 13px; flex: 1; }
.intent-code { font-family: var(--mono); font-size: 10px; color: var(--muted); }
.intent-desc {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.intent-version {
font-size: 11px;
color: var(--accent);
font-weight: 500;
margin-top: 2px;
}
.intent-version.empty { color: var(--muted); font-weight: normal; }
/* Редактор */
.editor {
padding: 20px 24px;
max-width: 900px;
}
.editor-empty {
margin: auto;
text-align: center;
color: var(--muted);
padding: 60px 20px;
font-style: italic;
}
.editor h2 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
}
.editor .sub {
font-size: 12px;
color: var(--muted);
margin: -10px 0 12px 0;
margin-bottom: 18px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.toolbar button {
background: none;
border: 1px solid var(--border);
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
color: var(--fg);
}
.toolbar button:hover { background: #f9fafb; }
.toolbar .toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
.field { margin-bottom: 14px; }
.field label {
@@ -91,8 +187,7 @@
color: var(--muted);
margin-bottom: 4px;
}
.field input[type=text],
.field textarea {
.field input[type=text], .field textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
@@ -102,21 +197,24 @@
background: #fff;
outline: none;
}
.field input[type=text]:focus,
.field textarea:focus { border-color: var(--accent); }
.field input[type=text]:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea {
font-family: var(--mono);
resize: vertical;
min-height: 160px;
min-height: 200px;
}
.field textarea.prompt { min-height: 260px; }
.field textarea.rules { min-height: 160px; }
.field textarea.prompt { min-height: 300px; }
.field textarea.rules { min-height: 140px; }
.editor-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding-top: 6px;
border-top: 1px solid var(--border);
margin-top: 10px;
padding: 14px 0 0 0;
}
.editor-actions button {
background: var(--accent);
@@ -129,11 +227,7 @@
cursor: pointer;
}
.editor-actions button:hover { background: var(--accent-hover); }
.editor-actions button.secondary {
background: none;
color: var(--fg);
border: 1px solid var(--border);
}
.editor-actions button.secondary { background: none; color: var(--fg); border: 1px solid var(--border); }
.editor-actions button.secondary:hover { background: #f9fafb; }
.editor-actions label {
display: inline-flex;
@@ -144,13 +238,13 @@
cursor: pointer;
}
/* Список версий */
.versions { max-height: 70vh; overflow-y: auto; }
/* Версии */
.versions { padding: 10px; }
.version-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
padding: 10px 12px;
margin-bottom: 8px;
background: #fafbfd;
}
.version-card.active {
@@ -158,54 +252,34 @@
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.version-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.version-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.v-num {
background: var(--chip-bg);
color: var(--accent);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.v-name {
font-weight: 500;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-name.empty { color: var(--muted); font-style: italic; font-weight: normal; }
.v-active-badge {
margin-left: auto;
background: var(--ok);
color: #fff;
padding: 2px 8px;
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
.v-name { font-weight: 500; font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v-name.empty { color: var(--muted); font-style: italic; font-weight: normal; }
.v-active-badge {
background: var(--ok);
color: #fff;
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.v-meta {
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
}
.v-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.v-meta { font-size: 10px; color: var(--muted); margin-bottom: 6px; }
.v-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.v-actions button {
background: none;
border: 1px solid var(--border);
padding: 3px 10px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-size: 10px;
cursor: pointer;
color: var(--fg);
}
@@ -215,7 +289,7 @@
.v-actions button.del:hover { border-color: var(--err); color: var(--err); }
.v-actions button:disabled { opacity: 0.4; cursor: not-allowed; }
.mini { color: var(--muted); font-size: 12px; font-style: italic; }
.mini { color: var(--muted); font-size: 12px; font-style: italic; padding: 14px; }
.toast {
position: fixed;
@@ -234,6 +308,34 @@
}
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
/* Свитч включён/выключен */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #cbd5e1;
border-radius: 10px;
transition: 0.2s;
}
.slider:before {
content: "";
position: absolute;
height: 14px; width: 14px;
left: 2px; bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .slider { background: var(--ok); }
.switch input:checked + .slider:before { transform: translateX(16px); }
</style>
</head>
<body>
@@ -250,37 +352,26 @@
<main>
<section class="panel">
<h2>Редактор конфигурации агента</h2>
<div class="sub">При сохранении всегда создаётся новая версия — существующие не меняются. Это даёт честный откат.</div>
<div class="field">
<label for="f-name">Имя версии (необязательно)</label>
<input type="text" id="f-name" placeholder="например: строгий тон, v2 после фидбэка операторов" maxlength="200">
<aside class="col-panel">
<div class="col-head">Ветки (intents)</div>
<div class="col-body" id="intents-list">
<div class="mini">загружаю…</div>
</div>
</aside>
<div class="field">
<label for="f-prompt">Системный промпт</label>
<textarea id="f-prompt" class="prompt" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-rules">Правила (в дополнение к промпту; свободная markdown-форма)</label>
<textarea id="f-rules" class="rules" spellcheck="false" placeholder="Например:&#10;- Если пациент спрашивает про цены — не называй конкретных сумм, переведи на оператора.&#10;- Если злится — сначала извинись, подтверди, что сейчас поможешь."></textarea>
</div>
<div class="editor-actions">
<button id="btn-save" onclick="saveVersion(false)">Сохранить как новую версию</button>
<button class="secondary" onclick="loadActiveIntoEditor()">Загрузить активную в редактор</button>
<label><input type="checkbox" id="chk-activate"> Сразу сделать активной</label>
<section class="col-panel">
<div class="col-head" id="editor-head">Выберите ветку слева</div>
<div class="col-body">
<div class="editor" id="editor">
<div class="editor-empty">Слева — список веток. Выберите, чтобы увидеть и отредактировать её активный промпт.</div>
</div>
</div>
</section>
<aside class="panel">
<h2>Версии</h2>
<div class="sub">Активная версия используется в «Песочнице» на каждый запрос.</div>
<div class="versions" id="versions">
<div class="mini">загружаю…</div>
<aside class="col-panel">
<div class="col-head">Версии <span id="versions-intent" style="color:var(--fg);text-transform:none;font-weight:normal;"></span></div>
<div class="col-body" id="versions">
<div class="mini">— выберите ветку —</div>
</div>
</aside>
@@ -292,7 +383,9 @@
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let configs = [];
let intents = [];
let currentIntentCode = null;
let versions = [];
function toast(msg, kind = "ok") {
const t = $("toast");
@@ -319,52 +412,128 @@ function fmtDate(iso) {
} catch (_) { return iso; }
}
async function refreshHealth() {
/* ---------- intents list ---------- */
async function refreshIntents() {
try {
const h = await api("/health");
const active = configs.find(c => c.is_active);
const vTag = active ? `активная: v${active.version}${active.name ? " · " + esc(active.name) : ""}` : "нет активной";
$("stats").innerHTML = `${vTag} · документов <b>${h.documents_count}</b> · чанков <b>${h.chunks_count}</b>`;
const d = await api("/intents");
intents = d.intents;
renderIntents();
} catch (e) {
$("stats").textContent = "недоступен";
$("intents-list").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
}
async function refreshConfigs() {
try {
const d = await api("/configs");
configs = d.configs;
renderVersions();
refreshHealth();
} catch (e) {
$("versions").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderIntents() {
const responders = intents.filter(i => !i.code.startsWith("_"));
const system = intents.filter(i => i.code.startsWith("_"));
function renderVersions() {
if (!configs.length) {
$("versions").innerHTML = '<div class="mini">версий ещё нет</div>';
return;
}
$("versions").innerHTML = configs.map(c => `
<div class="version-card ${c.is_active ? "active" : ""}">
<div class="version-head">
<span class="v-num">v${c.version}</span>
<span class="v-name ${c.name ? "" : "empty"}" title="${esc(c.name || '')}">${esc(c.name || "без имени")}</span>
${c.is_active ? '<span class="v-active-badge">активная</span>' : ""}
const renderItem = (i, isSystem) => `
<div class="intent-item ${i.code === currentIntentCode ? 'active' : ''} ${i.is_enabled ? '' : 'disabled'}" onclick="selectIntent('${i.code}')">
<div class="intent-top">
<span class="intent-name">${esc(i.name)}</span>
${isSystem
? '<span class="system-badge" title="Системная ветка — не выключается">система</span>'
: `<label class="switch" onclick="event.stopPropagation();">
<input type="checkbox" ${i.is_enabled ? 'checked' : ''} onchange="toggleIntent('${i.code}', this.checked)">
<span class="slider"></span>
</label>`}
</div>
<div class="v-meta">${esc(fmtDate(c.created_at))} · промпт ${c.system_prompt.length} симв.${c.rules_text ? " · правил " + c.rules_text.length : ""}</div>
<div class="v-actions">
<button onclick="loadIntoEditor(${c.id})">Загрузить в редактор</button>
${!c.is_active ? `<button class="primary" onclick="activate(${c.id})">Активировать</button>` : ""}
${!c.is_active ? `<button class="del" onclick="deleteVersion(${c.id}, ${c.version})">Удалить</button>` : '<button class="del" disabled title="Активную удалить нельзя — сначала активируйте другую">Удалить</button>'}
<div class="intent-desc">${esc(i.description)}</div>
<div class="intent-version ${i.active_config_version ? '' : 'empty'}">
<span class="intent-code">${esc(i.code)}</span>
${i.active_config_version ? `· активна v${i.active_config_version}` : '· нет активной'}
</div>
</div>
`).join("");
`;
let html = '<div class="section-header">Ветки-ответчики</div>';
html += responders.map(i => renderItem(i, false)).join("");
if (system.length) {
html += '<div class="section-header">Системные</div>';
html += system.map(i => renderItem(i, true)).join("");
}
$("intents-list").innerHTML = html;
}
function loadIntoEditor(id) {
const c = configs.find(x => x.id === id);
async function toggleIntent(code, enabled) {
try {
await api(`/intents/${code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_enabled: enabled }),
});
toast(`${code}: ${enabled ? 'включена' : 'выключена'}`);
refreshIntents();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- select intent ---------- */
async function selectIntent(code) {
currentIntentCode = code;
renderIntents();
renderEditor();
await refreshVersions(code);
await loadActiveIntoEditor();
}
function renderEditor() {
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) {
$("editor").innerHTML = '<div class="editor-empty">Выберите ветку слева.</div>';
$("editor-head").textContent = "Выберите ветку слева";
return;
}
$("editor-head").textContent = `${intent.name} · редактор`;
$("editor").innerHTML = `
<h2>${esc(intent.name)}</h2>
<div class="sub"><code>${esc(intent.code)}</code> · ${esc(intent.description)}</div>
<div class="field">
<label for="f-name">Имя версии (необязательно)</label>
<input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200">
</div>
<div class="field">
<label for="f-prompt">Системный промпт ветки</label>
<textarea id="f-prompt" class="prompt" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-rules">Правила (дополнение к промпту; свободная markdown-форма)</label>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div>
<div class="editor-actions">
<button onclick="saveVersion()">Сохранить как новую версию</button>
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
<label><input type="checkbox" id="chk-activate"> Сразу сделать активной</label>
</div>
`;
}
async function loadActiveIntoEditor() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent || !intent.active_config_id) {
// Новая ветка без активной версии — пусто.
if ($("f-name")) {
$("f-name").value = "";
$("f-prompt").value = "";
$("f-rules").value = "";
}
return;
}
try {
const c = await api(`/configs/active?intent_code=${encodeURIComponent(currentIntentCode)}`);
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
} catch (e) {
toast("Не удалось загрузить активную: " + e.message, "err");
}
}
function loadIntoEditor(configId) {
const c = versions.find(x => x.id === configId);
if (!c) return;
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
@@ -373,16 +542,47 @@ function loadIntoEditor(id) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function loadActiveIntoEditor() {
const active = configs.find(c => c.is_active);
if (!active) {
toast("Активной версии нет", "err");
return;
/* ---------- versions ---------- */
async function refreshVersions(code) {
const intent = intents.find(i => i.code === code);
$("versions-intent").textContent = intent ? ` — ${intent.name}` : "";
try {
const d = await api(`/configs?intent_code=${encodeURIComponent(code)}`);
versions = d.configs;
renderVersions();
} catch (e) {
$("versions").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
loadIntoEditor(active.id);
}
function renderVersions() {
if (!versions.length) {
$("versions").innerHTML = '<div class="mini">версий ещё нет</div>';
return;
}
$("versions").innerHTML = `<div class="versions">` + versions.map(c => `
<div class="version-card ${c.is_active ? "active" : ""}">
<div class="version-head">
<span class="v-num">v${c.version}</span>
<span class="v-name ${c.name ? "" : "empty"}" title="${esc(c.name || '')}">${esc(c.name || "без имени")}</span>
${c.is_active ? '<span class="v-active-badge">активная</span>' : ""}
</div>
<div class="v-meta">${esc(fmtDate(c.created_at))} · промпт ${c.system_prompt.length} симв.${c.rules_text ? " · правил " + c.rules_text.length : ""}</div>
<div class="v-actions">
<button onclick="loadIntoEditor(${c.id})">Загрузить</button>
${!c.is_active ? `<button class="primary" onclick="activateVersion(${c.id})">Активировать</button>` : ""}
${!c.is_active ? `<button class="del" onclick="deleteVersion(${c.id}, ${c.version})">Удалить</button>` : '<button class="del" disabled title="Активную удалить нельзя">Удалить</button>'}
</div>
</div>
`).join("") + `</div>`;
}
/* ---------- save / activate / delete ---------- */
async function saveVersion() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) return;
const name = $("f-name").value.trim();
const system_prompt = $("f-prompt").value.trim();
const rules_text = $("f-rules").value.trim();
@@ -393,39 +593,44 @@ async function saveVersion() {
return;
}
$("btn-save").disabled = true;
try {
const r = await api("/configs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name || null, system_prompt, rules_text, activate }),
body: JSON.stringify({
intent_id: intent.id,
name: name || null,
system_prompt,
rules_text,
activate,
}),
});
toast(`Сохранена v${r.version}${activate ? " · активирована" : ""}`);
toast(`v${r.version} сохранена${activate ? " · активирована" : ""}`);
$("chk-activate").checked = false;
await refreshConfigs();
await refreshIntents();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
} finally {
$("btn-save").disabled = false;
}
}
async function activate(id) {
async function activateVersion(id) {
try {
const r = await api(`/configs/${id}/activate`, { method: "POST" });
toast(`Активирована v${r.version}`);
refreshConfigs();
await refreshIntents();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function deleteVersion(id, version) {
if (!confirm(`Удалить версию v${version}?`)) return;
if (!confirm(`Удалить v${version}?`)) return;
try {
await api(`/configs/${id}`, { method: "DELETE" });
toast(`v${version} удалена`);
refreshConfigs();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
@@ -433,9 +638,8 @@ async function deleteVersion(id, version) {
/* ---------- init ---------- */
(async function init() {
await refreshConfigs();
// При первом заходе загружаем активную в редактор для удобства.
loadActiveIntoEditor();
await refreshIntents();
if (intents.length) selectIntent(intents[0].code);
})();
</script>