From 6e113bbdfed4f29c3496472f4c5e36e5a5ed5f43 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Fri, 17 Apr 2026 16:25:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(pipeline):=20S1=20=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E7=A8=BF=E4=B8=8E=E7=AC=AC=E4=B9=9D=E7=AB=A0=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=EF=BC=88report=5Fstrategy=5Fexcerpt=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 load_report_strategy_excerpt:优先 strategy_opportunities_llm.json 的 markdown,否则从 competitor_analysis.md 截取第九章;runner 成功时落盘 markdown 字段。STRATEGY_SYSTEM 收紧与 report_strategy_excerpt 一致及业务冲突标注。策略稿 API 返回节选来源与字符数;补充单测与 OpenAPI、规划文档。 Made-with: Cursor --- backend/pipeline/jd/runner.py | 2 + backend/pipeline/llm/generate_strategy.py | 11 ++- .../reporting/report_strategy_excerpt.py | 72 +++++++++++++++++++ .../tests/test_report_strategy_excerpt.py | 68 ++++++++++++++++++ backend/pipeline/views/job_report_views.py | 29 +++++--- docs/openapi/pipeline-jobs.openapi.yaml | 7 ++ .../strategy-marketing-content-alignment.md | 17 +++-- 7 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 backend/pipeline/reporting/report_strategy_excerpt.py create mode 100644 backend/pipeline/tests/test_report_strategy_excerpt.py diff --git a/backend/pipeline/jd/runner.py b/backend/pipeline/jd/runner.py index 12f6b56..12de0ca 100644 --- a/backend/pipeline/jd/runner.py +++ b/backend/pipeline/jd/runner.py @@ -751,6 +751,8 @@ def write_competitor_analysis_for_run_dir( strategy_opp_llm_rec["prior_chapter_narrative_keys"] = sorted( _strategy_narratives.keys() ) + if (llm_strategy_opp_md or "").strip(): + strategy_opp_llm_rec["markdown"] = llm_strategy_opp_md except Exception as e: strategy_opp_llm_rec["ok"] = False strategy_opp_llm_rec["error"] = str(e) diff --git a/backend/pipeline/llm/generate_strategy.py b/backend/pipeline/llm/generate_strategy.py index 21cbd8c..d35612a 100644 --- a/backend/pipeline/llm/generate_strategy.py +++ b/backend/pipeline/llm/generate_strategy.py @@ -12,7 +12,9 @@ from .llm_client import call_llm, estimate_chat_input_tokens, llm_context_window STRATEGY_SYSTEM = """你是市场策略顾问,根据**结构化监测摘要**与业务侧填写的**决策字段**,把「规则底稿」润色为可读的策略 Markdown。 **规则**: -- 输入 JSON 含 `rules_draft_markdown`(规则引擎生成的底稿,与同任务数据一致)、`structured_brief`(摘要子集)、`strategy_decisions`、`business_notes` 等; +- 输入 JSON 含 `rules_draft_markdown`(规则引擎生成的底稿,与同任务数据一致)、`structured_brief`(摘要子集)、`strategy_decisions`、`business_notes` 等;可选 `report_strategy_excerpt`(与同任务宿主报告**第九章**「策略与机会」正文同源,来自已落盘产物)。 +- **与报告第九章对齐(硬性,当 `report_strategy_excerpt` 非空时)**:润色后的全文在**战略方向与主要判断**上须与该节选**一致**,不得写出与之**明显矛盾**的结论(如定价档位、差异化主线、主要风险判断);若 `business_notes` 或 `strategy_decisions` 与节选冲突,须在正文适当位置简短说明「与报告第九章策略归纳不一致之处见业务备注」类表述,且**不得**把节选与 `structured_brief` 中均未出现的数字当作既定事实。 +- **当 `report_strategy_excerpt` 为空或仅为占位说明**:表示报告侧未生成或未重跑第九章大模型正文,仅依据 `rules_draft_markdown` 与 `structured_brief` 润色,**不得编造**与可能存在的报告第九章结论。 - **不得编造**输入中不存在的销量、占比、价格数字;若底稿与摘要中有数字,须保持一致;表述集中度时用「第一大……份额」「前三家合计」等中文,**不要用** CR1、CR3 等缩写; - 若 `structured_brief` 含 `matrix_overview_for_llm` 或矩阵相关字段,策略中应**呼应**细分类目分组与竞品矩阵结论,不得无故删光矩阵相关建议; - 可调整段落衔接、标题层级、列表与表格呈现,使更易读;可补充「建议」「待业务确认」类表述,但不虚构竞品名称或数据; @@ -29,7 +31,12 @@ def generate_strategy_draft_markdown_llm( business_notes: str, generated_at_iso: str, strategy_decisions: dict[str, Any], + report_strategy_excerpt: str | None = None, ) -> str: + """ + ``report_strategy_excerpt``:与同任务宿主报告第九章「策略与机会」正文对齐的节选(见 + ``reporting.report_strategy_excerpt.load_report_strategy_excerpt``);空字符串表示未生成或未重跑第九章大模型。 + """ rules_md = build_strategy_draft_markdown( job_id=job_id, keyword=keyword, @@ -39,6 +46,7 @@ def generate_strategy_draft_markdown_llm( strategy_decisions=strategy_decisions, ) compact = compact_brief_for_llm(brief, max_chars=80_000) + excerpt = (report_strategy_excerpt or "").strip() payload = { "job_id": job_id, "keyword": keyword, @@ -47,6 +55,7 @@ def generate_strategy_draft_markdown_llm( "business_notes": business_notes, "structured_brief": compact, "rules_draft_markdown": rules_md, + "report_strategy_excerpt": excerpt, } raw = json.dumps(payload, ensure_ascii=False) if len(raw) > 500_000: diff --git a/backend/pipeline/reporting/report_strategy_excerpt.py b/backend/pipeline/reporting/report_strategy_excerpt.py new file mode 100644 index 0000000..1bb93f3 --- /dev/null +++ b/backend/pipeline/reporting/report_strategy_excerpt.py @@ -0,0 +1,72 @@ +""" +从任务 run 目录加载「报告第九章 · 策略与机会」正文,供策略稿 LLM 与宿主报告对齐(阶段 S1)。 + +优先顺序: + +1. ``strategy_opportunities_llm.json`` 中的 ``markdown``(与 runner 落盘一致); +2. 否则从 ``competitor_analysis.md`` 截取 ``## 九、策略与机会提示`` 至 ``## 附录`` 之前。 +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Literal + +CHAPTER_NINE_HEADING = "## 九、策略与机会提示" + + +def extract_chapter_nine_strategy_markdown(full_md: str) -> str: + """ + 提取宿主报告中第九章策略块(含章节标题行),不含「附录」及之后内容。 + + 若未找到标题,返回空字符串。 + """ + t = full_md or "" + if not t.strip(): + return "" + key = CHAPTER_NINE_HEADING + i = t.find(key) + if i == -1: + return "" + chunk = t[i:] + j = chunk.find("\n## 附录") + if j != -1: + chunk = chunk[:j] + return chunk.rstrip() + + +def load_report_strategy_excerpt( + run_dir: Path | str, + *, + max_chars: int = 24_000, +) -> tuple[str, Literal["json_markdown", "competitor_analysis_md", "none"]]: + """ + 返回 ``(节选正文, 来源标签)``。节选可能为空(未生成第九章或未找到标题)。 + """ + root = Path(run_dir) + cap = max(512, int(max_chars)) + + json_path = root / "strategy_opportunities_llm.json" + if json_path.is_file(): + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = None + if isinstance(data, dict): + md = (data.get("markdown") or "").strip() + if md: + s = md if len(md) <= cap else md[: cap - 80].rstrip() + "\n\n…(已截断)\n" + return s, "json_markdown" + + md_path = root / "competitor_analysis.md" + if md_path.is_file(): + try: + full = md_path.read_text(encoding="utf-8") + except OSError: + return "", "none" + block = extract_chapter_nine_strategy_markdown(full) + if block.strip(): + s = block if len(block) <= cap else block[: cap - 80].rstrip() + "\n\n…(已截断)\n" + return s, "competitor_analysis_md" + + return "", "none" diff --git a/backend/pipeline/tests/test_report_strategy_excerpt.py b/backend/pipeline/tests/test_report_strategy_excerpt.py new file mode 100644 index 0000000..aa38c43 --- /dev/null +++ b/backend/pipeline/tests/test_report_strategy_excerpt.py @@ -0,0 +1,68 @@ +"""``report_strategy_excerpt``:第九章正文加载与截取。""" +from __future__ import annotations + +import json +from pathlib import Path + +from pipeline.reporting.report_strategy_excerpt import ( + CHAPTER_NINE_HEADING, + extract_chapter_nine_strategy_markdown, + load_report_strategy_excerpt, +) + + +def test_extract_chapter_nine_stops_before_appendix() -> None: + md = f"""# x + +{CHAPTER_NINE_HEADING}(假设清单,待验证) + +正文一段。 + +--- + +## 附录 A:数据留存说明 + +尾部。 +""" + out = extract_chapter_nine_strategy_markdown(md) + assert CHAPTER_NINE_HEADING in out + assert "正文一段" in out + assert "附录" not in out + assert "尾部" not in out + + +def test_load_prefers_json_markdown(tmp_path: Path) -> None: + (tmp_path / "strategy_opportunities_llm.json").write_text( + json.dumps( + {"schema_version": 1, "ok": True, "markdown": "JSON 内第九章正文"}, + ensure_ascii=False, + ), + encoding="utf-8", + ) + (tmp_path / "competitor_analysis.md").write_text( + f"{CHAPTER_NINE_HEADING}\n\n从 MD 来\n\n## 附录 A\n", + encoding="utf-8", + ) + text, src = load_report_strategy_excerpt(tmp_path) + assert src == "json_markdown" + assert "JSON 内第九章" in text + + +def test_load_falls_back_to_competitor_md(tmp_path: Path) -> None: + (tmp_path / "strategy_opportunities_llm.json").write_text( + json.dumps({"schema_version": 1, "ok": True}, ensure_ascii=False), + encoding="utf-8", + ) + (tmp_path / "competitor_analysis.md").write_text( + f"{CHAPTER_NINE_HEADING}(假设清单,待验证)\n\n从 MD 截取。\n\n## 附录 A\n", + encoding="utf-8", + ) + text, src = load_report_strategy_excerpt(tmp_path) + assert src == "competitor_analysis_md" + assert "从 MD 截取" in text + + +def test_load_none_when_missing(tmp_path: Path) -> None: + text, src = load_report_strategy_excerpt(tmp_path) + assert src == "none" + assert text == "" diff --git a/backend/pipeline/views/job_report_views.py b/backend/pipeline/views/job_report_views.py index d215055..6733d5b 100644 --- a/backend/pipeline/views/job_report_views.py +++ b/backend/pipeline/views/job_report_views.py @@ -22,6 +22,7 @@ from ..llm.generate import generate_strategy_draft_markdown_llm from ..models import JobStatus, PipelineJob from ..reporting.brief_pack import build_brief_pack_zip_bytes from ..reporting.md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes +from ..reporting.report_strategy_excerpt import load_report_strategy_excerpt from ..reporting.strategy_draft import build_strategy_draft_markdown from ..serializers import PipelineJobSerializer, StrategyDraftRequestSerializer from .common import job_run_dir_usable @@ -151,6 +152,12 @@ class JobStrategyDraftView(APIView): gen_at = timezone.now().isoformat() generator = (vd.get("generator") or "rules").strip() + excerpt_src = "none" + report_excerpt = "" + try: + report_excerpt, excerpt_src = load_report_strategy_excerpt(job.run_dir) + except OSError: + report_excerpt, excerpt_src = "", "none" try: if generator == "llm": md = generate_strategy_draft_markdown_llm( @@ -160,6 +167,7 @@ class JobStrategyDraftView(APIView): business_notes=notes, generated_at_iso=gen_at, strategy_decisions=strategy_decisions, + report_strategy_excerpt=report_excerpt, ) src = "llm_text_ai_crawler_v1" else: @@ -179,16 +187,17 @@ class JobStrategyDraftView(APIView): {"detail": f"大模型网关错误:{e}"}, status=status.HTTP_502_BAD_GATEWAY, ) - return Response( - { - "schema_version": 1, - "job_id": job.id, - "keyword": job.keyword, - "generated_at": gen_at, - "source": src, - "markdown": md, - } - ) + body: dict[str, object] = { + "schema_version": 1, + "job_id": job.id, + "keyword": job.keyword, + "generated_at": gen_at, + "source": src, + "markdown": md, + "report_strategy_excerpt_source": excerpt_src, + "report_strategy_excerpt_chars": len(report_excerpt or ""), + } + return Response(body) @method_decorator(csrf_exempt, name="dispatch") diff --git a/docs/openapi/pipeline-jobs.openapi.yaml b/docs/openapi/pipeline-jobs.openapi.yaml index 5c80754..185a03a 100644 --- a/docs/openapi/pipeline-jobs.openapi.yaml +++ b/docs/openapi/pipeline-jobs.openapi.yaml @@ -585,3 +585,10 @@ components: example: structured_summary_rules_v1 markdown: type: string + report_strategy_excerpt_source: + type: string + description: none=未找到第九章正文;json_markdown=strategy_opportunities_llm.json 的 markdown;competitor_analysis_md=从 competitor_analysis.md 截取 + enum: [none, json_markdown, competitor_analysis_md] + report_strategy_excerpt_chars: + type: integer + description: 参与对齐的第九章节选字符数(供策略稿 LLM 的 report_strategy_excerpt;generator=rules 时仅元数据) diff --git a/docs/planning/strategy-marketing-content-alignment.md b/docs/planning/strategy-marketing-content-alignment.md index c626f64..ee5cc72 100644 --- a/docs/planning/strategy-marketing-content-alignment.md +++ b/docs/planning/strategy-marketing-content-alignment.md @@ -7,7 +7,8 @@ | 能力 | 位置 | |------|------| | 规则策略底稿 | `pipeline/reporting/strategy_draft.py` → `build_strategy_draft_markdown` | -| 策略稿 LLM 润色 | `pipeline/llm/generate_strategy.py` → `generate_strategy_draft_markdown_llm` | +| 策略稿 LLM 润色 | `pipeline/llm/generate_strategy.py` → `generate_strategy_draft_markdown_llm`(payload 含 `report_strategy_excerpt`) | +| 第九章节选加载(S1) | `pipeline/reporting/report_strategy_excerpt.py` → `load_report_strategy_excerpt` | | 报告第九章策略归纳 | `generate_strategy_opportunities_llm`(与 `competitor_brief` + 各章节选对齐) | | 策略稿 API / 导出 | `pipeline/views/job_report_views.py` → `JobStrategyDraftView` | | 简报与压缩 | `pipeline/reporting/brief_compact.py` | @@ -26,15 +27,16 @@ --- -## 2. 策略稿:与报告内「策略建议」对齐(阶段 S1) +## 2. 策略稿:与报告内「策略建议」对齐(阶段 S1)— **已落地** **目标**:独立下载的策略稿与宿主报告第九章**方向一致**,避免与已发布报告矛盾。 -**拟议实现要点**: +**实现要点(与代码一致)**: -1. 策略稿生成/润色前,加载本任务 **`strategy_opportunities_llm.json`**(若存在),或从 **`competitor_analysis.md`** 抽取第九章相关正文片段,作为 **`report_strategy_excerpt`** 并入 LLM 的 JSON payload。 -2. 在 `STRATEGY_SYSTEM` 中明确:**须与 `report_strategy_excerpt` 策略方向一致**;若业务备注与报告冲突,文中标注「与报告不一致处见业务备注」。 -3. 产品侧:`generator: rules | llm` 区分展示;**规则版**可作为审计与对外发送前的对照底稿。 +1. **`load_report_strategy_excerpt(run_dir)`**:优先读 `strategy_opportunities_llm.json` 的 **`markdown`**(runner 在第九章大模型成功时写入);否则从 **`competitor_analysis.md`** 截取 `## 九、策略与机会提示` 至 `## 附录` 之前。 +2. **`STRATEGY_SYSTEM`**:当 `report_strategy_excerpt` 非空时,润色稿须与节选**战略方向一致**;若 `business_notes` / `strategy_decisions` 与节选冲突,须标注「与报告第九章策略归纳不一致之处见业务备注」类表述。节选为空时不得编造第九章结论。 +3. **API**:`POST /api/jobs/{id}/strategy-draft/` 响应增加 `report_strategy_excerpt_source`、`report_strategy_excerpt_chars`(`generator=rules` 时亦返回,便于核对是否加载到节选)。 +4. 产品侧:`generator: rules | llm` 仍为既有行为;规则版作审计底稿。 **验收**:抽样任务核对「第九章要点 ↔ 策略稿 bullet」可对应;数字仅来自 brief/底稿。 @@ -73,7 +75,7 @@ | 阶段 | 内容 | 产出 | |------|------|------| -| **S1** | 策略稿 payload 增加 `report_strategy_excerpt`,收紧 `STRATEGY_SYSTEM` | 策略稿与第九章可对照 | +| **S1** | 策略稿 payload 增加 `report_strategy_excerpt`,收紧 `STRATEGY_SYSTEM` | ✅ 已合并:`report_strategy_excerpt.py`、runner 落盘 `markdown`、OpenAPI 响应字段 | | **S2** | 营销模块 v1:单模板 + 强约束提示词 + 落盘 + 复用 Word/PDF 导出 | 可演示的营销初稿 | | **S3** | 前端:任务页入口、受众与渠道、生成历史 | 产品闭环 | | **S4**(可选) | 轻量校验:输出中数字与 brief 同源性启发式检查 | 降低明显幻觉 | @@ -92,5 +94,6 @@ | 日期 | 说明 | |------|------| | 2026-04-17 | 首版:对齐原则、S1~S4、代码锚点、API 示意。 | +| 2026-04-18 | S1 落地:`load_report_strategy_excerpt`、`STRATEGY_SYSTEM` 对齐条款、`strategy_opportunities_llm.json.markdown`、策略稿 API 响应字段。 | 后续变更请在本表追加一行,并在正文相应章节修改。