From 17f5ada9f2e27b356575d36e4be06d43efc356fe Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 21 Jun 2026 18:57:53 +0800 Subject: [PATCH] fix: normalize markdown contract content --- .../services/contract_content_normalizer.py | 34 ++++++++++++- .../services/contract_generation_service.py | 20 +++++++- .../tests/test_contract_content_normalizer.py | 49 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_contract_content_normalizer.py diff --git a/backend/app/services/contract_content_normalizer.py b/backend/app/services/contract_content_normalizer.py index 9b2571a8..e81fdd77 100644 --- a/backend/app/services/contract_content_normalizer.py +++ b/backend/app/services/contract_content_normalizer.py @@ -4,6 +4,14 @@ from html.parser import HTMLParser from typing import Any, Mapping, Optional +_MARKDOWN_CODE_FENCE_RE = re.compile(r"^\s*```[a-zA-Z0-9_-]*\s*$") +_MARKDOWN_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s*") +_MARKDOWN_TRAILING_HEADING_RE = re.compile(r"\s+#{1,6}\s*$") +_MARKDOWN_BULLET_RE = re.compile(r"^\s*[-*+]\s+") +_MARKDOWN_BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") +_MARKDOWN_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$") + + class ContractHTMLTextExtractor(HTMLParser): _BLOCK_TAGS = { "address", @@ -98,9 +106,9 @@ def normalize_contract_content(value: Any) -> str: if looks_like_html(text): stripped = html_to_text(text) - return stripped or text + return markdown_to_text(stripped or text) - return text + return markdown_to_text(text) def parse_contract_json_text(value: str) -> Optional[Mapping[str, Any]]: @@ -127,6 +135,28 @@ def html_to_text(value: str) -> str: return parser.get_text() +def markdown_to_text(value: str) -> str: + lines: list[str] = [] + for raw_line in str(value or "").splitlines(): + line = raw_line.strip() + if not line: + lines.append("") + continue + if _MARKDOWN_CODE_FENCE_RE.match(line) or _MARKDOWN_TABLE_SEPARATOR_RE.match(line): + continue + line = _MARKDOWN_BLOCKQUOTE_RE.sub("", line) + line = _MARKDOWN_HEADING_RE.sub("", line) + line = _MARKDOWN_TRAILING_HEADING_RE.sub("", line) + line = _MARKDOWN_BULLET_RE.sub("", line) + line = re.sub(r"(\*\*|__)(.*?)\1", r"\2", line) + line = re.sub(r"`([^`]+)`", r"\1", line) + line = line.replace("\\#", "#").strip() + lines.append(line) + text = "\n".join(lines) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + def looks_like_html(value: str) -> bool: return bool(re.search(r"]*>", str(value or ""))) diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index f0956dab..402c9479 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -244,6 +244,7 @@ class ContractGenerationService: "prompt_version": prompt_runtime_trace.get("prompt_version"), } + generated_content = normalize_contract_content(generated_content) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') file_content, file_name = self.export_service.export_contract( generated_content, @@ -3613,7 +3614,12 @@ class ContractGenerationService: related_legal_articles: List[Dict[str, Any]], ai_hints: List[Dict[str, Any]], ) -> str: - body = "".join(f"

{escape(part.strip())}

" for part in str(content or "").splitlines() if part.strip()) + paragraphs = [part.strip() for part in normalize_contract_content(content).splitlines() if part.strip()] + body_parts = [] + for part in paragraphs: + tag = "h2" if cls._looks_like_contract_heading(part) else "p" + body_parts.append(f"<{tag}>{escape(part)}") + body = "".join(body_parts) if not body: body = "

" hint_items = "".join(f"
  • {escape(str(item.get('message') or ''))}
  • " for item in ai_hints) @@ -3634,6 +3640,18 @@ class ContractGenerationService: "" ) + @staticmethod + def _looks_like_contract_heading(value: str) -> bool: + text = str(value or "").strip() + if not text: + return False + return bool( + re.match( + r"^(?:第[一二三四五六七八九十百千万\d]+[编章节条款]|[一二三四五六七八九十]+[、..]|[((][一二三四五六七八九十\d]+[))]|\d+[、..])", + text, + ) + ) + @classmethod def _build_alias_values(cls, flattened: Dict[str, Any]) -> Dict[str, Any]: party_a = cls._first(flattened, ["party_a.name", "buyer", "buyer_name", "employer.name", "landlord.name"]) diff --git a/backend/tests/test_contract_content_normalizer.py b/backend/tests/test_contract_content_normalizer.py new file mode 100644 index 00000000..0ddfd5ad --- /dev/null +++ b/backend/tests/test_contract_content_normalizer.py @@ -0,0 +1,49 @@ +import unittest + +from app.services.contract_content_normalizer import normalize_contract_content +from app.services.contract_generation_service import ContractGenerationService + + +class ContractContentNormalizerTestCase(unittest.TestCase): + def test_normalize_contract_content_removes_markdown_headings(self): + raw_content = """# 劳动合同 + +### 一、合同期限与试用期 +双方确认合同期限为三年。 + +### 六、保密、服务期与竞业限制 +**乙方**应履行保密义务。 +""" + + normalized = normalize_contract_content(raw_content) + + self.assertEqual( + normalized, + "劳动合同\n\n一、合同期限与试用期\n双方确认合同期限为三年。\n\n六、保密、服务期与竞业限制\n乙方应履行保密义务。", + ) + self.assertNotIn("###", normalized) + self.assertNotIn("**", normalized) + + def test_normalize_contract_content_removes_markdown_inside_html_payload(self): + raw_content = "

    # 劳动合同

    ### 一、合同期限与试用期

    " + + normalized = normalize_contract_content(raw_content) + + self.assertEqual(normalized, "劳动合同\n一、合同期限与试用期") + self.assertNotIn("#", normalized) + + def test_generated_contract_html_renders_clean_contract_headings(self): + html = ContractGenerationService._build_generated_contract_html( + "### 一、合同期限与试用期\n双方确认合同期限为三年。\n### 六、保密、服务期与竞业限制", + missing_fields=[], + related_legal_articles=[], + ai_hints=[], + ) + + self.assertIn("

    一、合同期限与试用期

    ", html) + self.assertIn("

    六、保密、服务期与竞业限制

    ", html) + self.assertNotIn("###", html) + + +if __name__ == "__main__": + unittest.main()