a6f89067e7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 55s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m13s
CI/CD Pipeline / Validate - Style (push) Successful in 4m37s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m30s
CI/CD Pipeline / Validate - Security (push) Successful in 10m55s
CI/CD Pipeline / Unit Tests (push) Successful in 11m17s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
161 lines
5.0 KiB
Python
161 lines
5.0 KiB
Python
"""Prompt 模板加载器:从 viral_video_prompt_templates 读模板,30 秒 TTL 热加载。
|
||
|
||
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
import sqlalchemy as sa
|
||
|
||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||
|
||
CACHE_TTL_SECONDS = 30.0
|
||
|
||
_VALID_TYPES = {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
||
|
||
|
||
@dataclass
|
||
class PromptTemplate:
|
||
name: str
|
||
prompt_type: str
|
||
version: int
|
||
system_prompt: str
|
||
user_prompt_template: str
|
||
example_output: str = ""
|
||
is_active: bool = True
|
||
|
||
|
||
_lock = threading.Lock()
|
||
_cache: dict[str, tuple[float, PromptTemplate]] = {}
|
||
|
||
|
||
def _fallback(prompt_type: str) -> Optional[PromptTemplate]:
|
||
for item in DEFAULT_TEMPLATES:
|
||
if item["prompt_type"] == prompt_type:
|
||
return PromptTemplate(
|
||
name=item["name"],
|
||
prompt_type=item["prompt_type"],
|
||
version=item["version"],
|
||
system_prompt=item["system_prompt"],
|
||
user_prompt_template=item["user_prompt_template"],
|
||
example_output=item["example_output"] or "",
|
||
is_active=bool(item["is_active"]),
|
||
)
|
||
return None
|
||
|
||
|
||
_lazy_session = None
|
||
|
||
|
||
def _get_session():
|
||
"""优先用全局 SessionLocal(worker);否则按应用配置懒建同步引擎(api)。"""
|
||
global _lazy_session
|
||
if _session_mod.SessionLocal is not None:
|
||
return _session_mod.SessionLocal()
|
||
if _lazy_session is not None:
|
||
return _lazy_session()
|
||
try:
|
||
from packages.config import get_shared_settings
|
||
|
||
url = str(get_shared_settings().database_url)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
if not url:
|
||
return None
|
||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||
url = url.replace("postgresql://", "postgresql+psycopg://") if url.startswith("postgresql://") else url
|
||
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
_lazy_session = sessionmaker(bind=engine)
|
||
return _lazy_session()
|
||
|
||
|
||
def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
|
||
session = None
|
||
try:
|
||
session = _get_session()
|
||
if session is None:
|
||
return None
|
||
sql = sa.text("""
|
||
SELECT name, prompt_type, version, system_prompt,
|
||
user_prompt_template, COALESCE(example_output, '') AS example_output,
|
||
is_active
|
||
FROM viral_video_prompt_templates
|
||
WHERE prompt_type = :pt AND is_active = TRUE
|
||
ORDER BY version DESC
|
||
LIMIT 1
|
||
""")
|
||
row = session.execute(sql, {"pt": prompt_type}).first()
|
||
if row is None:
|
||
return None
|
||
return PromptTemplate(
|
||
name=row[0],
|
||
prompt_type=row[1],
|
||
version=int(row[2]),
|
||
system_prompt=row[3],
|
||
user_prompt_template=row[4],
|
||
example_output=row[5] or "",
|
||
is_active=bool(row[6]),
|
||
)
|
||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||
return None
|
||
finally:
|
||
if session is not None:
|
||
try:
|
||
session.close()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
|
||
"""取某类型当前启用模板,30 秒缓存;DB 无数据则回落到代码默认模板。"""
|
||
if prompt_type not in _VALID_TYPES:
|
||
raise ValueError(f"未知 prompt_type: {prompt_type}")
|
||
|
||
now = time.monotonic()
|
||
with _lock:
|
||
cached = _cache.get(prompt_type)
|
||
if not force_refresh and cached and now - cached[0] < CACHE_TTL_SECONDS:
|
||
return cached[1]
|
||
|
||
template = _load_from_db(prompt_type) or _fallback(prompt_type)
|
||
if template is not None:
|
||
with _lock:
|
||
_cache[prompt_type] = (now, template)
|
||
return template
|
||
|
||
|
||
def invalidate() -> None:
|
||
"""清空缓存(测试用)。"""
|
||
with _lock:
|
||
_cache.clear()
|
||
|
||
|
||
class _SafeDict(dict):
|
||
def __missing__(self, key: str) -> str:
|
||
return "{" + key + "}"
|
||
|
||
|
||
def _safe_format(text: str, kwargs: dict) -> str:
|
||
try:
|
||
return text.format_map(_SafeDict(kwargs))
|
||
except Exception: # noqa: BLE001
|
||
return text
|
||
|
||
|
||
def render_user_prompt(template: PromptTemplate, **kwargs) -> str:
|
||
"""填充 user_prompt_template 占位符,缺键原样保留不报错。"""
|
||
return _safe_format(template.user_prompt_template, kwargs)
|
||
|
||
|
||
def render_system_prompt(template: PromptTemplate, **kwargs) -> str:
|
||
"""copy_fusion 等 system_prompt 含运行时变量时填充。"""
|
||
return _safe_format(template.system_prompt, kwargs)
|