2026-02-27 06:15:10 +00:00
|
|
|
"""
|
|
|
|
|
Prompt template service for AI Service.
|
|
|
|
|
[AC-AISVC-51~AC-AISVC-58] Template CRUD, version management, publish/rollback, and caching.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import time
|
|
|
|
|
import uuid
|
2026-02-28 04:52:50 +00:00
|
|
|
from collections.abc import Sequence
|
2026-02-27 06:15:10 +00:00
|
|
|
from datetime import datetime
|
2026-02-28 04:52:50 +00:00
|
|
|
from typing import Any
|
2026-02-27 06:15:10 +00:00
|
|
|
|
2026-02-28 04:52:50 +00:00
|
|
|
from sqlalchemy import select
|
2026-02-27 06:15:10 +00:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlmodel import col
|
|
|
|
|
|
2026-02-28 04:52:50 +00:00
|
|
|
from app.core.prompts import SYSTEM_PROMPT
|
2026-02-27 06:15:10 +00:00
|
|
|
from app.models.entities import (
|
|
|
|
|
PromptTemplate,
|
|
|
|
|
PromptTemplateCreate,
|
|
|
|
|
PromptTemplateUpdate,
|
2026-02-28 04:52:50 +00:00
|
|
|
PromptTemplateVersion,
|
2026-02-27 06:15:10 +00:00
|
|
|
TemplateVersionStatus,
|
|
|
|
|
)
|
|
|
|
|
from app.services.prompt.variable_resolver import VariableResolver
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
CACHE_TTL_SECONDS = 300
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TemplateCache:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-51] In-memory cache for published templates.
|
|
|
|
|
Key: (tenant_id, scene)
|
|
|
|
|
Value: (template_version, cached_at)
|
|
|
|
|
TTL: 300 seconds
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, ttl_seconds: int = CACHE_TTL_SECONDS):
|
|
|
|
|
self._cache: dict[tuple[str, str], tuple[PromptTemplateVersion, float]] = {}
|
|
|
|
|
self._ttl = ttl_seconds
|
|
|
|
|
|
|
|
|
|
def get(self, tenant_id: str, scene: str) -> PromptTemplateVersion | None:
|
|
|
|
|
"""Get cached template version if not expired."""
|
|
|
|
|
key = (tenant_id, scene)
|
|
|
|
|
if key in self._cache:
|
|
|
|
|
version, cached_at = self._cache[key]
|
|
|
|
|
if time.time() - cached_at < self._ttl:
|
|
|
|
|
return version
|
|
|
|
|
else:
|
|
|
|
|
del self._cache[key]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def set(self, tenant_id: str, scene: str, version: PromptTemplateVersion) -> None:
|
|
|
|
|
"""Cache a template version."""
|
|
|
|
|
key = (tenant_id, scene)
|
|
|
|
|
self._cache[key] = (version, time.time())
|
|
|
|
|
|
|
|
|
|
def invalidate(self, tenant_id: str, scene: str | None = None) -> None:
|
|
|
|
|
"""Invalidate cache for a tenant (optionally for a specific scene)."""
|
|
|
|
|
if scene:
|
|
|
|
|
key = (tenant_id, scene)
|
|
|
|
|
if key in self._cache:
|
|
|
|
|
del self._cache[key]
|
|
|
|
|
else:
|
|
|
|
|
keys_to_delete = [k for k in self._cache if k[0] == tenant_id]
|
|
|
|
|
for key in keys_to_delete:
|
|
|
|
|
del self._cache[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_template_cache = TemplateCache()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PromptTemplateService:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-52~AC-AISVC-58] Service for managing prompt templates.
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
Features:
|
|
|
|
|
- Template CRUD with tenant isolation
|
|
|
|
|
- Version management (auto-create new version on update)
|
|
|
|
|
- Publish/rollback functionality
|
|
|
|
|
- In-memory caching with TTL
|
|
|
|
|
- Fallback to hardcoded SYSTEM_PROMPT
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, session: AsyncSession):
|
|
|
|
|
self._session = session
|
|
|
|
|
self._cache = _template_cache
|
|
|
|
|
|
|
|
|
|
async def create_template(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
create_data: PromptTemplateCreate,
|
|
|
|
|
) -> PromptTemplate:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-52] Create a new prompt template with initial version.
|
2026-03-02 14:15:19 +00:00
|
|
|
[AC-IDSMETA-16] Support metadata field.
|
2026-02-27 06:15:10 +00:00
|
|
|
"""
|
|
|
|
|
template = PromptTemplate(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
name=create_data.name,
|
|
|
|
|
scene=create_data.scene,
|
|
|
|
|
description=create_data.description,
|
|
|
|
|
is_default=create_data.is_default,
|
2026-03-02 14:15:19 +00:00
|
|
|
metadata_=create_data.metadata_,
|
2026-02-27 06:15:10 +00:00
|
|
|
)
|
|
|
|
|
self._session.add(template)
|
|
|
|
|
await self._session.flush()
|
|
|
|
|
|
|
|
|
|
initial_version = PromptTemplateVersion(
|
|
|
|
|
template_id=template.id,
|
|
|
|
|
version=1,
|
|
|
|
|
status=TemplateVersionStatus.DRAFT.value,
|
|
|
|
|
system_instruction=create_data.system_instruction,
|
|
|
|
|
variables=create_data.variables,
|
|
|
|
|
)
|
|
|
|
|
self._session.add(initial_version)
|
|
|
|
|
await self._session.flush()
|
|
|
|
|
|
|
|
|
|
logger.info(
|
2026-03-02 14:15:19 +00:00
|
|
|
f"[AC-AISVC-52][AC-IDSMETA-16] Created prompt template: tenant={tenant_id}, "
|
2026-02-27 06:15:10 +00:00
|
|
|
f"id={template.id}, name={template.name}"
|
|
|
|
|
)
|
|
|
|
|
return template
|
|
|
|
|
|
|
|
|
|
async def list_templates(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
scene: str | None = None,
|
|
|
|
|
) -> Sequence[PromptTemplate]:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-57] List templates for a tenant, optionally filtered by scene.
|
|
|
|
|
"""
|
|
|
|
|
stmt = select(PromptTemplate).where(
|
|
|
|
|
PromptTemplate.tenant_id == tenant_id
|
|
|
|
|
)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
if scene:
|
|
|
|
|
stmt = stmt.where(PromptTemplate.scene == scene)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
stmt = stmt.order_by(col(PromptTemplate.created_at).desc())
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalars().all()
|
|
|
|
|
|
|
|
|
|
async def get_template(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
) -> PromptTemplate | None:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-58] Get template by ID with tenant isolation.
|
|
|
|
|
"""
|
|
|
|
|
stmt = select(PromptTemplate).where(
|
|
|
|
|
PromptTemplate.tenant_id == tenant_id,
|
|
|
|
|
PromptTemplate.id == template_id,
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def get_template_detail(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-58] Get template detail with all versions.
|
|
|
|
|
"""
|
|
|
|
|
template = await self.get_template(tenant_id, template_id)
|
|
|
|
|
if not template:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
versions = await self._get_versions(template_id)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
current_version = None
|
|
|
|
|
for v in versions:
|
|
|
|
|
if v.status == TemplateVersionStatus.PUBLISHED.value:
|
|
|
|
|
current_version = v
|
|
|
|
|
break
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
return {
|
|
|
|
|
"id": str(template.id),
|
|
|
|
|
"name": template.name,
|
|
|
|
|
"scene": template.scene,
|
|
|
|
|
"description": template.description,
|
|
|
|
|
"is_default": template.is_default,
|
2026-03-02 14:15:19 +00:00
|
|
|
"metadata": template.metadata_,
|
2026-02-27 06:15:10 +00:00
|
|
|
"current_version": {
|
|
|
|
|
"version": current_version.version,
|
|
|
|
|
"status": current_version.status,
|
|
|
|
|
"system_instruction": current_version.system_instruction,
|
|
|
|
|
"variables": current_version.variables or [],
|
|
|
|
|
} if current_version else None,
|
|
|
|
|
"versions": [
|
|
|
|
|
{
|
|
|
|
|
"version": v.version,
|
|
|
|
|
"status": v.status,
|
|
|
|
|
"created_at": v.created_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
for v in versions
|
|
|
|
|
],
|
|
|
|
|
"created_at": template.created_at.isoformat(),
|
|
|
|
|
"updated_at": template.updated_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def update_template(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
update_data: PromptTemplateUpdate,
|
|
|
|
|
) -> PromptTemplate | None:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-53] Update template and create a new version.
|
2026-03-02 14:15:19 +00:00
|
|
|
[AC-IDSMETA-16] Support metadata field.
|
2026-02-27 06:15:10 +00:00
|
|
|
"""
|
|
|
|
|
template = await self.get_template(tenant_id, template_id)
|
|
|
|
|
if not template:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
if update_data.name is not None:
|
|
|
|
|
template.name = update_data.name
|
|
|
|
|
if update_data.scene is not None:
|
|
|
|
|
template.scene = update_data.scene
|
|
|
|
|
if update_data.description is not None:
|
|
|
|
|
template.description = update_data.description
|
|
|
|
|
if update_data.is_default is not None:
|
|
|
|
|
template.is_default = update_data.is_default
|
2026-03-02 14:15:19 +00:00
|
|
|
if update_data.metadata_ is not None:
|
|
|
|
|
template.metadata_ = update_data.metadata_
|
2026-02-27 06:15:10 +00:00
|
|
|
template.updated_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
if update_data.system_instruction is not None:
|
|
|
|
|
latest_version = await self._get_latest_version(template_id)
|
|
|
|
|
new_version_num = (latest_version.version + 1) if latest_version else 1
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
new_version = PromptTemplateVersion(
|
|
|
|
|
template_id=template_id,
|
|
|
|
|
version=new_version_num,
|
|
|
|
|
status=TemplateVersionStatus.DRAFT.value,
|
|
|
|
|
system_instruction=update_data.system_instruction,
|
|
|
|
|
variables=update_data.variables,
|
|
|
|
|
)
|
|
|
|
|
self._session.add(new_version)
|
|
|
|
|
|
|
|
|
|
await self._session.flush()
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
self._cache.invalidate(tenant_id, template.scene)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
logger.info(
|
2026-03-02 14:15:19 +00:00
|
|
|
f"[AC-AISVC-53][AC-IDSMETA-16] Updated prompt template: tenant={tenant_id}, id={template_id}"
|
2026-02-27 06:15:10 +00:00
|
|
|
)
|
|
|
|
|
return template
|
|
|
|
|
|
|
|
|
|
async def publish_version(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
version: int,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-54] Publish a specific version of the template.
|
|
|
|
|
Old published version will be archived.
|
|
|
|
|
"""
|
|
|
|
|
template = await self.get_template(tenant_id, template_id)
|
|
|
|
|
if not template:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
versions = await self._get_versions(template_id)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
for v in versions:
|
|
|
|
|
if v.status == TemplateVersionStatus.PUBLISHED.value:
|
|
|
|
|
v.status = TemplateVersionStatus.ARCHIVED.value
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
target_version = None
|
|
|
|
|
for v in versions:
|
|
|
|
|
if v.version == version:
|
|
|
|
|
target_version = v
|
|
|
|
|
break
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
if not target_version:
|
|
|
|
|
return False
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
target_version.status = TemplateVersionStatus.PUBLISHED.value
|
|
|
|
|
await self._session.flush()
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
self._cache.invalidate(tenant_id, template.scene)
|
|
|
|
|
self._cache.set(tenant_id, template.scene, target_version)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
logger.info(
|
|
|
|
|
f"[AC-AISVC-54] Published template version: tenant={tenant_id}, "
|
|
|
|
|
f"template_id={template_id}, version={version}"
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def rollback_version(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
version: int,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-55] Rollback to a specific historical version.
|
|
|
|
|
"""
|
|
|
|
|
return await self.publish_version(tenant_id, template_id, version)
|
|
|
|
|
|
|
|
|
|
async def get_published_template(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
scene: str,
|
|
|
|
|
resolver: VariableResolver | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""
|
|
|
|
|
[AC-AISVC-51, AC-AISVC-56] Get the published template for a scene.
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
Resolution order:
|
|
|
|
|
1. Check in-memory cache
|
|
|
|
|
2. Query database for published version
|
|
|
|
|
3. Fallback to hardcoded SYSTEM_PROMPT
|
|
|
|
|
"""
|
|
|
|
|
cached = self._cache.get(tenant_id, scene)
|
|
|
|
|
if cached:
|
|
|
|
|
logger.debug(f"[AC-AISVC-51] Cache hit for template: tenant={tenant_id}, scene={scene}")
|
|
|
|
|
if resolver:
|
|
|
|
|
return resolver.resolve(cached.system_instruction, cached.variables)
|
|
|
|
|
return cached.system_instruction
|
|
|
|
|
|
|
|
|
|
stmt = (
|
|
|
|
|
select(PromptTemplateVersion)
|
|
|
|
|
.join(PromptTemplate, PromptTemplateVersion.template_id == PromptTemplate.id)
|
|
|
|
|
.where(
|
|
|
|
|
PromptTemplate.tenant_id == tenant_id,
|
|
|
|
|
PromptTemplate.scene == scene,
|
|
|
|
|
PromptTemplateVersion.status == TemplateVersionStatus.PUBLISHED.value,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
published_version = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
if published_version:
|
|
|
|
|
self._cache.set(tenant_id, scene, published_version)
|
|
|
|
|
logger.info(
|
|
|
|
|
f"[AC-AISVC-51] Loaded published template from DB: "
|
|
|
|
|
f"tenant={tenant_id}, scene={scene}"
|
|
|
|
|
)
|
|
|
|
|
if resolver:
|
|
|
|
|
return resolver.resolve(published_version.system_instruction, published_version.variables)
|
|
|
|
|
return published_version.system_instruction
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
f"[AC-AISVC-51] No published template found, using fallback: "
|
|
|
|
|
f"tenant={tenant_id}, scene={scene}"
|
|
|
|
|
)
|
|
|
|
|
return SYSTEM_PROMPT
|
|
|
|
|
|
|
|
|
|
async def get_published_version_info(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
) -> int | None:
|
|
|
|
|
"""Get the published version number for a template."""
|
|
|
|
|
stmt = (
|
|
|
|
|
select(PromptTemplateVersion)
|
|
|
|
|
.where(
|
|
|
|
|
PromptTemplateVersion.template_id == template_id,
|
|
|
|
|
PromptTemplateVersion.status == TemplateVersionStatus.PUBLISHED.value,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
version = result.scalar_one_or_none()
|
|
|
|
|
return version.version if version else None
|
|
|
|
|
|
|
|
|
|
async def _get_versions(
|
|
|
|
|
self,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
) -> Sequence[PromptTemplateVersion]:
|
|
|
|
|
"""Get all versions for a template, ordered by version desc."""
|
|
|
|
|
stmt = (
|
|
|
|
|
select(PromptTemplateVersion)
|
|
|
|
|
.where(PromptTemplateVersion.template_id == template_id)
|
|
|
|
|
.order_by(col(PromptTemplateVersion.version).desc())
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalars().all()
|
|
|
|
|
|
|
|
|
|
async def _get_latest_version(
|
|
|
|
|
self,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
) -> PromptTemplateVersion | None:
|
|
|
|
|
"""Get the latest version for a template."""
|
|
|
|
|
stmt = (
|
|
|
|
|
select(PromptTemplateVersion)
|
|
|
|
|
.where(PromptTemplateVersion.template_id == template_id)
|
|
|
|
|
.order_by(col(PromptTemplateVersion.version).desc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
result = await self._session.execute(stmt)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
async def delete_template(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
template_id: uuid.UUID,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Delete a template and all its versions."""
|
|
|
|
|
template = await self.get_template(tenant_id, template_id)
|
|
|
|
|
if not template:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-03-02 14:15:19 +00:00
|
|
|
from sqlalchemy import delete
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-03-02 14:15:19 +00:00
|
|
|
await self._session.execute(
|
|
|
|
|
delete(PromptTemplateVersion).where(
|
|
|
|
|
PromptTemplateVersion.template_id == template_id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await self._session.execute(
|
|
|
|
|
delete(PromptTemplate).where(
|
|
|
|
|
PromptTemplate.id == template_id
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-02-27 06:15:10 +00:00
|
|
|
await self._session.flush()
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
self._cache.invalidate(tenant_id, template.scene)
|
2026-02-28 04:52:50 +00:00
|
|
|
|
2026-02-27 06:15:10 +00:00
|
|
|
logger.info(
|
|
|
|
|
f"Deleted prompt template: tenant={tenant_id}, id={template_id}"
|
|
|
|
|
)
|
|
|
|
|
return True
|