diff --git a/backend/pipeline/reporting/report_matrix_group_evidence.py b/backend/pipeline/reporting/report_matrix_group_evidence.py index 98d91f7..aef47bc 100644 --- a/backend/pipeline/reporting/report_matrix_group_evidence.py +++ b/backend/pipeline/reporting/report_matrix_group_evidence.py @@ -4,8 +4,10 @@ 报告生成侧约定:矩阵/价盘/促销/评论/场景等 LLM 小节均以 ``#### `` + 与矩阵一致的细类名为小节标题 (见 ``generate_group_summaries`` 系统提示)。 -**8.3 评价正/负向主题**:细类下内层小节使用 ``#####``(由 ``demote_sentiment_inner_h4_to_h5_for_matrix_group`` 处理), -避免与外层 ``#### 细类名`` 同级导致本节正文抽取为空。 + +**第八章 8.3**(``generate_comment_sentiment_analysis_llm``)在每组 ``#### {细类}`` 下还会再嵌套 +``#### 正向体验主题`` 等四级标题(见 ``generate_sections.SENTIMENT_LLM_SYSTEM``), +通用抽取在遇到下一行 ``####`` 时即结束,会把 8.3 正文误判为空;故 8.3 单独解析后再拼回。 """ from __future__ import annotations @@ -13,6 +15,77 @@ import re from pathlib import Path from typing import Literal +# 与 ``jd_report.build_competitor_markdown`` 中 8.3 小节标题一致。 +_SENTIMENT_83_HEADING = "### 8.3 评价正/负向主题(按细类 · 大模型)" + +# 与 ``generate_sections.SENTIMENT_LLM_SYSTEM``「建议结构」四级标题一致;嵌套于 8.3 每组 ``#### 细类`` 之下。 +_SENTIMENT_LLM_INNER_LEVEL4 = frozenset( + { + "正向体验主题", + "负向评价主题归因", + "混合评价中的典型张力", + "使用注意", + } +) + + +def _md_splice_out_sentiment_83_section(md: str) -> str: + """去掉 8.3 整节,避免按细类抽取时把嵌套 ``####`` 误判为同级边界;其它章不变。""" + i = md.find(_SENTIMENT_83_HEADING) + if i < 0: + return md + tail = md[i + len(_SENTIMENT_83_HEADING) :] + m = re.search(r"^##\s+", tail, re.MULTILINE) + if not m: + return md[:i].rstrip() + "\n\n" + cut = i + len(_SENTIMENT_83_HEADING) + m.start() + return md[:i].rstrip() + "\n\n" + md[cut:].lstrip("\n") + + +def extract_sentiment_83_level4_body(md: str, group_title: str) -> str: + """ + 仅在 ``### 8.3 …`` 节内,抽取 ``#### {group_title}`` 下正文(**不含**该标题行)。 + + 允许正文内出现 ``#### 正向体验主题`` 等情感归纳子标题;遇下一 peer ``####``(另一细类)或 ``###``/``##`` 则结束。 + """ + title = (group_title or "").strip() + if not title or not (md or "").strip(): + return "" + + i = md.find(_SENTIMENT_83_HEADING) + if i < 0: + return "" + tail = md[i + len(_SENTIMENT_83_HEADING) :] + m_end = re.search(r"^##\s+", tail, re.MULTILINE) + chunk = tail[: m_end.start()] if m_end else tail + + lines = chunk.splitlines() + n = len(lines) + j = 0 + while j < n: + line = lines[j] + m4 = re.match(r"^####\s+(.+?)\s*$", line) + if m4 and m4.group(1).strip() == title: + j += 1 + body_lines: list[str] = [] + while j < n: + nxt = lines[j] + mpeer = re.match(r"^####\s+(.+?)\s*$", nxt) + if mpeer: + inner = mpeer.group(1).strip() + if inner in _SENTIMENT_LLM_INNER_LEVEL4: + body_lines.append(nxt) + j += 1 + continue + break + if re.match(r"^###\s", nxt) or re.match(r"^##\s", nxt): + break + body_lines.append(nxt) + j += 1 + return "\n".join(body_lines).strip() + j += 1 + return "" + def extract_level4_sections_by_group_title(md: str, group_title: str) -> list[str]: """ @@ -72,14 +145,19 @@ def load_report_matrix_group_evidence_markdown( except OSError: return "", "none" - parts = extract_level4_sections_by_group_title(full, group_title) + without_83 = _md_splice_out_sentiment_83_section(full) + parts = extract_level4_sections_by_group_title(without_83, group_title) + s83 = extract_sentiment_83_level4_body(full, group_title) + if s83: + parts.append(s83) if not parts: return "", "none" intro = ( f"> **说明**:以下为同任务《竞品分析报告》正文中、细类「**{group_title.strip()}**」下 " "「#### …」小节的**大模型归纳**摘录(按正文出现顺序拼接)," - "覆盖矩阵/价盘/促销/评论与场景等块中**已生成**的段落;" + "覆盖矩阵/价盘/促销/评论与场景等块中**已生成**的段落," + "并含 **§8.3 评价正/负向主题(按细类 · 大模型)** 内该细类段落(允许嵌套四级小标题);" "若某块未开 LLM 或未产出对应小节,则不会出现在此摘录中。\n\n" ) sep = "\n\n---\n\n" @@ -93,5 +171,6 @@ def load_report_matrix_group_evidence_markdown( __all__ = [ "extract_level4_sections_by_group_title", + "extract_sentiment_83_level4_body", "load_report_matrix_group_evidence_markdown", ] diff --git a/backend/pipeline/tests/test_report_matrix_group_evidence.py b/backend/pipeline/tests/test_report_matrix_group_evidence.py index 54b95da..e56e3bd 100644 --- a/backend/pipeline/tests/test_report_matrix_group_evidence.py +++ b/backend/pipeline/tests/test_report_matrix_group_evidence.py @@ -1,11 +1,15 @@ """从宿主报告 MD 按细类抽取大模型小节。""" from __future__ import annotations +from pathlib import Path +from tempfile import TemporaryDirectory + from django.test import SimpleTestCase -from pipeline.llm.generate_sections import demote_sentiment_inner_h4_to_h5_for_matrix_group from pipeline.reporting.report_matrix_group_evidence import ( extract_level4_sections_by_group_title, + extract_sentiment_83_level4_body, + load_report_matrix_group_evidence_markdown, ) @@ -39,23 +43,51 @@ B 段评论归纳。 [], ) - def test_extract_8_3_sentiment_when_inner_subsections_are_h5(self) -> None: - """8.3 在 #### 细类 下须用 ##### 子标题,否则抽取在首个 #### 子节处断开。""" - inner = demote_sentiment_inner_h4_to_h5_for_matrix_group( - "#### 正向体验主题\n\n正文A\n\n#### 负向评价主题归因\n\n正文B\n" - ) - md = ( - "### 8.3 评价正/负向主题\n\n" - "#### 饼干\n\n" - f"{inner}\n" - ) - parts = extract_level4_sections_by_group_title(md, "饼干") - self.assertEqual(len(parts), 1) - self.assertIn("正文A", parts[0]) - self.assertIn("正文B", parts[0]) + def test_sentiment_83_nested_level4(self) -> None: + md = """## 八、消费者反馈 - def test_demote_known_h4_titles_only(self) -> None: - raw = "#### 正向体验主题\nx\n#### 饼干\ny\n" - out = demote_sentiment_inner_h4_to_h5_for_matrix_group(raw) - self.assertIn("##### 正向体验主题", out) - self.assertIn("#### 饼干", out) +### 8.3 评价正/负向主题(按细类 · 大模型) + +> 说明 + +#### 饼干 + +#### 正向体验主题 +酥脆好评。 + +#### 负向评价主题归因 +略贵。 + +#### 西式糕点 + +#### 正向体验主题 +别的细类。 + +## 九、策略 +""" + body = extract_sentiment_83_level4_body(md, "饼干") + self.assertIn("酥脆好评", body) + self.assertIn("正向体验主题", body) + self.assertIn("略贵", body) + self.assertNotIn("别的细类", body) + + def test_load_includes_83_after_splice(self) -> None: + md = """#### 饼干 +矩阵段。 + +### 8.3 评价正/负向主题(按细类 · 大模型) + +#### 饼干 + +#### 正向体验主题 +情感段。 + +## 九、策略 +""" + with TemporaryDirectory() as td: + p = Path(td) / "competitor_analysis.md" + p.write_text(md, encoding="utf-8") + out, src = load_report_matrix_group_evidence_markdown(td, "饼干") + self.assertEqual(src, "competitor_analysis_md") + self.assertIn("矩阵段", out) + self.assertIn("情感段", out)