From 7ec2ba3c8f3318b19f1b9012414db5976ebe1a92 Mon Sep 17 00:00:00 2001
From: AR 15 M4
Date: Thu, 23 Apr 2026 19:59:06 +0500
Subject: [PATCH] =?UTF-8?q?feat(sprint3):=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?=
=?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD?=
=?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82=D0=B0?=
=?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=81=20?=
=?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=BE=D0=BD=D0=B8=D1=80=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Операторы получают веб-редактор: правят системный промпт и правила,
сохраняют как новую версию, активируют, откатываются. Активная версия
используется в «Песочнице» на каждый /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)
---
SPRINTS.md | 2 +-
db/models/__init__.py | 3 +-
db/models/agent_config.py | 27 ++
main.py | 9 +-
.../b4450e33664d_add_agent_configs_table.py | 43 ++
models/requests.py | 7 +
models/responses.py | 19 +
routers/configs.py | 89 ++++
services/chat_service.py | 10 +-
services/config_service.py | 115 +++++
static/index.html | 1 +
static/sandbox.html | 33 +-
static/settings.html | 443 ++++++++++++++++++
13 files changed, 796 insertions(+), 5 deletions(-)
create mode 100644 db/models/agent_config.py
create mode 100644 migrations/versions/b4450e33664d_add_agent_configs_table.py
create mode 100644 routers/configs.py
create mode 100644 services/config_service.py
create mode 100644 static/settings.html
diff --git a/SPRINTS.md b/SPRINTS.md
index d456adc..0de65d5 100644
--- a/SPRINTS.md
+++ b/SPRINTS.md
@@ -123,7 +123,7 @@
### Цель
Дать операторам веб-редактор системного промпта и списка правил («если спрашивают про X — отвечай так-то», «если пациент злится — делай то-то»). Версионирование: можно сохранить конфигурацию и откатиться.
-### Статус: ⏳ Запланирован
+### Статус: ✅ Закрыт
### Задачи
- [ ] Хранилище (SQLite): `agent_configs` (version, created_at, system_prompt, rules_text, is_active)
diff --git a/db/models/__init__.py b/db/models/__init__.py
index 5697071..f22ba38 100644
--- a/db/models/__init__.py
+++ b/db/models/__init__.py
@@ -1,5 +1,6 @@
+from db.models.agent_config import AgentConfig
from db.models.document import Document
from db.models.message import Message
from db.models.thread import Thread
-__all__ = ["Thread", "Message", "Document"]
+__all__ = ["Thread", "Message", "Document", "AgentConfig"]
diff --git a/db/models/agent_config.py b/db/models/agent_config.py
new file mode 100644
index 0000000..8716ba3
--- /dev/null
+++ b/db/models/agent_config.py
@@ -0,0 +1,27 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from db.base import Base
+
+
+def _utcnow() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+class AgentConfig(Base):
+ """Версионируемая конфигурация агента: системный промпт + правила.
+
+ Принцип: сохранённые версии не редактируются, только создаются новые.
+ Активна одна версия одновременно.
+ """
+ __tablename__ = "agent_configs"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ version: Mapped[int] = mapped_column(Integer, nullable=False, unique=True, index=True)
+ name: Mapped[str | None] = mapped_column(String(200), nullable=True)
+ system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
+ rules_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
diff --git a/main.py b/main.py
index a97b9ae..ea2cbda 100644
--- a/main.py
+++ b/main.py
@@ -23,6 +23,8 @@ logging.basicConfig(
force=True,
)
+from db.session import SessionLocal # noqa: E402
+from services import config_service # noqa: E402
from services.embeddings import EmbeddingService # noqa: E402
from services.llm_client import LLMClient # noqa: E402
from services.vectorstore import VectorStoreService # noqa: E402
@@ -56,6 +58,10 @@ async def lifespan(app: FastAPI):
logger.info("ChromaDB initialized at %s", settings.chroma_persist_dir)
llm_client = LLMClient()
logger.info("LLM client ready (model=%s)", llm_client.model)
+
+ async with SessionLocal() as session:
+ await config_service.ensure_seed(session)
+
yield
logger.info("Shutting down")
@@ -75,12 +81,13 @@ app.add_middleware(
allow_headers=["*"],
)
-from routers import chat, documents, health, query, threads # noqa: E402
+from routers import chat, configs, documents, health, 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.mount("/", StaticFiles(directory="static", html=True), name="static")
diff --git a/migrations/versions/b4450e33664d_add_agent_configs_table.py b/migrations/versions/b4450e33664d_add_agent_configs_table.py
new file mode 100644
index 0000000..b1ae83a
--- /dev/null
+++ b/migrations/versions/b4450e33664d_add_agent_configs_table.py
@@ -0,0 +1,43 @@
+"""add agent_configs table
+
+Revision ID: b4450e33664d
+Revises: 7ee7296ccd6d
+Create Date: 2026-04-23 19:03:27.867415
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'b4450e33664d'
+down_revision: Union[str, None] = '7ee7296ccd6d'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('agent_configs',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('version', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=200), nullable=True),
+ sa.Column('system_prompt', sa.Text(), nullable=False),
+ sa.Column('rules_text', sa.Text(), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_agent_configs_is_active'), 'agent_configs', ['is_active'], unique=False)
+ op.create_index(op.f('ix_agent_configs_version'), 'agent_configs', ['version'], unique=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_agent_configs_version'), table_name='agent_configs')
+ op.drop_index(op.f('ix_agent_configs_is_active'), table_name='agent_configs')
+ op.drop_table('agent_configs')
+ # ### end Alembic commands ###
diff --git a/models/requests.py b/models/requests.py
index f4333e0..9f95bae 100644
--- a/models/requests.py
+++ b/models/requests.py
@@ -19,3 +19,10 @@ class ChatRequest(BaseModel):
class ThreadRenameRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
+
+
+class AgentConfigCreateRequest(BaseModel):
+ system_prompt: str = Field(..., min_length=1)
+ rules_text: str = Field("", description="Правила в свободной markdown-форме")
+ name: str | None = Field(None, max_length=200)
+ activate: bool = Field(False, description="Сразу сделать новую версию активной")
diff --git a/models/responses.py b/models/responses.py
index 8bdc166..a69ad38 100644
--- a/models/responses.py
+++ b/models/responses.py
@@ -123,3 +123,22 @@ class ChatResponse(BaseModel):
class ThreadDeleteResponse(BaseModel):
ok: bool = True
deleted_messages: int
+
+
+class AgentConfigInfo(BaseModel):
+ id: int
+ version: int
+ name: str | None = None
+ system_prompt: str
+ rules_text: str = ""
+ is_active: bool
+ created_at: str
+
+
+class AgentConfigListResponse(BaseModel):
+ configs: list[AgentConfigInfo]
+ total: int
+
+
+class AgentConfigDeleteResponse(BaseModel):
+ ok: bool = True
diff --git a/routers/configs.py b/routers/configs.py
new file mode 100644
index 0000000..d472ef0
--- /dev/null
+++ b/routers/configs.py
@@ -0,0 +1,89 @@
+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)
diff --git a/services/chat_service.py b/services/chat_service.py
index f2cb837..143c70d 100644
--- a/services/chat_service.py
+++ b/services/chat_service.py
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from db.models import Message, Thread
+from services import config_service
from services.llm_client import LLMClient
from services.vectorstore import VectorStoreService
@@ -50,8 +51,14 @@ async def send_message(
max_tokens: int | None = None,
) -> dict:
"""Добавить реплику пациента в тред, получить ответ ассистента, сохранить оба сообщения."""
+ active_cfg = await config_service.get_active_config(session)
+ system_prompt = config_service.compose_full_system_prompt(active_cfg) if active_cfg else None
+
if thread_id is None:
- thread = Thread(name=_auto_thread_name(text))
+ thread = Thread(
+ name=_auto_thread_name(text),
+ agent_config_id=active_cfg.id if active_cfg else None,
+ )
session.add(thread)
await session.flush()
else:
@@ -81,6 +88,7 @@ async def send_message(
question=text,
sources=retrieved,
history=history,
+ system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
)
diff --git a/services/config_service.py b/services/config_service.py
new file mode 100644
index 0000000..c5a52da
--- /dev/null
+++ b/services/config_service.py
@@ -0,0 +1,115 @@
+"""Версионируемые конфигурации агента: создание, активация, чтение."""
+import logging
+from pathlib import Path
+
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from db.models import AgentConfig
+
+logger = logging.getLogger(__name__)
+
+SEED_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "system_prompt.md"
+
+
+def _load_seed_prompt() -> str:
+ try:
+ return SEED_PROMPT_PATH.read_text(encoding="utf-8").strip()
+ except FileNotFoundError:
+ logger.warning("Seed prompt file not found at %s — creating empty v1", SEED_PROMPT_PATH)
+ return ""
+
+
+async def ensure_seed(session: AsyncSession) -> None:
+ """Если таблица пустая, создать v1 из prompts/system_prompt.md и активировать."""
+ count = (await session.execute(select(func.count(AgentConfig.id)))).scalar_one()
+ if count > 0:
+ return
+
+ seed_text = _load_seed_prompt()
+ seed = AgentConfig(
+ version=1,
+ name="Исходная версия (из prompts/system_prompt.md)",
+ system_prompt=seed_text,
+ rules_text="",
+ is_active=True,
+ )
+ session.add(seed)
+ await session.commit()
+ logger.info("Seeded agent_configs with v1 from %s", SEED_PROMPT_PATH.name)
+
+
+async def list_configs(session: AsyncSession) -> list[AgentConfig]:
+ stmt = select(AgentConfig).order_by(AgentConfig.version.desc())
+ return list((await session.execute(stmt)).scalars().all())
+
+
+async def get_config(session: AsyncSession, config_id: int) -> AgentConfig | None:
+ return await session.get(AgentConfig, config_id)
+
+
+async def get_active_config(session: AsyncSession) -> AgentConfig | None:
+ stmt = select(AgentConfig).where(AgentConfig.is_active.is_(True)).limit(1)
+ return (await session.execute(stmt)).scalar_one_or_none()
+
+
+async def create_config(
+ session: AsyncSession,
+ system_prompt: str,
+ rules_text: str,
+ name: str | None = None,
+ activate: bool = False,
+) -> AgentConfig:
+ """Создать новую версию. При activate=True — сразу сделать активной."""
+ next_version = (await session.execute(select(func.coalesce(func.max(AgentConfig.version), 0)))).scalar_one() + 1
+
+ if activate:
+ await session.execute(
+ update(AgentConfig).where(AgentConfig.is_active.is_(True)).values(is_active=False)
+ )
+
+ cfg = AgentConfig(
+ version=next_version,
+ name=(name or "").strip() or None,
+ system_prompt=system_prompt,
+ rules_text=rules_text or "",
+ is_active=activate,
+ )
+ session.add(cfg)
+ await session.commit()
+ await session.refresh(cfg)
+ return cfg
+
+
+async def activate_config(session: AsyncSession, config_id: int) -> AgentConfig | None:
+ cfg = await session.get(AgentConfig, config_id)
+ if cfg is None:
+ return None
+ await session.execute(
+ update(AgentConfig).where(AgentConfig.is_active.is_(True)).values(is_active=False)
+ )
+ cfg.is_active = True
+ await session.commit()
+ await session.refresh(cfg)
+ return cfg
+
+
+async def delete_config(session: AsyncSession, config_id: int) -> tuple[bool, str]:
+ """Удалить версию. Нельзя удалить активную. Возвращает (ok, reason_if_fail)."""
+ cfg = await session.get(AgentConfig, config_id)
+ if cfg is None:
+ return False, "not_found"
+ if cfg.is_active:
+ return False, "active"
+ await session.delete(cfg)
+ await session.commit()
+ return True, ""
+
+
+def compose_full_system_prompt(cfg: AgentConfig) -> str:
+ """Собрать из system_prompt + rules_text единую строку для LLM."""
+ base = (cfg.system_prompt or "").strip()
+ rules = (cfg.rules_text or "").strip()
+ if not rules:
+ return base
+ return f"{base}\n\nДополнительные правила:\n{rules}"
diff --git a/static/index.html b/static/index.html
index 98326cc..53d76b0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -342,6 +342,7 @@
проверяю…
diff --git a/static/sandbox.html b/static/sandbox.html
index df2eb6a..5a4f5b5 100644
--- a/static/sandbox.html
+++ b/static/sandbox.html
@@ -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 @@
+
проверяю…
@@ -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);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/settings.html b/static/settings.html
new file mode 100644
index 0000000..32aa186
--- /dev/null
+++ b/static/settings.html
@@ -0,0 +1,443 @@
+
+
+