PMFlow/backend/app/services/prompt_assets_service.py

1766 lines
77 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Prompt asset read model for platform AI Ops."""
from __future__ import annotations
import hashlib
import json
import time
import string
from datetime import datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompt_registry import get_prompt_registry, get_prompt_registry_entry
from ..config.ai_prompt_eval_rubrics import get_prompt_eval_rubric
from ..models import (
AICallLog,
OptimizerCandidate,
PMFlowAIFeedbackEvent,
PromptGoldenCase,
PromptRelease,
PromptReleaseCheck,
PromptReplayCaseResult,
PromptReplayRun,
)
from ..utils.timezone import now_cn_naive
from .llm_service import llm_service
from .prompt_release import PromptReleaseService
RELEASE_CHECK_TYPES = ("variable_contract", "schema_smoke", "golden_replay")
QUALITY_WINDOWS_HOURS = {"24h": 24, "7d": 168, "30d": 720}
REPLAY_MODES = {"contract_smoke", "compare", "llm_compare"}
GOLDEN_CASE_TYPES = {"contract", "regression", "risk", "edge", "business"}
GOLDEN_CASE_PRIORITIES = {"blocking", "important", "observation"}
def _hash_text(text: str) -> str:
if not text:
return ""
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def _preview(text: str, limit: int = 420) -> str:
value = str(text or "").strip()
if len(value) <= limit:
return value
return f"{value[:limit].rstrip()}..."
def _template_variables(text: str) -> list[str]:
variables: set[str] = set()
formatter = string.Formatter()
try:
for _, field_name, _, _ in formatter.parse(str(text or "")):
if not field_name:
continue
head = str(field_name).split(".", 1)[0].split("[", 1)[0].strip()
if head and head.isidentifier():
variables.add(head)
except ValueError:
return []
return sorted(variables)
def _template_variables_or_error(text: str) -> list[str]:
variables: set[str] = set()
formatter = string.Formatter()
for _, field_name, _, _ in formatter.parse(str(text or "")):
if not field_name:
continue
head = str(field_name).split(".", 1)[0].split("[", 1)[0].strip()
if head and head.isidentifier():
variables.add(head)
return sorted(variables)
class PromptAssetService:
"""Combines static registry metadata with tenant prompt release state."""
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_assets(
self,
*,
tenant_id: int | None,
family: str | None = None,
runtime_source: str | None = None,
editable_policy: str | None = None,
keyword: str | None = None,
) -> dict[str, Any]:
releases = await self._release_index(tenant_id=tenant_id)
items = []
for entry in get_prompt_registry():
if family and str(entry.get("prompt_family") or "") != family:
continue
if runtime_source and str(entry.get("runtime_source") or "") != runtime_source:
continue
if editable_policy and str(entry.get("editable_policy") or "") != editable_policy:
continue
haystack = " ".join(
[
str(entry.get("prompt_key") or ""),
str(entry.get("business_scenario") or ""),
str(entry.get("module") or ""),
str(entry.get("responsibility_scope") or ""),
]
).lower()
if keyword and str(keyword).strip().lower() not in haystack:
continue
items.append(self._asset_payload(entry, releases=releases, include_body=False))
family_counts: dict[str, int] = {}
policy_counts: dict[str, int] = {}
source_counts: dict[str, int] = {}
for item in items:
family_counts[item["prompt_family"]] = family_counts.get(item["prompt_family"], 0) + 1
policy_counts[item["editable_policy"]] = policy_counts.get(item["editable_policy"], 0) + 1
source_counts[item["runtime_source"]] = source_counts.get(item["runtime_source"], 0) + 1
return {
"items": items,
"total": len(items),
"summary": {
"total_assets": len(items),
"active_release_count": sum(1 for item in items if item.get("active_release")),
"needs_migration_count": sum(1 for item in items if item.get("editable_policy") == "needs_migration"),
"guarded_count": sum(1 for item in items if item.get("editable_policy") == "guarded_release"),
"family_counts": family_counts,
"editable_policy_counts": policy_counts,
"runtime_source_counts": source_counts,
},
}
async def get_asset(self, *, tenant_id: int, prompt_key: str) -> dict[str, Any]:
entry = get_prompt_registry_entry(prompt_key)
if entry is None:
raise ValueError("prompt asset not found")
releases = await self._release_index(tenant_id=tenant_id, prompt_key=prompt_key, include_body=True)
payload = self._asset_payload(entry, releases=releases, include_body=True)
payload["quality_summary"] = await self.get_quality_summary(
tenant_id=tenant_id,
prompt_key=prompt_key,
window="7d",
)
return payload
async def create_release(
self,
*,
tenant_id: int,
prompt_key: str,
body_snapshot: str,
created_by: int | None,
notes: str | None = None,
) -> dict[str, Any]:
entry = self._releaseable_entry(prompt_key)
default_template = self._default_template(str(entry.get("prompt_key") or ""))
self._validate_release_body(default_template=default_template, body_snapshot=body_snapshot)
return await PromptReleaseService(self.db).create_release(
tenant_id=tenant_id,
prompt_key=str(entry.get("prompt_key") or "").strip(),
body_snapshot=body_snapshot,
created_by=created_by,
notes=notes,
)
async def list_releases(self, *, tenant_id: int, prompt_key: str) -> dict[str, Any]:
entry = get_prompt_registry_entry(prompt_key)
if entry is None:
raise ValueError("prompt asset not found")
return await PromptReleaseService(self.db).list_releases(tenant_id=tenant_id, prompt_key=prompt_key)
async def activate_release(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int,
actor_id: int | None,
) -> dict[str, Any]:
entry = self._releaseable_entry(prompt_key)
await self._assert_activation_allowed(
tenant_id=tenant_id,
prompt_key=prompt_key,
release_id=release_id,
entry=entry,
)
return await PromptReleaseService(self.db).activate_release(
tenant_id=tenant_id,
prompt_key=prompt_key,
release_id=release_id,
actor_id=actor_id,
)
async def rollback_release(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int,
actor_id: int | None,
) -> dict[str, Any]:
entry = self._releaseable_entry(prompt_key)
await self._assert_activation_allowed(
tenant_id=tenant_id,
prompt_key=prompt_key,
release_id=release_id,
entry=entry,
)
return await PromptReleaseService(self.db).rollback_release(
tenant_id=tenant_id,
prompt_key=prompt_key,
release_id=release_id,
actor_id=actor_id,
)
async def list_release_checks(self, *, tenant_id: int, prompt_key: str, release_id: int) -> dict[str, Any]:
release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id)
rows = (
await self.db.execute(
select(PromptReleaseCheck)
.where(
PromptReleaseCheck.tenant_id == int(tenant_id),
PromptReleaseCheck.prompt_key == str(prompt_key or "").strip(),
PromptReleaseCheck.release_id == int(release_id),
)
.order_by(PromptReleaseCheck.id.desc())
)
).scalars().all()
items = [self._check_payload(row) for row in rows]
return {
"release_id": int(release.id),
"prompt_key": str(release.prompt_key or ""),
"items": items,
"total": len(items),
"gate": self._gate_summary_from_checks(items),
}
async def run_release_checks(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int,
actor_id: int | None,
reason: str | None = None,
) -> dict[str, Any]:
entry = self._releaseable_entry(prompt_key)
release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id)
default_template = self._default_template(str(entry.get("prompt_key") or ""))
active_case_count = await self._active_golden_case_count(
tenant_id=tenant_id,
prompt_key=prompt_key,
)
latest_replay = await self._latest_replay_for_release(
tenant_id=tenant_id,
release_id=int(release_id),
)
check_specs = self._build_release_check_specs(
tenant_id=tenant_id,
entry=entry,
release=release,
default_template=default_template,
reason=reason,
active_case_count=active_case_count,
latest_replay=latest_replay,
)
rows: list[PromptReleaseCheck] = []
completed_at = now_cn_naive().isoformat()
for spec in check_specs:
row = PromptReleaseCheck(
tenant_id=int(tenant_id),
prompt_key=str(prompt_key or "").strip(),
release_id=int(release_id),
check_type=spec["check_type"],
status=spec["status"],
severity=spec["severity"],
summary=spec["summary"],
details_json=spec.get("details_json") or {},
created_by=actor_id,
completed_at=completed_at,
)
self.db.add(row)
rows.append(row)
await self.db.commit()
for row in rows:
await self.db.refresh(row)
items = [self._check_payload(row) for row in rows]
return {
"release_id": int(release.id),
"prompt_key": str(release.prompt_key or ""),
"items": items,
"total": len(items),
"gate": self._gate_summary_from_checks(items),
}
async def get_quality_summary(
self,
*,
tenant_id: int,
prompt_key: str,
window: str = "7d",
status: str | None = None,
release_id: int | None = None,
feedback_tenant_id: int | None = None,
include_all_pmflow_feedback: bool = False,
) -> dict[str, Any]:
if get_prompt_registry_entry(prompt_key) is None:
raise ValueError("prompt asset not found")
hours = QUALITY_WINDOWS_HOURS.get(str(window or "7d").strip(), QUALITY_WINDOWS_HOURS["7d"])
filters = [
AICallLog.tenant_id == int(tenant_id),
AICallLog.prompt_key == str(prompt_key or "").strip(),
AICallLog.created_at >= datetime.now() - timedelta(hours=hours),
]
if status and status != "all":
filters.append(AICallLog.status == status)
if release_id is not None:
filters.append(AICallLog.prompt_release_id == int(release_id))
where_clause = and_(*filters)
summary_stmt = select(
func.count(AICallLog.id),
func.sum(AICallLog.cost),
func.avg(AICallLog.latency_ms),
).where(where_clause)
total, cost, latency = (await self.db.execute(summary_stmt)).one()
total_calls = int(total or 0)
rows = (
await self.db.execute(
select(
AICallLog.status,
AICallLog.prompt_source,
AICallLog.prompt_release_id,
func.count(AICallLog.id),
)
.where(where_clause)
.group_by(AICallLog.status, AICallLog.prompt_source, AICallLog.prompt_release_id)
)
).all()
failed_calls = 0
fallback_calls = 0
active_release_calls = 0
default_fallback_calls = 0
release_breakdown: dict[str, dict[str, Any]] = {}
for row_status, prompt_source, row_release_id, count in rows:
amount = int(count or 0)
if self._is_failed_status(str(row_status or "")):
failed_calls += amount
source = str(prompt_source or "unknown")
if source == "active_release":
active_release_calls += amount
if source == "default_fallback":
default_fallback_calls += amount
fallback_calls += amount
release_key = str(row_release_id) if row_release_id is not None else source
bucket = release_breakdown.setdefault(
release_key,
{
"prompt_release_id": int(row_release_id) if row_release_id is not None else None,
"prompt_source": source,
"total_calls": 0,
"failed_calls": 0,
},
)
bucket["total_calls"] += amount
if self._is_failed_status(str(row_status or "")):
bucket["failed_calls"] += amount
recent_failures = [
self._call_log_summary(row)
for row in (
await self.db.execute(
select(AICallLog)
.where(where_clause)
.order_by(AICallLog.created_at.desc(), AICallLog.id.desc())
.limit(20)
)
).scalars().all()
if self._is_failed_status(str(row.status or ""))
][:5]
feedback_summary = await self._pmflow_feedback_summary(
tenant_id=None if include_all_pmflow_feedback else feedback_tenant_id if feedback_tenant_id is not None else tenant_id,
prompt_key=str(prompt_key or "").strip(),
since=datetime.now() - timedelta(hours=hours),
release_id=release_id,
)
optimizer_candidates = await self._prompt_optimizer_candidates(
tenant_id=None if include_all_pmflow_feedback else feedback_tenant_id if feedback_tenant_id is not None else tenant_id,
prompt_key=str(prompt_key or "").strip(),
)
return {
"prompt_key": str(prompt_key or "").strip(),
"window": str(window or "7d"),
"total_calls": total_calls,
"failed_calls": failed_calls,
"failure_rate": round(failed_calls / total_calls, 4) if total_calls else 0,
"fallback_calls": fallback_calls,
"active_release_calls": active_release_calls,
"default_fallback_calls": default_fallback_calls,
"average_latency_ms": round(float(latency), 2) if latency is not None else None,
"estimated_cost": float(cost or 0),
"recent_failures": recent_failures,
"release_breakdown": list(release_breakdown.values()),
"pmflow_feedback": feedback_summary,
"optimizer_candidates": optimizer_candidates,
}
async def list_golden_cases(
self,
*,
tenant_id: int,
prompt_key: str,
status: str | None = None,
) -> dict[str, Any]:
if get_prompt_registry_entry(prompt_key) is None:
raise ValueError("prompt asset not found")
stmt = select(PromptGoldenCase).where(
PromptGoldenCase.tenant_id == int(tenant_id),
PromptGoldenCase.prompt_key == str(prompt_key or "").strip(),
)
if status and status != "all":
stmt = stmt.where(PromptGoldenCase.status == status)
rows = list((await self.db.execute(stmt.order_by(PromptGoldenCase.id.desc()))).scalars().all())
return {
"prompt_key": str(prompt_key or "").strip(),
"items": [self._golden_case_payload(row) for row in rows],
"total": len(rows),
"active_count": sum(1 for row in rows if str(row.status or "") == "active"),
}
async def create_golden_case(
self,
*,
tenant_id: int,
prompt_key: str,
case_name: str,
input_payload: dict[str, Any],
expected_contract: dict[str, Any] | None = None,
expected_contains: str | None = None,
case_type: str = "regression",
priority: str = "blocking",
scenario_key: str | None = None,
tags: list[str] | None = None,
source: str = "manual",
source_ai_call_log_id: int | None = None,
created_by: int | None = None,
notes: str | None = None,
) -> dict[str, Any]:
if get_prompt_registry_entry(prompt_key) is None:
raise ValueError("prompt asset not found")
name = str(case_name or "").strip()
if not name:
raise ValueError("case_name is required")
if not isinstance(input_payload, dict):
raise ValueError("input_payload must be a JSON object")
effective_case_type = str(case_type or "regression").strip() or "regression"
if effective_case_type not in GOLDEN_CASE_TYPES:
raise ValueError("unsupported golden case type")
effective_priority = str(priority or "blocking").strip() or "blocking"
if effective_priority not in GOLDEN_CASE_PRIORITIES:
raise ValueError("unsupported golden case priority")
row = PromptGoldenCase(
tenant_id=int(tenant_id),
prompt_key=str(prompt_key or "").strip(),
case_name=name,
status="active",
input_payload=input_payload,
expected_contract=expected_contract or {},
expected_contains=str(expected_contains or "").strip() or None,
source=str(source or "manual").strip() or "manual",
source_ai_call_log_id=source_ai_call_log_id,
case_type=effective_case_type,
priority=effective_priority,
scenario_key=str(scenario_key or "").strip() or None,
tags=[str(item).strip() for item in list(tags or []) if str(item).strip()],
created_by=created_by,
notes=str(notes or "").strip() or None,
)
self.db.add(row)
await self.db.commit()
await self.db.refresh(row)
return self._golden_case_payload(row)
async def create_golden_case_from_call(
self,
*,
tenant_id: int,
prompt_key: str,
ai_call_log_id: int,
created_by: int | None = None,
case_name: str | None = None,
notes: str | None = None,
) -> dict[str, Any]:
if get_prompt_registry_entry(prompt_key) is None:
raise ValueError("prompt asset not found")
row = (
await self.db.execute(
select(AICallLog).where(
AICallLog.tenant_id == int(tenant_id),
AICallLog.id == int(ai_call_log_id),
)
)
).scalar_one_or_none()
if row is None:
raise ValueError("AI call log not found")
if str(row.prompt_key or "").strip() != str(prompt_key or "").strip():
raise ValueError("AI call log is not linked to this prompt_key")
status_text = str(row.status or "").lower()
priority = "blocking" if "structured_invalid" in status_text or "invalid" in status_text else "important"
return await self.create_golden_case(
tenant_id=tenant_id,
prompt_key=prompt_key,
case_name=case_name or f"trace-{row.trace_id or row.id}",
input_payload={
"ai_call_log_id": int(row.id),
"trace_id": row.trace_id,
"function_name": row.function_name,
"prompt_release_id": row.prompt_release_id,
"prompt_source": row.prompt_source,
"prompt_preview": _preview(row.prompt or "", limit=1000),
},
expected_contract={
"status": row.status,
"response_preview": _preview(row.response or "", limit=1000),
},
source="ai_call_log",
source_ai_call_log_id=int(row.id),
case_type="regression",
priority=priority,
scenario_key=str(row.function_name or "").strip() or None,
tags=["from_ai_call_log", status_text] if status_text else ["from_ai_call_log"],
created_by=created_by,
notes=notes,
)
async def create_replay_run(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int,
created_by: int | None,
mode: str = "contract_smoke",
case_status: str = "active",
max_cases: int = 20,
include_llm: bool = False,
judge_enabled: bool = True,
reason: str | None = None,
) -> dict[str, Any]:
entry = self._releaseable_entry(prompt_key)
release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id)
run_mode = str(mode or "contract_smoke").strip() or "contract_smoke"
if include_llm and run_mode in {"contract_smoke", "compare"}:
run_mode = "llm_compare"
if run_mode not in REPLAY_MODES:
raise ValueError("unsupported replay mode")
if include_llm and int(max_cases or 20) > 50:
raise ValueError("LLM replay max_cases cannot exceed 50")
limit = max(1, min(int(max_cases or 20), 50 if include_llm else 100))
cases = await self._active_golden_cases(
tenant_id=tenant_id,
prompt_key=prompt_key,
status=case_status,
limit=limit,
)
if not cases:
raise ValueError("no active golden cases for replay")
baseline = await self._active_release_for_prompt(tenant_id=tenant_id, prompt_key=prompt_key)
started_at = now_cn_naive().isoformat()
run = PromptReplayRun(
tenant_id=int(tenant_id),
prompt_key=str(prompt_key or "").strip(),
release_id=int(release.id),
baseline_release_id=int(baseline.id) if baseline is not None else None,
run_status="running",
run_mode=run_mode,
case_count=0,
passed_count=0,
failed_count=0,
warning_count=0,
summary="回放执行中",
risk_flags_json=[],
created_by=created_by,
started_at=started_at,
)
self.db.add(run)
await self.db.flush()
passed = 0
failed = 0
warning = 0
risk_flags: list[str] = []
for golden_case in cases:
started = time.perf_counter()
result = await self._evaluate_replay_case(
tenant_id=tenant_id,
release=release,
baseline=baseline,
golden_case=golden_case,
entry=entry,
include_llm=include_llm or run_mode == "llm_compare",
judge_enabled=judge_enabled,
run_id=int(run.id),
)
latency_ms = max(0, int((time.perf_counter() - started) * 1000))
status = self._effective_replay_status(str(result["status"]), priority=str(golden_case.priority or "blocking"))
if status == "passed":
passed += 1
elif status == "failed":
failed += 1
else:
warning += 1
risk_flags.extend([str(item) for item in result.get("risk_flags") or [] if str(item).strip()])
self.db.add(
PromptReplayCaseResult(
tenant_id=int(tenant_id),
run_id=int(run.id),
golden_case_id=int(golden_case.id),
prompt_key=str(prompt_key or "").strip(),
release_id=int(release.id),
baseline_output=result.get("baseline_output"),
candidate_output=result.get("candidate_output"),
status=status,
contract_result_json=result.get("contract_result_json") or {},
diff_summary=result.get("diff_summary"),
risk_flags_json=result.get("risk_flags") or [],
baseline_ai_call_log_id=result.get("baseline_ai_call_log_id"),
candidate_ai_call_log_id=result.get("candidate_ai_call_log_id"),
judge_score=result.get("judge_score"),
judge_label=result.get("judge_label"),
judge_reason=result.get("judge_reason"),
latency_ms=latency_ms,
estimated_cost=float(result.get("estimated_cost") or 0.0),
error_summary=result.get("error_summary"),
)
)
golden_case.last_run_status = status
if status == "failed":
golden_case.last_failed_at = now_cn_naive().isoformat()
unique_risks = sorted(set(risk_flags))
completed_at = now_cn_naive().isoformat()
run.run_status = "completed"
run.case_count = len(cases)
run.passed_count = passed
run.failed_count = failed
run.warning_count = warning
run.score_delta = 0.0
run.risk_flags_json = unique_risks
if failed:
run.summary = f"回放完成:{failed} 个样本阻塞,{warning} 个样本需复核。"
elif warning:
run.summary = f"回放完成:无阻塞失败,{warning} 个样本需人工复核。"
elif include_llm or run_mode == "llm_compare":
run.summary = "LLM 回放完成:所有样本通过评估,建议进入发布评审。"
else:
run.summary = "回放完成:所有样本通过确定性契约冒烟。"
run.completed_at = completed_at
await self.db.commit()
await self.db.refresh(run)
return await self.get_replay_run(tenant_id=tenant_id, prompt_key=prompt_key, run_id=int(run.id))
async def list_replay_runs(self, *, tenant_id: int, prompt_key: str, release_id: int) -> dict[str, Any]:
release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id)
rows = list(
(
await self.db.execute(
select(PromptReplayRun)
.where(
PromptReplayRun.tenant_id == int(tenant_id),
PromptReplayRun.prompt_key == str(prompt_key or "").strip(),
PromptReplayRun.release_id == int(release.id),
)
.order_by(PromptReplayRun.id.desc())
)
).scalars().all()
)
return {
"prompt_key": str(prompt_key or "").strip(),
"release_id": int(release.id),
"items": [self._replay_run_payload(row, include_results=False) for row in rows],
"total": len(rows),
}
async def get_replay_run(self, *, tenant_id: int, prompt_key: str, run_id: int) -> dict[str, Any]:
run = (
await self.db.execute(
select(PromptReplayRun).where(
PromptReplayRun.tenant_id == int(tenant_id),
PromptReplayRun.prompt_key == str(prompt_key or "").strip(),
PromptReplayRun.id == int(run_id),
)
)
).scalar_one_or_none()
if run is None:
raise ValueError("prompt replay run not found")
results = list(
(
await self.db.execute(
select(PromptReplayCaseResult)
.where(
PromptReplayCaseResult.tenant_id == int(tenant_id),
PromptReplayCaseResult.run_id == int(run.id),
)
.order_by(PromptReplayCaseResult.id.asc())
)
).scalars().all()
)
return self._replay_run_payload(run, include_results=True, results=results)
async def get_release_watch_summary(self, *, tenant_id: int, prompt_key: str, release_id: int) -> dict[str, Any]:
release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id)
quality_24h = await self.get_quality_summary(tenant_id=tenant_id, prompt_key=prompt_key, window="24h", release_id=int(release.id))
quality_7d = await self.get_quality_summary(tenant_id=tenant_id, prompt_key=prompt_key, window="7d", release_id=int(release.id))
failure_rate = float(quality_24h.get("failure_rate") or 0)
fallback_rate = round(float(quality_24h.get("default_fallback_calls") or 0) / max(1, int(quality_24h.get("total_calls") or 0)), 4)
structured_invalid_count = int(
(
await self.db.execute(
select(func.count(AICallLog.id)).where(
AICallLog.tenant_id == int(tenant_id),
AICallLog.prompt_key == str(prompt_key or "").strip(),
AICallLog.prompt_release_id == int(release.id),
AICallLog.status == "structured_invalid",
AICallLog.created_at >= datetime.now() - timedelta(hours=24),
)
)
).scalar()
or 0
)
if int(quality_24h.get("total_calls") or 0) <= 0:
recommendation = "watch"
summary = "观察中,暂无 release 级运行样本。"
elif failure_rate >= 0.2 or structured_invalid_count >= 3:
recommendation = "rollback_suggested"
summary = "24h 失败或结构化异常偏高,建议复核并准备回滚。"
elif failure_rate >= 0.08 or fallback_rate >= 0.15 or structured_invalid_count > 0:
recommendation = "needs_review"
summary = "已出现可疑失败、结构化异常或默认回退,建议复核失败样本并补充 golden case。"
else:
recommendation = "healthy"
summary = "当前观察窗口未发现明显异常。"
return {
"prompt_key": str(prompt_key or "").strip(),
"release_id": int(release.id),
"release_version": int(release.version or 0),
"status": str(release.status or ""),
"windows": {"24h": quality_24h, "7d": quality_7d},
"structured_invalid_24h": structured_invalid_count,
"fallback_rate_24h": fallback_rate,
"recommendation": recommendation,
"summary": summary,
"recent_failures": quality_24h.get("recent_failures") or [],
}
async def _release_index(self, *, tenant_id: int, prompt_key: str | None = None, include_body: bool = False) -> 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 = list((await self.db.execute(stmt.order_by(PromptRelease.prompt_key.asc(), PromptRelease.version.desc()))).scalars().all())
checks_by_release = await self._latest_checks_by_release(
tenant_id=tenant_id,
release_ids=[int(row.id) for row in rows],
)
replay_by_release = await self._latest_replay_by_release(
tenant_id=tenant_id,
release_ids=[int(row.id) for row in rows],
)
by_key: dict[str, dict[str, Any]] = {}
for row in rows:
key = str(row.prompt_key or "").strip()
if not key:
continue
bucket = by_key.setdefault(key, {"releases": [], "active": None})
snapshot = self._release_payload(
row,
include_body=include_body,
checks=checks_by_release.get(int(row.id), []),
latest_replay=replay_by_release.get(int(row.id)),
)
bucket["releases"].append(snapshot)
if row.status == "active" and bucket["active"] is None:
bucket["active"] = snapshot
return by_key
def _asset_payload(self, entry: dict[str, Any], *, releases: dict[str, Any], include_body: bool) -> dict[str, Any]:
prompt_key = str(entry.get("prompt_key") or "").strip()
default_template = self._default_template(prompt_key)
release_bucket = releases.get(prompt_key, {})
active_release = release_bucket.get("active")
all_releases = list(release_bucket.get("releases") or [])
payload = {
"prompt_key": prompt_key,
"module": str(entry.get("module") or ""),
"business_scenario": str(entry.get("business_scenario") or ""),
"prompt_family": str(entry.get("prompt_family") or ""),
"responsibility_scope": str(entry.get("responsibility_scope") or ""),
"priority": str(entry.get("priority") or ""),
"audience": str(entry.get("audience") or ""),
"risk_level": str(entry.get("risk_level") or ""),
"runtime_source": str(entry.get("runtime_source") or ""),
"editable_policy": str(entry.get("editable_policy") or ""),
"input_contract": str(entry.get("input_contract") or ""),
"output_contract": str(entry.get("output_contract") or ""),
"call_sites": list(entry.get("call_sites") or []),
"default_template_available": bool(default_template),
"default_template_hash": _hash_text(default_template),
"default_template_preview": _preview(default_template),
"template_variables": _template_variables(default_template),
"active_release": active_release,
"release_count": len(all_releases),
"draft_release_count": sum(1 for item in all_releases if item.get("status") == "draft"),
"latest_release": all_releases[0] if all_releases else None,
"migration_status": self._migration_status(entry, default_template),
}
if include_body:
payload["default_template_body"] = default_template
payload["releases"] = all_releases
return payload
@staticmethod
def _release_payload(
row: PromptRelease,
*,
include_body: bool = False,
checks: list[dict[str, Any]] | None = None,
latest_replay: dict[str, Any] | None = None,
) -> dict[str, Any]:
body = str(row.body_snapshot or "")
check_items = list(checks or [])
payload = {
"id": int(row.id),
"prompt_key": str(row.prompt_key or ""),
"version": int(row.version or 0),
"status": str(row.status or ""),
"notes": row.notes,
"body_hash": _hash_text(body),
"body_preview": _preview(body),
"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,
"release_gate": PromptAssetService._gate_summary_from_checks(check_items),
"latest_checks": check_items,
"latest_replay_run": latest_replay,
}
if include_body:
payload["body_snapshot"] = body
return payload
def _releaseable_entry(self, prompt_key: str) -> dict[str, Any]:
entry = get_prompt_registry_entry(str(prompt_key or "").strip())
if entry is None:
raise ValueError("prompt asset not found")
policy = str(entry.get("editable_policy") or "")
if policy == "needs_migration":
raise ValueError("prompt call sites must be migrated before release operations")
if policy not in {"managed_release", "guarded_release"}:
raise ValueError("prompt asset is not editable")
if str(entry.get("runtime_source") or "") == "inline_discovered":
raise ValueError("inline prompt must be migrated before release operations")
if not self._default_template(str(entry.get("prompt_key") or "")):
raise ValueError("prompt asset has no default template")
return entry
async def _assert_activation_allowed(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int,
entry: dict[str, Any],
) -> None:
release = await self._get_release(tenant_id=tenant_id, prompt_key=prompt_key, release_id=release_id)
if str(entry.get("editable_policy") or "") != "guarded_release":
return
checks = await self._latest_checks_for_release(
tenant_id=tenant_id,
release_id=int(release.id),
)
gate = self._gate_summary_from_checks(checks)
if gate.get("status") != "passed":
raise ValueError(f"guarded prompt release is blocked by gate: {gate.get('summary') or 'checks not passed'}")
async def _get_release(self, *, tenant_id: int, prompt_key: str, release_id: int) -> PromptRelease:
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")
return row
async def _latest_checks_by_release(self, *, tenant_id: int, release_ids: list[int]) -> dict[int, list[dict[str, Any]]]:
ids = [int(item) for item in release_ids if item is not None]
if not ids:
return {}
rows = (
await self.db.execute(
select(PromptReleaseCheck)
.where(
PromptReleaseCheck.tenant_id == int(tenant_id),
PromptReleaseCheck.release_id.in_(ids),
)
.order_by(PromptReleaseCheck.release_id.asc(), PromptReleaseCheck.check_type.asc(), PromptReleaseCheck.id.desc())
)
).scalars().all()
by_release: dict[int, dict[str, dict[str, Any]]] = {}
for row in rows:
release_id = int(row.release_id)
bucket = by_release.setdefault(release_id, {})
check_type = str(row.check_type or "")
if check_type not in bucket:
bucket[check_type] = self._check_payload(row)
return {release_id: list(checks.values()) for release_id, checks in by_release.items()}
async def _latest_checks_for_release(self, *, tenant_id: int, release_id: int) -> list[dict[str, Any]]:
grouped = await self._latest_checks_by_release(tenant_id=tenant_id, release_ids=[int(release_id)])
return grouped.get(int(release_id), [])
async def _latest_replay_by_release(self, *, tenant_id: int, release_ids: list[int]) -> dict[int, dict[str, Any]]:
ids = [int(item) for item in release_ids if item is not None]
if not ids:
return {}
rows = list(
(
await self.db.execute(
select(PromptReplayRun)
.where(
PromptReplayRun.tenant_id == int(tenant_id),
PromptReplayRun.release_id.in_(ids),
)
.order_by(PromptReplayRun.release_id.asc(), PromptReplayRun.id.desc())
)
).scalars().all()
)
latest: dict[int, dict[str, Any]] = {}
for row in rows:
release_id = int(row.release_id)
if release_id not in latest:
latest[release_id] = self._replay_run_payload(row, include_results=False)
return latest
async def _latest_replay_for_release(self, *, tenant_id: int, release_id: int) -> dict[str, Any] | None:
grouped = await self._latest_replay_by_release(tenant_id=tenant_id, release_ids=[int(release_id)])
return grouped.get(int(release_id))
async def _pmflow_feedback_summary(
self,
*,
tenant_id: int,
prompt_key: str,
since: datetime,
release_id: int | None,
) -> dict[str, Any]:
filters = [
PMFlowAIFeedbackEvent.prompt_key == str(prompt_key or "").strip(),
PMFlowAIFeedbackEvent.created_at >= since,
]
if tenant_id is not None:
filters.append(PMFlowAIFeedbackEvent.tenant_id == int(tenant_id))
if release_id is not None:
filters.append(PMFlowAIFeedbackEvent.prompt_release_id == int(release_id))
rows = list(
(
await self.db.execute(
select(PMFlowAIFeedbackEvent)
.where(and_(*filters))
.order_by(PMFlowAIFeedbackEvent.created_at.desc(), PMFlowAIFeedbackEvent.id.desc())
.limit(200)
)
).scalars().all()
)
total = len(rows)
positive = sum(1 for row in rows if row.rating == "positive")
negative = sum(1 for row in rows if row.rating == "negative")
reason_counts: dict[str, int] = {}
answer_kind_counts: dict[str, int] = {}
for row in rows:
answer_kind_counts[row.answer_kind] = answer_kind_counts.get(row.answer_kind, 0) + 1
for tag in row.reason_tags or []:
label = str(tag or "").strip()
if label:
reason_counts[label] = reason_counts.get(label, 0) + 1
return {
"total_feedback": total,
"positive_feedback": positive,
"negative_feedback": negative,
"negative_rate": round(negative / total, 4) if total else 0,
"reason_tag_counts": [
{"tag": tag, "count": count}
for tag, count in sorted(reason_counts.items(), key=lambda item: (-item[1], item[0]))
],
"answer_kind_counts": [
{"answer_kind": answer_kind, "count": count}
for answer_kind, count in sorted(answer_kind_counts.items(), key=lambda item: (-item[1], item[0]))
],
"recent_feedback": [self._pmflow_feedback_payload(row) for row in rows[:8]],
}
async def _prompt_optimizer_candidates(self, *, tenant_id: int | None, prompt_key: str) -> list[dict[str, Any]]:
filters = [
OptimizerCandidate.target_type == "prompt_key",
OptimizerCandidate.target_ref == str(prompt_key or "").strip(),
OptimizerCandidate.status == "draft",
]
if tenant_id is not None:
filters.append(OptimizerCandidate.tenant_id == int(tenant_id))
rows = list(
(
await self.db.execute(
select(OptimizerCandidate)
.where(and_(*filters))
.order_by(OptimizerCandidate.created_at.desc(), OptimizerCandidate.id.desc())
.limit(8)
)
).scalars().all()
)
return [
{
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"candidate_type": row.candidate_type,
"target_type": row.target_type,
"target_ref": row.target_ref,
"status": row.status,
"suggestion": row.suggestion or {},
"evidence": row.evidence or {},
"notes": row.notes,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in rows
]
@staticmethod
def _pmflow_feedback_payload(row: PMFlowAIFeedbackEvent) -> dict[str, Any]:
metadata = row.feedback_metadata if isinstance(row.feedback_metadata, dict) else {}
prompt_optimization = metadata.get("promptOptimization") if isinstance(metadata.get("promptOptimization"), dict) else {}
return {
"id": int(row.id),
"tenant_id": int(row.tenant_id),
"project_id": int(row.project_id),
"target_type": row.target_type,
"target_id": row.target_id,
"answer_kind": row.answer_kind,
"rating": row.rating,
"reason_tags": list(row.reason_tags or []),
"comment": row.comment,
"scene_key": row.scene_key,
"prompt_release_id": int(row.prompt_release_id) if row.prompt_release_id is not None else None,
"runtime_profile_id": row.runtime_profile_id,
"optimizer_candidate_id": prompt_optimization.get("candidateId"),
"created_at": row.created_at.isoformat() if row.created_at else None,
}
async def _active_golden_cases(
self,
*,
tenant_id: int,
prompt_key: str,
status: str | None,
limit: int,
) -> list[PromptGoldenCase]:
stmt = (
select(PromptGoldenCase)
.where(
PromptGoldenCase.tenant_id == int(tenant_id),
PromptGoldenCase.prompt_key == str(prompt_key or "").strip(),
)
.order_by(PromptGoldenCase.id.asc())
.limit(max(1, min(int(limit or 20), 100)))
)
effective_status = str(status or "active").strip()
if effective_status and effective_status != "all":
stmt = stmt.where(PromptGoldenCase.status == effective_status)
return list((await self.db.execute(stmt)).scalars().all())
async def _active_release_for_prompt(self, *, tenant_id: int, prompt_key: str) -> PromptRelease | None:
return (
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()
def _build_release_check_specs(
self,
*,
tenant_id: int,
entry: dict[str, Any],
release: PromptRelease,
default_template: str,
reason: str | None,
active_case_count: int,
latest_replay: dict[str, Any] | None,
) -> list[dict[str, Any]]:
release_body = str(release.body_snapshot or "")
specs: list[dict[str, Any]] = []
try:
default_vars = set(_template_variables_or_error(default_template))
release_vars = set(_template_variables_or_error(release_body))
unknown_vars = sorted(release_vars - default_vars)
if unknown_vars:
specs.append(
{
"check_type": "variable_contract",
"status": "failed",
"severity": "blocking",
"summary": f"模板引用了未登记变量:{', '.join(unknown_vars)}",
"details_json": {"default_variables": sorted(default_vars), "release_variables": sorted(release_vars), "unknown_variables": unknown_vars},
}
)
else:
specs.append(
{
"check_type": "variable_contract",
"status": "passed",
"severity": "info",
"summary": "变量契约通过,未引入默认模板之外的新变量。",
"details_json": {"default_variables": sorted(default_vars), "release_variables": sorted(release_vars), "reason": reason},
}
)
except ValueError as exc:
specs.append(
{
"check_type": "variable_contract",
"status": "failed",
"severity": "blocking",
"summary": f"模板语法错误:{exc}",
"details_json": {"reason": reason},
}
)
contract_text = " ".join(
[
str(entry.get("input_contract") or ""),
str(entry.get("output_contract") or ""),
str(entry.get("prompt_key") or ""),
]
).lower()
structured = "json" in contract_text or "结构化" in contract_text
if not structured:
specs.append(
{
"check_type": "schema_smoke",
"status": "skipped",
"severity": "info",
"summary": "该 prompt 未声明结构化 JSON 输出,schema smoke 本轮跳过。",
"details_json": {"reason": reason},
}
)
elif "json" in release_body.lower() or "json" in contract_text:
specs.append(
{
"check_type": "schema_smoke",
"status": "passed",
"severity": "info",
"summary": "结构化输出契约已声明 JSON/schema 约束。",
"details_json": {"output_contract": str(entry.get("output_contract") or ""), "reason": reason},
}
)
else:
specs.append(
{
"check_type": "schema_smoke",
"status": "failed",
"severity": "blocking",
"summary": "结构化 prompt 缺少 JSON/schema 输出约束。",
"details_json": {"output_contract": str(entry.get("output_contract") or ""), "reason": reason},
}
)
_ = tenant_id
replay_status = str((latest_replay or {}).get("run_status") or "")
replay_failed_count = int((latest_replay or {}).get("failed_count") or 0)
replay_case_count = int((latest_replay or {}).get("case_count") or 0)
replay_risks = list((latest_replay or {}).get("risk_flags_json") or [])
replay_passed = replay_status == "completed" and replay_case_count > 0 and replay_failed_count == 0
if active_case_count <= 0:
specs.append(
{
"check_type": "golden_replay",
"status": "skipped",
"severity": "blocking" if str(entry.get("editable_policy") or "") == "guarded_release" else "warning",
"summary": "尚未接入 active golden case,不能把 replay 结果伪造成通过。",
"details_json": {"case_count": 0, "reason": reason},
}
)
elif replay_passed:
specs.append(
{
"check_type": "golden_replay",
"status": "passed",
"severity": "info",
"summary": f"最近一次 replay 已通过:{replay_case_count} 个样本,{int((latest_replay or {}).get('warning_count') or 0)} 个需复核,无阻塞失败。",
"details_json": {
"case_count": active_case_count,
"run_id": (latest_replay or {}).get("id"),
"mode": (latest_replay or {}).get("run_mode") or "contract_smoke",
"risk_flags": replay_risks,
"reason": reason,
},
}
)
else:
if latest_replay:
summary = str(latest_replay.get("summary") or "最近一次 replay 未通过。")
details = {
"case_count": active_case_count,
"run_id": latest_replay.get("id"),
"run_status": latest_replay.get("run_status"),
"failed_count": replay_failed_count,
"risk_flags": replay_risks,
"reason": reason,
}
status = "failed" if replay_failed_count > 0 or replay_risks else "skipped"
else:
summary = "已有 active golden case,但尚未执行 release replay run。"
details = {"case_count": active_case_count, "reason": reason}
status = "skipped"
specs.append(
{
"check_type": "golden_replay",
"status": status,
"severity": "blocking" if str(entry.get("editable_policy") or "") == "guarded_release" else "warning",
"summary": summary,
"details_json": details,
}
)
return specs
async def _active_golden_case_count(self, *, tenant_id: int, prompt_key: str) -> int:
return int(
(
await self.db.execute(
select(func.count(PromptGoldenCase.id)).where(
PromptGoldenCase.tenant_id == int(tenant_id),
PromptGoldenCase.prompt_key == str(prompt_key or "").strip(),
PromptGoldenCase.status == "active",
)
)
).scalar()
or 0
)
@staticmethod
def _check_payload(row: PromptReleaseCheck) -> dict[str, Any]:
return {
"id": int(row.id),
"prompt_key": str(row.prompt_key or ""),
"release_id": int(row.release_id),
"check_type": str(row.check_type or ""),
"status": str(row.status or ""),
"severity": str(row.severity or ""),
"summary": str(row.summary or ""),
"details_json": row.details_json or {},
"created_by": int(row.created_by) if row.created_by is not None else None,
"created_at": row.created_at.isoformat() if getattr(row, "created_at", None) is not None else None,
"completed_at": row.completed_at,
}
@staticmethod
def _golden_case_payload(row: PromptGoldenCase) -> dict[str, Any]:
return {
"id": int(row.id),
"prompt_key": str(row.prompt_key or ""),
"case_name": str(row.case_name or ""),
"status": str(row.status or ""),
"input_payload": row.input_payload or {},
"expected_contract": row.expected_contract or {},
"expected_contains": row.expected_contains,
"source": str(row.source or ""),
"source_ai_call_log_id": int(row.source_ai_call_log_id) if row.source_ai_call_log_id is not None else None,
"case_type": str(row.case_type or "regression"),
"priority": str(row.priority or "blocking"),
"scenario_key": row.scenario_key,
"tags": row.tags or [],
"last_run_status": row.last_run_status,
"last_failed_at": row.last_failed_at,
"created_by": int(row.created_by) if row.created_by is not None else None,
"notes": row.notes,
"created_at": row.created_at.isoformat() if getattr(row, "created_at", None) is not None else None,
}
async def _evaluate_replay_case(
self,
*,
tenant_id: int,
release: PromptRelease,
baseline: PromptRelease | None,
golden_case: PromptGoldenCase,
entry: dict[str, Any],
include_llm: bool,
judge_enabled: bool,
run_id: int,
) -> dict[str, Any]:
payload = golden_case.input_payload if isinstance(golden_case.input_payload, dict) else {}
candidate_template = str(release.body_snapshot or "")
baseline_template = str(baseline.body_snapshot or "") if baseline is not None else ""
candidate_output = ""
baseline_output = ""
failures: list[str] = []
warnings: list[str] = []
risks: list[str] = []
try:
candidate_output = candidate_template.format(**payload).strip()
except Exception as exc:
failures.append(f"candidate_render_failed: {exc}")
risks.append("candidate_render_failed")
if baseline_template:
try:
baseline_output = baseline_template.format(**payload).strip()
except Exception as exc:
warnings.append(f"baseline_render_failed: {exc}")
risks.append("baseline_render_failed")
baseline_ai_call_log_id = None
candidate_ai_call_log_id = None
judge_score = None
judge_label = None
judge_reason = None
estimated_cost = 0.0
if include_llm and not failures:
baseline_model_output = baseline_output
candidate_model_output = candidate_output
try:
if baseline_output:
baseline_model_output, baseline_ai_call_log_id = await self._run_llm_replay_prompt(
tenant_id=tenant_id,
prompt_key=str(release.prompt_key or ""),
release_id=int(baseline.id) if baseline is not None else None,
prompt=baseline_output,
function_name="prompt_replay_baseline",
run_id=run_id,
)
candidate_model_output, candidate_ai_call_log_id = await self._run_llm_replay_prompt(
tenant_id=tenant_id,
prompt_key=str(release.prompt_key or ""),
release_id=int(release.id),
prompt=candidate_output,
function_name="prompt_replay_candidate",
run_id=run_id,
)
baseline_output = baseline_model_output
candidate_output = candidate_model_output
if judge_enabled:
judge = await self._judge_replay_case(
tenant_id=tenant_id,
entry=entry,
golden_case=golden_case,
baseline_output=baseline_output,
candidate_output=candidate_output,
run_id=run_id,
)
judge_score = judge.get("score")
judge_label = str(judge.get("label") or "manual_review")
judge_reason = str(judge.get("reason") or "")
risks.extend([str(item) for item in judge.get("risk_flags") or [] if str(item).strip()])
decision = str(judge.get("decision") or "")
if decision == "block_release" or judge_label in {"worse", "risky", "invalid"}:
failures.append(judge_reason or "LLM judge blocked this replay case")
elif decision in {"needs_revision", "manual_review"} or judge_label == "same":
warnings.append(judge_reason or "LLM judge requires manual review")
except Exception as exc:
failures.append(f"llm_replay_failed: {exc}")
risks.append("llm_replay_failed")
contract_result = self._evaluate_expected_contract(
expected_contract=golden_case.expected_contract if isinstance(golden_case.expected_contract, dict) else {},
expected_contains=golden_case.expected_contains,
input_payload=payload,
candidate_output=candidate_output,
entry=entry,
)
failures.extend(contract_result["failures"])
warnings.extend(contract_result["warnings"])
risks.extend(contract_result["risk_flags"])
status = "failed" if failures else "warning" if warnings else "passed"
return {
"status": status,
"baseline_output": baseline_output,
"candidate_output": candidate_output,
"contract_result_json": {
"case_name": golden_case.case_name,
"case_type": str(golden_case.case_type or "regression"),
"priority": str(golden_case.priority or "blocking"),
"failures": failures,
"warnings": warnings,
"mode": "llm_compare" if include_llm else "contract_smoke_no_llm",
"judge": {
"score": judge_score,
"label": judge_label,
"reason": judge_reason,
},
},
"diff_summary": self._diff_summary(baseline_output=baseline_output, candidate_output=candidate_output),
"risk_flags": sorted(set(risks)),
"baseline_ai_call_log_id": baseline_ai_call_log_id,
"candidate_ai_call_log_id": candidate_ai_call_log_id,
"judge_score": judge_score,
"judge_label": judge_label,
"judge_reason": judge_reason,
"estimated_cost": estimated_cost,
"error_summary": ";".join(failures or warnings)[:800] or None,
}
@staticmethod
def _effective_replay_status(raw_status: str, *, priority: str) -> str:
status = str(raw_status or "").strip() or "warning"
case_priority = str(priority or "blocking").strip()
if status != "failed":
return status
if case_priority == "blocking":
return "failed"
return "warning"
async def _run_llm_replay_prompt(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int | None,
prompt: str,
function_name: str,
run_id: int,
) -> tuple[str, int | None]:
routing_meta = {
"prompt_key": prompt_key,
"prompt_release_id": release_id,
"prompt_source": "replay_candidate" if function_name.endswith("candidate") else "replay_baseline",
"tier_alias": "low",
"run_id": f"prompt-replay-{run_id}-{function_name}",
"scene_key": "prompt_replay",
}
output = await llm_service.generate(
prompt,
db=self.db,
tenant_id=tenant_id,
function_name=function_name,
temperature=0.0,
max_tokens=1200,
routing_meta=routing_meta,
)
call_id = await self._latest_ai_call_log_id(
tenant_id=tenant_id,
prompt_key=prompt_key,
release_id=release_id,
function_name=function_name,
)
return str(output or "").strip(), call_id
async def _judge_replay_case(
self,
*,
tenant_id: int,
entry: dict[str, Any],
golden_case: PromptGoldenCase,
baseline_output: str,
candidate_output: str,
run_id: int,
) -> dict[str, Any]:
rubric = get_prompt_eval_rubric(
prompt_key=str(entry.get("prompt_key") or ""),
prompt_family=str(entry.get("prompt_family") or ""),
)
judge_prompt = "\n".join(
[
"你是 PMFlow 平台 Prompt 发布评估器。只输出 JSON,不输出解释性正文。",
"评估候选 Prompt 输出相对 baseline 是否适合发布。",
f"Prompt Key: {entry.get('prompt_key')}",
f"业务场景: {entry.get('business_scenario')}",
f"输入契约: {entry.get('input_contract')}",
f"输出契约: {entry.get('output_contract')}",
f"Rubric: {json.dumps(rubric, ensure_ascii=False)}",
f"Golden Case: {golden_case.case_name}",
f"Expected Contract: {json.dumps(golden_case.expected_contract or {}, ensure_ascii=False)}",
f"Baseline Output:\n{baseline_output or '无 baseline'}",
f"Candidate Output:\n{candidate_output}",
"输出 JSON schema: {\"score\":0.0,\"label\":\"better|same|worse|risky|invalid\",\"decision\":\"recommend_release|needs_revision|block_release|manual_review\",\"reason\":\"...\",\"risk_flags\":[],\"dimension_scores\":{\"contract_adherence\":0,\"business_correctness\":0,\"evidence_grounding\":0,\"safety_boundary\":0,\"operator_readability\":0}}",
]
)
try:
result = await llm_service.generate_structured_object(
judge_prompt,
db=self.db,
tenant_id=tenant_id,
function_name="prompt_replay_judge",
temperature=0.0,
max_tokens=900,
routing_meta={
"prompt_key": str(entry.get("prompt_key") or ""),
"prompt_source": "replay_judge",
"tier_alias": "structured",
"run_id": f"prompt-replay-{run_id}-judge",
"scene_key": "prompt_replay",
},
)
payload = result.payload if isinstance(result.payload, dict) else {}
except Exception as exc:
return {
"score": None,
"label": "manual_review",
"decision": "manual_review",
"reason": f"Judge 结构化评估失败:{exc}",
"risk_flags": ["judge_invalid"],
}
return self._normalize_judge_payload(payload)
@staticmethod
def _normalize_judge_payload(payload: dict[str, Any]) -> dict[str, Any]:
label = str(payload.get("label") or "manual_review").strip()
if label not in {"better", "same", "worse", "risky", "invalid"}:
label = "manual_review"
decision = str(payload.get("decision") or "manual_review").strip()
if decision not in {"recommend_release", "needs_revision", "block_release", "manual_review"}:
decision = "manual_review"
try:
score = float(payload.get("score")) if payload.get("score") is not None else None
except (TypeError, ValueError):
score = None
return {
"score": score,
"label": label,
"decision": decision,
"reason": str(payload.get("reason") or "").strip()[:1200],
"risk_flags": [str(item).strip() for item in list(payload.get("risk_flags") or []) if str(item).strip()],
"dimension_scores": payload.get("dimension_scores") if isinstance(payload.get("dimension_scores"), dict) else {},
}
async def _latest_ai_call_log_id(
self,
*,
tenant_id: int,
prompt_key: str,
release_id: int | None,
function_name: str,
) -> int | None:
filters = [
AICallLog.tenant_id == int(tenant_id),
AICallLog.prompt_key == str(prompt_key or "").strip(),
AICallLog.function_name == str(function_name or "").strip(),
]
if release_id is not None:
filters.append(AICallLog.prompt_release_id == int(release_id))
row = (
await self.db.execute(
select(AICallLog)
.where(and_(*filters))
.order_by(AICallLog.id.desc())
.limit(1)
)
).scalar_one_or_none()
return int(row.id) if row is not None else None
@staticmethod
def _evaluate_expected_contract(
*,
expected_contract: dict[str, Any],
expected_contains: str | None,
input_payload: dict[str, Any],
candidate_output: str,
entry: dict[str, Any],
) -> dict[str, list[str]]:
failures: list[str] = []
warnings: list[str] = []
risk_flags: list[str] = []
required_inputs = expected_contract.get("required_input_keys") or expected_contract.get("required_variables") or []
if isinstance(required_inputs, str):
required_inputs = [required_inputs]
if isinstance(required_inputs, list):
missing = [str(item) for item in required_inputs if str(item) not in input_payload]
if missing:
failures.append(f"缺少输入变量:{', '.join(missing)}")
risk_flags.append("missing_input_variable")
required_contains = expected_contract.get("required_prompt_contains") or expected_contract.get("required_contains") or []
if isinstance(required_contains, str):
required_contains = [required_contains]
if isinstance(required_contains, list):
missing_text = [str(item) for item in required_contains if str(item) and str(item) not in candidate_output]
if missing_text:
failures.append(f"候选渲染缺少期望片段:{', '.join(missing_text[:5])}")
risk_flags.append("missing_required_prompt_fragment")
if expected_contains and str(expected_contains).strip() not in candidate_output:
failures.append("候选渲染未包含 expected_contains。")
risk_flags.append("missing_expected_contains")
forbidden_terms = expected_contract.get("forbidden_terms") or []
if isinstance(forbidden_terms, str):
forbidden_terms = [forbidden_terms]
if isinstance(forbidden_terms, list):
hits = [str(item) for item in forbidden_terms if str(item) and str(item) in candidate_output]
if hits:
failures.append(f"候选渲染包含禁止片段:{', '.join(hits[:5])}")
risk_flags.append("forbidden_prompt_fragment")
output_required = expected_contract.get("required") or expected_contract.get("required_keys")
if output_required:
warnings.append("该 golden case 声明了输出字段契约;contract smoke 不执行 LLM,只能记录为人工复核项。")
risk_flags.append("unsupported_output_contract")
if str(entry.get("output_contract") or "").lower().find("json") >= 0 and "json" not in candidate_output.lower():
warnings.append("资产声明 JSON 输出,但候选 prompt 渲染未显式包含 JSON 约束。")
risk_flags.append("json_contract_weak")
return {"failures": failures, "warnings": warnings, "risk_flags": risk_flags}
@staticmethod
def _diff_summary(*, baseline_output: str, candidate_output: str) -> str:
if not baseline_output:
return "无 baseline release,仅验证候选渲染。"
if baseline_output == candidate_output:
return "候选渲染与 baseline 一致。"
return f"候选渲染与 baseline 不同:baseline {len(baseline_output)} 字符,candidate {len(candidate_output)} 字符。"
@staticmethod
def _replay_run_payload(
row: PromptReplayRun,
*,
include_results: bool,
results: list[PromptReplayCaseResult] | None = None,
) -> dict[str, Any]:
payload = {
"id": int(row.id),
"prompt_key": str(row.prompt_key or ""),
"release_id": int(row.release_id),
"baseline_release_id": int(row.baseline_release_id) if row.baseline_release_id is not None else None,
"run_status": str(row.run_status or ""),
"run_mode": str(row.run_mode or ""),
"case_count": int(row.case_count or 0),
"passed_count": int(row.passed_count or 0),
"failed_count": int(row.failed_count or 0),
"warning_count": int(row.warning_count or 0),
"score_delta": float(row.score_delta or 0),
"summary": row.summary,
"risk_flags_json": row.risk_flags_json or [],
"created_by": int(row.created_by) if row.created_by is not None else None,
"started_at": row.started_at,
"completed_at": row.completed_at,
"created_at": row.created_at.isoformat() if getattr(row, "created_at", None) is not None else None,
}
if include_results:
payload["results"] = [
{
"id": int(item.id),
"run_id": int(item.run_id),
"golden_case_id": int(item.golden_case_id),
"prompt_key": str(item.prompt_key or ""),
"release_id": int(item.release_id),
"baseline_output": item.baseline_output,
"candidate_output": item.candidate_output,
"status": str(item.status or ""),
"contract_result_json": item.contract_result_json or {},
"diff_summary": item.diff_summary,
"risk_flags_json": item.risk_flags_json or [],
"baseline_ai_call_log_id": int(item.baseline_ai_call_log_id) if item.baseline_ai_call_log_id is not None else None,
"candidate_ai_call_log_id": int(item.candidate_ai_call_log_id) if item.candidate_ai_call_log_id is not None else None,
"judge_score": float(item.judge_score) if item.judge_score is not None else None,
"judge_label": item.judge_label,
"judge_reason": item.judge_reason,
"latency_ms": item.latency_ms,
"estimated_cost": float(item.estimated_cost or 0),
"error_summary": item.error_summary,
"created_at": item.created_at.isoformat() if getattr(item, "created_at", None) is not None else None,
}
for item in list(results or [])
]
return payload
@staticmethod
def _call_log_summary(row: AICallLog) -> dict[str, Any]:
return {
"id": int(row.id),
"trace_id": row.trace_id,
"status": row.status,
"function_name": row.function_name,
"prompt_release_id": int(row.prompt_release_id) if row.prompt_release_id is not None else None,
"prompt_source": row.prompt_source,
"latency_ms": row.latency_ms,
"estimated_cost": float(row.cost or 0),
"created_at": row.created_at.isoformat() if row.created_at else None,
"error_summary": _preview(row.response or "", limit=240),
}
@staticmethod
def _is_failed_status(status: str) -> bool:
text = str(status or "").strip().lower()
return text not in {"success", "ok", "success_cache_hit", "success_failover"} and (
"fail" in text or "error" in text or "invalid" in text or "empty" in text
)
@staticmethod
def _gate_summary_from_checks(items: list[dict[str, Any]]) -> dict[str, Any]:
latest: dict[str, dict[str, Any]] = {}
for item in items:
check_type = str(item.get("check_type") or "")
if check_type and check_type not in latest:
latest[check_type] = item
if not latest:
return {
"status": "not_run",
"summary": "尚未运行发布门禁检查。",
"blocking_reasons": ["尚未运行发布门禁检查。"],
"required_checks": list(RELEASE_CHECK_TYPES),
}
blocking_reasons: list[str] = []
for check_type in RELEASE_CHECK_TYPES:
item = latest.get(check_type)
if item is None:
blocking_reasons.append(f"{check_type} 尚未运行。")
continue
if item.get("status") == "failed":
blocking_reasons.append(str(item.get("summary") or f"{check_type} 未通过。"))
elif item.get("status") == "skipped" and item.get("severity") == "blocking":
blocking_reasons.append(str(item.get("summary") or f"{check_type} 被跳过。"))
if blocking_reasons:
return {
"status": "blocked",
"summary": ";".join(blocking_reasons),
"blocking_reasons": blocking_reasons,
"required_checks": list(RELEASE_CHECK_TYPES),
}
return {
"status": "passed",
"summary": "发布门禁检查已通过。",
"blocking_reasons": [],
"required_checks": list(RELEASE_CHECK_TYPES),
}
@staticmethod
def _validate_release_body(*, default_template: str, body_snapshot: str) -> None:
body = str(body_snapshot or "").strip()
if not body:
raise ValueError("body_snapshot is required")
try:
default_vars = set(_template_variables_or_error(default_template))
release_vars = set(_template_variables_or_error(body))
except ValueError as exc:
raise ValueError(f"invalid prompt template syntax: {exc}") from exc
unknown_vars = sorted(release_vars - default_vars)
if unknown_vars:
raise ValueError(f"release template references unknown variables: {', '.join(unknown_vars)}")
@staticmethod
def _default_template(prompt_key: str) -> str:
try:
ai_prompts = import_module("app.config.ai_prompts")
except Exception:
return ""
value = getattr(ai_prompts, str(prompt_key or "").strip(), "")
return str(value or "").strip() if isinstance(value, str) else ""
@staticmethod
def _migration_status(entry: dict[str, Any], default_template: str) -> str:
policy = str(entry.get("editable_policy") or "")
if policy == "needs_migration":
return "inline_prompt_needs_runtime_migration"
if not default_template:
return "definition_only"
if policy == "guarded_release":
return "guarded_runtime_configurable"
return "runtime_configurable"