Stabilize frontend contract workflows

This commit is contained in:
2026-05-27 17:28:33 +08:00
parent 8fa329d1ac
commit 018106e20e
4 changed files with 181 additions and 60 deletions
+83 -24
View File
@@ -295,6 +295,11 @@ function resolveStructuredActionCard(reviewId: string, section: ReviewSection) {
};
}
function latestAssistantText(messages: ChatMessage[]) {
const latestAssistant = [...messages].reverse().find((item) => item.role === "assistant");
return latestAssistant?.content ?? "";
}
function inferReviewActionCards(reviewContext: ReviewChatContext | null, messages: ChatMessage[], reviewSections: ReviewSection[]): ActionCard[] {
if (!reviewContext?.reviewId) {
return [];
@@ -310,10 +315,7 @@ function inferReviewActionCards(reviewContext: ReviewChatContext | null, message
return structuredCards.slice(0, REVIEW_ACTION_CARD_LIMIT);
}
const assistantText = messages
.filter((item) => item.role === "assistant")
.map((item) => item.content)
.join("\n");
const assistantText = latestAssistantText(messages);
const matchedCards = ACTION_CARD_BUILDERS.filter((item) => item.match.test(assistantText)).map((item) => item.build(reviewId, focus));
const baseCards = matchedCards.length ? matchedCards : buildContextFallbackCards(reviewId, reviewContext);
@@ -330,10 +332,7 @@ function inferReviewActionCards(reviewContext: ReviewChatContext | null, message
}
function hasActionableAnswer(messages: ChatMessage[]) {
const assistantText = messages
.filter((item) => item.role === "assistant")
.map((item) => item.content)
.join("\n");
const assistantText = latestAssistantText(messages);
return ACTION_CARD_LABELS.some((keyword) => assistantText.includes(keyword));
}
@@ -561,6 +560,59 @@ function parseReviewSections(content: string): ReviewSection[] {
return hasStructuredTitles ? sections : [];
}
function isRetrievalWrapperAnswer(content: string) {
return /I found relevant knowledge-base content for this question/i.test(content) || /Please treat this as AI-assisted guidance/i.test(content);
}
function buildChineseFallbackAnswer(question: string, sources: ChatSourceItem[]) {
const sourceTitles = sources
.slice(0, 3)
.map((item) => item.title)
.filter(Boolean)
.join("、");
const paymentFocused = /付款|费用|价款|支付|缴费/.test(question);
const propertyFocused = /物业/.test(question) || sources.some((item) => `${item.title}\n${item.content}`.includes("物业服务合同"));
if (paymentFocused && propertyFocused) {
return [
"物业服务合同的付款条款建议重点核对以下风险:",
"1. 费用标准和计费口径是否清楚,包括按面积、按户、按服务项目计费的依据。",
"2. 付款周期、缴费期限、开票条件和逾期责任是否明确,避免只写笼统付款义务。",
"3. 服务质量、验收或整改与付款之间的关系是否清楚,避免服务未达标仍无法扣减或拒付。",
"4. 代收代缴、能耗、公区收益、维修资金等费用是否与物业服务费分开列明,避免混同收费。",
"5. 调价机制、新增收费项目和公示程序是否有业主大会、合同约定或法定依据。",
sourceTitles ? `可结合下方来源继续复核:${sourceTitles}。` : "请结合下方来源和合同原文继续人工复核。",
].join("\n");
}
return [
"已检索到相关依据,但上游只返回了检索摘要。建议先按以下方向人工复核:",
"1. 明确合同主体、核心义务、履行期限和交付/验收标准。",
"2. 核对付款、违约责任、解除条件、争议解决和通知送达条款。",
"3. 对照下方引用来源确认是否需要补充法定程序、证据留存或人工审批。",
sourceTitles ? `可结合下方来源继续复核:${sourceTitles}。` : "请结合下方来源和合同原文继续人工复核。",
].join("\n");
}
function normalizeAssistantAnswer(content: string, question: string, sources: ChatSourceItem[]) {
if (!isRetrievalWrapperAnswer(content)) {
return content;
}
return buildChineseFallbackAnswer(question, sources);
}
function normalizeMessagesForDisplay(items: ChatMessage[], lastQuestion: string, sources: ChatSourceItem[]) {
return items.map((item) => {
if (item.role !== "assistant") {
return item;
}
return {
...item,
content: normalizeAssistantAnswer(item.content, lastQuestion, sources),
};
});
}
function mergeAssistantContent(previous: string, incoming: string) {
const trimmedPrevious = previous.trim();
const trimmedIncoming = incoming.trim();
@@ -804,6 +856,10 @@ function upsertConversationItem(items: ConversationListItem[], conversationId: s
);
}
function shouldStartFreshConversation(conversationId: string | null, messages: ChatMessage[]) {
return !conversationId && messages.length === 0;
}
export default function ChatPage() {
const [messageApi, contextHolder] = message.useMessage();
const [input, setInput] = useState("");
@@ -891,17 +947,19 @@ export default function ChatPage() {
];
}, [reviewContext]);
const displayMessages = useMemo(() => normalizeMessagesForDisplay(messages, lastQuestion, sources), [lastQuestion, messages, sources]);
const answerText = useMemo(() => {
return messages
return displayMessages
.filter((item) => item.role === "assistant")
.map((item) => item.content)
.join("\n\n");
}, [messages]);
}, [displayMessages]);
const latestAssistantMessage = useMemo(() => {
const assistants = messages.filter((item) => item.role === "assistant");
const assistants = displayMessages.filter((item) => item.role === "assistant");
return assistants[assistants.length - 1] ?? null;
}, [messages]);
}, [displayMessages]);
const reviewSections = useMemo(() => {
if (!latestAssistantMessage) {
@@ -914,10 +972,10 @@ export default function ChatPage() {
const fallbackMessages = useMemo(() => {
if (hasStructuredReview) {
return messages.filter((item) => item.role !== "assistant");
return displayMessages.filter((item) => item.role !== "assistant");
}
return messages;
}, [hasStructuredReview, messages]);
return displayMessages;
}, [displayMessages, hasStructuredReview]);
const summarySection = useMemo(
() => reviewSections.find((section) => section.title.includes("摘要")) ?? null,
@@ -975,14 +1033,14 @@ export default function ChatPage() {
];
}, [hasStructuredReview, reviewContext, reviewSections.length, sourceRiskTone, sources.length]);
const reviewActionCards = useMemo(() => buildReviewActionCards(reviewContext, messages, reviewSections), [messages, reviewContext, reviewSections]);
const reviewActionCards = useMemo(() => buildReviewActionCards(reviewContext, displayMessages, reviewSections), [displayMessages, reviewContext, reviewSections]);
const actionCardHeader = useMemo(() => buildActionCardHeader(reviewContext), [reviewContext]);
const actionCardEyebrow = useMemo(() => buildActionCardEyebrow(reviewContext), [reviewContext]);
const actionCardSubtitle = useMemo(() => buildActionCardSubtitle(reviewContext), [reviewContext]);
const actionCardEmptyText = useMemo(() => buildActionCardEmptyText(reviewContext), [reviewContext]);
const sourcePanelMeta = useMemo(() => buildSourceState(sources, reviewContext), [reviewContext, sources]);
const answerSummaryText = summarySection?.items.join("\n") ?? answerText;
const answerSummaryText = summarySection?.items.join("\n") ?? latestAssistantMessage?.content ?? "";
const loadConversationDetail = useCallback(async (targetConversationId: string) => {
setDetailLoading(true);
@@ -1024,14 +1082,15 @@ export default function ChatPage() {
const submitQuestion = useCallback(async (rawQuestion?: string, options: SubmitQuestionOptions = {}) => {
const question = (rawQuestion ?? input).trim();
const forceNewConversation = Boolean(options.forceNewConversation);
const targetConversationId = forceNewConversation ? null : conversationId;
const startFreshConversation = forceNewConversation || shouldStartFreshConversation(conversationId, messages);
const targetConversationId = startFreshConversation ? null : conversationId;
if (!question) {
messageApi.error("请输入问题");
return;
}
if (forceNewConversation) {
if (startFreshConversation) {
setConversationId(null);
setActiveConversationTitle(null);
}
@@ -1041,7 +1100,7 @@ export default function ChatPage() {
setSources([]);
setSourceStatusText("正在等待回答与来源...");
setLastQuestion(question);
setMessages((current) => upsertUserQuestion(forceNewConversation ? [] : current, question));
setMessages((current) => upsertUserQuestion(startFreshConversation ? [] : current, question));
setInput("");
let sawTextChunk = false;
let latestSourceCount = 0;
@@ -1093,7 +1152,7 @@ export default function ChatPage() {
} finally {
setLoading(false);
}
}, [conversationId, input, loadConversations, messageApi, reviewContext]);
}, [conversationId, input, loadConversations, messageApi, messages, reviewContext]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -1468,9 +1527,9 @@ export default function ChatPage() {
</section>
) : null}
</div>
) : messages.length ? (
) : displayMessages.length ? (
<div className="chat-message-list">
{messages.map((item, index) => (
{displayMessages.map((item, index) => (
<div className={`chat-message chat-message-${item.role}`} key={`${item.role}-${index}`}>
<div className="chat-message-role">{item.role === "user" ? "你" : "AI"}</div>
<div className="chat-message-content">{item.content}</div>
@@ -1542,7 +1601,7 @@ export default function ChatPage() {
</section>
</div>
) : (
<div className="chat-answer-summary">{answerText}</div>
<div className="chat-answer-summary">{answerSummaryText}</div>
)}
</Card>
) : null}
+78 -23
View File
@@ -131,6 +131,36 @@ const dueStatusMeta: Record<string, { label: string; color: string }> = {
closed: { label: "已关闭", color: "default" },
};
const GENERATE_RECOVERY_TIMEOUT_MS = 15000;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
function getRequiredVariableValidationMessage(template: ContractTemplate, variableValues: Record<string, string> | undefined) {
const missing = template.variables
.filter((variable) => variable.required)
.filter((variable) => !(variableValues?.[variable.name] ?? "").trim())
.map((variable) => variable.label || variable.name);
if (!missing.length) {
return null;
}
const visible = missing.slice(0, 5).join("、");
const suffix = missing.length > 5 ? ` 等 ${missing.length} 项` : "";
return `请先填写必填变量:${visible}${suffix}`;
}
function formatAutoReviewStatus(value: string | null | undefined) {
if (!value) {
return "未触发";
@@ -451,14 +481,15 @@ export default function ContractsPage() {
return;
}
const values = form.getFieldsValue();
if (!values.template_id || !values.variable_values) {
messageApi.warning("请先选择模板并填写变量");
return;
}
setPreviewing(true);
try {
const values = await form.validateFields();
const validationMessage = getRequiredVariableValidationMessage(selectedTemplate, values.variable_values);
if (validationMessage) {
messageApi.warning(validationMessage);
return;
}
setPreviewing(true);
const result = await previewContract({
template_id: values.template_id,
variable_values: values.variable_values,
@@ -472,8 +503,12 @@ export default function ContractsPage() {
setPreviewResult(result.data);
setCurrentStep(2);
messageApi.success("合同预览生成成功");
} catch {
messageApi.error("合同预览失败,请稍后重试");
} catch (error: unknown) {
if (Array.isArray((error as { errorFields?: unknown[] }).errorFields)) {
messageApi.warning("请先补齐必填变量,再预览合同");
return;
}
messageApi.error(getErrorMessage(error, "合同预览失败,请稍后重试"));
} finally {
setPreviewing(false);
}
@@ -484,20 +519,29 @@ export default function ContractsPage() {
return;
}
const values = form.getFieldsValue();
if (!values.template_id || !values.variable_values || !values.export_format) {
messageApi.warning("请填写完整信息");
return;
}
setGenerating(true);
try {
const result = await generateContract({
template_id: values.template_id,
variable_values: values.variable_values,
export_format: values.export_format,
options: restoredOptions,
});
const values = await form.validateFields();
const validationMessage = getRequiredVariableValidationMessage(selectedTemplate, values.variable_values);
if (validationMessage) {
messageApi.warning(validationMessage);
return;
}
if (!values.export_format) {
messageApi.warning("请选择导出格式");
return;
}
setGenerating(true);
const result = await withTimeout(
generateContract({
template_id: values.template_id,
variable_values: values.variable_values,
export_format: values.export_format,
options: restoredOptions,
}),
GENERATE_RECOVERY_TIMEOUT_MS,
"合同生成仍在处理中,可稍后在历史结果查看。",
);
if (result.code !== 0) {
messageApi.error(result.message || "合同生成失败");
@@ -511,7 +555,18 @@ export default function ContractsPage() {
await loadHistory(1, historyPageSize, historyContractType, historyKeyword, historyLifecycleStatus);
messageApi.success("合同生成成功!");
} catch (error: unknown) {
messageApi.error(getErrorMessage(error, "合同生成失败,请稍后重试"));
if (Array.isArray((error as { errorFields?: unknown[] }).errorFields)) {
messageApi.warning("请先补齐必填变量,再生成合同");
return;
}
const nextMessage = getErrorMessage(error, "合同生成失败,请稍后重试");
if (nextMessage.includes("仍在处理中")) {
messageApi.warning(nextMessage);
setHistoryPage(1);
await loadHistory(1, historyPageSize, historyContractType, historyKeyword, historyLifecycleStatus);
return;
}
messageApi.error(nextMessage);
} finally {
setGenerating(false);
}
+7 -5
View File
@@ -111,27 +111,29 @@ export default function ReviewPage() {
submitLockRef.current = false;
};
const requestSubmit = () => {
const handleSubmit = async (values: ReviewFormValues) => {
if (submitLockRef.current || submitting) {
return;
}
submitLockRef.current = true;
setSubmitting(true);
void form.submit();
};
const handleSubmit = async (values: ReviewFormValues) => {
const text = values.text?.trim();
const selectedOriginFile = selectedFile?.originFileObj;
if (!selectedOriginFile && !text) {
setSubmissionGuidance(null);
setSubmitting(false);
submitLockRef.current = false;
messageApi.error("请上传合同文件或填写合同文本");
return;
}
if (selectedOriginFile && !isSupportedReviewFile(selectedOriginFile.name)) {
setSubmissionGuidance(null);
setSubmitting(false);
submitLockRef.current = false;
messageApi.error("仅支持 PDF / DOCX / TXT 文件");
return;
}
@@ -310,7 +312,7 @@ export default function ReviewPage() {
title="提交后将自动进入详情页,可查看风险总览、条款摘要和报告下载。"
/>
<Button block type="primary" htmlType="button" loading={submitting} disabled={submitting} onClick={requestSubmit}>
<Button block type="primary" htmlType="submit" loading={submitting} disabled={submitting}>
提交审查
</Button>
</Card>
+13 -8
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import type { MouseEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Col, Descriptions, Empty, Input, Modal, Progress, Row, Space, Tag, Typography, message } from "antd";
@@ -563,8 +563,10 @@ function resolveRiskActionMatchText(item: NonNullable<ReviewResult["risk_items"]
return item.evidence?.quote ?? null;
}
export default function ReviewDetailPage({ params }: { params: Promise<{ id: string }> }) {
export default function ReviewDetailPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const reviewId = Array.isArray(params.id) ? params.id[0] : params.id;
const currentUser = useAuthStore((state) => state.currentUser);
const [messageApi, contextHolder] = message.useMessage();
const [loading, setLoading] = useState(true);
@@ -579,21 +581,24 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
if (!reviewId) {
return;
}
const loadReview = async () => {
setLoading(true);
setNotFound(false);
setLoadError(null);
try {
const { id } = await params;
const detailResponse = await getReviewDetail(id);
const detailResponse = await getReviewDetail(reviewId);
if (detailResponse.code !== 0) {
setLoadError(detailResponse.message || "审查详情加载失败");
return;
}
const [resultResponse, reportResponse, riskActionsResponse] = await Promise.all([
getReviewResult(id),
getReviewReport(id),
getReviewRiskActions(id).catch(() => null),
getReviewResult(reviewId),
getReviewReport(reviewId),
getReviewRiskActions(reviewId).catch(() => null),
]);
if (resultResponse.code !== 0) {
setLoadError(resultResponse.message || "审查结果加载失败");
@@ -615,7 +620,7 @@ export default function ReviewDetailPage({ params }: { params: Promise<{ id: str
};
void loadReview();
}, [messageApi, params]);
}, [reviewId]);
const riskItems = result?.risk_items ?? [];
const riskActions = riskActionState?.items ?? [];