9eef2dab3a
Заменили строковый тег [STATE: ...] из Спринта 5 на структурированный выход
ветки в виде JSON-блока в хвосте ответа: {state_after, slots_updated}, парсимый
балансировкой скобок. Шаги state machine вынесены из монолитного промпта в
таблицу intent_steps (intent_id FK, code, name, order_index, system_prompt,
allowed_next JSON, guards JSON) и редактируются через UI. Валидатор переходов
сверяет state_after с allowed_next и блокирует невалидные прыжки.
Базовый промпт new_booking разбит на base + 6 файлов шагов (intro/qualify/
present/offer_time/book/close), которые сидятся при старте через
ensure_seed_steps. В chat_service промпт собирается как base + step + блок
[ТЕКУЩЕЕ СОСТОЯНИЕ].
Попутно реализован мини-блок G (sticky state machine): когда диалог идёт по
sm-ветке и роутер на новой реплике предлагает другую — state НЕ сбрасывается,
в системный промпт ветки подаётся блок [ПОДСКАЗКА РОУТЕРА], LLM сама решает
(STATE_JSON или INTENT_CHANGE). Это сняло ключевую дыру Спринта 5: «Меня
зовут Алексей» / «болит ухо» внутри записи больше не сбрасывают сценарий.
Промпт ветки new_booking ужесточён: бытовые жалобы — это повод записи (слот
reason + сочувствие), не повод уводить в medical_question. Шаг present теперь
использует reason в формулировке. Промпт _router расширен живыми примерами
для всех 6 веток, особенно для reschedule («не смогу подойти», «перенесите»).
Надёжность внешнего LLM:
- ретрай в LLMClient с паузой 500 мс + новое исключение LLMUnavailableError;
- ретрай в RouterClient (DeepSeek периодически моргает);
- /chat при ошибке делает session.rollback() и возвращает 503 с понятным
сообщением — больше не остаётся «диалогов-призраков» с одной репликой;
- UI убирает свой пузырь и возвращает текст в поле ввода для повторной отправки.
UI «Настройки» — добавлена вкладка «Шаги» для веток с state machine: список
шагов chip-ами, редактор промпта/имени/allowed_next/guards, сохранение через
PATCH /intents/{code}/steps/{step_code} без версионирования. Иконка ⓘ возле
поля «Правила» открывает popover с пояснением, что туда писать.
UI «Песочница»:
- блок «Состояние диалога» показывает имя шага из intent_steps (а не сырое
число), для не-sm-веток пишется «без пошагового сценария»;
- подсветка illegal-переходов (валидатор отклонил state_after) и parse_error
для sm-веток;
- блок «Решение роутера» развёрнут в три исхода: «попал в ту же ветку» /
«удержались в ветке» / «ветка сама передала управление через INTENT_CHANGE»;
- секция «Найденные фрагменты» сворачивается, карточки чанков раскрываются
по клику — правый сайдбар стал компактнее.
Терминология (по договорённости — простой русский в UI):
- «тред» → «диалог» в текстах для оператора (в коде/API thread_id оставлен);
- «sticky state machine» → «удержались в ветке»;
- «state machine» → «пошаговый сценарий» в видимых местах.
SPRINTS.md: блок G в Спринте 6b сокращён — sticky-логика уже сделана здесь,
осталась только вторая линия (передача thread_state в системный промпт самого
роутера для ещё более точной первичной классификации).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
|
|
from alembic import command
|
|
from alembic.config import Config as AlembicConfig
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from config import settings
|
|
|
|
# Настройка логов до импорта приложения: uvicorn ставит свои handlers
|
|
# на root-logger, поэтому basicConfig в lifespan уже не срабатывает
|
|
# (handlers есть — basicConfig no-op). force=True перебивает.
|
|
logging.basicConfig(
|
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
handlers=[logging.StreamHandler(sys.stderr)],
|
|
force=True,
|
|
)
|
|
|
|
from db.session import SessionLocal # noqa: E402
|
|
from services import config_service, intent_service, intent_step_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__)
|
|
|
|
embedding_service: EmbeddingService | None = None
|
|
vectorstore_service: VectorStoreService | None = None
|
|
llm_client: LLMClient | None = None
|
|
router_client: RouterClient | None = None
|
|
|
|
|
|
def _run_migrations() -> None:
|
|
"""Автоматически подтягиваем схему до последней ревизии при старте."""
|
|
os.makedirs(os.path.dirname(settings.sqlite_path), exist_ok=True)
|
|
cfg = AlembicConfig("alembic.ini")
|
|
command.upgrade(cfg, "head")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
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)
|
|
embedding_service = EmbeddingService(settings.embedding_model)
|
|
logger.info("Embedding model loaded")
|
|
vectorstore_service = VectorStoreService(
|
|
persist_dir=settings.chroma_persist_dir,
|
|
embedding_service=embedding_service,
|
|
)
|
|
logger.info("ChromaDB initialized at %s", settings.chroma_persist_dir)
|
|
llm_client = LLMClient()
|
|
router_client = RouterClient()
|
|
logger.info("LLM + Router clients ready (model=%s)", llm_client.model)
|
|
|
|
async with SessionLocal() as 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)
|
|
await intent_step_service.ensure_seed_steps(session)
|
|
|
|
yield
|
|
logger.info("Shutting down")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Chat Agent for Patients — Tuning Tool",
|
|
description="RAG-ядро и инструмент настройки пациентского чат-агента",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
from routers import chat, configs, documents, health, intents, query, threads # noqa: E402
|
|
|
|
app.include_router(health.router)
|
|
app.include_router(documents.router)
|
|
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")
|