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 @@ Песочница Настройки - - проверяю… + проверяю…
@@ -420,6 +430,10 @@