From ebb8ca0754fd6756d0e6640c136624c5509d91e9 Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 22 Jun 2026 12:46:29 +0800 Subject: [PATCH] fix: cite legal basis in chat answers --- backend/app/api/chat.py | 61 +++++++++++++++++++++++++++++++++- backend/tests/test_chat_api.py | 8 +++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 7daed773..eb67bdb9 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -371,11 +371,17 @@ def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: Lis return emitted_sources = False + emitted_basis_intro = False + basis_intro = _build_legal_basis_intro(local_sources) for chunk in upstream: event = _parse_sse_event(chunk) if event and event.get("type") == "text": content = str(event.get("content") or "") - yield _format_sse_event({"type": "text", "content": _polish_chat_answer(content, message, local_sources)}) + polished_content = _polish_chat_answer(content, message, local_sources) + if basis_intro and not emitted_basis_intro: + emitted_basis_intro = True + polished_content = _prepend_legal_basis_intro(polished_content, basis_intro) + yield _format_sse_event({"type": "text", "content": polished_content}) continue if event and event.get("type") == "sources": merged = _dedupe_sources([*(event.get("sources") or []), *local_sources]) @@ -388,6 +394,59 @@ def _merge_local_sources_into_stream(upstream: Iterable[str], local_sources: Lis yield chunk +def _prepend_legal_basis_intro(content: str, basis_intro: str) -> str: + normalized_content = str(content or "").lstrip() + if not normalized_content: + return basis_intro + return f"{basis_intro}\n\n{normalized_content}" + + +def _build_legal_basis_intro(sources: List[Dict[str, Any]]) -> str: + legal_sources = [source for source in sources if _is_legal_article_source(source)] + if not legal_sources: + return "" + lines = [ + "本次回答已先检索本地法规库,优先依据如下法条:", + ] + for index, source in enumerate(legal_sources[:3], start=1): + citation = _format_legal_article_citation(source) + excerpt = _format_legal_article_excerpt(source.get("content")) + if excerpt: + lines.append(f"{index}. {citation}:{excerpt}") + else: + lines.append(f"{index}. {citation}") + lines.append("以下为结合上述依据形成的分析意见:") + return "\n".join(lines) + + +def _is_legal_article_source(source: Mapping[str, Any]) -> bool: + detail_path = str(source.get("detail_path") or "") + return source.get("source_type") == "legal_article" or detail_path.startswith("/legal-articles") + + +def _format_legal_article_citation(source: Mapping[str, Any]) -> str: + metadata = source.get("metadata") if isinstance(source.get("metadata"), Mapping) else {} + article_no = str(source.get("article_no") or "").strip() + source_title = str(metadata.get("source_title") or "").strip() + title = str(source.get("title") or "").strip() + if not source_title and article_no and title.endswith(article_no): + source_title = title[: -len(article_no)].strip() + if not source_title: + source_title = title + if source_title and article_no: + return f"《{source_title}》{article_no}" + return title or "本地法规库" + + +def _format_legal_article_excerpt(value: Any, max_length: int = 120) -> str: + text = " ".join(str(value or "").split()) + if not text: + return "" + if len(text) <= max_length: + return text + return f"{text[:max_length].rstrip()}..." + + def _polish_chat_answer(content: str, message: str, sources: List[Dict[str, Any]]) -> str: if not _looks_like_raw_retrieval_answer(content): return content diff --git a/backend/tests/test_chat_api.py b/backend/tests/test_chat_api.py index 6b7eb8f0..99758425 100644 --- a/backend/tests/test_chat_api.py +++ b/backend/tests/test_chat_api.py @@ -325,9 +325,13 @@ class ChatApiTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) events = [line.removeprefix("data: ") for line in response.text.split("\n\n") if line.startswith("data: ")] payloads = [json.loads(item) for item in events] + text_event = next(item for item in payloads if item["type"] == "text") sources_event = next(item for item in payloads if item["type"] == "sources") legal_source = next(item for item in sources_event["sources"] if item.get("source_type") == "legal_article") + self.assertIn("本次回答已先检索本地法规库", text_event["content"]) + self.assertIn("优先依据如下法条", text_event["content"]) + self.assertIn("以下为结合上述依据形成的分析意见", text_event["content"]) self.assertEqual(legal_source["source_id"], "lart-civil-585") self.assertEqual(legal_source["detail_path"], "/legal-articles?article_id=lart-civil-585") self.assertEqual(self.chat_service.stream_calls[0][3]["legalArticleRefs"][0]["id"], "lart-civil-585") @@ -361,9 +365,13 @@ class ChatApiTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) events = [line.removeprefix("data: ") for line in response.text.split("\n\n") if line.startswith("data: ")] payloads = [json.loads(item) for item in events] + text_event = next(item for item in payloads if item["type"] == "text") sources_event = next(item for item in payloads if item["type"] == "sources") legal_source = next(item for item in sources_event["sources"] if item.get("source_type") == "legal_article") + self.assertIn("本次回答已先检索本地法规库", text_event["content"]) + self.assertIn("优先依据如下法条", text_event["content"]) + self.assertIn("以下为结合上述依据形成的分析意见", text_event["content"]) self.assertEqual(legal_source["source_id"], "lart-cyber-001") self.assertEqual(legal_source["detail_path"], "/legal-articles?article_id=lart-cyber-001") self.assertEqual(legal_article_service.calls[0][1], "网络安全法第一条是什么")