feat(strategy): 无表单决策时由大模型推断草案并净化对外成稿约束

新增 strategy_decisions_substantive 与 JSON 载荷字段;STRATEGY_SYSTEM/USER_PREFIX 要求实质成稿、禁止技术泄露;build_strategy_draft_markdown(for_llm_input=True) 供归纳用弱化路径与字段名。规则下载稿默认行为不变。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-21 17:03:55 +08:00
parent 805e9b4de4
commit 4cf73e7602
4 changed files with 207 additions and 57 deletions

View File

@ -21,8 +21,6 @@ from .generate_group_summaries import (
PRICE_GROUPS_USER_PREFIX,
PROMO_GROUPS_SYSTEM,
PROMO_GROUPS_USER_PREFIX,
SCENARIO_GROUPS_SYSTEM,
SCENARIO_GROUPS_USER_PREFIX,
_join_chunked_group_markdown,
generate_comment_group_summaries_llm,
generate_comment_group_summaries_llm_chunked,
@ -32,8 +30,6 @@ from .generate_group_summaries import (
generate_price_group_summaries_llm_chunked,
generate_promo_group_summaries_llm,
generate_promo_group_summaries_llm_chunked,
generate_scenario_group_summaries_llm,
generate_scenario_group_summaries_llm_chunked,
)
from .generate_sections import (
BRIDGE_SECTIONS_SYSTEM,
@ -49,6 +45,7 @@ from .generate_strategy import (
STRATEGY_USER_PREFIX,
generate_strategy_draft_markdown_llm,
generate_strategy_opportunities_llm,
strategy_decisions_substantive,
)
from .llm_client import call_llm as _call_llm
@ -64,8 +61,6 @@ __all__ = [
"PROMO_GROUPS_USER_PREFIX",
"REPORT_SYSTEM",
"REPORT_USER_PREFIX",
"SCENARIO_GROUPS_SYSTEM",
"SCENARIO_GROUPS_USER_PREFIX",
"SENTIMENT_LLM_SYSTEM",
"STRATEGY_OPPORTUNITIES_SYSTEM",
"STRATEGY_OPPORTUNITIES_USER_PREFIX",
@ -83,10 +78,9 @@ __all__ = [
"generate_price_group_summaries_llm_chunked",
"generate_promo_group_summaries_llm",
"generate_promo_group_summaries_llm_chunked",
"generate_scenario_group_summaries_llm",
"generate_scenario_group_summaries_llm_chunked",
"generate_section_bridges_llm",
"generate_strategy_draft_markdown_llm",
"generate_strategy_opportunities_llm",
"split_competitor_report_for_bridges",
"strategy_decisions_substantive",
]

View File

@ -12,6 +12,37 @@ from ..reporting.strategy_draft import (
)
from .llm_client import call_llm, estimate_chat_input_tokens, llm_context_window_size
# 与策略生成表单 POST 字段一致:任一则视为业务已提供「实质决策」,否则由模型基于数据推断草案。
_STRATEGY_DECISION_SUBSTANTIVE_KEYS: tuple[str, ...] = (
"product_role",
"battlefield_one_line",
"audience_segment",
"time_horizon",
"success_criteria",
"non_goals",
"positioning_choice",
"competitive_stance",
"pillar_product",
"pillar_price",
"pillar_channel",
"pillar_comm",
"marketing_strategy",
"general_strategy",
"competitor_reference",
"resource_notes",
)
def strategy_decisions_substantive(strategy_decisions: dict[str, Any] | None) -> bool:
"""是否填写了至少一项策略表单文本字段(用于 LLM 是否「自动推断」全稿)。"""
if not isinstance(strategy_decisions, dict):
return False
for k in _STRATEGY_DECISION_SUBSTANTIVE_KEYS:
v = strategy_decisions.get(k)
if isinstance(v, str) and v.strip():
return True
return False
def _omit_ch8_probe_wordchart_fields(compact: dict[str, Any]) -> None:
"""
@ -89,10 +120,21 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
**落实范围**上文全局禁止编造适用于**摘要一至十附录**的每一句话与表格每一格**不得**因章节不同而放宽
**对外成稿与禁止技术泄露硬性**
- 正文须为**可直接对业务或合作方阅读**的正式策略文档对外前仍须按需脱敏**禁止**出现反引号代码体JSON 键名英文字段名内部数据结构名源码或仓库路径类文件名任务 ID工作台规则骨架等系统痕迹**禁止**照抄底稿中以 *成稿**回答**占位**骨架* 开头的**元说明句**须改写为正式业务表述
- **一级标题**用文书式例如 `# 「{{keyword}}」市场策略建议书(草案)`,其中 `keyword` 取自本消息 JSON 的同名字段;**勿**使用「草稿」「底稿」「归纳用」等对内用词。
- **附录**中的采集范围等信息用**中文短句**列表页约采集第 310 **禁止** `page_start=` 等键值对或英文键名
**业务决策未填写时的成稿义务 JSON `strategy_decisions_substantive` false **
- 视为未提交表单决策须基于监测摘要细类报告节选底稿数据表及 `business_notes`若有**主动推断**一套连贯的**假设性**策略策略范围与前提写清推断前提假设待业务确认对阶段目标类型给出 **AE 类选项及你从数据中归纳的推荐倾向**不得只列选项而无立场
- **§2.1 针对痛点要怎么做**须含**多行实质内容**覆盖监测已支撑的主要细类与痛点**禁止**整表留白或满篇 *待填*
- 仍须遵守全局禁止编造数字品牌店铺用户原话活动规则仅可来自输入依据无依据处用假设语气
**决策边界硬性**
- **业务已在 `strategy_decisions` 中填写的项**角色时间成功标准战场一句话定位勾选竞争倾向四柱目标客群/对标/资源备注**营销策略****总体策略**视为**已定决策**成稿须**落实为具体执行句****不得**改写成相反结论或再要求用户请选择
- **表单中为空或占位 *待填**骨架占位*的项**结合 `structured_brief`节选与数据摘录**补全为可执行表述**补全须与数据方向一致**例外****策略范围与前提**中若仍无依据**允许**保留待确认**列出** AE 类目标选项**禁止**用虚构本品角色或目标补满
- **成稿阶段避免**反复请业务决策待确认后再定**不确定时**须在 §2.1 表中用类目/细类 +待业务定类假设**分类**写清**禁止**只写泛化一句带过
- ** `strategy_decisions_substantive` true **业务已在 `strategy_decisions` 中填写的项角色时间成功标准战场一句话定位勾选竞争倾向四柱目标客群/对标/资源备注**营销策略****总体策略**视为**已定决策**成稿须**落实为具体执行句****不得**改写成相反结论或再要求用户请选择
- ** `strategy_decisions_substantive` true** 而部分表单项仍为空或占位结合监测摘要与节选**补全为可执行表述**与数据方向一致
- ** `strategy_decisions_substantive` false **适用上文业务决策未填写时的成稿义务**禁止**请先填表类表述搪塞全篇
- **成稿阶段避免**反复请业务决策不确定时在 §2.1 类目/细类+假设待业务确认**写清****禁止**只写泛化一句
**输出结构与阅读顺序须与 `rules_draft_markdown` 章节一致勿另起目录**
**策略范围与前提生成前先对齐** **摘要** **顾客是谁**含人群与路径细类讨论本品聚焦 **产品价值与用户痛点**** §2.1 针对痛点要怎么做** ****再设独立痛点表/价值对表/负向归因子节 **为什么要买这款产品**** §3.1 品类与时机**** §3.2**转化与价带应对已在 §2 表内则**勿重复** **为什么要选这个品牌** **与其它品牌有何不同** **阶段目标与路径** **品牌四线**建设·打造·运营·体验 **战术支柱**产品/定价/促销/渠道与传播 **风险假设与待验证** **下一步与节奏**含业务备注 **附录**
@ -105,7 +147,7 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
- **§2.1 针对痛点要怎么做**若底稿已有表头**填写实质内容**全稿**动作总锚** §2.1若无表须在 **§** **§** 用等价分条写清痛点动作落地验证
**分节要求与底稿章节一一对应勿省略**
- **策略范围与前提**回答**这份策略是针对什么做的**监测任务本品角色战场主推类目**本阶段目标类型**时间成功标准 `strategy_decisions``business_notes` 对齐表格占位未填时**禁止**编造决策应保留待确认**列出**可选目标类型 `STRATEGY_USER_PREFIX` 与规划文档启动前可写建议选项供业务勾选**禁止**与后文 §2.1§ 自相矛盾
- **策略范围与前提**回答**这份策略是针对什么做的**监测任务本品角色战场主推类目**本阶段目标类型**时间成功标准业务表单及备注对齐`strategy_decisions_substantive` false 时须写清假设前提与推荐目标类型可含 AE 选项 true 时未填项不得与已填决策矛盾**禁止**与后文 §2.1§ 自相矛盾
- **摘要**除范围样本外**阶段重点**须含 12 **可执行动作**且能回扣 §2 中的优先痛点非空泛加强运营**承接**上文策略范围与前提已定的边界
- **顾客是谁****禁止**重复报告中的细类词频分品类样本量展开文本挖掘方法 **少量结论句**谁搜关心什么即可**1.3 本品聚焦**须写清本期主攻人群/场景 **§2.1** 的对应关系
- ****** §2.1** 一张表类目/细类本决策适用| 用户痛点简述| 策略动作 | 具体怎么做 | 如何验证须覆盖监测已支撑的主要维度**按类目分行**口感/质地分线分量/规格信任与价格等依数据取舍**类目列 + 痛点简述列**遵守§2 条款**禁止**再写独立痛点与证据表价值对表负向归因子节 § 重复的内容一律并入本表或删去
@ -138,9 +180,11 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
**输出** Markdown 正文不要 ``` 围栏须收束各小节与全文勿中途截断"""
STRATEGY_USER_PREFIX = (
"请基于以下 JSON 输出最终策略稿Markdown"
"输出前自检:全文不得包含输入 JSON 中未出现的具体数字、品牌/店铺名、用户引语与活动规则;不确定处须写「假设」「输入未体现」或「待核对」。"
"「策略范围与前提」一节:若 `strategy_decisions` 中本品角色、战场、成功标准、主推类目等大量为空,**不得**编造;须列「待确认」并给出**目标类型选项**供业务选择,例如:**A** 上市/首发前验证 **B** 份额追赶 **C** 利润与价盘防守 **D** 新场景/新人群拓展 **E** 其它(简述)——具体措辞见项目规划文档「策略生成-框架确定」之「启动前」。\n\n"
"请基于以下 JSON 输出最终策略稿Markdown正文须为对外可读正式文档不得泄露 JSON 键名、字段名、源码路径或底稿中的编写提示语。\n"
"输出前自检:全文不得包含输入中未出现的具体数字、品牌/店铺名、用户引语与活动规则;不确定处须写「假设」「监测未体现」或「待业务核对」。\n"
"若 JSON 中 `strategy_decisions_substantive` 为 false你须基于监测摘要与细类报告节选**主动推断**完整策略草案(含 §2.1 多行实质内容),"
"在「策略范围与前提」标明假设前提,并对阶段目标给出 AE 类型选项及**推荐倾向**;禁止全文停留在待填占位。\n"
"若 `strategy_decisions_substantive` 为 true已填表单项视为已定须落实空项结合数据补全并与后文一致。\n\n"
)
@ -171,6 +215,7 @@ def generate_strategy_draft_markdown_llm(
generated_at_iso=generated_at_iso,
strategy_decisions=strategy_decisions,
report_config=report_config,
for_llm_input=True,
)
excerpt_raw = (report_strategy_excerpt or "").strip()
group_evidence_raw = (report_matrix_group_evidence_md or "").strip()
@ -208,6 +253,9 @@ def generate_strategy_draft_markdown_llm(
"keyword": keyword,
"generated_at_iso": generated_at_iso,
"strategy_decisions": strategy_decisions,
"strategy_decisions_substantive": strategy_decisions_substantive(
strategy_decisions
),
"business_notes": business_notes,
"structured_brief": compact,
"rules_draft_markdown": rd,

View File

@ -162,14 +162,29 @@ def build_strategy_draft_markdown(
generated_at_iso: str = "",
strategy_decisions: dict[str, Any] | None = None,
report_config: dict[str, Any] | None = None,
for_llm_input: bool = False,
) -> str:
"""生成可下载的 Markdown与「六主轴 + 品牌四线」示例稿同构的规则骨架,附录为数据速览。"""
"""生成可下载的 Markdown与「六主轴 + 品牌四线」示例稿同构的规则骨架,附录为数据速览。
``for_llm_input=True`` 时供大模型归纳用弱化源码/路径/任务 ID 等痕迹减少对外成稿误复述
"""
use_ch8_probe = report_uses_chapter8_text_mining_probe(report_config)
d = strategy_decisions or {}
pos = _esc(d.get("positioning_choice") or "").strip()
kw = _esc(brief.get("keyword")) or _esc(keyword) or ""
batch = _esc(brief.get("batch_label")) or ""
if for_llm_input:
lines: list[str] = [
f"# 「{kw}",
"",
]
if generated_at_iso:
lines.append(
f"> **生成时间**{_esc(generated_at_iso)}(归纳用,**勿写入对外成稿正文**"
)
lines.append("")
else:
lines = [
f"# 市场策略制定草稿 · 「{kw}",
"",
"> **骨架说明**:本页为规则骨架;结构与 [`docs/demo`](docs/demo) 示例一致。**全局禁止编造**见 `generate_strategy.py` 中 `STRATEGY_DATA_RULES`。",
@ -192,13 +207,24 @@ def build_strategy_draft_markdown(
def _scope_cell(val: str, placeholder: str) -> str:
return val if val else f"*{placeholder}*"
scope_prelude = (
[
"*回答:**这份策略是针对什么、在什么边界里做的**——属「立项靶心」,不是执行摘要。表单已填则写成短句;未填保留占位,**勿**编造。*",
"*业务侧在动策略稿之前,应先对齐本节;可选目标类型见 `docs/planning/策略生成-框架确定.md`「启动前:须先明确的项」。*",
"",
]
if not for_llm_input
else [
"*监测任务边界与业务角色:表单已填则写成短句;未填时由归纳稿基于监测摘要提出**假设性**表述。*",
"*阶段目标类型可列 AE 备选(上市验证 / 份额追赶 / 价盘防守 / 新场景或新人群 / 其它),并写明推荐倾向。*",
"",
]
)
lines.extend(
[
"## 策略范围与前提(生成前先对齐)",
"",
"*回答:**这份策略是针对什么、在什么边界里做的**——属「立项靶心」,不是执行摘要。表单已填则写成短句;未填保留占位,**勿**编造。*",
"*业务侧在动策略稿之前,应先对齐本节;可选目标类型见 `docs/planning/策略生成-框架确定.md`「启动前:须先明确的项」。*",
"",
*scope_prelude,
"| 须明确项 | 填写或待确认 |",
"|----------|----------------|",
f"| **监测任务(数据同源)** | 关键词「{kw}」;批次 **{batch}**;与同任务《竞品分析报告》一致 |",
@ -296,7 +322,11 @@ def build_strategy_draft_markdown(
"",
"*本节**仅**用下表写清**针对痛点要怎么做****类目** + 痛点简述 + 动作 + 落地 + 验证)。**不再**单设「痛点表 / 价值对表 / 负向归因」子节,避免与 §三、§八重复。*",
"",
"*「用户痛点(简述)」须与 `structured_brief` / 策略线索 / 报告节选**可核对****禁止**编造「用户反馈『……』」式引语,除非原句已出现在上述输入中。*",
(
"*「用户痛点(简述)」须与监测摘要 / 策略线索 / 报告节选**可核对****禁止**编造「用户反馈『……』」式引语,除非原句已出现在上述输入中。*"
if for_llm_input
else "*「用户痛点(简述)」须与 `structured_brief` / 策略线索 / 报告节选**可核对****禁止**编造「用户反馈『……』」式引语,除非原句已出现在上述输入中。*"
),
"",
"*「类目/细类」列:写明本行决策**适用于哪一类**(如饼干/面包/全检索池);多细类须**分行****禁止**用一句「全站」覆盖彼此冲突的策略;类目未定可写「待业务定类」并附分类假设。*",
"",
@ -317,7 +347,9 @@ def build_strategy_draft_markdown(
else (list(raw_hints) if isinstance(raw_hints, list) else [])
)
if hints:
lines.append("**摘要自动线索(`strategy_hints`**")
lines.append(
"**监测摘要自动线索**" if for_llm_input else "**摘要自动线索(`strategy_hints`**"
)
lines.append("")
for h in hints:
lines.append(f"- {_esc(h)}")
@ -335,7 +367,9 @@ def build_strategy_draft_markdown(
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
lines.append(
f"- **列表申报规模resultCount**{_num(raw.get('result_count_consensus'))}(非销售额)"
f"- **平台申报检索规模**{_num(raw.get('result_count_consensus'))}(站内匹配条数量级,非销售额)"
if for_llm_input
else f"- **列表申报规模resultCount**{_num(raw.get('result_count_consensus'))}(非销售额)"
)
elif merged_n is not None:
lines.append(f"- **深入样本 SKU 数**{_num(merged_n)}")
@ -344,9 +378,10 @@ def build_strategy_draft_markdown(
lines.append("")
if pst.get("n"):
src = _esc(brief.get("price_stats_source")) or ""
src_disp = "本监测样本" if for_llm_input and src == "strategy_scope_matrix_group_skus" else src
lines.extend(
[
f"- **价格摘录(可支撑购买理由与锚点)**:来源 {src}n = {_num(pst.get('n'))}"
f"- **价格摘录(可支撑购买理由与锚点)**:来源 {src_disp}n = {_num(pst.get('n'))}"
f"区间 {_num(pst.get('min'))}{_num(pst.get('max'))};中位数 {_num(pst.get('median'))}",
"",
]
@ -517,10 +552,15 @@ def build_strategy_draft_markdown(
if use_ch8_probe:
pst_sig = brief.get("price_promotion_signals") or {}
has_promo = isinstance(pst_sig, dict) and bool(pst_sig)
promo_hint = (
"*促销与活动线索:须与摘要中的价差与活动信号及报告第六章已有归纳一致;无则勿编造具体满减门槛。*"
if for_llm_input
else "*促销与活动线索:须与摘要 `price_promotion_signals` 及第六章/第九章已有归纳一致;无则勿编造具体满减门槛。*"
)
lines.extend(
[
"",
"*促销与活动线索:须与摘要 `price_promotion_signals` 及第六章/第九章已有归纳一致;无则勿编造具体满减门槛。*"
promo_hint
if has_promo
else "*促销与价差:若摘要或价格信号有归纳则承接;无则勿编造。*",
"",
@ -544,7 +584,11 @@ def build_strategy_draft_markdown(
"### 8.3 促销与活动策略",
"",
"*须写促销**原则**(券/到手价/跟价节奏);**满减、满折、跨店**等:能引用的写清来源;监测未捕获具体门槛时写「待与运营/后台对齐」,**勿**整节留空,**勿**编造门槛数字。*",
"*与 `price_promotion_signals`、报告第六章一致;勿虚构活动。*",
(
"*与监测摘要中的促销与价差信号、报告第六章一致;勿虚构活动。*"
if for_llm_input
else "*与 `price_promotion_signals`、报告第六章一致;勿虚构活动。*"
),
"",
"### 8.4 渠道与传播",
"",
@ -602,6 +646,20 @@ def build_strategy_draft_markdown(
"scenario_filter_enabled": "场景筛选",
}
if isinstance(meta, dict) and meta:
if for_llm_input:
bits_llm: list[str] = []
ps, pt = meta.get("page_start"), meta.get("page_to")
if ps is not None and pt is not None:
bits_llm.append(f"列表页约第 {_esc(ps)}{_esc(pt)}")
elif ps is not None:
bits_llm.append(f"列表自第 {_esc(ps)} 页起采集")
if meta.get("max_skus_config") is not None:
bits_llm.append(f"深入样本上限约 {_num(meta.get('max_skus_config'))} 个 SKU")
if meta.get("scenario_filter_enabled"):
bits_llm.append("已启用场景筛选")
if bits_llm:
lines.append(f"- **采集范围**{''.join(bits_llm)}")
else:
bits = []
for k in ("page_start", "page_to", "max_skus_config", "scenario_filter_enabled"):
if k in meta:
@ -612,8 +670,21 @@ def build_strategy_draft_markdown(
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
lines.append(
f"- **列表申报规模resultCount**{_num(raw.get('result_count_consensus'))}"
f"- **平台申报检索规模**{_num(raw.get('result_count_consensus'))}"
if for_llm_input
else f"- **列表申报规模resultCount**{_num(raw.get('result_count_consensus'))}"
)
if for_llm_input:
lines.extend(
[
"",
"*可与同任务《竞品分析报告》及本批次数据表对照核验。*",
"",
"---",
"",
]
)
else:
lines.extend(
[
"",

View File

@ -4,10 +4,45 @@ from __future__ import annotations
from django.test import SimpleTestCase
from pipeline.llm.generate_strategy import _omit_ch8_probe_wordchart_fields
from pipeline.llm.generate_strategy import strategy_decisions_substantive
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
class StrategyDraftTests(SimpleTestCase):
def test_strategy_decisions_substantive(self) -> None:
self.assertFalse(strategy_decisions_substantive(None))
self.assertFalse(strategy_decisions_substantive({}))
self.assertFalse(
strategy_decisions_substantive(
{"ack_risk_keywords": True, "product_role": " "}
)
)
self.assertTrue(
strategy_decisions_substantive({"product_role": "新品"})
)
def test_for_llm_input_omits_dev_traces(self) -> None:
brief = {
"schema_version": 1,
"keyword": "K",
"batch_label": "b1",
"scope": {"merged_sku_count": 2},
"strategy_hints": ["线索1"],
"meta": {"page_start": 1, "page_to": 3, "max_skus_config": 100},
}
md = build_strategy_draft_markdown(
job_id=7,
keyword="K",
brief=brief,
generated_at_iso="2026-01-01",
for_llm_input=True,
)
self.assertNotIn("任务 ID", md)
self.assertNotIn("generate_strategy.py", md)
self.assertNotIn("strategy_hints", md)
self.assertIn("监测摘要自动线索", md)
self.assertIn("列表页约第 13 页", md)
def test_build_contains_sections_and_notes(self) -> None:
brief = {
"schema_version": 1,
@ -65,7 +100,7 @@ class StrategyDraftTests(SimpleTestCase):
self.assertIn("侧翼切入", md)
self.assertIn("做低糖配方", md)
self.assertIn("### 7.1 品牌建设", md)
self.assertIn("- [x] 关注词/场景统计是否以偏概全", md)
self.assertIn("- [x] 评论侧归纳是否以偏概全", md)
self.assertIn("- [ ] 价格带是否含大促", md)
self.assertIn("- [x] 列表集中度与深入样本品牌是否不一致", md)
@ -92,7 +127,7 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("子串统计命中约 **501**", md)
self.assertNotIn("场景「控糖", md)
def test_legacy_report_shows_focus_scenario_bullets(self) -> None:
def test_non_probe_path_no_preset_focus_enumeration(self) -> None:
brief = {
"schema_version": 1,
"keyword": "低GI",
@ -111,8 +146,10 @@ class StrategyDraftTests(SimpleTestCase):
brief=brief,
report_config={"chapter8_text_mining_probe": False},
)
self.assertIn("「口感」:子串统计命中约 **501** 次", md)
self.assertIn("场景「控糖/血糖相关」:约 **305** 条", md)
self.assertIn("不再", md)
self.assertIn("预设关注词", md)
self.assertNotIn("子串统计命中约 **501**", md)
self.assertNotIn("场景「控糖", md)
def test_shops_unique_sku_basis_rendered(self) -> None:
brief = {