fix: add missing get_review_report and get_review_result methods; redesign prompt-assets page UI
- Fix 500 error on /reviews/{id}/report endpoint caused by missing get_review_report
and get_review_result methods in ReviewService
- Fix indentation bug in build_report_pdf and _build_report_pdf_fallback methods
- Redesign prompt-assets page: replace 3-column layout with KPI cards + full-width
table + detail drawer + create/edit modal
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -542,6 +542,88 @@ class ReviewService:
|
||||
"source_document": None,
|
||||
}
|
||||
|
||||
def get_review_result(self, current_user: Dict[str, Any], review_id: str) -> Optional[dict]:
|
||||
review = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"])
|
||||
if review is None:
|
||||
return None
|
||||
|
||||
review_result = self.review_result_repository.get_by_review_task_id(review_id)
|
||||
if review_result is None:
|
||||
return self._to_result_without_result(review)
|
||||
|
||||
rendered_report = (review_result.confidence or {}).get("rendered_report") if review_result.confidence else None
|
||||
return {
|
||||
"rendered_report": rendered_report,
|
||||
"basic_info": review_result.basic_info,
|
||||
"clauses": review_result.clauses,
|
||||
"risk_items": review_result.risk_items,
|
||||
"risk_summary": review_result.risk_summary,
|
||||
"review_opinion": review_result.review_opinion,
|
||||
"recommendation": review_result.recommendation,
|
||||
"contract_structure": review_result.basic_info.get("contract_structure") or {},
|
||||
"report_file_id": review_result.report_file_id,
|
||||
}
|
||||
|
||||
def _to_result_without_result(self, review) -> dict:
|
||||
rules = self._load_risk_rules(review.contract_type)
|
||||
text = review.input_text or review.extracted_text or ""
|
||||
local_risks = self._enrich_with_local_risk_rules(text, review.contract_type)
|
||||
|
||||
high_count = sum(1 for r in local_risks if r.get("level") == "high")
|
||||
medium_count = sum(1 for r in local_risks if r.get("level") == "medium")
|
||||
low_count = sum(1 for r in local_risks if r.get("level") == "low")
|
||||
|
||||
overall = "high" if high_count > 0 else ("medium" if medium_count > 0 else "low")
|
||||
|
||||
return {
|
||||
"rendered_report": None,
|
||||
"basic_info": {},
|
||||
"clauses": [],
|
||||
"risk_items": local_risks,
|
||||
"risk_summary": {
|
||||
"high": high_count,
|
||||
"medium": medium_count,
|
||||
"low": low_count,
|
||||
"overall": overall,
|
||||
},
|
||||
"review_opinion": DEFAULT_OPINION,
|
||||
"recommendation": DEFAULT_RECOMMENDATION,
|
||||
"contract_structure": {},
|
||||
"report_file_id": None,
|
||||
}
|
||||
|
||||
def get_review_report(self, current_user: Dict[str, Any], review_id: str) -> Optional[dict]:
|
||||
review = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"])
|
||||
if review is None:
|
||||
return None
|
||||
|
||||
review_result = self.review_result_repository.get_by_review_task_id(review_id)
|
||||
result = self.get_review_result(current_user, review_id)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
legal_article_refs = self._collect_unique_legal_article_refs(result.get("risk_items") or [])
|
||||
report_download_metadata = self._build_report_download_metadata(legal_article_refs)
|
||||
|
||||
file_name = f"review-report-{review_id}.pdf"
|
||||
download_url = None
|
||||
if review_result and review_result.report_file_id:
|
||||
try:
|
||||
file_info = self.file_service.get_download_payload(
|
||||
current_user, review_result.report_file_id, allow_owner_access=True
|
||||
)
|
||||
download_url = file_info.get("download_url")
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
return {
|
||||
"review_id": review_id,
|
||||
"file_name": file_name,
|
||||
"download_url": download_url,
|
||||
"legal_article_ref_count": report_download_metadata.get("legal_article_ref_count", 0),
|
||||
"report_metadata": report_download_metadata,
|
||||
}
|
||||
|
||||
def retry_review_task(self, current_user: Dict[str, Any], review_id: str) -> Optional[dict]:
|
||||
original = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"])
|
||||
if original is None:
|
||||
@@ -792,93 +874,93 @@ class ReviewService:
|
||||
})
|
||||
return hits
|
||||
|
||||
def build_report_pdf(self, current_user: Dict[str, Any], review_id: str) -> Optional[bytes]:
|
||||
review = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"])
|
||||
if review is None:
|
||||
return None
|
||||
|
||||
review_result = self.review_result_repository.get_by_review_task_id(review_id)
|
||||
result = self.get_review_result(current_user, review_id)
|
||||
legal_article_refs = self._collect_unique_legal_article_refs((result or {}).get("risk_items") or [])
|
||||
report_download_metadata = self._build_report_download_metadata(legal_article_refs)
|
||||
if (
|
||||
review_result
|
||||
and review_result.report_file_id
|
||||
and (
|
||||
not legal_article_refs
|
||||
or self._report_download_metadata_matches(review_result.confidence, report_download_metadata)
|
||||
)
|
||||
):
|
||||
try:
|
||||
existing = self.file_service.get_download_payload(current_user, review_result.report_file_id, allow_owner_access=True)
|
||||
return existing["content"]
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
from app.services.review_report_template import build_report_html
|
||||
html = build_report_html(review, result, legal_article_refs)
|
||||
def build_report_pdf(self, current_user: Dict[str, Any], review_id: str) -> Optional[bytes]:
|
||||
review = self.repository.get_by_id(review_id, current_user["tenant_id"], current_user["id"])
|
||||
if review is None:
|
||||
return None
|
||||
|
||||
review_result = self.review_result_repository.get_by_review_task_id(review_id)
|
||||
result = self.get_review_result(current_user, review_id)
|
||||
legal_article_refs = self._collect_unique_legal_article_refs((result or {}).get("risk_items") or [])
|
||||
report_download_metadata = self._build_report_download_metadata(legal_article_refs)
|
||||
if (
|
||||
review_result
|
||||
and review_result.report_file_id
|
||||
and (
|
||||
not legal_article_refs
|
||||
or self._report_download_metadata_matches(review_result.confidence, report_download_metadata)
|
||||
)
|
||||
):
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
pdf_content = HTML(string=html).write_pdf()
|
||||
existing = self.file_service.get_download_payload(current_user, review_result.report_file_id, allow_owner_access=True)
|
||||
return existing["content"]
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
from app.services.review_report_template import build_report_html
|
||||
html = build_report_html(review, result, legal_article_refs)
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
pdf_content = HTML(string=html).write_pdf()
|
||||
except Exception:
|
||||
try:
|
||||
import pdfkit
|
||||
pdf_content = pdfkit.from_string(html, False)
|
||||
except Exception:
|
||||
try:
|
||||
import pdfkit
|
||||
pdf_content = pdfkit.from_string(html, False)
|
||||
except Exception:
|
||||
pdf_content = self._build_report_pdf_fallback(current_user, review_id, review, result)
|
||||
if pdf_content is None:
|
||||
return None
|
||||
pdf_content = self._build_report_pdf_fallback(current_user, review_id, review, result)
|
||||
if pdf_content is None:
|
||||
return None
|
||||
|
||||
if review_result:
|
||||
file_item = self.file_service.create_file_record(
|
||||
current_user=current_user,
|
||||
file_name=f"review-report-{review.id}.pdf",
|
||||
content=pdf_content,
|
||||
file_type="report",
|
||||
mime_type="application/pdf",
|
||||
require_admin=False,
|
||||
)
|
||||
review_result.report_file_id = file_item["id"]
|
||||
confidence_summary = dict(review_result.confidence or {})
|
||||
confidence_summary["report_download_metadata"] = report_download_metadata
|
||||
confidence_summary["rendered_report"] = html
|
||||
review_result.confidence = confidence_summary
|
||||
self.review_result_repository.update(review_result)
|
||||
if review_result:
|
||||
file_item = self.file_service.create_file_record(
|
||||
current_user=current_user,
|
||||
file_name=f"review-report-{review.id}.pdf",
|
||||
content=pdf_content,
|
||||
file_type="report",
|
||||
mime_type="application/pdf",
|
||||
require_admin=False,
|
||||
)
|
||||
review_result.report_file_id = file_item["id"]
|
||||
confidence_summary = dict(review_result.confidence or {})
|
||||
confidence_summary["report_download_metadata"] = report_download_metadata
|
||||
confidence_summary["rendered_report"] = html
|
||||
review_result.confidence = confidence_summary
|
||||
self.review_result_repository.update(review_result)
|
||||
|
||||
return pdf_content
|
||||
return pdf_content
|
||||
|
||||
def _build_report_pdf_fallback(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
review_id: str,
|
||||
review: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> Optional[bytes]:
|
||||
"""当 WeasyPrint/pdfkit 均不可用时的最终回退方案"""
|
||||
content = self.build_report_content(current_user, review_id)
|
||||
if content is None:
|
||||
return None
|
||||
buffer = io.BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
pdf.setTitle(f"review-report-{review_id}")
|
||||
pdf.setAuthor("AI合同平台")
|
||||
pdf.setFont(PDF_FONT_NAME, 11)
|
||||
page_width, page_height = A4
|
||||
margin_x = 48
|
||||
line_height = 18
|
||||
current_y = page_height - 56
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line or " "
|
||||
segments = self._split_pdf_line(line, PDF_FONT_NAME, 11, page_width - margin_x * 2)
|
||||
for segment in segments:
|
||||
if current_y <= 56:
|
||||
pdf.showPage()
|
||||
pdf.setFont(PDF_FONT_NAME, 11)
|
||||
current_y = page_height - 56
|
||||
pdf.drawString(margin_x, current_y, segment)
|
||||
current_y -= line_height
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
def _build_report_pdf_fallback(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
review_id: str,
|
||||
review: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> Optional[bytes]:
|
||||
"""当 WeasyPrint/pdfkit 均不可用时的最终回退方案"""
|
||||
content = self.build_report_content(current_user, review_id)
|
||||
if content is None:
|
||||
return None
|
||||
buffer = io.BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
pdf.setTitle(f"review-report-{review_id}")
|
||||
pdf.setAuthor("AI合同平台")
|
||||
pdf.setFont(PDF_FONT_NAME, 11)
|
||||
page_width, page_height = A4
|
||||
margin_x = 48
|
||||
line_height = 18
|
||||
current_y = page_height - 56
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line or " "
|
||||
segments = self._split_pdf_line(line, PDF_FONT_NAME, 11, page_width - margin_x * 2)
|
||||
for segment in segments:
|
||||
if current_y <= 56:
|
||||
pdf.showPage()
|
||||
pdf.setFont(PDF_FONT_NAME, 11)
|
||||
current_y = page_height - 56
|
||||
pdf.drawString(margin_x, current_y, segment)
|
||||
current_y -= line_height
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
|
||||
@@ -6846,3 +6846,58 @@ textarea.ant-input:focus,
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt Assets KPI Cards */
|
||||
.prompt-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.prompt-kpi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.prompt-kpi-card span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.prompt-kpi-card strong {
|
||||
color: var(--ink);
|
||||
font-size: 32px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.prompt-kpi-card-success strong {
|
||||
color: #13795b;
|
||||
}
|
||||
|
||||
.prompt-kpi-card-warning strong {
|
||||
color: #a85d00;
|
||||
}
|
||||
|
||||
.prompt-kpi-card-muted strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.prompt-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prompt-kpi-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CheckCircleOutlined,
|
||||
CloudUploadOutlined,
|
||||
ExperimentOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
@@ -11,7 +12,23 @@ import {
|
||||
RollbackOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, Typography, message } from "antd";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -38,7 +55,7 @@ import type {
|
||||
PromptAssetVersionItem,
|
||||
} from "@/types/api";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
type PromptAssetFormValues = {
|
||||
prompt_key: string;
|
||||
@@ -165,8 +182,20 @@ export default function PromptAssetsPage() {
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// UI state for modal/drawer
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
|
||||
|
||||
const currentVersion = useMemo(() => selectedDetail?.versions.find((item) => item.is_current) ?? null, [selectedDetail]);
|
||||
|
||||
// KPI stats
|
||||
const kpiStats = useMemo(() => {
|
||||
const draftCount = items.filter((item) => item.status === "draft").length;
|
||||
const publishedCount = items.filter((item) => item.status === "published").length;
|
||||
const inactiveCount = items.filter((item) => item.status === "inactive").length;
|
||||
return { total: items.length, draft: draftCount, published: publishedCount, inactive: inactiveCount };
|
||||
}, [items]);
|
||||
|
||||
const loadAssets = useCallback(
|
||||
async (nextPage = page, nextPageSize = pageSize, nextPromptType = promptTypeFilter, nextStatus = statusFilter) => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
@@ -210,6 +239,20 @@ export default function PromptAssetsPage() {
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const openDetail = async (item: PromptAssetItem) => {
|
||||
setMutatingId(item.id);
|
||||
try {
|
||||
const detail = await loadDetail(item.id);
|
||||
if (detail) {
|
||||
setDetailDrawerOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : "Prompt 详情加载失败");
|
||||
} finally {
|
||||
setMutatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (item: PromptAssetItem) => {
|
||||
setMutatingId(item.id);
|
||||
try {
|
||||
@@ -236,6 +279,7 @@ export default function PromptAssetsPage() {
|
||||
run_type: item.prompt_type,
|
||||
resource_type: item.prompt_type === "contract_generation" ? "generated_contract" : "ai_run",
|
||||
});
|
||||
setCreateModalOpen(true);
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : "Prompt 详情加载失败");
|
||||
} finally {
|
||||
@@ -298,9 +342,11 @@ export default function PromptAssetsPage() {
|
||||
return;
|
||||
}
|
||||
messageApi.success(editingAsset ? "新版本已创建" : "Prompt 草稿已创建");
|
||||
setCreateModalOpen(false);
|
||||
await loadAssets(page, pageSize);
|
||||
await loadDetail(result.data.id);
|
||||
setEditingAsset(result.data);
|
||||
if (editingAsset && selectedDetail?.asset.id === editingAsset.id) {
|
||||
await loadDetail(editingAsset.id);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : "Prompt 保存失败,请检查 JSON 格式");
|
||||
} finally {
|
||||
@@ -310,11 +356,8 @@ export default function PromptAssetsPage() {
|
||||
|
||||
const refreshAfterMutation = async (assetId: string) => {
|
||||
await loadAssets(page, pageSize);
|
||||
if (selectedDetail?.asset.id === assetId || editingAsset?.id === assetId) {
|
||||
const detail = await loadDetail(assetId);
|
||||
if (detail) {
|
||||
setEditingAsset(detail.asset);
|
||||
}
|
||||
if (selectedDetail?.asset.id === assetId) {
|
||||
await loadDetail(assetId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,12 +484,24 @@ export default function PromptAssetsPage() {
|
||||
void loadAssets(nextPage, nextPageSize);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingAsset(null);
|
||||
resetForm();
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCreateModal = () => {
|
||||
setCreateModalOpen(false);
|
||||
setEditingAsset(null);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PromptAssetItem> = [
|
||||
{
|
||||
title: "Prompt",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 220,
|
||||
width: 240,
|
||||
render: (_, item) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong>{item.name}</Text>
|
||||
@@ -465,13 +520,13 @@ export default function PromptAssetsPage() {
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
width: 100,
|
||||
render: (value: string) => getStatusTag(value),
|
||||
},
|
||||
{
|
||||
title: "版本",
|
||||
key: "version",
|
||||
width: 140,
|
||||
width: 120,
|
||||
render: (_, item) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text>{item.current_version_label}</Text>
|
||||
@@ -482,16 +537,8 @@ export default function PromptAssetsPage() {
|
||||
{
|
||||
title: "评测",
|
||||
key: "evaluation",
|
||||
width: 170,
|
||||
render: (_, item) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
{getEvaluationTag(item.latest_evaluation_status)}
|
||||
{getEvaluationModeTag(item.latest_evaluation_report)}
|
||||
<Text type="secondary">
|
||||
{item.latest_evaluation_score == null ? "-" : `分数 ${item.latest_evaluation_score}`} · 样本 {getEvaluationSampleResultCount(item.latest_evaluation_report)}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
width: 140,
|
||||
render: (_, item) => getEvaluationTag(item.latest_evaluation_status),
|
||||
},
|
||||
{
|
||||
title: "发布时间",
|
||||
@@ -503,19 +550,15 @@ export default function PromptAssetsPage() {
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 270,
|
||||
width: 240,
|
||||
render: (_, item) => (
|
||||
<Space wrap>
|
||||
<Button icon={<HistoryOutlined />} loading={mutatingId === item.id} size="small" onClick={() => void handleEdit(item)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button icon={<ExperimentOutlined />} loading={mutatingId === item.id} size="small" onClick={() => void handleRunEvaluation(item)}>
|
||||
评测
|
||||
</Button>
|
||||
{item.status === "published" ? (
|
||||
<Button icon={<StopOutlined />} loading={mutatingId === item.id} size="small" onClick={() => void handleUnpublish(item)}>
|
||||
下线
|
||||
</Button>
|
||||
<Popconfirm title="确认下线此 Prompt?" okText="下线" cancelText="取消" onConfirm={() => void handleUnpublish(item)}>
|
||||
<Button icon={<StopOutlined />} loading={mutatingId === item.id} size="small" danger>
|
||||
下线
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
disabled={item.latest_evaluation_status !== "passed"}
|
||||
@@ -528,6 +571,9 @@ export default function PromptAssetsPage() {
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ExperimentOutlined />} loading={mutatingId === item.id} size="small" onClick={() => void handleRunEvaluation(item)}>
|
||||
评测
|
||||
</Button>
|
||||
{item.version_count > 1 ? (
|
||||
<Popconfirm title={`回滚到 ${getPreviousVersionLabel(item.current_version_label)}?`} okText="回滚" cancelText="取消" onConfirm={() => void handleRollback(item)}>
|
||||
<Button icon={<RollbackOutlined />} loading={mutatingId === item.id} size="small">
|
||||
@@ -631,7 +677,7 @@ export default function PromptAssetsPage() {
|
||||
<main className="page-shell review-history-page">
|
||||
{contextHolder}
|
||||
<Card className="review-list-card">
|
||||
<Alert title="仅管理员可管理 Prompt 资产。" type="warning" showIcon />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="仅管理员可管理 Prompt 资产。" />
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
@@ -641,6 +687,7 @@ export default function PromptAssetsPage() {
|
||||
<main className="page-shell review-page-grid">
|
||||
{contextHolder}
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
{/* Hero */}
|
||||
<section className="review-page-hero">
|
||||
<div className="review-page-hero-copy">
|
||||
<span className="landing-badge">Prompt Governance</span>
|
||||
@@ -660,181 +707,276 @@ export default function PromptAssetsPage() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<div className="chat-layout-grid chat-layout-grid-history">
|
||||
<Card className="review-surface-card" title={editingAsset ? "创建 Prompt 新版本" : "创建 Prompt 草稿"}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item label="Prompt 编码" name="prompt_key" rules={[{ required: !editingAsset, message: "请输入 Prompt 编码" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="contract_generation_default" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Prompt 类型" name="prompt_type" rules={[{ required: true, message: "请选择 Prompt 类型" }]}>
|
||||
<Select disabled={Boolean(editingAsset)} options={promptTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="名称" name="name" rules={[{ required: !editingAsset, message: "请输入名称" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="合同生成默认 Prompt" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="说明这个 Prompt 的业务边界" />
|
||||
</Form.Item>
|
||||
<Form.Item label="适用场景" name="target_scene">
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="legal_document_generation" />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统 Prompt" name="system_prompt">
|
||||
<Input.TextArea rows={3} placeholder="可选:系统角色、边界和安全要求" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Prompt 内容" name="prompt_content" rules={[{ required: true, message: "请输入 Prompt 内容" }]}>
|
||||
<Input.TextArea rows={7} placeholder="写入业务 Prompt 主体" />
|
||||
</Form.Item>
|
||||
<Form.Item label="变量 JSON" name="variables_json">
|
||||
<Input.TextArea rows={3} placeholder='[{"name":"party_a","required":true}]' />
|
||||
</Form.Item>
|
||||
<Form.Item label="输出 Schema JSON" name="output_schema_json">
|
||||
<Input.TextArea rows={3} placeholder='{"type":"object"}' />
|
||||
</Form.Item>
|
||||
<Form.Item label="模型配置 JSON" name="model_config_json">
|
||||
<Input.TextArea rows={3} placeholder='{"model":"gpt-5.4","temperature":0.2}' />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
title="发布前评测说明"
|
||||
description="LLM 评测会逐条调用共享 AI 对黄金样本打分;本地兜底仅做静态检查和样本装载校验。发布前请重点确认评测模式、样本数和失败项。"
|
||||
/>
|
||||
<Form.Item label="评测状态" name="evaluation_status">
|
||||
<Select options={evaluationStatusOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="评测分数" name="evaluation_score">
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="评测报告 JSON" name="evaluation_report_json">
|
||||
<Input.TextArea rows={5} />
|
||||
</Form.Item>
|
||||
<Form.Item label="变更说明" name="change_summary">
|
||||
<Input placeholder="例如:补充法条来源注入要求" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button icon={<PlusOutlined />} type="primary" htmlType="submit" loading={submitting}>
|
||||
{editingAsset ? "创建新版本" : "创建草稿"}
|
||||
</Button>
|
||||
<Button onClick={resetForm}>清空</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
{/* KPI Stats */}
|
||||
<div className="prompt-kpi-grid">
|
||||
<div className="prompt-kpi-card">
|
||||
<Text>资产总数</Text>
|
||||
<strong>{kpiStats.total}</strong>
|
||||
</div>
|
||||
<div className="prompt-kpi-card prompt-kpi-card-success">
|
||||
<Text>已发布</Text>
|
||||
<strong>{kpiStats.published}</strong>
|
||||
</div>
|
||||
<div className="prompt-kpi-card prompt-kpi-card-warning">
|
||||
<Text>草稿</Text>
|
||||
<strong>{kpiStats.draft}</strong>
|
||||
</div>
|
||||
<div className="prompt-kpi-card prompt-kpi-card-muted">
|
||||
<Text>已停用</Text>
|
||||
<strong>{kpiStats.inactive}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset List */}
|
||||
<Card
|
||||
className="review-surface-card"
|
||||
title="Prompt 资产列表"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button icon={<RocketOutlined />} loading={submitting} onClick={() => void handleSeedFirstPriorityBlueprint()}>
|
||||
种入第一优先级
|
||||
</Button>
|
||||
<Select
|
||||
value={promptTypeFilter}
|
||||
style={{ width: 180 }}
|
||||
options={[{ value: "all", label: "全部类型" }, ...promptTypeOptions]}
|
||||
onChange={(value) => {
|
||||
setPromptTypeFilter(value);
|
||||
setLoading(true);
|
||||
void loadAssets(1, pageSize, value, statusFilter);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
style={{ width: 140 }}
|
||||
options={statusOptions}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value);
|
||||
setLoading(true);
|
||||
void loadAssets(1, pageSize, promptTypeFilter, value);
|
||||
}}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} type="primary" onClick={handleOpenCreate}>
|
||||
创建草稿
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{ current: page, pageSize, total, showSizeChanger: true }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有 Prompt 资产。" /> }}
|
||||
columns={columns}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 1280 }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => void openDetail(record),
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<Drawer
|
||||
title={selectedDetail ? `资产详情:${selectedDetail.asset.name}` : "资产详情"}
|
||||
open={detailDrawerOpen}
|
||||
onClose={() => {
|
||||
setDetailDrawerOpen(false);
|
||||
setSelectedDetail(null);
|
||||
}}
|
||||
width={720}
|
||||
styles={{ body: { padding: "0 24px" } }}
|
||||
>
|
||||
{selectedDetail ? (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Card
|
||||
className="review-surface-card"
|
||||
title="Prompt 资产列表"
|
||||
extra={
|
||||
{/* Basic Info */}
|
||||
<section>
|
||||
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Text strong>基本信息</Text>
|
||||
<Space wrap>
|
||||
<Button icon={<RocketOutlined />} loading={submitting} onClick={() => void handleSeedFirstPriorityBlueprint()}>
|
||||
种入第一优先级
|
||||
</Button>
|
||||
<Select
|
||||
value={promptTypeFilter}
|
||||
style={{ width: 180 }}
|
||||
options={[{ value: "all", label: "全部类型" }, ...promptTypeOptions]}
|
||||
onChange={(value) => {
|
||||
setPromptTypeFilter(value);
|
||||
setLoading(true);
|
||||
void loadAssets(1, pageSize, value, statusFilter);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
style={{ width: 140 }}
|
||||
options={statusOptions}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value);
|
||||
setLoading(true);
|
||||
void loadAssets(1, pageSize, promptTypeFilter, value);
|
||||
}}
|
||||
/>
|
||||
{getStatusTag(selectedDetail.asset.status)}
|
||||
{getEvaluationTag(selectedDetail.asset.latest_evaluation_status)}
|
||||
{getEvaluationModeTag(selectedDetail.asset.latest_evaluation_report)}
|
||||
<Text type="secondary">
|
||||
类型:{promptTypeOptions.find((o) => o.value === selectedDetail.asset.prompt_type)?.label ?? selectedDetail.asset.prompt_type}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{selectedDetail.asset.description ? <Paragraph>{selectedDetail.asset.description}</Paragraph> : null}
|
||||
<Text type="secondary">
|
||||
当前版本:{selectedDetail.asset.current_version_label} · 共 {selectedDetail.asset.version_count} 个版本
|
||||
</Text>
|
||||
{currentVersion ? (
|
||||
<Text type="secondary">
|
||||
样本:{getEvaluationSampleResultCount(selectedDetail.asset.latest_evaluation_report)} 条
|
||||
{selectedDetail.asset.latest_evaluation_score != null ? ` · 分数 ${selectedDetail.asset.latest_evaluation_score}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</section>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<section>
|
||||
<Text strong>快速操作</Text>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Button icon={<FileTextOutlined />} onClick={() => void handleEdit(selectedDetail.asset)}>
|
||||
创建新版本
|
||||
</Button>
|
||||
<Button icon={<ExperimentOutlined />} loading={mutatingId === selectedDetail.asset.id} onClick={() => void handleRunEvaluation(selectedDetail.asset)}>
|
||||
运行评测
|
||||
</Button>
|
||||
{selectedDetail.asset.status === "published" ? (
|
||||
<Button icon={<StopOutlined />} danger loading={mutatingId === selectedDetail.asset.id} onClick={() => void handleUnpublish(selectedDetail.asset)}>
|
||||
下线
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={selectedDetail.asset.latest_evaluation_status !== "passed"}
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={mutatingId === selectedDetail.asset.id}
|
||||
type="primary"
|
||||
onClick={() => void handlePublish(selectedDetail.asset)}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
{selectedDetail.asset.version_count > 1 ? (
|
||||
<Popconfirm title={`回滚到 ${getPreviousVersionLabel(selectedDetail.asset.current_version_label)}?`} okText="回滚" cancelText="取消" onConfirm={() => void handleRollback(selectedDetail.asset)}>
|
||||
<Button icon={<RollbackOutlined />} loading={mutatingId === selectedDetail.asset.id}>
|
||||
回滚
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
</section>
|
||||
|
||||
{/* Version History */}
|
||||
<section>
|
||||
<Text strong>版本历史</Text>
|
||||
<Table rowKey="id" size="small" pagination={false} columns={versionColumns} dataSource={selectedDetail.versions} />
|
||||
</section>
|
||||
|
||||
{/* Evaluation History */}
|
||||
<section>
|
||||
<Text strong>评测历史</Text>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{ current: page, pageSize, total, showSizeChanger: true }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有 Prompt 资产。" /> }}
|
||||
columns={columns}
|
||||
onChange={handleTableChange}
|
||||
scroll={{ x: 1280 }}
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={evaluationRunColumns}
|
||||
dataSource={selectedDetail.evaluation_runs}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有评测运行记录。" /> }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{selectedDetail ? (
|
||||
<Card
|
||||
className="review-surface-card"
|
||||
title={`版本与运行证据:${selectedDetail.asset.name}`}
|
||||
extra={
|
||||
currentVersion ? (
|
||||
<Space>
|
||||
<CheckCircleOutlined />
|
||||
<Text>{currentVersion.version_label}</Text>
|
||||
<Button icon={<ExperimentOutlined />} loading={mutatingId === selectedDetail.asset.id} size="small" onClick={() => void handleRunEvaluation(selectedDetail.asset)}>
|
||||
运行评测
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Text type="secondary">
|
||||
当前状态:{getStatusTag(selectedDetail.asset.status)} 最新评测:{getEvaluationTag(selectedDetail.asset.latest_evaluation_status)} {getEvaluationModeTag(selectedDetail.asset.latest_evaluation_report)} 样本 {getEvaluationSampleResultCount(selectedDetail.asset.latest_evaluation_report)} 条
|
||||
</Text>
|
||||
<Table rowKey="id" size="small" pagination={false} columns={versionColumns} dataSource={selectedDetail.versions} />
|
||||
<Space orientation="vertical" size="small" style={{ width: "100%" }}>
|
||||
<Text strong>评测运行历史</Text>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={evaluationRunColumns}
|
||||
dataSource={selectedDetail.evaluation_runs}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有评测运行记录。" /> }}
|
||||
/>
|
||||
</Space>
|
||||
<Form form={runLinkForm} layout="vertical" onFinish={handleRecordRunLink}>
|
||||
<Form.Item label="运行类型" name="run_type" rules={[{ required: true, message: "请输入运行类型" }]}>
|
||||
<Input placeholder="contract_generation" />
|
||||
</Form.Item>
|
||||
<Form.Item label="运行 ID" name="run_id">
|
||||
<Input placeholder="workflow-run-001" />
|
||||
</Form.Item>
|
||||
<Form.Item label="资源类型" name="resource_type">
|
||||
<Input placeholder="generated_contract / review_task / conversation" />
|
||||
</Form.Item>
|
||||
<Form.Item label="资源 ID" name="resource_id">
|
||||
<Input placeholder="contract-001" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Trace JSON" name="trace_json">
|
||||
<Input.TextArea rows={3} placeholder='{"provider":"shared-ai-platform","model":"gpt-5.4"}' />
|
||||
</Form.Item>
|
||||
<Button icon={<LinkOutlined />} loading={mutatingId === selectedDetail.asset.id} type="primary" htmlType="submit">
|
||||
登记运行证据
|
||||
</Button>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={selectedDetail.run_links}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有运行证据。" /> }}
|
||||
columns={[
|
||||
{ title: "运行 ID", dataIndex: "run_id", key: "run_id" },
|
||||
{ title: "资源", key: "resource", render: (_, item) => `${item.resource_type ?? "-"} / ${item.resource_id ?? "-"}` },
|
||||
{ title: "版本", dataIndex: "prompt_asset_version_id", key: "prompt_asset_version_id" },
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", render: (value: string) => formatDateTime(value) },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Run Links */}
|
||||
<section>
|
||||
<Text strong>运行证据登记</Text>
|
||||
<Form form={runLinkForm} layout="vertical" onFinish={handleRecordRunLink} style={{ marginTop: 8 }}>
|
||||
<Form.Item label="运行类型" name="run_type" rules={[{ required: true, message: "请输入运行类型" }]}>
|
||||
<Input placeholder="contract_generation" />
|
||||
</Form.Item>
|
||||
<Form.Item label="运行 ID" name="run_id">
|
||||
<Input placeholder="workflow-run-001" />
|
||||
</Form.Item>
|
||||
<Form.Item label="资源类型" name="resource_type">
|
||||
<Input placeholder="generated_contract / review_task / conversation" />
|
||||
</Form.Item>
|
||||
<Form.Item label="资源 ID" name="resource_id">
|
||||
<Input placeholder="contract-001" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Trace JSON" name="trace_json">
|
||||
<Input.TextArea rows={3} placeholder='{"provider":"shared-ai-platform","model":"gpt-5.4"}' />
|
||||
</Form.Item>
|
||||
<Button icon={<LinkOutlined />} loading={mutatingId === selectedDetail.asset.id} type="primary" htmlType="submit">
|
||||
登记运行证据
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
{/* Run Links List */}
|
||||
<section>
|
||||
<Text strong>已登记运行证据</Text>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={selectedDetail.run_links}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有运行证据。" /> }}
|
||||
columns={[
|
||||
{ title: "运行 ID", dataIndex: "run_id", key: "run_id" },
|
||||
{ title: "资源", key: "resource", render: (_, item) => `${item.resource_type ?? "-"} / ${item.resource_id ?? "-"}` },
|
||||
{ title: "版本", dataIndex: "prompt_asset_version_id", key: "prompt_asset_version_id" },
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", render: (value: string) => formatDateTime(value) },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
title={editingAsset ? `创建新版本:${editingAsset.name}` : "创建 Prompt 草稿"}
|
||||
open={createModalOpen}
|
||||
onCancel={handleCloseCreateModal}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: 16 }}>
|
||||
<Form.Item label="Prompt 编码" name="prompt_key" rules={[{ required: !editingAsset, message: "请输入 Prompt 编码" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="contract_generation_default" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Prompt 类型" name="prompt_type" rules={[{ required: true, message: "请选择 Prompt 类型" }]}>
|
||||
<Select disabled={Boolean(editingAsset)} options={promptTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="名称" name="name" rules={[{ required: !editingAsset, message: "请输入名称" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="合同生成默认 Prompt" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="说明这个 Prompt 的业务边界" />
|
||||
</Form.Item>
|
||||
<Form.Item label="适用场景" name="target_scene">
|
||||
<Input disabled={Boolean(editingAsset)} placeholder="legal_document_generation" />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统 Prompt" name="system_prompt">
|
||||
<Input.TextArea rows={3} placeholder="可选:系统角色、边界和安全要求" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Prompt 内容" name="prompt_content" rules={[{ required: true, message: "请输入 Prompt 内容" }]}>
|
||||
<Input.TextArea rows={7} placeholder="写入业务 Prompt 主体" />
|
||||
</Form.Item>
|
||||
<Form.Item label="变量 JSON" name="variables_json">
|
||||
<Input.TextArea rows={3} placeholder='[{"name":"party_a","required":true}]' />
|
||||
</Form.Item>
|
||||
<Form.Item label="输出 Schema JSON" name="output_schema_json">
|
||||
<Input.TextArea rows={3} placeholder='{"type":"object"}' />
|
||||
</Form.Item>
|
||||
<Form.Item label="模型配置 JSON" name="model_config_json">
|
||||
<Input.TextArea rows={3} placeholder='{"model":"gpt-5.4","temperature":0.2}' />
|
||||
</Form.Item>
|
||||
<Form.Item label="评测状态" name="evaluation_status">
|
||||
<Select options={evaluationStatusOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="评测分数" name="evaluation_score">
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="评测报告 JSON" name="evaluation_report_json">
|
||||
<Input.TextArea rows={5} />
|
||||
</Form.Item>
|
||||
<Form.Item label="变更说明" name="change_summary">
|
||||
<Input placeholder="例如:补充法条来源注入要求" />
|
||||
</Form.Item>
|
||||
<Space wrap style={{ width: "100%", justifyContent: "flex-end" }}>
|
||||
<Button onClick={handleCloseCreateModal}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
{editingAsset ? "创建新版本" : "创建草稿"}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user