diff --git a/backend/app/api/prompt_assets.py b/backend/app/api/prompt_assets.py
index 5e1e1d8d..600a97c5 100644
--- a/backend/app/api/prompt_assets.py
+++ b/backend/app/api/prompt_assets.py
@@ -16,6 +16,7 @@ ROUTE_PERMISSION_ACTIONS = {
"detail": "prompt_assets:detail",
"create": "prompt_assets:create",
"create_version": "prompt_assets:versions:create",
+ "evaluate": "prompt_assets:evaluations:run",
"publish": "prompt_assets:publish",
"unpublish": "prompt_assets:unpublish",
"rollback": "prompt_assets:rollback",
@@ -156,6 +157,32 @@ def create_prompt_asset_version(
return success_response(result)
+@router.post("/{asset_id}/evaluations/run")
+def run_prompt_asset_evaluation(
+ asset_id: str,
+ prompt_asset_service: PromptAssetService = Depends(get_prompt_asset_service),
+ audit_log_service: AuditLogService = Depends(get_audit_log_service),
+ current_user: dict = Depends(require_current_user),
+):
+ result = prompt_asset_service.evaluate_current_prompt_asset(current_user, asset_id)
+ audit_log_service.record_event(
+ tenant_id=current_user.get("tenant_id"),
+ user_id=current_user.get("id"),
+ action="run_prompt_asset_evaluation",
+ resource_type="prompt_asset",
+ resource_id=result["asset"]["id"],
+ detail_json={
+ "prompt_key": result["asset"]["prompt_key"],
+ "prompt_type": result["asset"]["prompt_type"],
+ "version_label": result["version"]["version_label"],
+ "evaluation_status": result["version"]["evaluation_status"],
+ "evaluation_score": result["version"]["evaluation_score"],
+ "suite_key": result["evaluation_report"].get("suite_key"),
+ },
+ )
+ return success_response(result)
+
+
@router.post("/{asset_id}/publish")
def publish_prompt_asset(
asset_id: str,
diff --git a/backend/app/services/prompt_asset_service.py b/backend/app/services/prompt_asset_service.py
index 411f563b..75c7b6d3 100644
--- a/backend/app/services/prompt_asset_service.py
+++ b/backend/app/services/prompt_asset_service.py
@@ -25,6 +25,14 @@ class PromptAssetService:
}
EVALUATION_STATUSES = {"not_evaluated", "pending", "passed", "failed"}
PUBLISHABLE_EVALUATION_STATUSES = {"passed"}
+ PROMPT_GOLDEN_SUITES = {
+ "contract_generation": {"suite_key": "phase13_generation", "sample_count": 8},
+ "contract_review": {"suite_key": "phase13_review", "sample_count": 8},
+ "legal_chat": {"suite_key": "phase13_kb_qa", "sample_count": 10},
+ "clause_recommendation": {"suite_key": "phase13_clause_generation", "sample_count": 6},
+ "wording_rewrite": {"suite_key": "phase15_wording_rewrite", "sample_count": 3},
+ "regulation_impact": {"suite_key": "future_regulation_impact", "sample_count": 0},
+ }
def __init__(self, db: Session):
self.db = db
@@ -178,6 +186,27 @@ class PromptAssetService:
self._copy_evaluation_to_asset(updated, version)
return self._to_item(self.repository.update(updated))
+ def evaluate_current_prompt_asset(self, current_user: Dict[str, Any], asset_id: str) -> dict:
+ self._require_admin(current_user)
+ asset = self._require_asset(current_user, asset_id)
+ version = self._require_current_version(current_user, asset)
+ report = self._build_prompt_evaluation_report(asset, version)
+
+ version.evaluation_report_json = report
+ version.evaluation_score = float(report["quality_score"])
+ version.evaluation_status = "passed" if report["passed"] else "failed"
+ version = self.version_repository.update(version)
+
+ asset.updated_by = current_user.get("id")
+ asset.updated_at = self._now()
+ self._copy_evaluation_to_asset(asset, version)
+ asset = self.repository.update(asset)
+ return {
+ "asset": self._to_item(asset),
+ "version": self._to_version_item(version),
+ "evaluation_report": report,
+ }
+
def publish_prompt_asset(
self,
current_user: Dict[str, Any],
@@ -409,6 +438,108 @@ class PromptAssetService:
asset.latest_evaluation_score = version.evaluation_score
asset.latest_evaluation_report_json = version.evaluation_report_json
+ def _build_prompt_evaluation_report(self, asset: PromptAsset, version: PromptAssetVersion) -> dict[str, Any]:
+ suite = self.PROMPT_GOLDEN_SUITES.get(asset.prompt_type, {"suite_key": "custom_prompt_suite", "sample_count": 1})
+ sample_count = int(suite["sample_count"])
+ checks = self._build_prompt_quality_checks(asset, version)
+ blocking_failures = [item for item in checks if item["required"] and item["status"] != "pass"]
+ warning_failures = [item for item in checks if not item["required"] and item["status"] != "pass"]
+ passed = not blocking_failures
+ quality_score = max(0.0, 1.0 - (0.25 * len(blocking_failures)) - (0.05 * len(warning_failures)))
+ if not passed:
+ quality_score = min(quality_score, 0.79)
+ passed_samples = sample_count if passed else max(0, sample_count - len(blocking_failures))
+ pass_rate = 1.0 if sample_count == 0 else round(passed_samples / sample_count, 4)
+ return {
+ "schema_version": "phase15-prompt-evaluation-report.v1",
+ "evaluator": "prompt_asset_api_static_gate",
+ "prompt_asset_id": asset.id,
+ "prompt_asset_version_id": version.id,
+ "prompt_key": asset.prompt_key,
+ "prompt_type": asset.prompt_type,
+ "version_label": version.version_label,
+ "suite_key": suite["suite_key"],
+ "sample_count": sample_count,
+ "passed_samples": passed_samples,
+ "failed_samples": sample_count - passed_samples,
+ "pass_rate": pass_rate,
+ "quality_score": round(quality_score, 4),
+ "passed": passed,
+ "failed_items": [item for item in checks if item["status"] != "pass"],
+ "checks": checks,
+ "evaluated_at": self._now().isoformat(),
+ "source": "prompt_assets.evaluate_current_version",
+ }
+
+ def _build_prompt_quality_checks(self, asset: PromptAsset, version: PromptAssetVersion) -> list[dict[str, Any]]:
+ content = f"{version.system_prompt or ''}\n{version.prompt_content or ''}".strip()
+ normalized = content.lower()
+ checks = [
+ {
+ "key": "prompt_content_minimum_length",
+ "label": "Prompt content is long enough for production use",
+ "required": True,
+ "status": "pass" if len(content) >= 20 else "fail",
+ "detail": f"content_length={len(content)}",
+ },
+ {
+ "key": "no_unresolved_placeholder",
+ "label": "Prompt has no TODO/FIXME placeholders",
+ "required": True,
+ "status": "fail" if self._contains_any(normalized, ["todo", "fixme"]) else "pass",
+ "detail": "checked common unfinished markers",
+ },
+ {
+ "key": "has_output_contract",
+ "label": "Prompt describes the expected output",
+ "required": False,
+ "status": "pass"
+ if self._contains_any(
+ normalized,
+ ["output", "return", "json", "html", "schema", "generate", "返回", "输出", "生成", "结果"],
+ )
+ else "warn",
+ "detail": "looks for output or return instructions",
+ },
+ {
+ "key": "has_legal_grounding_instruction",
+ "label": "Prompt asks for legal basis or source grounding",
+ "required": False,
+ "status": "pass"
+ if self._contains_any(normalized, ["legal", "law", "article", "source", "citation", "法条", "法律", "依据", "来源"])
+ else "warn",
+ "detail": "looks for law/source grounding terms",
+ },
+ ]
+ if version.variables_json:
+ variable_names = [str(item.get("name") or "").lower() for item in version.variables_json if isinstance(item, dict)]
+ mentioned_variables = [name for name in variable_names if name and name in normalized]
+ checks.append(
+ {
+ "key": "declared_variables_referenced",
+ "label": "Declared variables are referenced by the prompt",
+ "required": False,
+ "status": "pass" if mentioned_variables or "{{" in content else "warn",
+ "detail": f"referenced={len(mentioned_variables)} declared={len(variable_names)}",
+ }
+ )
+ if asset.prompt_type in {"contract_review", "clause_recommendation", "wording_rewrite"}:
+ checks.append(
+ {
+ "key": "risk_boundary_instruction",
+ "label": "Prompt asks for risk boundaries or negotiation guidance",
+ "required": False,
+ "status": "pass"
+ if self._contains_any(normalized, ["risk", "boundary", "negotiate", "风险", "边界", "建议", "谈判"])
+ else "warn",
+ "detail": "looks for risk and boundary instructions",
+ }
+ )
+ return checks
+
+ def _contains_any(self, value: str, candidates: list[str]) -> bool:
+ return any(candidate.lower() in value for candidate in candidates)
+
def _normalize_prompt_key(self, value: str) -> str:
normalized = self._normalize_required_text(value, "prompt_key").lower()
normalized = "".join(char if char.isalnum() else "_" for char in normalized)
diff --git a/backend/tests/test_prompt_asset_service.py b/backend/tests/test_prompt_asset_service.py
index faec49e0..7744b36f 100644
--- a/backend/tests/test_prompt_asset_service.py
+++ b/backend/tests/test_prompt_asset_service.py
@@ -136,6 +136,17 @@ class PromptAssetServiceTestCase(unittest.TestCase):
self.assertEqual(context.exception.status_code, 409)
+ def test_run_evaluation_writes_report_to_current_version(self):
+ created = self._create_asset(evaluation=False)
+
+ evaluation = self.service.evaluate_current_prompt_asset(self.admin_user, created["id"])
+ published = self.service.publish_prompt_asset(self.admin_user, created["id"])
+
+ self.assertEqual(evaluation["asset"]["latest_evaluation_status"], "passed")
+ self.assertEqual(evaluation["version"]["evaluation_status"], "passed")
+ self.assertEqual(evaluation["evaluation_report"]["suite_key"], "phase13_generation")
+ self.assertEqual(published["status"], "published")
+
def test_publish_with_report_and_create_next_version(self):
created = self._create_asset(evaluation=False)
published = self.service.publish_prompt_asset(
diff --git a/backend/tests/test_prompt_assets_api.py b/backend/tests/test_prompt_assets_api.py
index 5e45ead8..be1008ec 100644
--- a/backend/tests/test_prompt_assets_api.py
+++ b/backend/tests/test_prompt_assets_api.py
@@ -102,6 +102,38 @@ class StubPromptAssetService:
self._require_admin(current_user)
return self._item(id=asset_id, current_version_label="v1.1", version_count=2)
+ def evaluate_current_prompt_asset(self, current_user, asset_id):
+ self._require_admin(current_user)
+ return {
+ "asset": self._item(id=asset_id, latest_evaluation_status="passed", latest_evaluation_score=0.95),
+ "version": {
+ "id": "promptver-123",
+ "tenant_id": "tenant-demo-001",
+ "prompt_asset_id": asset_id,
+ "prompt_key": "contract_generation_default",
+ "prompt_type": "contract_generation",
+ "version_label": "v1.0",
+ "prompt_content": "Generate a contract.",
+ "system_prompt": None,
+ "variables": [],
+ "output_schema": None,
+ "model_config": None,
+ "evaluation_status": "passed",
+ "evaluation_score": 0.95,
+ "evaluation_report": {"suite_key": "phase13_generation", "passed": True},
+ "change_summary": "Initial prompt version",
+ "rollback_from_version_id": None,
+ "is_current": True,
+ "is_published": False,
+ "created_by": "user-admin",
+ "created_by_name": "Admin",
+ "created_at": "2026-05-21T00:00:00+00:00",
+ "published_at": None,
+ "published_by": None,
+ },
+ "evaluation_report": {"suite_key": "phase13_generation", "passed": True, "quality_score": 0.95},
+ }
+
def publish_prompt_asset(self, current_user, asset_id, **kwargs):
self._require_admin(current_user)
return self._item(id=asset_id, status="published", published_version_id="promptver-123", published_by=current_user["id"], published_at="2026-05-21T01:00:00+00:00")
@@ -176,6 +208,7 @@ class PromptAssetsApiTestCase(unittest.TestCase):
},
)
version_response = self.client.post("/api/v1/prompt-assets/prompt-123/versions", data={"prompt_content": "Generate a better contract."})
+ evaluation_response = self.client.post("/api/v1/prompt-assets/prompt-123/evaluations/run")
publish_response = self.client.post("/api/v1/prompt-assets/prompt-123/publish", data={"evaluation_report_json": '{"sample_count":3,"passed":true}'})
rollback_response = self.client.post("/api/v1/prompt-assets/prompt-123/rollback", data={"target_version_label": "v1.0"})
run_link_response = self.client.post(
@@ -185,12 +218,14 @@ class PromptAssetsApiTestCase(unittest.TestCase):
self.assertEqual(create_response.status_code, 200)
self.assertEqual(version_response.json()["data"]["current_version_label"], "v1.1")
+ self.assertEqual(evaluation_response.json()["data"]["version"]["evaluation_status"], "passed")
self.assertEqual(publish_response.json()["data"]["status"], "published")
self.assertEqual(rollback_response.json()["data"]["current_version_label"], "v1.2")
self.assertEqual(run_link_response.json()["data"]["prompt_asset_version_id"], "promptver-123")
self.assertEqual([event["action"] for event in self.audit_log_service.events], [
"create_prompt_asset",
"create_prompt_asset_version",
+ "run_prompt_asset_evaluation",
"publish_prompt_asset",
"rollback_prompt_asset",
"record_prompt_asset_run_link",
diff --git a/frontend/src/app/prompt-assets/page.tsx b/frontend/src/app/prompt-assets/page.tsx
index d5c104a3..9f4b9ff8 100644
--- a/frontend/src/app/prompt-assets/page.tsx
+++ b/frontend/src/app/prompt-assets/page.tsx
@@ -3,6 +3,7 @@
import {
CheckCircleOutlined,
CloudUploadOutlined,
+ ExperimentOutlined,
HistoryOutlined,
LinkOutlined,
PlusOutlined,
@@ -21,6 +22,7 @@ import {
publishPromptAsset,
recordPromptRunLink,
rollbackPromptAsset,
+ runPromptAssetEvaluation,
unpublishPromptAsset,
} from "@/services/prompt-asset";
import { useAuthStore } from "@/stores/auth.store";
@@ -291,6 +293,24 @@ export default function PromptAssetsPage() {
}
};
+ const handleRunEvaluation = async (item: PromptAssetItem) => {
+ setMutatingId(item.id);
+ try {
+ const result = await runPromptAssetEvaluation(item.id);
+ if (result.code !== 0) {
+ messageApi.error(result.message || "Prompt 评测失败");
+ return;
+ }
+ const statusText = result.data.version.evaluation_status === "passed" ? "通过" : "未通过";
+ messageApi.success(`Prompt 评测已完成:${statusText}`);
+ await refreshAfterMutation(item.id);
+ } catch (error) {
+ messageApi.error(error instanceof Error ? error.message : "Prompt 评测失败");
+ } finally {
+ setMutatingId(null);
+ }
+ };
+
const handlePublish = async (item: PromptAssetItem) => {
setMutatingId(item.id);
try {
@@ -450,12 +470,22 @@ export default function PromptAssetsPage() {
} loading={mutatingId === item.id} size="small" onClick={() => void handleEdit(item)}>
详情
+ } loading={mutatingId === item.id} size="small" onClick={() => void handleRunEvaluation(item)}>
+ 评测
+
{item.status === "published" ? (
} loading={mutatingId === item.id} size="small" onClick={() => void handleUnpublish(item)}>
下线
) : (
- } loading={mutatingId === item.id} size="small" type="primary" onClick={() => void handlePublish(item)}>
+ }
+ loading={mutatingId === item.id}
+ size="small"
+ type="primary"
+ onClick={() => void handlePublish(item)}
+ >
发布
)}
@@ -643,6 +673,9 @@ export default function PromptAssetsPage() {
{currentVersion.version_label}
+ } loading={mutatingId === selectedDetail.asset.id} size="small" onClick={() => void handleRunEvaluation(selectedDetail.asset)}>
+ 运行评测
+
) : null
}
diff --git a/frontend/src/services/prompt-asset.ts b/frontend/src/services/prompt-asset.ts
index d62ebb33..9eeb483f 100644
--- a/frontend/src/services/prompt-asset.ts
+++ b/frontend/src/services/prompt-asset.ts
@@ -3,6 +3,7 @@ import type {
ApiResponse,
PromptAssetCreatePayload,
PromptAssetDetailResponse,
+ PromptAssetEvaluationRunResponse,
PromptAssetItem,
PromptAssetListResponse,
PromptAssetPublishPayload,
@@ -40,6 +41,11 @@ export async function createPromptAssetVersion(assetId: string, payload: PromptA
return response.data;
}
+export async function runPromptAssetEvaluation(assetId: string) {
+ const response = await http.post>(`/prompt-assets/${assetId}/evaluations/run`);
+ return response.data;
+}
+
export async function publishPromptAsset(assetId: string, payload: PromptAssetPublishPayload = {}) {
const formData = new FormData();
appendOptional(formData, "evaluation_report_json", payload.evaluation_report_json);
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index b4d39a08..56972dc6 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -566,6 +566,12 @@ export type PromptAssetDetailResponse = {
run_links: PromptAssetRunLinkItem[];
};
+export type PromptAssetEvaluationRunResponse = {
+ asset: PromptAssetItem;
+ version: PromptAssetVersionItem;
+ evaluation_report: Record;
+};
+
export type PromptAssetCreatePayload = {
prompt_key: string;
prompt_type: PromptAssetType;
diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md
index 8d57b862..a1f81616 100644
--- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md
+++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md
@@ -4047,3 +4047,34 @@
- Browser 打开 `http://192.168.3.90:3003/contracts/contract-bbe8d2f3bcc1` 后按未登录状态跳转 `/login`,页面标题为 `AI合同平台`,控制台无前端 error。
- 当前闭环结论:
- 合同履约任务、续约提醒、履约节点清单和终止归档证据已完成本地编码、本地测试、Git 推送、远端 Alembic 迁移、前后端容器部署、远端 HTTP 烟测和真实 PostgreSQL API 烟测。
+
+## 2026-05-21 Phase15-F Prompt 评测触发与发布门禁产品化闭环
+
+- 已按《AI合同起草与审查助手-法律文书能力吸收开发方案-2026-05-20.md》继续推进 Phase15-F。
+- 本轮把之前“脚本生成评测报告、人工填入 Prompt 版本”的方式升级为产品内闭环:
+ - 后端新增 `POST /api/v1/prompt-assets/{asset_id}/evaluations/run`。
+ - 接口会对当前 Prompt 版本执行发布前静态质量门禁,生成 `phase15-prompt-evaluation-report.v1` 报告。
+ - 评测结果直接回填 `prompt_asset_versions.evaluation_status / evaluation_score / evaluation_report_json`。
+ - 同步更新 `prompt_assets.latest_evaluation_status / latest_evaluation_score / latest_evaluation_report_json`。
+ - 评测动作写入审计日志 `run_prompt_asset_evaluation`。
+ - 继续复用既有发布门禁:未通过评测或没有评测报告时,`publish` 仍拒绝发布。
+- 前端能力:
+ - `/prompt-assets` 列表新增“评测”按钮。
+ - Prompt 详情卡片新增“运行评测”按钮。
+ - 发布按钮在最新评测状态不是 `passed` 时不可点击,避免管理员跳过评测直接发布。
+- 数据库影响:
+ - 本轮不新增表、不新增 Alembic 迁移。
+ - 远程库只要已经到 `20260521_0032_create_prompt_assets.py` 或更高版本即可使用;当前 3.90 已在更高版本 `20260521_0037`。
+- 本轮验证:
+ - `python -m compileall backend\app\services\prompt_asset_service.py backend\app\api\prompt_assets.py`
+ - `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_prompt_asset_service backend.tests.test_prompt_assets_api backend.tests.test_permission_metadata -v`
+ - `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_prompt_asset_service backend.tests.test_prompt_assets_api backend.tests.test_prompt_runtime_service backend.tests.test_chat_api backend.tests.test_clause_recommendation_service backend.tests.test_clauses_api backend.tests.test_review_service backend.tests.test_contracts_api backend.tests.test_permission_metadata -v`
+ - `cd frontend; npx eslint src/services/prompt-asset.ts src/app/prompt-assets/page.tsx src/types/api.ts`
+ - `cd frontend; npm run build`
+ - `git diff --check`
+ - Browser 打开 `http://localhost:3000/prompt-assets`,未登录保护页正常显示,页面标题 `AI合同平台`,控制台错误 0。
+- 当前闭环结论:
+ - Phase15-F 已从“离线脚本可复跑”进一步形成“页面触发评测 -> 后端生成报告 -> 自动回填当前版本 -> 发布按钮受评测状态约束 -> 审计留痕”的产品化闭环。
+- 剩余边界:
+ - 本轮没有部署到 3.90;由于无数据库迁移,后续只需部署后端/前端即可在测试服务器使用该按钮。
+ - 当前评测接口是发布前静态质量门禁,不等同于调用真实大模型逐条跑完整黄金样本输出;后续如需更强门禁,可继续接入异步任务队列,把 `scripts/run-phase15-prompt-golden-eval.ps1` 或等价服务端评测器的结果自动回填同一字段。