Files
RAG_helper/routers/configs.py
T
AR 15 M4 7ec2ba3c8f feat(sprint3): редактор системного промпта и правил с версионированием
Операторы получают веб-редактор: правят системный промпт и правила,
сохраняют как новую версию, активируют, откатываются. Активная версия
используется в «Песочнице» на каждый /chat.

Принципы, согласованные заранее:
- Сохранённые версии не редактируются — только создание новой. Честный
  откат: v1 всегда та же, что была при создании.
- Правила на этом этапе — свободный markdown (textarea). Переход на
  структурированные правила (pattern → instruction) — в бэклог.
- Файл prompts/system_prompt.md становится сид-источником: при первом
  старте, если таблица agent_configs пустая, из него создаётся v1 и
  активируется. Дальше правда идёт из БД, файл не трогаем.
- rules_text конкатенируется с system_prompt в один system-message
  через compose_full_system_prompt: "{prompt}\n\nДополнительные
  правила:\n{rules}".
- Активную версию удалить нельзя — сначала активируют другую.

Модель и миграция:
- db/models/AgentConfig: id, version (unique/indexed), name (nullable),
  system_prompt, rules_text, is_active (indexed), created_at.
  Без updated_at — версии неизменяемы.
- Миграция b4450e33664d_add_agent_configs_table.

Сервисы и роутеры:
- services/config_service: ensure_seed (seed v1 из файла),
  list/get/get_active/create (version=max+1, при activate атомарно
  сбрасывает is_active у остальных и ставит новой),
  activate_config (та же схема), delete_config (возвращает причину
  отказа: not_found / active), compose_full_system_prompt.
- services/chat_service.send_message: берёт active_cfg, собирает
  system_prompt через compose_full_system_prompt, пишет
  thread.agent_config_id при создании треда (колонка была nullable
  ещё со Спринта 2 — пригодилась именно здесь).
- routers/configs: GET /configs, GET /configs/active, GET /configs/{id},
  POST /configs (activate-флаг), POST /configs/{id}/activate,
  DELETE /configs/{id} (404 / 400 если активная).
- Pydantic: AgentConfigCreateRequest, AgentConfigInfo, ListResponse,
  DeleteResponse.
- main.py: ensure_seed в lifespan после инициализации БД/Chroma/LLM.

UI:
- static/settings.html — трёхблочная страница: имя версии, textarea
  промпта, textarea правил, «Сохранить как новую» + галка
  «Сразу активировать», «Загрузить активную в редактор». Справа —
  список версий с бейджем «активная», действиями «Активировать» /
  «Удалить» (disabled у активной) / «Загрузить в редактор». При
  первом заходе активная версия автоматом подгружается в редактор.
- В nav на index.html и sandbox.html добавлена ссылка «Настройки».
- В шапке «Песочницы» — зелёный кликабельный бейдж «активная: vN · имя»
  (ведёт на /settings.html), обновляется раз в 15 с.

E2E проверено: создана v2 с правилом «ВСЕГДА начинай со слов СПАСИБО
ЗА ВОПРОС», активирована; следующий /chat вернул ответ, начинающийся
ровно с этой фразы; assembled_prompt содержит блок «Дополнительные
правила». После отката на v1 тест-v2 удалена.

SPRINTS.md: Спринт 3 помечен закрытым.

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

90 lines
3.0 KiB
Python

import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AgentConfig
from db.session import get_session
from models.requests import AgentConfigCreateRequest
from models.responses import (
AgentConfigDeleteResponse,
AgentConfigInfo,
AgentConfigListResponse,
)
from services import config_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/configs", tags=["configs"])
def _to_info(cfg: AgentConfig) -> AgentConfigInfo:
return AgentConfigInfo(
id=cfg.id,
version=cfg.version,
name=cfg.name,
system_prompt=cfg.system_prompt,
rules_text=cfg.rules_text or "",
is_active=cfg.is_active,
created_at=cfg.created_at.isoformat(),
)
@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))
@router.get("/active", response_model=AgentConfigInfo)
async def get_active(session: AsyncSession = Depends(get_session)):
cfg = await config_service.get_active_config(session)
if cfg is None:
raise HTTPException(status_code=404, detail="No active config")
return _to_info(cfg)
@router.get("/{config_id}", response_model=AgentConfigInfo)
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)
@router.post("", response_model=AgentConfigInfo)
async def create_config(
req: AgentConfigCreateRequest,
session: AsyncSession = Depends(get_session),
):
cfg = await config_service.create_config(
session=session,
system_prompt=req.system_prompt,
rules_text=req.rules_text,
name=req.name,
activate=req.activate,
)
return _to_info(cfg)
@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)
@router.delete("/{config_id}", response_model=AgentConfigDeleteResponse)
async def delete_config(config_id: int, session: AsyncSession = Depends(get_session)):
ok, reason = await config_service.delete_config(session, config_id)
if not ok:
if reason == "not_found":
raise HTTPException(status_code=404, detail="Config not found")
if reason == "active":
raise HTTPException(
status_code=400,
detail="Нельзя удалить активную версию — сначала активируйте другую",
)
return AgentConfigDeleteResponse(ok=True)