7ec2ba3c8f
Операторы получают веб-редактор: правят системный промпт и правила,
сохраняют как новую версию, активируют, откатываются. Активная версия
используется в «Песочнице» на каждый /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>
90 lines
3.0 KiB
Python
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)
|