from __future__ import annotations from typing import Any, Optional from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from ...config.ai_prompt_registry import get_prompt_registry_entry from ...models import PromptRelease from ...utils.timezone import now_cn_naive class PromptReleaseService: def __init__(self, db: AsyncSession): self.db = db @staticmethod def _snapshot(row: PromptRelease) -> dict[str, Any]: return { "id": int(row.id), "tenant_id": int(row.tenant_id), "prompt_key": row.prompt_key, "version": int(row.version), "body_snapshot": row.body_snapshot, "status": row.status, "notes": row.notes, "created_by": int(row.created_by) if row.created_by is not None else None, "activated_by": int(row.activated_by) if row.activated_by is not None else None, "activated_at": row.activated_at, } async def create_release( self, *, tenant_id: int, prompt_key: str, body_snapshot: str, created_by: Optional[int], notes: Optional[str] = None, ) -> dict[str, Any]: prompt_key = str(prompt_key or "").strip() if not prompt_key: raise ValueError("prompt_key is required") if get_prompt_registry_entry(prompt_key) is None: raise ValueError("unsupported prompt_key") body_snapshot = str(body_snapshot or "").strip() if not body_snapshot: raise ValueError("body_snapshot is required") current_max = ( await self.db.execute( select(func.max(PromptRelease.version)).where( PromptRelease.tenant_id == int(tenant_id), PromptRelease.prompt_key == prompt_key, ) ) ).scalar_one_or_none() row = PromptRelease( tenant_id=int(tenant_id), prompt_key=prompt_key, version=int(current_max or 0) + 1, body_snapshot=body_snapshot, status="draft", notes=str(notes or "").strip() or None, created_by=created_by, ) self.db.add(row) await self.db.commit() await self.db.refresh(row) return self._snapshot(row) async def activate_release( self, *, tenant_id: int, release_id: int, actor_id: Optional[int], ) -> dict[str, Any]: row = ( await self.db.execute( select(PromptRelease).where( PromptRelease.tenant_id == int(tenant_id), PromptRelease.id == int(release_id), ) ) ).scalar_one_or_none() if row is None: raise ValueError("prompt release not found") previous = ( await self.db.execute( select(PromptRelease).where( PromptRelease.tenant_id == int(tenant_id), PromptRelease.prompt_key == row.prompt_key, PromptRelease.status == "active", ) ) ).scalars().all() for item in previous: item.status = "archived" row.status = "active" row.activated_by = actor_id row.activated_at = now_cn_naive().isoformat() await self.db.commit() await self.db.refresh(row) return self._snapshot(row) async def list_releases( self, *, tenant_id: int, prompt_key: Optional[str] = None, ) -> dict[str, Any]: stmt = select(PromptRelease).where(PromptRelease.tenant_id == int(tenant_id)) if prompt_key: stmt = stmt.where(PromptRelease.prompt_key == str(prompt_key).strip()) rows = ( await self.db.execute( stmt.order_by(PromptRelease.prompt_key.asc(), PromptRelease.version.desc(), PromptRelease.id.desc()) ) ).scalars().all() return {"items": [self._snapshot(row) for row in rows], "total": len(rows)} async def get_active_template( self, *, tenant_id: int, prompt_key: str, ) -> Optional[str]: row = ( await self.db.execute( select(PromptRelease.body_snapshot).where( PromptRelease.tenant_id == int(tenant_id), PromptRelease.prompt_key == str(prompt_key or "").strip(), PromptRelease.status == "active", ) ) ).scalar_one_or_none() return str(row).strip() if row else None async def render_prompt( self, *, tenant_id: int, prompt_key: str, fallback_template: str, _override_template: Optional[str] = None, **kwargs: Any, ) -> str: template = str(_override_template or "").strip() if _override_template is not None else "" if not template: template = str(await self.get_active_template(tenant_id=tenant_id, prompt_key=prompt_key) or "").strip() if not template: template = str(fallback_template or "") try: return str(template).format(**kwargs).strip() except Exception: return str(fallback_template or "").format(**kwargs).strip()