feat(pipeline): S1 策略稿与第九章对齐(report_strategy_excerpt)

新增 load_report_strategy_excerpt:优先 strategy_opportunities_llm.json 的 markdown,否则从 competitor_analysis.md 截取第九章;runner 成功时落盘 markdown 字段。STRATEGY_SYSTEM 收紧与 report_strategy_excerpt 一致及业务冲突标注。策略稿 API 返回节选来源与字符数;补充单测与 OpenAPI、规划文档。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-17 16:25:53 +08:00
parent 224e7d0c16
commit 6e113bbdfe
7 changed files with 188 additions and 18 deletions

View File

@ -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)

View File

@ -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` 润色**不得编造**与可能存在的报告第九章结论
- **不得编造**输入中不存在的销量占比价格数字若底稿与摘要中有数字须保持一致表述集中度时用第一大份额前三家合计等中文**不要用** CR1CR3 等缩写
- `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:

View File

@ -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"

View File

@ -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 == ""

View File

@ -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")

View File

@ -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 的 markdowncompetitor_analysis_md=从 competitor_analysis.md 截取
enum: [none, json_markdown, competitor_analysis_md]
report_strategy_excerpt_chars:
type: integer
description: 参与对齐的第九章节选字符数(供策略稿 LLM 的 report_strategy_excerptgenerator=rules 时仅元数据)

View File

@ -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 | 首版对齐原则、S1S4、代码锚点、API 示意。 |
| 2026-04-18 | S1 落地:`load_report_strategy_excerpt``STRATEGY_SYSTEM` 对齐条款、`strategy_opportunities_llm.json.markdown`、策略稿 API 响应字段。 |
后续变更请在本表追加一行,并在正文相应章节修改。