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 @@ + + + + + +Chat Agent for Patients — Настройки + + + + +
+

Chat Agent for Patients

+ + +
+ +
+ +
+

Редактор конфигурации агента

+
При сохранении всегда создаётся новая версия — существующие не меняются. Это даёт честный откат.
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + +