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>
This commit is contained in:
AR 15 M4
2026-04-23 19:59:06 +05:00
parent 6348d9a206
commit 7ec2ba3c8f
13 changed files with 796 additions and 5 deletions
+32 -1
View File
@@ -54,8 +54,19 @@
}
.nav-link:hover { background: var(--chip-bg); color: var(--fg); }
.nav-link.active { background: var(--accent); color: #fff; }
.status {
.active-config {
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
font-size: 12px;
cursor: pointer;
}
.active-config:empty { display: none; }
.active-config:hover { background: #d1fae5; }
.status {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -375,7 +386,9 @@
<nav class="nav">
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link active">Песочница</a>
<a href="/settings.html" class="nav-link">Настройки</a>
</nav>
<span class="active-config" id="active-config" title="Нажмите, чтобы перейти в Настройки" onclick="location.href='/settings.html'"></span>
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
</header>
@@ -457,6 +470,22 @@ async function api(path, opts = {}) {
return res.json();
}
/* ---------- active config ---------- */
async function refreshActiveConfig() {
try {
const res = await fetch("/configs/active");
if (!res.ok) {
$("active-config").textContent = "";
return;
}
const c = await res.json();
const label = `активная: v${c.version}${c.name ? " · " + c.name : ""}`;
$("active-config").textContent = label;
} catch (_) {
$("active-config").textContent = "";
}
}
/* ---------- health ---------- */
async function refreshHealth() {
try {
@@ -674,8 +703,10 @@ async function deleteThread(id, name) {
/* ---------- init ---------- */
refreshHealth();
refreshActiveConfig();
refreshThreads();
setInterval(refreshHealth, 15000);
setInterval(refreshActiveConfig, 15000);
</script>
</body>