"""Версионируемые конфигурации агента, привязанные к ветке (intent). Со Спринта 4 каждая версия относится к конкретной ветке графовой архитектуры. Активна одна версия в пределах ветки, не глобально. """ import logging from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from db.models import AgentConfig, Intent from services import intent_service logger = logging.getLogger(__name__) async def ensure_seed_configs(session: AsyncSession) -> None: """Для каждой ветки без конфигов — создать v1 из prompts/intents/{code}.md и активировать.""" intents = await intent_service.list_intents(session) for intent in intents: has_config = (await session.execute( select(func.count(AgentConfig.id)).where(AgentConfig.intent_id == intent.id) )).scalar_one() if has_config > 0: continue seed_text = intent_service.load_seed_prompt(intent.code) session.add(AgentConfig( intent_id=intent.id, version=1, name=f"Исходная версия (из prompts/intents/{intent.code}.md)", system_prompt=seed_text, rules_text="", is_active=True, )) logger.info("Seeded v1 for intent %r", intent.code) await session.commit() async def migrate_legacy_config_to_general_info(session: AsyncSession) -> None: """Одноразовая миграция: старый конфиг без intent_id цепляем к general_info. Он был сохранён как «единый» системный промпт — теперь это стартовый промпт общей справочной ветки. Если в general_info уже есть конфиги (сид отработал раньше) — не трогаем, чтобы не задвоить. """ orphan_stmt = select(AgentConfig).where(AgentConfig.intent_id.is_(None)) orphans = list((await session.execute(orphan_stmt)).scalars().all()) if not orphans: return general = await intent_service.get_intent_by_code(session, "general_info") if general is None: logger.warning("general_info intent not found, can't migrate legacy configs") return existing_versions = set((await session.execute( select(AgentConfig.version).where(AgentConfig.intent_id == general.id) )).scalars().all()) # Переносим по одному, смещая version при столкновении с сид-версиями. next_free = max(existing_versions, default=0) + 1 for cfg in sorted(orphans, key=lambda c: c.version): cfg.intent_id = general.id if cfg.version in existing_versions: cfg.version = next_free next_free += 1 # Если у ветки уже есть активная — ставим новые как неактивные. has_active = (await session.execute( select(func.count(AgentConfig.id)).where( AgentConfig.intent_id == general.id, AgentConfig.is_active.is_(True), AgentConfig.id != cfg.id, ) )).scalar_one() if has_active > 0: cfg.is_active = False existing_versions.add(cfg.version) await session.commit() logger.info("Migrated %d legacy config(s) to general_info", len(orphans)) async def list_configs_for_intent(session: AsyncSession, intent_id: int) -> list[AgentConfig]: stmt = ( select(AgentConfig) .where(AgentConfig.intent_id == intent_id) .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_for_intent(session: AsyncSession, intent_id: int) -> AgentConfig | None: stmt = ( select(AgentConfig) .where(AgentConfig.intent_id == intent_id, AgentConfig.is_active.is_(True)) .limit(1) ) return (await session.execute(stmt)).scalar_one_or_none() async def get_active_config_by_intent_code( session: AsyncSession, intent_code: str ) -> tuple[Intent, AgentConfig] | None: """Удобный шорткат для оркестратора: по коду ветки вернуть её + активный конфиг.""" intent = await intent_service.get_intent_by_code(session, intent_code) if intent is None: return None cfg = await get_active_config_for_intent(session, intent.id) if cfg is None: return None return intent, cfg async def create_config( session: AsyncSession, intent_id: int, 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)).where(AgentConfig.intent_id == intent_id) )).scalar_one() + 1 if activate: await session.execute( update(AgentConfig) .where(AgentConfig.intent_id == intent_id, AgentConfig.is_active.is_(True)) .values(is_active=False) ) cfg = AgentConfig( intent_id=intent_id, 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 or cfg.intent_id is None: return None await session.execute( update(AgentConfig) .where(AgentConfig.intent_id == cfg.intent_id, 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]: 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: base = (cfg.system_prompt or "").strip() rules = (cfg.rules_text or "").strip() if not rules: return base return f"{base}\n\nДополнительные правила:\n{rules}"