236 lines
8.5 KiB
Python
236 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
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, PromptReleaseCheck
|
|
from ...utils.timezone import now_cn_naive
|
|
|
|
|
|
_RELEASE_CHECK_TYPES = ("variable_contract", "schema_smoke", "golden_replay")
|
|
PLATFORM_RUNTIME_TENANT_ID = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
|
|
|
|
|
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],
|
|
prompt_key: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
filters = [
|
|
PromptRelease.tenant_id == int(tenant_id),
|
|
PromptRelease.id == int(release_id),
|
|
]
|
|
if prompt_key:
|
|
filters.append(PromptRelease.prompt_key == str(prompt_key).strip())
|
|
row = (
|
|
await self.db.execute(
|
|
select(PromptRelease).where(*filters)
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise ValueError("prompt release not found")
|
|
registry_entry = get_prompt_registry_entry(row.prompt_key)
|
|
if registry_entry and str(registry_entry.get("editable_policy") or "") == "guarded_release":
|
|
await self._assert_guarded_gate_passed(tenant_id=tenant_id, release_id=int(row.id))
|
|
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 _assert_guarded_gate_passed(self, *, tenant_id: int, release_id: int) -> None:
|
|
rows = (
|
|
await self.db.execute(
|
|
select(PromptReleaseCheck)
|
|
.where(
|
|
PromptReleaseCheck.tenant_id == int(tenant_id),
|
|
PromptReleaseCheck.release_id == int(release_id),
|
|
)
|
|
.order_by(PromptReleaseCheck.check_type.asc(), PromptReleaseCheck.id.desc())
|
|
)
|
|
).scalars().all()
|
|
latest: dict[str, PromptReleaseCheck] = {}
|
|
for row in rows:
|
|
check_type = str(row.check_type or "")
|
|
if check_type and check_type not in latest:
|
|
latest[check_type] = row
|
|
blocking: list[str] = []
|
|
for check_type in _RELEASE_CHECK_TYPES:
|
|
row = latest.get(check_type)
|
|
if row is None:
|
|
blocking.append(f"{check_type} not run")
|
|
elif str(row.status or "") != "passed":
|
|
blocking.append(f"{check_type}: {row.summary}")
|
|
if blocking:
|
|
raise ValueError(f"guarded prompt release is blocked by gate: {'; '.join(blocking)}")
|
|
|
|
async def rollback_release(
|
|
self,
|
|
*,
|
|
tenant_id: int,
|
|
prompt_key: str,
|
|
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.prompt_key == str(prompt_key or "").strip(),
|
|
PromptRelease.id == int(release_id),
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise ValueError("prompt release not found")
|
|
if row.status != "archived":
|
|
raise ValueError("only archived prompt releases can be rolled back")
|
|
return await self.activate_release(
|
|
tenant_id=tenant_id,
|
|
prompt_key=prompt_key,
|
|
release_id=release_id,
|
|
actor_id=actor_id,
|
|
)
|
|
|
|
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.get_active_release(tenant_id=tenant_id, prompt_key=prompt_key)
|
|
return str(row.get("body_snapshot") or "").strip() if row else None
|
|
|
|
async def get_active_release(
|
|
self,
|
|
*,
|
|
tenant_id: int,
|
|
prompt_key: str,
|
|
) -> Optional[dict[str, Any]]:
|
|
row = (
|
|
await self.db.execute(
|
|
select(PromptRelease).where(
|
|
PromptRelease.tenant_id == int(tenant_id),
|
|
PromptRelease.prompt_key == str(prompt_key or "").strip(),
|
|
PromptRelease.status == "active",
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
return self._snapshot(row) 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 and int(tenant_id) != PLATFORM_RUNTIME_TENANT_ID:
|
|
template = str(
|
|
await self.get_active_template(tenant_id=PLATFORM_RUNTIME_TENANT_ID, prompt_key=prompt_key) or ""
|
|
).strip()
|
|
if not template:
|
|
raise RuntimeError(f"active prompt release not found: {prompt_key}")
|
|
try:
|
|
return str(template).format(**kwargs).strip()
|
|
except Exception as exc:
|
|
raise RuntimeError(f"active prompt release render failed: {prompt_key}") from exc
|