diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index b04cba12..95015a70 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -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() { ) : null} - ) : messages.length ? ( + ) : displayMessages.length ? (