Files
RAG_helper/main.py
T
AR 15 M4 b24e985f82 feat(sprint4): фундамент графа — intents + роутер + переключение веток
Первый шаг графовой архитектуры из GRAPH_ARCHITECTURE.md. Заменили
«один активный промпт на всё» на «свой промпт на каждую ветку +
роутер выбирает ветку на каждой реплике».

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:20:23 +05:00

100 lines
3.5 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 # 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)
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")