From f7c6d10928b459095c6cef2ba96cf90ffa28288a Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 1 Jun 2026 02:22:45 +0800 Subject: [PATCH] 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 --- backend/app/services/review_service.py | 246 +++++++---- frontend/src/app/globals.css | 55 +++ frontend/src/app/prompt-assets/page.tsx | 540 +++++++++++++++--------- 3 files changed, 560 insertions(+), 281 deletions(-) diff --git a/backend/app/services/review_service.py b/backend/app/services/review_service.py index 8328b7f3..8d4a5629 100644 --- a/backend/app/services/review_service.py +++ b/backend/app/services/review_service.py @@ -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() diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 869230f7..0eb02b7a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; + } +} diff --git a/frontend/src/app/prompt-assets/page.tsx b/frontend/src/app/prompt-assets/page.tsx index 2a1f9b5b..6aa4f451 100644 --- a/frontend/src/app/prompt-assets/page.tsx +++ b/frontend/src/app/prompt-assets/page.tsx @@ -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 = [ { title: "Prompt", dataIndex: "name", key: "name", - width: 220, + width: 240, render: (_, item) => ( {item.name} @@ -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) => ( {item.current_version_label} @@ -482,16 +537,8 @@ export default function PromptAssetsPage() { { title: "评测", key: "evaluation", - width: 170, - render: (_, item) => ( - - {getEvaluationTag(item.latest_evaluation_status)} - {getEvaluationModeTag(item.latest_evaluation_report)} - - {item.latest_evaluation_score == null ? "-" : `分数 ${item.latest_evaluation_score}`} · 样本 {getEvaluationSampleResultCount(item.latest_evaluation_report)} - - - ), + 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) => ( - - {item.status === "published" ? ( - + void handleUnpublish(item)}> + + ) : ( {item.version_count > 1 ? ( void handleRollback(item)}> - - - - + {/* KPI Stats */} +
+
+ 资产总数 + {kpiStats.total} +
+
+ 已发布 + {kpiStats.published} +
+
+ 草稿 + {kpiStats.draft} +
+
+ 已停用 + {kpiStats.inactive} +
+
+ {/* Asset List */} + + + { + setStatusFilter(value); + setLoading(true); + void loadAssets(1, pageSize, promptTypeFilter, value); + }} + /> + +
+ } + > + }} + columns={columns} + onChange={handleTableChange} + scroll={{ x: 1280 }} + onRow={(record) => ({ + onClick: () => void openDetail(record), + style: { cursor: "pointer" }, + })} + /> + + + + {/* Detail Drawer */} + { + setDetailDrawerOpen(false); + setSelectedDetail(null); + }} + width={720} + styles={{ body: { padding: "0 24px" } }} + > + {selectedDetail ? ( - + + 基本信息 - - { - 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)} + + 类型:{promptTypeOptions.find((o) => o.value === selectedDetail.asset.prompt_type)?.label ?? selectedDetail.asset.prompt_type} + - } - > + {selectedDetail.asset.description ? {selectedDetail.asset.description} : null} + + 当前版本:{selectedDetail.asset.current_version_label} · 共 {selectedDetail.asset.version_count} 个版本 + + {currentVersion ? ( + + 样本:{getEvaluationSampleResultCount(selectedDetail.asset.latest_evaluation_report)} 条 + {selectedDetail.asset.latest_evaluation_score != null ? ` · 分数 ${selectedDetail.asset.latest_evaluation_score}` : ""} + + ) : null} + + + + {/* Quick Actions */} +
+ 快速操作 + + + + {selectedDetail.asset.status === "published" ? ( + + ) : ( + + )} + {selectedDetail.asset.version_count > 1 ? ( + void handleRollback(selectedDetail.asset)}> + + + ) : null} + +
+ + {/* Version History */} +
+ 版本历史 +
+ + + {/* Evaluation History */} +
+ 评测历史
}} - columns={columns} - onChange={handleTableChange} - scroll={{ x: 1280 }} + size="small" + pagination={false} + columns={evaluationRunColumns} + dataSource={selectedDetail.evaluation_runs} + locale={{ emptyText: }} /> - + - {selectedDetail ? ( - - - {currentVersion.version_label} - - - ) : null - } - > - - - 当前状态:{getStatusTag(selectedDetail.asset.status)} 最新评测:{getEvaluationTag(selectedDetail.asset.latest_evaluation_status)} {getEvaluationModeTag(selectedDetail.asset.latest_evaluation_report)} 样本 {getEvaluationSampleResultCount(selectedDetail.asset.latest_evaluation_report)} 条 - -
- - 评测运行历史 -
}} - /> - - - - - - - - - - - - - - - - - - - -
}} - 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) }, - ]} - /> - - - ) : null} + {/* Run Links */} +
+ 运行证据登记 +
+ + + + + + + + + + + + + + + + + +
+ + {/* Run Links List */} +
+ 已登记运行证据 +
}} + 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) }, + ]} + /> + - - + ) : null} + + + {/* Create/Edit Modal */} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
); }