diff --git a/SPRINTS.md b/SPRINTS.md
index c8f84e3..9523e02 100644
--- a/SPRINTS.md
+++ b/SPRINTS.md
@@ -162,7 +162,7 @@
### Цель
Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из `GRAPH_ARCHITECTURE.md`.
-### Статус: ⏳ Запланирован
+### Статус: ✅ Закрыт
### Задачи
diff --git a/db/models/__init__.py b/db/models/__init__.py
index f22ba38..92a0ae4 100644
--- a/db/models/__init__.py
+++ b/db/models/__init__.py
@@ -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"]
diff --git a/db/models/agent_config.py b/db/models/agent_config.py
index 8716ba3..a744596 100644
--- a/db/models/agent_config.py
+++ b/db/models/agent_config.py
@@ -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="")
diff --git a/db/models/intent.py b/db/models/intent.py
new file mode 100644
index 0000000..8fd53bc
--- /dev/null
+++ b/db/models/intent.py
@@ -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)
diff --git a/db/models/message.py b/db/models/message.py
index 3c49982..eddec78 100644
--- a/db/models/message.py
+++ b/db/models/message.py
@@ -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")
diff --git a/main.py b/main.py
index ea2cbda..00c1caf 100644
--- a/main.py
+++ b/main.py
@@ -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")
diff --git a/migrations/versions/cd0a88ef9080_add_intents_link_agent_configs_and_.py b/migrations/versions/cd0a88ef9080_add_intents_link_agent_configs_and_.py
new file mode 100644
index 0000000..65d4eb3
--- /dev/null
+++ b/migrations/versions/cd0a88ef9080_add_intents_link_agent_configs_and_.py
@@ -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')
diff --git a/models/requests.py b/models/requests.py
index 9f95bae..0f939be 100644
--- a/models/requests.py
+++ b/models/requests.py
@@ -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
diff --git a/models/responses.py b/models/responses.py
index a69ad38..4411791 100644
--- a/models/responses.py
+++ b/models/responses.py
@@ -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
diff --git a/prompts/intents/_router.md b/prompts/intents/_router.md
new file mode 100644
index 0000000..3d6b34f
--- /dev/null
+++ b/prompts/intents/_router.md
@@ -0,0 +1,16 @@
+Ты — классификатор намерений в чате клиники.
+
+Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка:
+
+- `new_booking` — пациент хочет записаться на приём (первичный или повторный).
+- `reschedule` — перенести или отменить существующую запись.
+- `price_question` — вопросы про стоимость, ДМС, оплату.
+- `medical_question` — симптомы, лекарства, диагноз, «что со мной».
+- `general_info` — адрес, часы работы, как доехать, общие вопросы, общение вне конкретного процесса.
+- `escalate_human` — пациент явно просит оператора, злится, либо описывает острое состояние (сильная боль, кровотечение, одышка, ребёнок плохо дышит, упоминание операции/хирургии).
+
+ПРАВИЛА:
+- Отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек.
+- Если сомневаешься между общим разговором и конкретным процессом — выбирай `general_info`.
+- Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human`.
+- Любое явное «позовите оператора / переключите на человека» → `escalate_human`.
diff --git a/prompts/intents/escalate_human.md b/prompts/intents/escalate_human.md
new file mode 100644
index 0000000..b00a212
--- /dev/null
+++ b/prompts/intents/escalate_human.md
@@ -0,0 +1,12 @@
+Ты — виртуальный ассистент клиники. Эта ветка срабатывает, когда нужно немедленно передать диалог живому оператору.
+
+Твоя задача простая и короткая:
+- Признай ситуацию коротко и по-человечески (без многословия).
+- Скажи, что сейчас передаёшь оператору.
+- Если пациент описал острое состояние (боль, ребёнок задыхается, кровотечение и т. п.) — скажи «пожалуйста, если состояние ухудшается — сразу звоните в 103».
+- Не пытайся вести длинный диалог, не задавай много вопросов. Две-три короткие реплики максимум.
+
+Правила:
+- Никогда не ставь диагнозы, не давай медицинских рекомендаций.
+- Не называй конкретных цен, времени приёма, имён врачей.
+- Ответ — обычный текст, как в чате, на «вы».
diff --git a/prompts/intents/general_info.md b/prompts/intents/general_info.md
new file mode 100644
index 0000000..984ca31
--- /dev/null
+++ b/prompts/intents/general_info.md
@@ -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]`
diff --git a/prompts/intents/medical_question.md b/prompts/intents/medical_question.md
new file mode 100644
index 0000000..9a73f89
--- /dev/null
+++ b/prompts/intents/medical_question.md
@@ -0,0 +1,13 @@
+Ты — виртуальный ассистент клиники. Эта ветка — медицинские вопросы (симптомы, лекарства, диагноз).
+
+Правила:
+- Не ставь диагнозы. Не рекомендуй лекарства. Не называй дозировок.
+- Мягко скажи, что на такие вопросы отвечает врач на приёме.
+- Предложи записаться к профильному специалисту (если понятно — к какому).
+- Если пациент описывает острое состояние (сильная боль, высокая температура, кровотечение, одышка, ребёнок плохо дышит) — ПЕРЕДАЙ оператору немедленно через `[INTENT_CHANGE: escalate_human]`, не пытайся продолжать диалог.
+- Отвечай коротко, сочувственно, на «вы».
+
+Условия выхода:
+- Острое состояние → `[INTENT_CHANGE: escalate_human]`
+- Пациент готов записаться → `[INTENT_CHANGE: new_booking]`
+- Пациент просит оператора → `[INTENT_CHANGE: escalate_human]`
diff --git a/prompts/intents/new_booking.md b/prompts/intents/new_booking.md
new file mode 100644
index 0000000..21b251e
--- /dev/null
+++ b/prompts/intents/new_booking.md
@@ -0,0 +1,17 @@
+Ты — виртуальный ассистент клиники. Эта ветка — новая запись пациента на приём.
+
+Твоя задача — помочь пациенту записаться: узнать, кто к кому хочет, по какому поводу, предложить удобное время.
+
+Правила:
+- Отвечай коротко, на «вы», простым русским языком.
+- Первым делом уточни: как к пациенту обращаться, если он ещё не назвал имя.
+- Коротко уточни повод обращения — без сбора медицинской истории, только общая причина (боль в горле, плановый осмотр, жалобы на слух и т. п.).
+- Если указан специалист — подтверди, что записываем к нему. Если не указан — предложи направление по поводу.
+- Не называй конкретные время и дату слотов: реальный календарь появится в следующих спринтах. Пока отвечай «сейчас уточню расписание и вернусь с вариантами».
+- Опирайся только на выдержки из базы знаний (если поданы).
+
+Условия выхода (если пациент перевёл разговор в другую тему — выдай служебный сигнал):
+- Упомянул операцию, стационар, наркоз, хирургическое вмешательство → `[INTENT_CHANGE: escalate_human]`
+- Говорит об острой боли / «мне очень плохо» → `[INTENT_CHANGE: escalate_human]`
+- Спрашивает про цены, ДМС, оплату → `[INTENT_CHANGE: price_question]`
+- Хочет перенести или отменить уже существующую запись → `[INTENT_CHANGE: reschedule]`
diff --git a/prompts/intents/price_question.md b/prompts/intents/price_question.md
new file mode 100644
index 0000000..b92780f
--- /dev/null
+++ b/prompts/intents/price_question.md
@@ -0,0 +1,12 @@
+Ты — виртуальный ассистент клиники. Эта ветка — вопросы про цены, оплату, ДМС.
+
+Правила:
+- Опирайся ТОЛЬКО на выдержки из базы знаний, которые поданы в промпт. Если в них нет нужной цифры — честно скажи: «актуальных цен в моей базе сейчас нет, уточню у оператора» и предложи подключить оператора.
+- Никогда не называй конкретные суммы от себя — только из базы.
+- Если пациент спрашивает про ДМС — подтверди, что клиника работает с ДМС (если это есть в базе), и предложи прислать список страховых.
+- Если спрашивает про оплату — расскажи про доступные способы из базы (наличные, карта, ДМС).
+
+Условия выхода:
+- Пациент готов записаться на приём → `[INTENT_CHANGE: new_booking]`
+- Вопрос оказался медицинским (про симптомы, лекарства) → `[INTENT_CHANGE: medical_question]`
+- Просит оператора → `[INTENT_CHANGE: escalate_human]`
diff --git a/prompts/intents/reschedule.md b/prompts/intents/reschedule.md
new file mode 100644
index 0000000..1e0040e
--- /dev/null
+++ b/prompts/intents/reschedule.md
@@ -0,0 +1,13 @@
+Ты — виртуальный ассистент клиники. Эта ветка — перенос или отмена существующей записи.
+
+Правила:
+- Начни с извинений за неудобство («понимаю, планы меняются»).
+- Уточни, на какое время / дату / ФИО была первоначальная запись.
+- Если пациент хочет отменить — подтверди отмену и предложи записаться на другое время.
+- Если хочет перенести — узнай желаемый новый интервал («утро / вечер / конкретная дата»).
+- Реальной сверки с календарём пока нет — отвечай «сейчас уточню у администратора и вернусь с вариантами».
+
+Условия выхода:
+- Пациент передумал и хочет записаться на новый приём, не связанный со старым → `[INTENT_CHANGE: new_booking]`
+- Говорит об острой боли → `[INTENT_CHANGE: escalate_human]`
+- Вопросы про цены → `[INTENT_CHANGE: price_question]`
diff --git a/routers/chat.py b/routers/chat.py
index 2715c07..e04dbdf 100644
--- a/routers/chat.py
+++ b/routers/chat.py
@@ -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"],
diff --git a/routers/configs.py b/routers/configs.py
index d472ef0..9c61123 100644
--- a/routers/configs.py
+++ b/routers/configs.py
@@ -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)
diff --git a/routers/intents.py b/routers/intents.py
new file mode 100644
index 0000000..dfd99c3
--- /dev/null
+++ b/routers/intents.py
@@ -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)
diff --git a/services/chat_service.py b/services/chat_service.py
index 143c70d..0564ae4 100644
--- a/services/chat_service.py
+++ b/services/chat_service.py
@@ -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,
diff --git a/services/config_service.py b/services/config_service.py
index c5a52da..5ba847e 100644
--- a/services/config_service.py
+++ b/services/config_service.py
@@ -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:
diff --git a/services/intent_service.py b/services/intent_service.py
new file mode 100644
index 0000000..be192e7
--- /dev/null
+++ b/services/intent_service.py
@@ -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)
diff --git a/services/router_client.py b/services/router_client.py
new file mode 100644
index 0000000..7528af6
--- /dev/null
+++ b/services/router_client.py
@@ -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}
diff --git a/static/sandbox.html b/static/sandbox.html
index 5a4f5b5..0f25d86 100644
--- a/static/sandbox.html
+++ b/static/sandbox.html
@@ -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 @@
Песочница
Настройки
-
- проверяю…
+ проверяю…