1766 lines
77 KiB
Python
1766 lines
77 KiB
Python
"""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"
|