feat(pipeline): 竞品简报与策略管线、报告导出及 JD 前端联动

补充策略决策键、fixture 与相关单测;分析/营销包本地存储与任务状态;竞品简报 schema 与 LLM 章节生成、促销与导出调整;并纳入 Cursor 工程规则 user-dev-style。

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-27 15:48:09 +08:00
parent 7b33af8a37
commit 614d631261
45 changed files with 2699 additions and 1327 deletions

View File

@ -0,0 +1,44 @@
---
description: 用户个人开发习惯user-dev-style- 本仓库内始终应用
alwaysApply: true
---
# 用户开发习惯(基线 · 始终应用)
在本仓库内,助手应**默认**按以下习惯工作,**无需**用户每轮都说「按我习惯」。**完整条目**见个人 Cursor 技能 **`user-dev-style`**`~/.cursor/skills/user-dev-style/SKILL.md`)。冲突时:**用户当条消息 > 本文件与 SKILL > 其他一般建议**。
## Git 与 PR
- 提交说明与 PR 描述以**中文为主**`type(scope): 中文描述`;正文用完整中文句(可与 `git-commit-zh` 并存,不冲突)。
- 不整段英文、不中英碎片硬拼。
## 代码与架构
- **分层分模块**、**最小必要改动**;不顺手大重构、不格式化无关文件。
- 与周边代码风格一致;优先复用现有抽象。
- Python**`.venv`**;不提交 `.venv/``.env` 真密钥不入库,维护 `.env.example`。
## SeleniumBase / 爬虫
- 遵循专用技能 **`seleniumbase-cdp-scraping`**若任务相关UC + `activate_cdp_mode`、步骤间 sleep、产出落盘 CSV/JSON 等。
## 流程
- **先对齐再改代码**(仓库另有「先对齐」规则时一并遵守);用户写明跳过则可跳过。
- **验证后再声称完成**(测试/命令/日志等依据);不假装已跑过。
## 工具、安全、任务
- **MCP**:先读 schema 再调用。
- **密钥与隐私**不入库、不当聊天示例。
- **Todo**:完成即标完成,不长期 `in_progress`。
## 书面与文档(总结 / 规划 / 日报)
- 默认**不主动新建** md用户要总结/规划时:**短而全**;单日「一天一句」,跨日「日期区间 + `-` 分点」;**不把本周规划写进上周已完成**;默认**不用表格**(除非用户要)。
- 日报:非技术可读;若存在 **`日报/model/daily-report.md`** 则**以该团队模板为准****完成/产出各通常一句**、无考勤须**约 X h** 与**文首合计**(详见 **`daily-report`** 技能)。无模板时可用 **今日进展 / 今日收获 / 明日计划** 简版。
- 周报 / 上周总结:若存在 **`日报/model/weekly-report.md`** 则**以该团队模板为准**;轻量总结仍「一日一句」、跨日区间下分点;**按日期/时间顺序**、表格格内也宜短句,详见 **`weekly-summary`**。
## 需求与表述
- 范围不清时用**选择题**收口径;回复完整句、少套路收尾;代码引用与链接格式按 Cursor 规范。

View File

@ -1,4 +1,4 @@
"""评价关键词命中、星级与口语词表、情感 lexicon、大模型情感 payload"""
"""评价文本单元迭代、星级解析、大模型情感载荷(语义池);保留旧版 lexicon 辅助函数供测试,不再进入报告主链路"""
from __future__ import annotations
import hashlib
@ -405,30 +405,34 @@ def build_comment_sentiment_llm_payload(
shuffle_seed: str = "",
) -> dict[str, Any]:
"""
供大模型做正/负向语义归纳附规则统计**评分优先或关键词**归类后的抽样以及 **sample_reviews_semantic_pool**
全量去重后的评价句确定性洗牌抽样供模型结合语境自行判断褒贬
供大模型做正/负向**语义**归纳****提供去重后的 ``sample_reviews_semantic_pool``洗牌抽样原文
以及可选 ``star_rating_distribution``有有效评分列时 12 / 3 / 45 星条数****预设子串词表
``sentiment_bucket_method``有有效评分列时为 ``score_then_lexeme``否则为 ``keyword_substring_heuristic``
``comment_sentiment_lexicon`` 与各象限计数一致竞品报告与 brief **已不再**发布同口径图正文归纳仍以整句语义为准
已移除``comment_sentiment_lexicon``预设口语短语命中按关键词/词表机械分桶的样本列表与报告已废弃口径一致
参数 ``max_samples_*`` 保留签名以兼容旧调用方**不再使用**
"""
_ = (max_samples_positive, max_samples_negative, max_samples_mixed)
use_score_column = bool(
scores is not None
and len(scores) == len(texts)
and any(s is not None for s in scores)
)
pos_only_texts: list[str] = []
neg_only_texts: list[str] = []
mixed_texts: list[str] = []
use_attr = (
attributed_texts is not None
and len(attributed_texts) == len(texts)
)
all_unique_disp: list[str] = []
seen_unique: set[str] = set()
text_unit_count = 0
star_dist: dict[str, int] | None = None
if use_score_column:
star_dist = {"score_1_2": 0, "score_3": 0, "score_4_5": 0, "no_score": 0}
for i, t in enumerate(texts):
s = (t or "").strip()
if not s:
continue
text_unit_count += 1
disp = (
(attributed_texts[i] or s).strip()
if use_attr
@ -437,14 +441,16 @@ def build_comment_sentiment_llm_payload(
if disp and disp not in seen_unique:
seen_unique.add(disp)
all_unique_disp.append(disp)
sc = scores[i] if use_score_column and scores is not None else None
quad = _sentiment_quadrant_for_row(s, sc, use_score_column=use_score_column)
if quad == "mixed":
mixed_texts.append(disp)
elif quad == "pos_only":
pos_only_texts.append(disp)
elif quad == "neg_only":
neg_only_texts.append(disp)
if star_dist is not None and scores is not None:
sc = scores[i] if i < len(scores) else None
if sc is None:
star_dist["no_score"] += 1
elif sc <= 2:
star_dist["score_1_2"] += 1
elif sc == 3:
star_dist["score_3"] += 1
else:
star_dist["score_4_5"] += 1
def _semantic_pool(seq: list[str], cap: int) -> list[str]:
"""去重列表的洗牌子样本shuffle_seed 非空时按种子固定顺序以便同任务可复现。"""
@ -467,39 +473,18 @@ def build_comment_sentiment_llm_payload(
semantic_pool = _semantic_pool(all_unique_disp, semantic_pool_max)
def _sample(seq: list[str], cap: int) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in seq:
if raw in seen:
continue
seen.add(raw)
if len(raw) > max_chars_per_review:
out.append(raw[:max_chars_per_review] + "")
else:
out.append(raw)
if len(out) >= cap:
break
return out
lex = _comment_sentiment_lexicon(texts, scores)
pos_h = lex.get("positive_tone_lexeme_hits") or []
neg_h = lex.get("negative_tone_lexeme_hits") or []
pos_h_top = [x for x in pos_h[:12] if isinstance(x, dict)]
neg_h_top = [x for x in neg_h[:12] if isinstance(x, dict)]
bucket_method = (
"score_then_lexeme" if use_score_column else "keyword_substring_heuristic"
)
return {
"comment_sentiment_lexicon": lex,
"positive_lexeme_hits_top": pos_h_top,
"negative_lexeme_hits_top": neg_h_top,
"sentiment_bucket_method": bucket_method,
out: dict[str, Any] = {
"text_unit_count": text_unit_count,
"unique_attributed_snippets_count": len(all_unique_disp),
"sample_reviews_semantic_pool": semantic_pool,
"sample_reviews_positive_biased": _sample(pos_only_texts, max_samples_positive),
"sample_reviews_negative_biased": _sample(neg_only_texts, max_samples_negative),
"sample_reviews_mixed_tone": _sample(mixed_texts, max_samples_mixed),
"semantic_pool_note": (
"为去重后的评价原文抽样(可含细类/SKU/店铺前缀)。请据**整句语义**归纳正向体验与负向抱怨;"
"勿引用已废弃的预设子串词表、勿把星级分布等同于具体抱怨主题。"
),
}
if star_dist is not None:
out["star_rating_distribution"] = star_dist
return out
__all__ = [

View File

@ -1,60 +1,9 @@
"""``report_config`` JSON → 关注词、场景组、外部市场表行。"""
"""``report_config`` JSON → 外部市场表行(不再解析预设关注词/场景词组)"""
from __future__ import annotations
from typing import Any
from .constants import (
COMMENT_FOCUS_WORDS,
COMMENT_SCENARIO_GROUPS,
EXTERNAL_MARKET_TABLE_ROWS,
)
def _normalize_focus_words(raw: Any) -> tuple[str, ...]:
if not isinstance(raw, list) or not raw:
return COMMENT_FOCUS_WORDS
out: list[str] = []
for x in raw[:120]:
s = str(x).strip()
if len(s) > 48:
s = s[:48]
if s:
out.append(s)
return tuple(out) if out else COMMENT_FOCUS_WORDS
def _normalize_scenario_groups(
raw: Any,
) -> tuple[tuple[str, tuple[str, ...]], ...]:
if not isinstance(raw, list) or not raw:
return COMMENT_SCENARIO_GROUPS
parsed: list[tuple[str, tuple[str, ...]]] = []
for item in raw[:40]:
label = ""
triggers: list[str] = []
if isinstance(item, dict):
label = str(item.get("label") or "").strip()[:80]
tr = item.get("triggers")
if isinstance(tr, list):
for t in tr[:48]:
s = str(t).strip()
if len(s) > 48:
s = s[:48]
if s:
triggers.append(s)
elif isinstance(item, (list, tuple)) and len(item) >= 2:
label = str(item[0]).strip()[:80]
tr = item[1]
if isinstance(tr, (list, tuple)):
for t in tr[:48]:
s = str(t).strip()
if len(s) > 48:
s = s[:48]
if s:
triggers.append(s)
if label and triggers:
parsed.append((label, tuple(triggers)))
return tuple(parsed) if parsed else COMMENT_SCENARIO_GROUPS
from .constants import EXTERNAL_MARKET_TABLE_ROWS
def _normalize_external_market_rows(
@ -85,16 +34,11 @@ def _normalize_external_market_rows(
def resolve_report_tuning(
report_config: dict[str, Any] | None,
) -> tuple[
tuple[str, ...],
tuple[tuple[str, tuple[str, ...]], ...],
tuple[tuple[str, str, str, str], ...],
]:
) -> tuple[tuple[tuple[str, str, str, str], ...]]:
"""仅解析第三方市场摘录表;预设关注词/场景词组已废弃,不再参与报告或 brief。"""
if not report_config:
return COMMENT_FOCUS_WORDS, COMMENT_SCENARIO_GROUPS, EXTERNAL_MARKET_TABLE_ROWS
return (EXTERNAL_MARKET_TABLE_ROWS,)
return (
_normalize_focus_words(report_config.get("comment_focus_words")),
_normalize_scenario_groups(report_config.get("comment_scenario_groups")),
_normalize_external_market_rows(
report_config.get("external_market_table_rows")
),
@ -104,6 +48,4 @@ def resolve_report_tuning(
__all__ = [
"resolve_report_tuning",
"_normalize_external_market_rows",
"_normalize_focus_words",
"_normalize_scenario_groups",
]

View File

@ -71,50 +71,10 @@ _K_PROP_COL = JD_SEARCH_CSV_HEADERS["attributes"]
EXTERNAL_MARKET_TABLE_ROWS: tuple[tuple[str, str, str, str], ...] = ()
COMMENT_FOCUS_WORDS: tuple[str, ...] = (
"口感",
"",
"",
"血糖",
"控糖",
"低糖",
"无糖",
"饱腹",
"升糖",
"GI",
"gi",
"孕妇",
"老人",
"糖尿病",
"价格",
"",
"便宜",
"回购",
"包装",
"物流",
"分量",
"量少",
"克重",
)
COMMENT_SCENARIO_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("早餐/代餐", ("早餐", "代餐", "早饭", "当早餐", "当早饭", "早上吃", "晨起")),
("零食/加餐/解馋", ("零食", "加餐", "嘴馋", "小零食", "解馋", "垫肚子", "饿了", "肚子饿", "两餐之间", "间食")),
("控糖/血糖相关", ("控糖", "血糖高", "升糖", "糖友", "糖尿病", "孕期控糖", "妊娠糖", "血糖")),
("孕期/育儿", ("孕期", "孕妇", "怀孕", "产妇", "坐月子", "哺乳", "给宝宝", "给娃", "孩子吃", "小孩吃", "宝宝吃")),
("健身/减脂", ("减肥", "减脂", "瘦身", "健身", "卡路里", "热量低", "低脂")),
("长辈/家庭", ("老人", "爸妈", "父母", "长辈", "爷爷奶奶", "给家里")),
("办公/外出", ("办公室", "上班吃", "出门", "外出", "随身带", "包里", "便携")),
("送礼/囤货", ("送礼", "送人", "囤货", "年货")),
("夜宵/熬夜", ("夜宵", "熬夜", "晚上饿")),
)
__all__ = [
"_COMMENT_CSV_BODY",
"_COMMENT_CSV_SCORE",
"_COMMENT_CSV_SKU",
"COMMENT_FOCUS_WORDS",
"COMMENT_SCENARIO_GROUPS",
"EXTERNAL_MARKET_TABLE_ROWS",
"_COMMENT_FUZZ_KEYS",
"_COMMENT_SCORE_NEG_MAX",

View File

@ -1,12 +1,9 @@
"""按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价/场景)。"""
"""按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价摘录)。"""
from __future__ import annotations
from collections import Counter
from typing import Any
from pipeline.csv.schema import JD_SEARCH_CSV_HEADERS, MERGED_FIELD_TO_CSV_HEADER
from .comment_sentiment import _comment_keyword_hits
from .constants import (
_COMMENT_CSV_BODY,
_COMMENT_CSV_SKU,
@ -26,53 +23,6 @@ from .matrix_group import _competitor_matrix_group_key, _merged_rows_grouped_for
from .price_stats import _price_stats_extended
def _comment_scenario_counts(
texts: list[str],
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
) -> tuple[Counter[str], int]:
"""每组统计「至少命中一个触发词」的条数。返回 (各组条数, 有效文本条数)。"""
c: Counter[str] = Counter()
n = len(texts)
for blob in texts:
for label, triggers in scenario_groups:
if any(t in blob for t in triggers):
c[label] += 1
return c, n
def _text_hits_scenario_triggers(
text: str,
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
) -> bool:
blob = text or ""
for _lbl, triggers in scenario_groups:
if any(t in blob for t in triggers):
return True
return False
def _group_keyword_hits(
comment_rows_in_group: list[dict[str, str]],
texts_fallback: list[str],
*,
focus_words: tuple[str, ...],
) -> Counter[str]:
h = _comment_keyword_hits(comment_rows_in_group, focus_words)
if h:
return h
if not texts_fallback:
return Counter()
blob = "\n".join(texts_fallback)
c: Counter[str] = Counter()
for w in focus_words:
if len(w) < 2:
continue
n = blob.count(w)
if n:
c[w] += n
return c
def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
title = _md_cell(_cell(row, title_h), 100)
sp = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 120)
@ -278,119 +228,12 @@ def build_comment_groups_llm_payload(
return out
def build_scenario_groups_llm_payload(
*,
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
merged_rows: list[dict[str, str]],
sku_header: str,
title_h: str,
) -> dict[str, Any]:
"""供 ``generate_scenario_group_summaries_llm``;计数与 **§8.2** 关注词/场景路径下图右栏(场景)一致。"""
if not feedback_groups:
return {}
sku_meta: dict[str, tuple[str, str, str]] = {}
for row in merged_rows:
sku = _cell(row, sku_header).strip()
if not sku:
continue
gk = _competitor_matrix_group_key(row)
if not gk:
continue
sku_meta[sku] = (
gk,
_cell(row, title_h),
_cell(row, *_MERGED_SHOP_CELL_KEYS),
)
lexicon = [
{"label": lbl, "trigger_examples": list(trigs[:12])}
for lbl, trigs in scenario_groups
]
groups_out: list[dict[str, Any]] = []
for gname, cr, tu in feedback_groups:
if not tu and not cr:
continue
scen_g, scen_ng = _comment_scenario_counts(tu, scenario_groups)
dist: list[dict[str, Any]] = []
for lbl, n in scen_g.most_common():
if n <= 0:
continue
dist.append(
{
"scenario": lbl,
"mention_rows": int(n),
"share_of_effective_texts": round(
float(n) / float(scen_ng), 4
)
if scen_ng > 0
else 0.0,
}
)
snippets: list[str] = []
for row in cr:
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if not txt:
continue
if not _text_hits_scenario_triggers(txt, scenario_groups):
continue
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
meta = sku_meta.get(sku)
if meta:
sg, tit, shop = meta
prefix = (
f"【细类:{sg}\uff5cSKU{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
f"店铺:{_md_cell(shop, 28)}"
)
snippets.append(prefix + txt[:300])
else:
snippets.append(
f"【细类:{gname}\uff5cSKU{sku or ''}" + txt[:320]
)
if len(snippets) >= 16:
break
if len(snippets) < 5:
for row in cr:
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if not txt:
continue
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
meta = sku_meta.get(sku)
if meta:
sg, tit, shop = meta
prefix = (
f"【细类:{sg}\uff5cSKU{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
f"店铺:{_md_cell(shop, 28)}"
)
snippets.append(prefix + txt[:260])
else:
snippets.append(
f"【细类:{gname}\uff5cSKU{sku or ''}" + txt[:280]
)
if len(snippets) >= 10:
break
groups_out.append(
{
"group": gname,
"effective_text_count": int(scen_ng),
"scenario_distribution": dist[:18],
"sample_text_snippets": snippets,
}
)
if not groups_out:
return {}
return {"scenario_lexicon": lexicon, "groups": groups_out}
__all__ = [
"build_comment_groups_llm_payload",
"build_matrix_groups_llm_payload",
"build_price_groups_llm_payload",
"build_promo_groups_llm_payload",
"build_scenario_groups_llm_payload",
"_comment_scenario_counts",
"_group_keyword_hits",
"_listing_price_snippet_for_llm",
"_matrix_excerpt_line_for_llm",
"_promo_snippet_for_llm",
"_text_hits_scenario_triggers",
]

View File

@ -85,6 +85,75 @@ def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]:
}
def price_promotion_signals_strategy_brief_cn(p: Any) -> str:
"""
``_analyze_price_promotions`` 数字严格一致的中文摘要供策略 LLM **固定**读取
避免同输入下 §8.3 有价差统计未检测到促销之间随机摇摆
"""
if not isinstance(p, dict) or not p:
return (
"监测摘要未携带列表侧价差统计price_promotion_signals 为空)。"
"§8.3 勿编造「已监测到/未监测到」具体满减门槛或价差行数;可写结合商详与运营核对。"
)
n = int(p.get("row_count") or 0)
wb = int(p.get("rows_with_both_list_and_coupon") or 0)
cb = int(p.get("rows_coupon_below_list_price") or 0)
wj = int(p.get("rows_with_list_price") or 0)
wc = int(p.get("rows_with_coupon_price") or 0)
sh = p.get("share_coupon_below_list_when_both")
med = p.get("median_discount_pct_when_coupon_below")
mean = p.get("mean_discount_pct_when_coupon_below")
oa = int(p.get("rows_original_price_above_list_price") or 0)
parts: list[str] = [
"(以下为**监测边界**摘要:用于核对 §8.3 **不得否认**下列事实;**禁止**把本段行数、占比抄进 §8.3 当正文。"
"§8.3 正文须以**本品促销决策**为主——如拟采用的满减/满折档位、到手价呈现原则等。"
"竞品页面上已出现的「满××减××」等**原文**仅当报告节选或 brief 已收录时可作对标复述;"
"**本品主张**的具体门槛/折扣可写为「拟满…减…」「拟满…享…折」,无输入数字时须标注待运营按毛利与后台规则核定。)"
]
parts.append(
f"监测行数 **{n}**;其中解析到标价字段 **{wj}** 行、券后/到手价字段 **{wc}** 行。"
)
if wb > 0:
frag_sh = (
f"占可对齐行的 **{100.0 * float(sh):.1f}%**"
if isinstance(sh, (int, float))
else ""
).strip()
line = (
f"**同时有标价与券后/到手价且数值可对齐** 的行 **{wb}** 行;"
f"其中展示券后/到手**严格低于**标价的行 **{cb}** 行"
+ (f"{frag_sh}" if frag_sh else "")
+ ""
)
parts.append(line)
if isinstance(med, (int, float)) and cb > 0:
frag_mean = (
f",平均相对标价约 **{float(mean):.1f}%**"
if isinstance(mean, (int, float))
else ""
)
parts.append(
f"在「券后低于标价」子集中,展示价差的中位数约 **{float(med):.1f}%**(相对标价){frag_mean}"
"通常对应列表上满减、券、限时价等叠加呈现。"
)
elif cb > 0:
parts.append("存在券后低于标价的样本;条数较少故未给稳健分位数。")
else:
parts.append(
"**缺少**「标价与券后/到手价同时可解析且可对齐」的有效行,不宜写「全样本普遍存在到手价差」;"
"若报告第六章或节选有促销形态归纳§8.3 应与之对齐。"
)
if oa > 0:
parts.append(
f"另有约 **{oa}** 行呈现「划线原价高于当前标价」类陈列(页面展示口径)。"
)
parts.append(
"(再次提醒:上列数字**不得**作为 §8.3 主体段落§8.3 请写清**我方**在促销上的决定或拟定方案。)"
)
return "\n".join(parts)
def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]:
"""第六章第一节优惠活动与价差信号Markdown 行列表)。"""
lines: list[str] = [
@ -144,4 +213,5 @@ def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]:
__all__ = [
"_analyze_price_promotions",
"_markdown_price_promotion_section",
"price_promotion_signals_strategy_brief_cn",
]

View File

@ -1,4 +1,7 @@
"""报告 Markdown 片段Mermaid、场景摘要、规则策略提示、插图路径与解读段落。"""
"""报告 Markdown 片段:规则策略提示、插图路径、矩阵图文件名等。
含若干**已废弃口径** Mermaid/场景摘要辅助函数关注词子串预设场景标签当前主报告链路**不再调用**保留仅为历史图表命名一致或后续清理
"""
from __future__ import annotations
import re
@ -54,12 +57,9 @@ def _strategy_hints(
*,
cr1: float | None,
pst: dict[str, Any],
hits: Counter[str],
n_comments: int,
scen_counts: Counter[str],
scen_n_texts: int,
) -> list[str]:
"""基于规则的「提示性」结论,均标注待验证"""
"""基于规则的「提示性」结论,均标注待验证(不含预设关注词/场景子串统计)。"""
hints: list[str] = []
if cr1 is not None and cr1 >= 0.45:
hints.append(
@ -75,23 +75,10 @@ def _strategy_hints(
hints.append(
"价格离散度较高,同时存在偏低价与偏高价陈列,可分别对标「性价比带」与「品质/功能带」竞品(**终端到手价受促销影响,非成本结构**)。"
)
if hits:
top = hits.most_common(3)
top_s = "".join(w for w, _ in top)
hints.append(
f"评价文本中「{top_s}」等主题出现较多,可作为消费者沟通与产品卖点的假设输入(**非严格主题模型,建议人工抽样复核**)。"
)
if n_comments < 5:
hints.append(
"有效评价样本偏少,消费者洞察部分仅作方向参考,正式结论建议加大 SKU 数或评论分页。"
)
if scen_n_texts >= 5 and scen_counts:
top_lbl, top_n = scen_counts.most_common(1)[0]
share = top_n / scen_n_texts
if share >= 0.25:
hints.append(
f"用途/场景中「{top_lbl}」在约 {100 * share:.0f}% 的有效评价自述中出现,可作为沟通场景与卖点的优先假设(**词组规则,建议抽样核对原句**)。"
)
if not hints:
hints.append(
"当前样本下自动规则未触发强信号;请结合业务目标人工解读对比矩阵与原始 CSV。"

View File

@ -17,6 +17,8 @@
输出默认写入 ``<run_dir>/chapter8_text_mining_probe.md``
环境变量 ``MA_PROBE_LLM_DIAG=1`` stderr 打印每个 LLM 分块的 JSON 大小与耗时定位哪一类超时
嵌入竞品报告流水线默认开启``get_default_report_config`` ``chapter8_text_mining_probe``: true若任务显式关闭则为 false开启时会生成本稿并调用 ``markdown_embed_body_for_competitor_report`` 写入 ``competitor_analysis.md`` **第八章第二节评论文本补充分析**替代原关注词 + 场景条图及对应两段大模型**不再**嵌入原评价正负面粗判预设口语短语扇形图/条形图及同口径大模型块
"""
from __future__ import annotations
@ -434,14 +436,29 @@ def _merge_snippets_from_comment_groups(
row["sample_text_snippets"] = [str(x)[:220] for x in sn[:8]]
def _probe_llm_diag_enabled() -> bool:
return os.environ.get("MA_PROBE_LLM_DIAG", "").strip().lower() in (
"1",
"true",
"yes",
)
def _run_probe_text_mining_llm(
payload: dict[str, Any],
*,
chunked: bool,
) -> str:
"""补充分析专用:``PROBE_TEXT_MINING_SYSTEM`` + 结构化 JSON可选按细类拆分调用。"""
"""补充分析专用:``PROBE_TEXT_MINING_SYSTEM`` + 结构化 JSON可选按细类拆分调用。
- **分块模式**每个 ``probe_status == ok`` 的细类**单独**请求某一类失败时**保留**已成功类的正文该类下追加失败说明不再整段被外层 ``except`` 吃掉
- 设置环境变量 ``MA_PROBE_LLM_DIAG=1`` 时向 **stderr** 打印每类 JSON 字符数与耗时便于定位超时发生在哪一类
"""
if not payload.get("groups"):
return "> **补充分析 LLM 解读**:无分组数据,跳过。"
import time as _time
diag = _probe_llm_diag_enabled()
try:
if not chunked:
p = _truncate_probe_payload(payload)
@ -451,10 +468,35 @@ def _run_probe_text_mining_llm(
raw[:82_000]
+ "\n\nJSON 过长已截断,仅依据可见字段撰写。)\n"
)
return _call_llm(
PROBE_TEXT_MINING_SYSTEM,
PROBE_TEXT_MINING_USER_PREFIX + raw,
).strip()
if diag:
print(
f"[probe-llm] mode=single json_chars={len(raw)}",
file=sys.stderr,
flush=True,
)
t0 = _time.perf_counter()
try:
out = _call_llm(
PROBE_TEXT_MINING_SYSTEM,
PROBE_TEXT_MINING_USER_PREFIX + raw,
).strip()
except Exception as e:
if diag:
print(
f"[probe-llm] mode=single FAIL after "
f"{_time.perf_counter() - t0:.1f}s: {e}",
file=sys.stderr,
flush=True,
)
return f"> **补充分析 LLM 解读**调用失败:{e}"
if diag:
print(
f"[probe-llm] mode=single OK "
f"{_time.perf_counter() - t0:.1f}s out_chars={len(out)}",
file=sys.stderr,
flush=True,
)
return out
kw = str(payload.get("keyword") or "")
note = str(payload.get("probe_note") or "")
parts: list[str] = []
@ -477,12 +519,38 @@ def _run_probe_text_mining_llm(
raw = json.dumps(mini, ensure_ascii=False)
if len(raw) > 48_000:
raw = raw[:44_000] + "\n\n"
parts.append(
_call_llm(
if diag:
print(
f"[probe-llm] mode=chunked group={gname!r} json_chars={len(raw)}",
file=sys.stderr,
flush=True,
)
t0 = _time.perf_counter()
try:
chunk_out = _call_llm(
PROBE_TEXT_MINING_SYSTEM,
PROBE_TEXT_MINING_USER_PREFIX + raw,
).strip()
)
parts.append(chunk_out)
if diag:
print(
f"[probe-llm] group={gname!r} OK "
f"{_time.perf_counter() - t0:.1f}s out_chars={len(chunk_out)}",
file=sys.stderr,
flush=True,
)
except Exception as e:
if diag:
print(
f"[probe-llm] group={gname!r} FAIL "
f"{_time.perf_counter() - t0:.1f}s: {e}",
file=sys.stderr,
flush=True,
)
parts.append(
f"#### {gname}\n\n"
f"> **本细类补充分析 LLM 调用失败**{e}"
)
return "\n\n---\n\n".join(parts)
except Exception as e:
return f"> **补充分析 LLM 解读**调用失败:{e}"

View File

@ -34,28 +34,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
def _strategy_decisions_empty() -> dict[str, Any]:
return {
"product_role": "",
"stage_goal_type": "",
"time_horizon": "",
"success_criteria": "",
"non_goals": "",
"battlefield_one_line": "",
"positioning_choice": "",
"competitive_stance": "",
"pillar_product": "",
"pillar_price": "",
"pillar_channel": "",
"pillar_comm": "",
"audience_segment": "",
"competitor_reference": "",
"resource_notes": "",
"marketing_strategy": "",
"general_strategy": "",
"ack_risk_keywords": False,
"ack_risk_price": False,
"ack_risk_concentration": False,
}
from pipeline.strategy_decision_keys import empty_strategy_decisions
return empty_strategy_decisions()
def _merge_decisions(base: dict[str, Any], overlay: dict[str, Any] | None) -> dict[str, Any]:

View File

@ -0,0 +1,348 @@
"""
复现第九章 · 策略与机会一次 LLM 调用的输入不请求网关
从指定 ``run_dir`` 读取 ``effective_report_config.json````run_meta.json``
``competitor_analysis.md``切片第五六章大模型归纳``chapter8_text_mining_probe.md``
runner 同源 ``markdown_embed_body_for_competitor_report``再按
``generate_strategy_opportunities_llm`` 的截断阶梯求首个可通过
``_strategy_prompt_ok_for_call`` 的档位
可将**网关实际收到的** ``system`` ``user``user = 前缀 + 单行 JSON写入 Markdown
用法 backend 目录::
python -m pipeline.demos.dump_strategy_opportunities_llm_volume --run-dir \".../某批次\"
# 指定输出路径
python -m pipeline.demos.dump_strategy_opportunities_llm_volume --run-dir \"...\" -o path/to/snap.md
# 只打印体积、不写文件
python -m pipeline.demos.dump_strategy_opportunities_llm_volume --run-dir \"...\" --no-md
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "market_assistant.settings")
def _slice_between(md: str, start: str, end: str) -> str:
i = md.find(start)
if i < 0:
return ""
j = md.find(end, i + len(start))
if j < 0:
return md[i:].strip()
return md[i:j].strip()
def _resolve_first_ok_payload(
*,
brief: dict[str, Any],
kw: str,
narr_in: dict[str, str],
STRATEGY_OPPORTUNITIES_SYSTEM: str,
STRATEGY_OPPORTUNITIES_USER_PREFIX: str,
compact_brief_for_llm: Any,
_truncate_strategy_narrative: Any,
_strategy_prompt_ok_for_call: Any,
_min_strategy_completion_tokens: Any,
) -> tuple[
dict[str, Any],
str,
str,
int,
int,
dict[str, int],
bool,
]:
"""
返回: payload, user, tier_note, cap_brief, cap_narr, narrative_lens, used_narratives
"""
min_comp = _min_strategy_completion_tokens()
min_relaxed = max(256, min_comp // 2)
tiers = (
(48_000, 2_800),
(42_000, 2_200),
(36_000, 1_700),
(30_000, 1_300),
(26_000, 950),
(22_000, 700),
(18_000, 500),
(16_000, 400),
(14_000, 320),
(12_000, 260),
(10_000, 200),
)
for cap_brief, cap_narr in tiers:
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
narratives = {
k: _truncate_strategy_narrative(v, cap_narr) for k, v in narr_in.items()
}
payload: dict[str, Any] = {"keyword": kw, "competitor_brief": compact}
if narratives:
payload["prior_chapter_llm_narratives"] = narratives
user = STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps(
payload, ensure_ascii=False
)
if _strategy_prompt_ok_for_call(
STRATEGY_OPPORTUNITIES_SYSTEM, user, min_completion_tokens=min_comp
):
lens = {k: len(v) for k, v in narratives.items()}
note = (
f"与 ``generate_strategy_opportunities_llm`` 一致的首档:"
f"`compact_brief` max_chars={cap_brief}"
f"`prior_chapter_llm_narratives` 每键截断上限 {cap_narr} 字。"
)
return payload, user, note, cap_brief, cap_narr, lens, True
for cap_brief in (40_000, 32_000, 26_000, 20_000, 16_000, 14_000, 12_000, 10_000):
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
payload = {"keyword": kw, "competitor_brief": compact}
user = STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps(
payload, ensure_ascii=False
)
if _strategy_prompt_ok_for_call(
STRATEGY_OPPORTUNITIES_SYSTEM, user, min_completion_tokens=min_comp
):
note = (
"叙事整体过长,已退化为**仅** ``keyword`` + ``competitor_brief``(无 "
"``prior_chapter_llm_narratives``"
f"`max_chars={cap_brief}`。"
)
return payload, user, note, cap_brief, 0, {}, False
for cap_brief in (14_000, 12_000, 10_000, 8_000):
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
payload = {"keyword": kw, "competitor_brief": compact}
user = STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps(
payload, ensure_ascii=False
)
if _strategy_prompt_ok_for_call(
STRATEGY_OPPORTUNITIES_SYSTEM, user, min_completion_tokens=min_relaxed
):
note = (
"叙事与长 brief 均超出预算,已使用 **relaxed** completion 阈值下的仅 brief 档,"
f"`max_chars={cap_brief}`。"
)
return payload, user, note, cap_brief, 0, {}, False
cap_brief = 8_000
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
payload = {"keyword": kw, "competitor_brief": compact}
user = STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps(payload, ensure_ascii=False)
note = (
"所有 ``_strategy_prompt_ok_for_call`` 档位均未通过,与生产代码一致时将仍组装该 user "
"并调用 ``call_llm``(可能由网关或客户端再报错)。"
f"`max_chars={cap_brief}`,无叙事。"
)
return payload, user, note, cap_brief, 0, {}, False
def main() -> int:
import django
django.setup()
from pipeline.demos.chapter8_text_mining_probe import (
markdown_embed_body_for_competitor_report,
)
from pipeline.jd.runner import build_competitor_brief_for_job
from pipeline.llm.generate_strategy import (
STRATEGY_OPPORTUNITIES_SYSTEM,
STRATEGY_OPPORTUNITIES_USER_PREFIX,
_min_strategy_completion_tokens,
_strategy_prompt_ok_for_call,
_truncate_strategy_narrative,
)
from pipeline.llm.llm_client import estimate_chat_input_tokens
from pipeline.reporting.brief_compact import compact_brief_for_llm
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--run-dir",
type=Path,
required=True,
help="pipeline 运行目录(含 competitor_analysis.md 等)",
)
ap.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="输出 Markdown 路径默认run_dir/strategy_opportunities_llm_input_snapshot.md",
)
ap.add_argument(
"--no-md",
action="store_true",
help="不写入 Markdown仅打印控制台摘要",
)
args = ap.parse_args()
run_dir = args.run_dir.expanduser().resolve()
if not run_dir.is_dir():
print(f"run_dir 不存在: {run_dir}", file=sys.stderr)
return 1
rc_path = run_dir / "effective_report_config.json"
meta_path = run_dir / "run_meta.json"
if not rc_path.is_file() or not meta_path.is_file():
print("缺少 effective_report_config.json 或 run_meta.json", file=sys.stderr)
return 1
rc = json.loads(rc_path.read_text(encoding="utf-8"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
kw = (meta.get("keyword") or "").strip()
if not kw:
print("run_meta 无 keyword", file=sys.stderr)
return 1
brief = build_competitor_brief_for_job(str(run_dir), kw, report_config=rc)
md_path = run_dir / "competitor_analysis.md"
if not md_path.is_file():
print(f"缺少 {md_path.name}", file=sys.stderr)
return 1
md = md_path.read_text(encoding="utf-8")
matrix = _slice_between(
md,
"#### 细类要点归纳(大模型",
"\n---\n\n## 六、价格分析",
)
price = _slice_between(
md,
"#### 细类价盘要点归纳(大模型",
"\n\n#### 细类促销与活动要点归纳(大模型",
)
promo = _slice_between(
md,
"#### 细类促销与活动要点归纳(大模型",
"\n---\n\n## 八、消费者反馈与用户画像",
)
probe_path = run_dir / "chapter8_text_mining_probe.md"
ch8_embed = ""
if probe_path.is_file():
ch8_embed = markdown_embed_body_for_competitor_report(
probe_path.read_text(encoding="utf-8")
)
narr_in: dict[str, str] = {}
if matrix.strip():
narr_in["sec5_matrix_group_summaries"] = matrix.strip()
if price.strip():
narr_in["sec6_price_group_summaries"] = price.strip()
if promo.strip():
narr_in["sec6_promo_group_summaries"] = promo.strip()
if ch8_embed.strip():
narr_in["sec8_3_text_mining_probe"] = ch8_embed.strip()
payload, user, tier_note, cap_brief, cap_narr, narrative_lens, used_narr = (
_resolve_first_ok_payload(
brief=brief,
kw=kw,
narr_in=narr_in,
STRATEGY_OPPORTUNITIES_SYSTEM=STRATEGY_OPPORTUNITIES_SYSTEM,
STRATEGY_OPPORTUNITIES_USER_PREFIX=STRATEGY_OPPORTUNITIES_USER_PREFIX,
compact_brief_for_llm=compact_brief_for_llm,
_truncate_strategy_narrative=_truncate_strategy_narrative,
_strategy_prompt_ok_for_call=_strategy_prompt_ok_for_call,
_min_strategy_completion_tokens=_min_strategy_completion_tokens,
)
)
sys_prompt = STRATEGY_OPPORTUNITIES_SYSTEM
sys_len = len(sys_prompt)
user_len = len(user)
est_in = estimate_chat_input_tokens(sys_prompt, user)
min_comp = _min_strategy_completion_tokens()
print("run_dir:", run_dir)
print("keyword:", kw)
print("narrative keys (source):", sorted(narr_in.keys()))
for k, v in narr_in.items():
print(f" raw {k}: {len(v)} chars")
print("STRATEGY_OPPORTUNITIES_SYSTEM chars:", sys_len)
print("user chars:", user_len)
print("system + user chars:", sys_len + user_len)
print("estimate_chat_input_tokens (internal heuristic):", est_in)
print("tier:", tier_note)
if narrative_lens:
print("narrative lens (after truncate):", narrative_lens)
print("used prior_chapter_llm_narratives:", used_narr)
if args.no_md:
return 0
out_path = args.output
if out_path is None:
out_path = run_dir / "strategy_opportunities_llm_input_snapshot.md"
else:
out_path = out_path.expanduser().resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
compact = payload.get("competitor_brief")
compact_json_len = len(json.dumps(compact, ensure_ascii=False)) if compact else 0
lines: list[str] = [
"# 第九章 · 策略与机会 · 大模型真实入参快照",
"",
"> **说明**:与一次 ``call_llm(STRATEGY_OPPORTUNITIES_SYSTEM, user)`` 一致。"
"``user`` = ``STRATEGY_OPPORTUNITIES_USER_PREFIX`` + **单行** ``json.dumps(payload)``(与生产相同,非排版版)。",
"",
"## 元数据",
"",
f"- **run_dir**`{run_dir}`",
f"- **keyword**{kw}",
f"- **effective_report_config.llm_strategy_opportunities**{rc.get('llm_strategy_opportunities')!r}(本快照仍按若开启第九章 LLM 时的输入还原)",
f"- **MA_STRATEGY_MIN_COMPLETION_TOKENS**{min_comp}",
f"- **选用档位说明**{tier_note}",
f"- **叙事是否进入 payload**{'' if used_narr else ''}",
f"- **System 字符数**{sys_len}",
f"- **User 字符数**{user_len}",
f"- **合计字符数**{sys_len + user_len}",
f"- **estimate_chat_input_tokens项目内启发式**{est_in}",
f"- **competitor_brief 序列化长度**{compact_json_len}",
"",
"---",
"",
"## 1. System 消息(完整,角色 system",
"",
"```text",
sys_prompt,
"```",
"",
"---",
"",
"## 2. User 消息(完整,角色 user",
"",
"以下为网关收到的 **整段** user 字符串(前缀 + 单行 JSON",
"",
"```text",
user,
"```",
"",
"---",
"",
"## 3. 同上 JSON 的排版版(便于阅读;以第 2 节为准)",
"",
"```json",
json.dumps(payload, ensure_ascii=False, indent=2),
"```",
"",
]
out_path.write_text("\n".join(lines), encoding="utf-8")
kb = out_path.stat().st_size // 1024
print(f"Wrote {out_path} ({kb} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,23 @@
{
"product_role": "新品定位中端健康低GI饼干主打代餐与控糖场景",
"stage_goal_type": "冷启动期以占品类心智与新客获取为主12周内跑通「搜得到、点得进、买得起」闭环",
"time_horizon": "未来12周",
"success_criteria": "新客占比≥55%商详首屏核心卖点一致率100%抽检搜索词「低GI饼干」下本品商详曝光位次进入前15首购转化率≥12%(待与运营核对后台口径)",
"non_goals": "本阶段不以全店冲量、不以多品类同时打爆为KPI不做无依据的头部品牌指名攻击表述",
"battlefield_one_line": "在京东「低GI/粗粮饼干」需求下,与列表内同价带选手抢「控糖+饱腹+可感知健康标签」的点击与首购",
"positioning_choice": "mid",
"competitive_stance": "flank",
"pillar_product": "明确「0添加蔗糖/高膳食纤维/低GI代餐」标签体系与规格带如独立小包装与家庭装分区",
"pillar_price": "卡位监测中位价带,用「到手价」与满减档与列表侧对齐,避免仅标价不可比",
"pillar_channel": "搜索与推荐为主,商详与主图统一健康叙事;必要时配合站内活动位测试",
"pillar_comm": "健康科普+场景(早餐/加餐)+对比「普通甜饼干」的获得感,不夸大医疗功效",
"tactic_promotion": "本阶段拟设:满 99 减 10、满 2 件 9 折,新客首单再减 3 元;主图/商详写清叠券后到手价与活动规则,不与监测「有价差但无据」的竞品具体数字相绑。",
"audience_segment": "关注血糖管理、减脂、健康早餐的2045岁城市用户决策路径多为搜索→列表比价→商详看配料与评价",
"competitor_reference": "以监测列表内高曝光单品与同价带品牌为动态对标,不锁死单一名称;若 brief 中已出现具体店铺/品牌则仅可复述已给事实",
"resource_notes": "需运营定稿满减/券档位与主图/首屏客服话术与详情页第2屏成分表为必审项无新增监测数据时不扩写新卖点",
"marketing_strategy": "先统一商详与主图信息架构,再小流量测图测标题;大促前锁定到手价表达与活动规则",
"general_strategy": "以冷启动与转化为先,价带不冒进;差异化落在可验证配料与健康承诺上,避免与报告数据冲突",
"ack_risk_keywords": true,
"ack_risk_price": true,
"ack_risk_concentration": true
}

View File

@ -0,0 +1,27 @@
{
"generator": "llm",
"business_notes": "(与 fixture 联调:业务备注;可与「仅 strategy_decisions」文件对照。",
"strategy_matrix_group": "",
"strategy_matrix_group_index": null,
"product_role": "新品定位中端健康低GI饼干主打代餐与控糖场景",
"stage_goal_type": "冷启动期以占品类心智与新客获取为主12周内跑通「搜得到、点得进、买得起」闭环",
"time_horizon": "未来12周",
"success_criteria": "新客占比≥55%商详首屏核心卖点一致率100%抽检搜索词「低GI饼干」下本品商详曝光位次进入前15首购转化率≥12%(待与运营核对后台口径)",
"non_goals": "本阶段不以全店冲量、不以多品类同时打爆为KPI不做无依据的头部品牌指名攻击表述",
"battlefield_one_line": "在京东「低GI/粗粮饼干」需求下,与列表内同价带选手抢「控糖+饱腹+可感知健康标签」的点击与首购",
"positioning_choice": "mid",
"competitive_stance": "flank",
"pillar_product": "明确「0添加蔗糖/高膳食纤维/低GI代餐」标签体系与规格带如独立小包装与家庭装分区",
"pillar_price": "卡位监测中位价带,用「到手价」与满减档与列表侧对齐,避免仅标价不可比",
"pillar_channel": "搜索与推荐为主,商详与主图统一健康叙事;必要时配合站内活动位测试",
"pillar_comm": "健康科普+场景(早餐/加餐)+对比「普通甜饼干」的获得感,不夸大医疗功效",
"tactic_promotion": "本阶段拟设:满 99 减 10、满 2 件 9 折,新客首单再减 3 元;主图/商详写清叠券后到手价与活动规则,不与监测「有价差但无据」的竞品具体数字相绑。",
"audience_segment": "关注血糖管理、减脂、健康早餐的2045岁城市用户决策路径多为搜索→列表比价→商详看配料与评价",
"competitor_reference": "以监测列表内高曝光单品与同价带品牌为动态对标,不锁死单一名称;若 brief 中已出现具体店铺/品牌则仅可复述已给事实",
"resource_notes": "需运营定稿满减/券档位与主图/首屏客服话术与详情页第2屏成分表为必审项无新增监测数据时不扩写新卖点",
"marketing_strategy": "先统一商详与主图信息架构,再小流量测图测标题;大促前锁定到手价表达与活动规则",
"general_strategy": "以冷启动与转化为先,价带不冒进;差异化落在可验证配料与健康承诺上,避免与报告数据冲突",
"ack_risk_keywords": true,
"ack_risk_price": true,
"ack_risk_concentration": true
}

View File

@ -0,0 +1,352 @@
"""
同一组入参连续调用独立策略稿LLM 若干次比较全文是否漂移
用法 backend 目录::
python -m pipeline.demos.probe_strategy_llm_stability --run-dir \"D:/.../pipeline_runs/某批次\" --rounds 4
# 仅**探针/复现时**:用 ``--stability-preset`` 在 **strategy_decisions + business_notes**
# 上收窄「阶段目标/促销」方向,**不**改动产线 `STRATEGY_SYSTEM` 等提示词工程。
python -m pipeline.demos.probe_strategy_llm_stability --run-dir \"...\" --stability-preset cold_start_promo
# 每轮完整稿落盘UTF-8便于搜 §8.3、阶段目标等
python -m pipeline.demos.probe_strategy_llm_stability --run-dir \"...\" --write-md-dir ./probe_rounds
默认 temperature ``AI_crawler.chat_completion_text``当前默认 0.2未显式设 seed
不同轮次出文可略有差异属预期
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
# 探针专用:经表单同源字段 `strategy_decisions` / `business_notes` 传入,不改编产线系统提示词。
_STABILITY_PRESET_COLD_START_GOAL = "冷启动期,以占品类心智与新客获取为主"
_STABILITY_PRESET_COLD_START_NOTES = (
"【稳定性探针·测试约定】本批为多次调用对比,请在成稿中:"
"①「本阶段策略目标类型」、§1.3 与 §六 须与上表**本阶段策略目标类型**一致,侧重冷启动、占品类心智与拉新,"
"避免在缺乏说明时以纯冲量/冲榜/搜索位次为**唯一**主轴;"
"② §8.3 须给出可照后台配置的**拟定**满减/满折档(可写满[门槛]减[面额]、满[门槛]享[折]数,"
"并标「待运营按毛利与后台活动核定」),勿仅用「加强大促/做活动」句带过而无档位骨架。"
)
def _empty_strategy_decisions() -> dict[str, Any]:
return {
"product_role": "",
"stage_goal_type": "",
"time_horizon": "",
"success_criteria": "",
"non_goals": "",
"battlefield_one_line": "",
"positioning_choice": "",
"competitive_stance": "",
"pillar_product": "",
"pillar_price": "",
"pillar_channel": "",
"pillar_comm": "",
"audience_segment": "",
"competitor_reference": "",
"resource_notes": "",
"marketing_strategy": "",
"general_strategy": "",
"ack_risk_keywords": False,
"ack_risk_price": False,
"ack_risk_concentration": False,
}
def _merge_decisions(
base: dict[str, Any], overlay: dict[str, Any] | None
) -> dict[str, Any]:
out = dict(base)
if isinstance(overlay, dict):
out.update(overlay)
return out
def _apply_stability_preset(
preset: str,
strategy_decisions: dict[str, Any],
business_notes: str,
) -> tuple[dict[str, Any], str]:
"""
**探针**里收窄输入方向不修改 `generate_strategy` system prompt
合并顺序先由调用方组好 ``strategy_decisions``可含 --decisions-json
再于本处**覆盖** preset 所涉字段以免与产线未填表则模型自推的基线混测
"""
sd = dict(strategy_decisions)
notes = (business_notes or "").strip()
if preset == "none" or not preset:
return sd, notes
if preset == "cold_start_promo":
sd["stage_goal_type"] = _STABILITY_PRESET_COLD_START_GOAL
extra = _STABILITY_PRESET_COLD_START_NOTES
if notes:
return sd, f"{notes}\n\n{extra}"
return sd, extra
raise ValueError(f"unknown stability preset: {preset!r}")
def main() -> int:
import django
django.setup()
from django.utils import timezone
from pipeline.jd.runner import build_competitor_brief_for_job
from pipeline.llm.generate_strategy import generate_strategy_draft_markdown_llm
from pipeline.models import JobStatus, PipelineJob
from pipeline.reporting.brief_strategy_scope import (
filter_brief_for_strategy_matrix_group,
)
from pipeline.reporting.report_matrix_group_evidence import (
load_report_matrix_group_evidence_markdown,
)
from pipeline.reporting.report_strategy_excerpt import load_report_strategy_excerpt
p = argparse.ArgumentParser(description=__doc__)
src = p.add_mutually_exclusive_group()
src.add_argument("--job-id", type=int, default=None, help="PipelineJob 主键")
src.add_argument(
"--run-dir",
type=Path,
default=None,
help="运行目录;与 --job-id 二选一",
)
p.add_argument("--rounds", type=int, default=4, help="连续调用次数(默认 4")
p.add_argument(
"--matrix-index",
type=int,
default=0,
help="矩阵分组下标;-1 表示不收窄",
)
p.add_argument(
"--no-scope",
action="store_true",
help="与 --matrix-index -1 相同",
)
p.add_argument(
"--decisions-json",
type=Path,
default=None,
help="覆盖 strategy_decisions 的 JSON 文件",
)
p.add_argument(
"--business-notes",
type=str,
default="",
)
p.add_argument(
"--snapshot-job-id",
type=int,
default=None,
help="payload.job_id仅 --run-dir 时,默认 0",
)
p.add_argument(
"--head-chars",
type=int,
default=800,
help="每轮打印文首字符数,便于肉眼看方向是否一致",
)
p.add_argument(
"--stability-preset",
type=str,
default="none",
choices=("none", "cold_start_promo"),
help=(
"探针专用none=与线上同(空表则由模型自推)。"
"cold_start_promo=写入 stage_goal 冷启动/占心智/新客,并在 business_notes 中约定"
"§8.3 满减/满折拟定档;**不**改产线 system prompt"
),
)
p.add_argument(
"--write-md-dir",
type=Path,
default=None,
help=(
"若指定,则每轮将**完整**策略稿 Markdown 写入该目录,文件名为 round_01.md、round_02.md …"
"UTF-8 无 BOM目录不存在会创建"
),
)
args = p.parse_args()
if args.rounds < 1:
print("rounds 须 >= 1", file=sys.stderr)
return 1
run_dir_s: str
kw: str
rc: dict[str, Any] | None
job_id: int
if args.run_dir is not None:
run_dir_p = args.run_dir.expanduser().resolve()
if not run_dir_p.is_dir():
print(f"run_dir 不存在: {run_dir_p}", file=sys.stderr)
return 1
rc_path = run_dir_p / "effective_report_config.json"
meta_path = run_dir_p / "run_meta.json"
if not rc_path.is_file() or not meta_path.is_file():
print("缺少 effective_report_config.json 或 run_meta.json", file=sys.stderr)
return 1
rc = json.loads(rc_path.read_text(encoding="utf-8"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
kw = (meta.get("keyword") or "").strip()
if not kw:
print("run_meta 无 keyword", file=sys.stderr)
return 1
run_dir_s = str(run_dir_p)
job_id = int(args.snapshot_job_id) if args.snapshot_job_id is not None else 0
else:
jid = args.job_id
if jid:
job = PipelineJob.objects.filter(pk=jid).first()
else:
job = (
PipelineJob.objects.filter(status=JobStatus.SUCCESS)
.exclude(run_dir="")
.order_by("-id")
.first()
)
if not job:
print("无可用任务:请指定 --job-id 或 --run-dir", file=sys.stderr)
return 1
run_dir_s = job.run_dir
kw = job.keyword
rc = job.report_config if isinstance(job.report_config, dict) else None
job_id = job.id
brief = build_competitor_brief_for_job(
run_dir_s,
kw,
report_config=rc,
)
matrix_index: int | None = args.matrix_index
if args.no_scope:
matrix_index = -1
scoped_label = ""
if matrix_index is not None and matrix_index >= 0:
mg = brief.get("matrix_by_group")
if isinstance(mg, list) and matrix_index < len(mg):
scoped_label = (mg[matrix_index].get("group") or "").strip()
brief = filter_brief_for_strategy_matrix_group(
brief, matrix_group_index=matrix_index
)
else:
print(f"matrix_index {matrix_index} 超出范围", file=sys.stderr)
return 1
sd = _empty_strategy_decisions()
if args.decisions_json is not None:
dp = args.decisions_json.expanduser().resolve()
if not dp.is_file():
print(f"decisions-json 不存在: {dp}", file=sys.stderr)
return 1
loaded = json.loads(dp.read_text(encoding="utf-8"))
if not isinstance(loaded, dict):
print("decisions-json 根须为 JSON 对象", file=sys.stderr)
return 1
sd = _merge_decisions(sd, loaded)
try:
sd, business_notes_effective = _apply_stability_preset(
args.stability_preset, sd, (args.business_notes or "").strip()
)
except ValueError as e:
print(str(e), file=sys.stderr)
return 1
gen_at = timezone.now().isoformat()
report_excerpt, ex_src = load_report_strategy_excerpt(run_dir_s)
report_excerpt = (report_excerpt or "").strip()
evidence_md = ""
evidence_src = "none"
if scoped_label:
evidence_md, evidence_src = load_report_matrix_group_evidence_markdown(
run_dir_s,
scoped_label,
)
print("run_dir:", run_dir_s)
print("job_id (payload):", job_id)
print("keyword:", kw)
print("matrix scope:", scoped_label or "(未收窄)")
print("rounds:", args.rounds)
print("stability_preset:", args.stability_preset)
if args.stability_preset != "none":
print("stage_goal_type (preset):", sd.get("stage_goal_type", ""))
print("report_strategy_excerpt:", ex_src, "chars", len(report_excerpt))
print("report_matrix_group_evidence:", evidence_src, "chars", len((evidence_md or "").strip()))
print("---")
print(
"说明:与线上一致走 generate_strategy_draft_markdown_llm"
"温度见 AI_crawler.chat_completion_text 默认;未设 seed 时多次调用可不同。"
)
print("---")
write_dir: Path | None = None
if args.write_md_dir is not None:
write_dir = args.write_md_dir.expanduser().resolve()
write_dir.mkdir(parents=True, exist_ok=True)
print("write_md_dir:", write_dir)
print("---")
hashes: list[str] = []
for i in range(1, args.rounds + 1):
md = generate_strategy_draft_markdown_llm(
job_id=job_id,
keyword=kw,
brief=brief,
business_notes=business_notes_effective,
generated_at_iso=gen_at,
strategy_decisions=sd,
report_strategy_excerpt=report_excerpt or None,
report_matrix_group_evidence_md=(evidence_md or "").strip() or None,
report_config=rc,
)
digest = hashlib.sha256(md.encode("utf-8")).hexdigest()
hashes.append(digest)
if write_dir is not None:
out_path = write_dir / f"round_{i:02d}.md"
out_path.write_text(
(md or "").replace("\r\n", "\n"),
encoding="utf-8",
newline="\n",
)
head = (md[: args.head_chars]).replace("\r\n", "\n")
print(f"=== 第 {i} 次 full_sha256={digest} 总长={len(md)} ===")
if write_dir is not None:
print(f" -> 已写: {write_dir / f'round_{i:02d}.md'}")
print(head)
if len(md) > args.head_chars:
print("...")
print()
u = len(set(hashes))
n = len(hashes)
if u == 1:
tag = f"{n} 轮全文完全一致"
elif u == n:
tag = "各轮全文均不同"
else:
tag = "部分轮次撞全文、部分不同"
print("SUMMARY:", f"不同全文数 = {u} / {n}", f"-> {tag}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,88 @@
"""
全量strategy_decisions fixture 走一遍规则底稿验证字段贯通不请求大模型
用法在仓库 `backend` 目录下::
python -m pipeline.demos.run_strategy_decisions_full_fixture_demo
- ``fixtures/strategy_decisions_full_lowgi_biscuit.json`` 21 ``strategy_decisions`` ``--decisions-json`` 合并
- ``fixtures/strategy_draft_request_full_lowgi_biscuit.json`` ``POST /api/jobs//strategy-draft/`` 相同的**顶栏全字段** ``generator````business_notes``矩阵作用域等
与线上一致联调大模型入参时可配合 ::
python -m pipeline.demos.dump_strategy_llm_input_md --run-dir <你的run_dir> \\
--decisions-json pipeline/demos/fixtures/strategy_decisions_full_lowgi_biscuit.json
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from pipeline.strategy_decision_keys import STRATEGY_DECISION_FIELD_NAMES
# 子进程 / 无 Django 时也可仅校验 JSON
_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "strategy_decisions_full_lowgi_biscuit.json"
# 与 ``JobStrategyDraftView`` 组装的 strategy_decisions 键一致(全量联调用)
EXPECTED_DECISION_KEYS: frozenset[str] = frozenset(STRATEGY_DECISION_FIELD_NAMES)
def load_full_fixture() -> dict:
data = json.loads(_FIXTURE.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("fixture 根须为 JSON 对象")
missing = EXPECTED_DECISION_KEYS - set(data.keys())
extra = set(data.keys()) - EXPECTED_DECISION_KEYS
if missing:
raise ValueError(f"fixture 缺少键: {sorted(missing)}")
if extra:
raise ValueError(f"fixture 多余键: {sorted(extra)}")
return data
def main() -> int:
sd = load_full_fixture()
from pipeline.llm.generate_strategy import strategy_decisions_substantive
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
if not strategy_decisions_substantive(sd):
print("strategy_decisions_substantive: 预期为 True实际为 False", file=sys.stderr)
return 1
brief = {
"schema_version": 1,
"keyword": "低GI饼干",
"batch_label": "demo_fixture",
"scope": {"merged_sku_count": 2},
"strategy_hints": ["fixture 联调"],
"meta": {"page_start": 1, "page_to": 3, "max_skus_config": 100},
"category_mix_top": [
{"label": "粗粮饼干", "count": 11},
{"label": "酥性饼干", "count": 10},
],
"pc_search_raw": {"result_count_consensus": 100000},
"price_stats": {"n": 21, "min": 14.38, "max": 64.97, "median": 27.97},
}
md = build_strategy_draft_markdown(
job_id=0,
keyword="低GI饼干",
brief=brief,
business_notes="fixture 演示:可替换为业务备注。)",
generated_at_iso="2026-01-01T00:00:00+00:00",
strategy_decisions=sd,
for_llm_input=False,
report_config=None,
)
if "表单促销策略" not in md or str(sd.get("tactic_promotion", "")) not in md:
print("成稿中未出现 fixture 的促销决策锚点,请检查 strategy_draft 与 fixture。", file=sys.stderr)
return 1
if "卡位监测中位" not in md:
print("成稿中未出现 fixture 中价格支柱文本。", file=sys.stderr)
return 1
print("OK — strategy_decisions_substantive:", strategy_decisions_substantive(sd))
print("OK — build_strategy_draft_markdown 长度:", len(md), "字符")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -155,158 +155,6 @@ def generate_comment_group_summaries_llm(
return call_llm(COMMENT_GROUPS_SYSTEM, user)
SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON``keyword``、``scenario_lexicon``、``groups``。
``scenario_lexicon`` 列出各场景标签及示例触发子串与报告 **第八章第二节**关注词与场景路径右栏统计规则一致
``groups`` 每项含 ``group`` 第五章矩阵一致的细分类目名``effective_text_count``有效评价文本条数
``scenario_distribution``各预设场景的 ``mention_rows`` ``share_of_effective_texts``**一条评价可计入多场景** **第八章第二节** 图右栏同源
``sample_text_snippets``摘录行常含细类SKU品名店铺等前缀的短引文已截断
统计为**子串命中**不是语义主题模型
**为每个细类**输出一小段 Markdown全部 groups 都要写顺序与输入一致
- ``#### `` + 与该条 ``group`` 字段**完全一致**的细类名作为小节标题;
- 每段约 **100220 **归纳该细类用户**自述的使用场景/用途**结构哪些场景标签相对突出多场景叠加是否常见可点到与其他细类的差异**所有条数与占比须与 ``scenario_distribution````effective_text_count`` 一致**禁止编造
- 引用原话时须保留或复述摘录中的店铺/SKU/品名信息勿虚构
- **禁止** Markdown 表格禁止复述全部摘录 ``effective_text_count`` 很小写明样本较少归纳供启发
总输出约 **6003200 **仅输出正文 Markdown不要用代码围栏包裹全文"""
SCENARIO_GROUPS_USER_PREFIX = (
"请根据以下 JSON 撰写竞品报告 第八章第二节右栏使用场景之后的「使用场景要点归纳」正文Markdown\n\n"
)
def generate_scenario_group_summaries_llm(
payload: dict[str, Any], *, keyword: str
) -> str:
"""与 ``generate_comment_group_summaries_llm`` 类似:细类多时长 JSON 按档压缩。"""
def _compact_group(
g: dict[str, Any],
*,
dist_n: int,
sn_n: int,
sn_max: int,
) -> dict[str, Any]:
g2: dict[str, Any] = {
"group": g.get("group"),
"effective_text_count": g.get("effective_text_count"),
}
dist = g.get("scenario_distribution")
if isinstance(dist, list):
g2["scenario_distribution"] = []
for x in dist[:dist_n]:
if not isinstance(x, dict):
continue
g2["scenario_distribution"].append(
{
"scenario": x.get("scenario"),
"mention_rows": x.get("mention_rows"),
"share_of_effective_texts": x.get(
"share_of_effective_texts"
),
}
)
else:
g2["scenario_distribution"] = []
sn = g.get("sample_text_snippets")
if isinstance(sn, list):
g2["sample_text_snippets"] = [
str(x)[:sn_max] for x in sn[:sn_n]
]
else:
g2["sample_text_snippets"] = []
return g2
def _compact_lex(raw: Any, *, max_items: int, trig_n: int) -> list[dict[str, Any]]:
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw[:max_items]:
if not isinstance(item, dict):
continue
tr = item.get("trigger_examples")
te = (
[str(x)[:48] for x in tr[:trig_n]]
if isinstance(tr, list)
else []
)
out.append({"label": item.get("label"), "trigger_examples": te})
return out
groups_in = [g for g in (payload.get("groups") or []) if isinstance(g, dict)]
ctx = llm_context_window_size()
budget = ctx - 512 - 256
def _input_ok(system: str, user_p: str) -> bool:
est = estimate_chat_input_tokens(system, user_p)
return est < 15_500
levels: list[tuple[int, int, int, int, int]] = [
(16, 14, 260, 10, 12),
(14, 12, 220, 8, 10),
(12, 10, 180, 8, 8),
(10, 8, 150, 6, 6),
(8, 6, 120, 5, 5),
(6, 5, 100, 4, 4),
(5, 4, 80, 3, 3),
]
user = ""
chosen = levels[-1]
for level in levels:
chosen = level
dist_n, sn_n, sn_max, lex_n, trig_n = level
trimmed_g = [
_compact_group(g, dist_n=dist_n, sn_n=sn_n, sn_max=sn_max)
for g in groups_in
]
lex_c = _compact_lex(
payload.get("scenario_lexicon"),
max_items=lex_n,
trig_n=trig_n,
)
body = {
"keyword": keyword,
"scenario_lexicon": lex_c,
"groups": trimmed_g,
}
raw = json.dumps(body, ensure_ascii=False)
if len(raw) > 48_000:
raw = raw[:44_000] + "\n\n"
user = SCENARIO_GROUPS_USER_PREFIX + raw
if _input_ok(SCENARIO_GROUPS_SYSTEM, user):
break
else:
tail = "\n\nJSON 已截断以适配上下文;仅依据可见字段撰写。)\n"
dist_n, sn_n, sn_max, lex_n, trig_n = chosen
trimmed_g = [
_compact_group(g, dist_n=dist_n, sn_n=sn_n, sn_max=sn_max)
for g in groups_in
]
lex_c = _compact_lex(
payload.get("scenario_lexicon"),
max_items=lex_n,
trig_n=trig_n,
)
raw = json.dumps(
{
"keyword": keyword,
"scenario_lexicon": lex_c,
"groups": trimmed_g,
},
ensure_ascii=False,
)
room = max(
2000,
int((budget - 800) / 0.55)
- len(SCENARIO_GROUPS_SYSTEM)
- len(SCENARIO_GROUPS_USER_PREFIX)
- len(tail),
)
user = SCENARIO_GROUPS_USER_PREFIX + raw[: max(1500, room)] + tail
return call_llm(SCENARIO_GROUPS_SYSTEM, user)
PRICE_GROUPS_SYSTEM = """你是定价与渠道顾问。输入为 JSON``keyword`` 与 ``groups``。
每个 group ``group``细分类目名 第五章矩阵第六章按细类价盘小节一致``sku_count````price_stats``该细类可解析展示价的 min/max/median/mean/n与第六章各细类 Markdown 分位数表同源
``listing_snippets``若干标题标价券后详情价摘录来自合并表字段已截断
@ -446,24 +294,3 @@ def generate_comment_group_summaries_llm_chunked(
generate_comment_group_summaries_llm([g], keyword=keyword) for g in clean
]
return _join_chunked_group_markdown(parts)
def generate_scenario_group_summaries_llm_chunked(
payload: dict[str, Any], *, keyword: str
) -> str:
"""``scenario_lexicon`` 每轮原样附带,``groups`` 每次只含一个细类。"""
groups_in = [g for g in (payload.get("groups") or []) if isinstance(g, dict)]
if not groups_in:
return ""
lex = payload.get("scenario_lexicon")
base: dict[str, Any] = {
"scenario_lexicon": lex if isinstance(lex, list) else [],
}
parts = [
generate_scenario_group_summaries_llm(
{**base, "groups": [g]},
keyword=keyword,
)
for g in groups_in
]
return _join_chunked_group_markdown(parts)

View File

@ -15,15 +15,16 @@ CORE_CARD_SYSTEM = """你是电商营销内容顾问。根据用户提供的「
- 食品/健康相关**禁止**治疗承诺与夸大疗效无依据写输入未体现待法务确认
- 句子短可落地兼顾**购买者决策****列表/商详/主图等多触点**上架可用性
- **读者第一眼须知道在卖什么**禁止通篇只有价值感信任体验**不出现可识别的品类/形态**如饼干燕麦奶粉饮料等若输入未给出具体 SKU 仍须写清**类目 + 形态/规格层级** GI 方向早餐饼干待业务定款不得用优质好物健康之选**无品类**的句子糊弄本条
- **对照式理由不编造**当策略或备注能概括普通/常规同类的典型痛点如升糖快甜腻纤维低易饿**why_this_product** **differentiation_vs_alternatives** 须用**对照**写清为何选本品**禁止**捏造未出现的品牌检测值具体/低百分之几无对标素材时写本品独特点并在 **open_points_for_business** 提示可补充的对照数据或检测依据若无则空串
**JSON 须全部出现值为字符串无内容用空串**
- what_we_sell**卖的是什么**必填建议 2580 写清**品类 + 主推形态/规格或适用场景**让读者**不读策略稿**也能回答你们在卖哪种货**仅可**综合策略稿`strategy_decisions`尤其 **pillar_product**battlefield_one_lineaudience_segmentmarketing_strategy`business_notes` `keyword` 监测语境中已出现的信息 `pillar_product` 非空须与之**不矛盾**无具体商品名时须明确写待业务补充主推 SKU/品名并保留类目词可与关键词监测范围对读
- one_liner_value一句话价值主张买家能得到什么
- buyer_job_to_be_done购买者的任务或情境一句
- key_pain_or_desire核心痛点或欲望与策略一致
- why_this_product为何要选这一款相对同类一句
- why_this_product为何要选这一款**优先** 12 句写相对**常规/普通同类**的核心理由可用泛称如普通甜面包常见饼干可从蛋白膳食纤维饱腹感GI 或糖负担口感包装形态配料表等**择输入已支持**的维度无对照素材则写本品独特点
- proof_or_trust_angle信任或证明角度无依据写输入未体现
- differentiation_vs_alternatives与替代方案相比的差异一句
- differentiation_vs_alternatives与替代方案相比的差异**须含**与常规同类对照的一句话结论营养数字GI每百克含量等**仅可**复述输入已有内容
- price_value_framing价位与价值感如何表述与策略价位可对读无则待业务确认
- compliance_taboos表述禁区摘要来自业务备注或策略风险
- open_points_for_business待业务补充无则空串
@ -36,6 +37,7 @@ DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定
- 购买者视角短句禁止输出 JSON 键名英文给最终读者值全部为中文**多触点上架**可用文案
- 不要泄露核心信息卡策略稿等内部词
- **更丰富编造**可增加条数与段落**每一条**须能从信息卡对应字段找到方向无依据处写输入未体现待业务核对**禁止**为凑字数新增数字销量认证评价引语具体竞品名
- **对照式表达写厚但不编造**学习优质商详先对比再购买**detail_headline** 在首句点明品类后**至少 1 **相对普通/常规同类泛称禁止编造品牌讲清差异或价值无依据时用中性句或具体对比数值待包装/检测与业务核对**selling_bullets** **至少 3 **须为**可感知的对照卖点**从蛋白膳食纤维饱腹感口感质地配料/清洁标签包装控量或便携GI/糖负担等角度择信息卡**已支持**的项信息卡未提的维度**不硬写****detail_mid_story_paragraphs** **至少 1 **为何不满足于普通同类叙事仍须紧扣信息卡禁止新数字与编造用户故事
- **每条 listing_titleslisting_subtitledetail_headlineselling_bullets 的前两条**均须让读者能识别**在卖什么品类/什么货**须与信息卡 **what_we_sell** 一致可缩写但**禁止**偷换品类或只剩空洞形容词若信息卡 `what_we_sell` 已写品类文案中**至少一处**直接出现该类目词或同义可识别表述
- **文生图/文生视频提示词**须为**可直接复制**到常见文生图文生视频模型的**中文**描述**仅可**依据信息卡已有事实与品类**禁止**在提示词里写策略稿信息卡JSON等元话语**禁止**要求生成未授权的具体品牌 Logo真实包装上的可辨认商标带疗效承诺的贴片字
- **文生图须有货有卖点画面**硬性
@ -45,12 +47,12 @@ DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定
- **配料/品类视觉**如全麦可写麸皮颗粒隐约可见浅褐全麦外皮**禁止**疗效字幕血糖仪前后对比治病画面
**JSON 须全部出现**
- listing_titles字符串数组**69** 条商品短标题备选每条约 30 字内**每条须含可识别品类或品名线索**禁止多条全是空洞套话可有 23 条侧重不同角度场景/质地/配料/人群
- listing_subtitle一条列表副文案 **6090** 字内信息不足则取下限
- detail_headline商品详情页首屏下 lead**23 ****首句须点明卖的是什么货**后接价值与差异总长约 **80160**
- selling_bullets字符串数组**812** 条卖点每条约 **40 字内**须覆盖品类形态口感/质地若信息卡有配料/健康表述合规场景信任点同类差异等**不同角度****禁止** 12 条重复同一句话换说法
- listing_titles字符串数组**69** 条商品短标题备选每条约 30 字内**每条须含可识别品类或品名线索**禁止多条全是空洞套话其中 **23 **可在有依据时含相对更/更少/不腻**对照**表述其余侧重场景/质地/配料/人群
- listing_subtitle一条列表副文案 **60100** 字内信息不足则取下限**鼓励**含一句与常规同类对照的价值无依据则省略
- detail_headline商品详情页首屏下 lead**24 ****首句须点明卖的是什么货****至少 1 **为相对常规同类的对照或价值总长约 **80200**
- selling_bullets字符串数组**812** 条卖点每条约 **40 字内****至少 3 **本品 vs 常规同类式差异整体须覆盖品类形态口感/质地若信息卡有蛋白/纤维/饱腹/GI 或糖负担**仅信息卡有则写**配料/健康表述合规包装/规格若信息卡有场景信任点常规品差异等**不同角度****禁止** 12 条重复同一句话换说法
- spec_sidebar_lines字符串数组**05** 条参数区旁短句可空数组
- faq对象数组每项含 questionanswer 字符串**58** 答句不得超出信息卡承诺可含怎么保存适合谁××区别××用泛称除非信息卡有品牌
- faq对象数组每项含 questionanswer 字符串**58** 答句不得超出信息卡承诺其中 **12** 组宜为和普通/常规××有什么不同××用泛称可含怎么保存适合谁
- detail_mid_story_paragraphs字符串数组**24 **详情页**首屏之后**的中段叙事每段 **70150** ****展开信息卡已有卖点与 `what_we_sell`可分段讲适合谁怎么吃为何值得**禁止**新数字新功效编造用户故事
- usage_and_pairing_tips字符串数组**25** 条食用场景保存提示搭配建议如早餐配牛奶信息卡未写保存条件则写输入未体现具体保质期与保存要求上架前请核对包装类中性句**禁止**编造保质期天数
- short_graphic_post_variants字符串数组**35** 条短图文/种草贴变体每条 **45110** **首句或次句**点明品类适合复制到站内动态**禁止**销量名次虚假好评引语

View File

@ -8,60 +8,32 @@ from typing import Any
from ..reporting.brief_compact import compact_brief_for_llm
from .llm_client import call_llm
SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON 含:
SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON **仅**开放语义材料(**不含**预设子串词表、不含机械分桶样本列表)
- ``comment_sentiment_lexicon``子串词表统计与载荷内各计数字段**同一计数方式**竞品报告**已不再**发布同口径扇形图/条形图**仅作定量参考**子串命中说话人态度
- ``positive_lexeme_hits_top`` / ``negative_lexeme_hits_top``短语级命中摘要同源
- ``sentiment_bucket_method````score_then_lexeme`` 表示**先按 15 星分桶**无评分行再按关键词``keyword_substring_heuristic`` 表示**仅关键词**分桶 ``comment_sentiment_lexicon`` 内四象限计数一致``sample_reviews_positive_biased`` / ``negative`` / ``mixed_tone`` 按该规则**机械归类**的抽样**可能与整句真实褒贬不一致**例如软硬适中曾被误归负向
- **``sample_reviews_semantic_pool``**若有本批评价经去重后的**随机/洗牌抽样**来自全部有效条不限于某一象限**归纳正/负向体验引用短引文时优先以此池与上述各列表中的原文为准自行结合语境理解**转折对比没那么甜软硬适中先抑后扬/先扬后抑整句态度**不得以子串是否命中负面词来断言该句为抱怨**
每条样本通常以 ``细类SKU品名店铺`` 开头表示 **第五章细类SKU品名店铺**写归纳与引文时须能还原哪家店哪条 SKU哪款品名或保留前缀**禁止**无指代地写用户普遍
- **``sample_reviews_semantic_pool``**本批评价去重后的**洗牌抽样**原文可含 ``细类SKU品名店铺`` 前缀**归纳正/负向体验短引文时只依据本池与 JSON 中其它明文字段**结合整句语境转折反讽先抑后扬等**禁止**凭单一敏感词断言整句为差评
- **``text_unit_count``** / **``unique_attributed_snippets_count``**条数统计勿编造
- **``star_rating_distribution``****若有**有评价星级数据时各档条数``score_1_2`` / ``score_3`` / ``score_4_5`` / ``no_score``**仅作辅助**低星多不自动等于口感硬等具体抱怨主题须回到原文语义**禁止**在输出中复述已废弃的预设短语命中lexeme_hits等口径
- **``semantic_pool_note``**字段说明遵循即可
**硬性要求**
- **仅输出 Markdown 正文**不要用 ``` 围栏包裹全文
- **不要编造**样本中未出现的具体事实品牌价格医学功效
- **定量数字**条数占比lexicon 各字段须与 ``comment_sentiment_lexicon`` **一致**勿编造
- **定性归纳**满意点/抱怨点引语是否算差评**整句语义**为准若某句在语义上为褒义或中性描述**不得**放入质地差口感硬等负向归因若词表归类结果与句意冲突**以句意为准**并在使用注意点明关键词归类**仅反映子串计数不作态度判断**
- **负向主题优先级硬性**主要集中突出类抱怨前**必须对照** ``negative_lexeme_hits_top`` 各短语的 ``texts_matched``口感硬/咬不动/发硬**预设短语命中为 0 或明显低于**其它维度如分量物流**不得**把质地硬写成首要负向主题若抽样原文与语义池里**反复出现**分量少太少不够吃等而预设短语未列出仍须**单独归纳**用户常用生活化表述不必与预设表完全一致
- 若某措辞****出现在任一抽样原文含前缀后正文**禁止**用引号写成直接引语
- **不要**只复述某词出现 N 若业务侧仍配图表则由图展示你的价值是**语义归纳**
- **定量**若输入含 ``star_rating_distribution``其中数字须与 JSON **一致**``text_unit_count`` 与池子规模须自洽勿编造
- **定性**负向主题**只写**你在原文中读后能站稳的抱怨若池中**几乎没有**明确批评句须如实写本批抽样内负向语义证据有限**禁止**为凑结构编造口感硬等未在引文中出现的典型抱怨
- 某措辞****出现在任一抽样原文含前缀后正文**禁止**用引号写成直接引语
- **不要**在输出里提及预设词表子串命中lexeme关键词分桶等已废弃机制
**建议结构**使用四级标题 ``####``
1. ``#### 正向体验主题``36 条;概括满意点(口感、甜度、性价比等),**尽量**用「」引用 ``sample_reviews_semantic_pool`` 或其它样本中**语义确为正面**的短句(勿把对比褒义句当差评例子)
2. ``#### 负向评价主题归因``**核心段落**。依据你读后判定为**确有不满**的句子,归纳 **48 个**问题维度(须覆盖**质地、分量/规格、价格、物流、包装**等中在原文中**实际出现**的类别,勿只写质地)。引文优先取自句意确为批评的原文(可来自任一档位键,不限于 ``sample_reviews_negative_biased``);引文须含 ``【细类…|…店铺…】`` 或同义店铺+品名/SKU
3. ``#### 混合评价中的典型张力``(可选):同一评价里褒贬并存时,说明在争什么;若无则略
4. ``#### 使用注意``关键词子串统计的局限、``sample_reviews_semantic_pool`` 与词表归类的差异、抽样截断、非医学结论。
1. ``#### 正向体验主题``36 条;尽量用「」引用池中**语义确为正面**的短句
2. ``#### 负向评价主题归因``依据原文归纳;证据不足时简短说明,勿硬写
3. ``#### 混合评价中的典型张力``(可选):若无则略。
4. ``#### 使用注意``抽样截断、星级与语义可能不一致、非医学结论。
**篇幅** JSON ``matrix_group_focus``单细类范围本节总字数约 **5001200 **勿再按全关键词池写全行业泛化**不含**该字段全量池总字数约 **7001600 **简体中文语气客观"""
# 嵌入报告 8.3 时外层为 ``#### {细类名}``;若内文仍用同级 ``#### 正向体验主题``
# ``extract_level4_sections_by_group_title`` 会在第一个子 ``####`` 处截断,导致策略摘录/心得侧「同细类报告摘录」拿不到正文。
_SENTIMENT_INNER_H4_TITLES: frozenset[str] = frozenset(
{
"正向体验主题",
"负向评价主题归因",
"混合评价中的典型张力",
"使用注意",
}
)
def demote_sentiment_inner_h4_to_h5_for_matrix_group(md: str) -> str:
"""将情感归纳四个固定小节从 ``####`` 降为 ``#####``,以便嵌在 ``#### 细类`` 下仍能被按细类抽取。"""
out_lines: list[str] = []
for line in (md or "").splitlines():
m = re.match(r"^####\s+(.+)$", line)
if m:
title = m.group(1).strip()
if title in _SENTIMENT_INNER_H4_TITLES:
out_lines.append(f"##### {title}")
continue
out_lines.append(line)
return "\n".join(out_lines)
**篇幅** JSON ``matrix_group_focus`` **5001200 **否则约 **7001600 **简体中文语气客观"""
def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
"""基于 lexicon 统计 + 语义池与按词表归类的抽样生成评价情感归纳段落Markdown**默认不**嵌入竞品报告正文"""
"""基于开放语义池(及可选星级分布)生成评价正/负向主题归纳Markdown"""
p = dict(payload)
scope_note = ""
mg = p.get("matrix_group_focus")
@ -72,23 +44,16 @@ def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
)
raw = json.dumps(p, ensure_ascii=False)
if len(raw) > 88_000:
for k, cap, maxlen in (
("sample_reviews_positive_biased", 6, 180),
("sample_reviews_mixed_tone", 4, 180),
("sample_reviews_negative_biased", 14, 200),
("sample_reviews_semantic_pool", 30, 340),
):
lst = p.get(k)
if isinstance(lst, list):
p[k] = [str(x)[:maxlen] for x in lst[:cap]]
lst = p.get("sample_reviews_semantic_pool")
if isinstance(lst, list):
p["sample_reviews_semantic_pool"] = [
str(x)[:280] for x in lst[:24]
]
raw = json.dumps(p, ensure_ascii=False)
if len(raw) > 88_000:
raw = raw[:82_000] + "\n\n…(输入过长已截断,请勿编造截断外内容)\n"
user = "请根据以下 JSON 按系统说明输出 Markdown" + scope_note + "\n\n" + raw
out = call_llm(SENTIMENT_LLM_SYSTEM, user)
if isinstance(mg, str) and mg.strip():
out = demote_sentiment_inner_h4_to_h5_for_matrix_group(out)
return out
return call_llm(SENTIMENT_LLM_SYSTEM, user)
def split_competitor_report_for_bridges(

View File

@ -3,8 +3,10 @@ from __future__ import annotations
import json
import os
import re
from typing import Any
from ..competitor_report.price_promo import price_promotion_signals_strategy_brief_cn
from ..reporting.brief_compact import compact_brief_for_llm
from ..reporting.strategy_draft import (
build_strategy_draft_markdown,
@ -12,6 +14,19 @@ from ..reporting.strategy_draft import (
)
from .llm_client import call_llm, estimate_chat_input_tokens, llm_context_window_size
def _strategy_llm_temperature() -> float:
"""
独立策略稿与策略与机会嵌入块所用采样温度默认略低于全库 ``chat_completion_text`` 0.2
以减轻同提示词多轮出稿的漂移可用环境变量覆盖``MA_STRATEGY_LLM_TEMPERATURE`` ``0````0.15``
"""
raw = (os.environ.get("MA_STRATEGY_LLM_TEMPERATURE") or "0.1").strip()
try:
t = float(raw)
except ValueError:
t = 0.1
return max(0.0, min(2.0, t))
# 与策略生成表单 POST 字段一致:任一则视为业务已提供「实质决策」,否则由模型基于数据推断草案。
_STRATEGY_DECISION_SUBSTANTIVE_KEYS: tuple[str, ...] = (
"product_role",
@ -27,6 +42,7 @@ _STRATEGY_DECISION_SUBSTANTIVE_KEYS: tuple[str, ...] = (
"pillar_price",
"pillar_channel",
"pillar_comm",
"tactic_promotion",
"marketing_strategy",
"general_strategy",
"competitor_reference",
@ -82,8 +98,9 @@ def _omit_ch8_probe_wordchart_fields(compact: dict[str, Any]) -> None:
STRATEGY_DATA_RULES = """**全局禁止编造(硬性)**:下列条款**同时**适用于 ① **独立策略稿**全文;② 若任务仍生成的**宿主报告内** ``####`` 策略归纳块JSON 含 ``competitor_brief``)。**默认产线**下报告内第九章大模型长文已关闭,**独立策略稿不以该块为默认事实源**。
- **事实与数字**销量GMV占比价带条数份额券面额满减/满折门槛到手价店铺/品牌计数与排名SKU 接口返回量等**仅可**来自**本次调用输入 JSON** 已给出的字段**独立策略稿**侧为`structured_brief``rules_draft_markdown` 内摘录**可选** `report_strategy_excerpt`**默认多为空** ``load_report_strategy_excerpt``**可选** `report_matrix_group_evidence_md`与同任务报告第五第八章细类归纳同源`strategy_decisions``business_notes`**报告内嵌策略块**侧为 ``competitor_brief``可选 ``prior_chapter_llm_narratives``**禁止**凭空新增改口径或写成已监测证实而无字段支撑
- **主体与名称****禁止**引入上述输入中**未出现****具体**品牌名店铺名SKU 商品标题作为**事实陈述** `strategy_decisions`/备注/brief/节选已含则可写否则用头部/同类竞品等泛称或待业务指定对标
- **用户侧表述****禁止**虚构评价原文访谈引语带引号的用户说细则见下文§2 针对痛点要怎么做**痛点简述**
- **促销与活动****禁止**编造活动名具体规则补贴比例细则见下文促销与第八章探针相关条款
- **用户侧表述****禁止**虚构评价原文访谈引语带引号的用户说**用户/评论/竞品侧**之事实心理趋势在**全文**均须与**下文全文证据与表达分线****§2.1 各条**同严谨****只在痛点表管自宽
- **全文证据与表达分线硬性全稿与 §2.1 同严谨度****摘要附录**中凡写**用户/评论/竞品**之具体行为缺陷心理或行业判断****可指回`report_matrix_group_evidence_md``structured_brief``business_notes`或本次**输入 JSON 已出现**的字段**无依据即不写**或须标**假设待原评/调研核实****特别禁止**** §2.1 段落**使用**无摘录支撑****部分竞品/多数竞品/部分用户/用户普遍/行业通常+可证伪细节** §2.1 禁写部分竞品偏硬/不便/难拆**同一标准****本品策略**价位主图/商详到手价满减拉新/分层渠道**拟采取**须用**/建议/本阶段/待运营核定****不得**伪装为**用户或评论已提出之要求/抱怨******同向摘录时**监测与货架**价带列表价差促销形态**可作**背景+故我方拟****评论同向主题时**不得**转述为**确定句****用户因××而痛****禁止**全篇**无据**却用**肯定语气****自我矛盾**之心理/态度****对价差不敏感又希望透明****全篇****节支撑
- **促销与活动****禁止****策略主张**写成监测已证实××满减的事实口吻§8.3 须写**本品**拟采用的促销机制见下文促销专条竞品侧具体规则**仅可**来自输入本品侧档位可用待核定
- **策略动作与落地结果**可写建议假设待验证的动作方向**不得**编造已执行已上线数据显示转化率/复购提升**无输入依据**的结果
- **信息不足**须写输入未体现待核对假设待验证**禁止**用确定语气掩盖缺失依据
- ** §2.1类目/细类列一致全文** §2.1 表格外**摘要**凡写策略动作阶段重点资源分配差异化或竞争应对**优先**标明适用**类目/细类**多细类策略冲突时**分条****禁止**全站用户整体上一句覆盖与 §2.1 已分行决策**矛盾**的表述
@ -97,15 +114,22 @@ STRATEGY_DATA_RULES = """**全局禁止编造(硬性)**:下列条款**同
- **§2针对痛点要怎么做反捏造 + 分类目硬性**
- **类目/细类本决策适用**须与 `structured_brief` 中类目混排矩阵分组§1.2 细类讨论或 `strategy_decisions` 已选战场**可对上****禁止**编造未出现的类目名**多细类并存**如饼干 vs 面包**必须分行**分策**禁止**全站用户整体策略**泛化**一句覆盖彼此冲突的动作****类目或主推线尚不确定该行可写待业务定类假设优先××线并说明**分类决策依据或待补信息**仍须避免与数据明显矛盾
- **用户痛点简述****禁止**书写用户反馈评价称**带引号的逐字原话**除非该片段在 `structured_brief``strategy_hints``report_strategy_excerpt` `business_notes` **已出现相同或明显包含**的文本否则一律**不得**用引号假装引用
- 若输入仅有主题级信号关注词负向归因方向价差行数等痛点简述应写**可追溯归纳**例如 brief ×× 字段一致与报告第八章/节选已归纳的 ×× 主题一致监测摘要见 `strategy_hints` n 或写**待原评论抽样核实****禁止**把合理推测写成用户已明确说的事实口吻
- **禁止**凭空发明痛点行配料相似卖点雷同性价比一般作为**已监测结论**此类表述仅当 `structured_brief`节选或备注中**确有同类主题或措辞**时方可写入否则不写或标为待验证假设
- **用户痛点简述 · 业务定义与合格内容硬性****用户痛点**指用户在**生活工作使用本品类/产品**过程中**具体感受到的困难烦恼不便难受麻烦****长期未被满足且必要的刚性需求**未兑现**用户主观感受到的负面体验****强烈不满/焦虑****通常须能落到具体场景/时刻/情境****何时何地在何种任务下** 感到费劲不踏实难判断用得不爽不敢吃/不敢买 **亟待被解决的迫切感****不是** 产品优点**不是** 卖点**不是** 抽象需求升级**不是** 纯策略/内部用语**禁止** **单句**可感知性存疑可核性信任需建立认知盲区与预期不一致但不说清糟在哪**无具体烦恼无场景** 的空话冒充痛点那是执行缺口描述**不得** 单独占满痛点格**** 节选/§8 **** 归纳了**满意正向复购好吃** **** 能把它们改写成伪痛塞在本列**优点与差异化** **§3§5**本列**** **可叙述的负面/不便/抱怨** 下文允许的**典型场景·假设**
- ** `report_matrix_group_evidence_md` / §8 可对上硬性**每一行**用户痛点**须能在节选里找到**同向**支撑**首重**负向评价混合评价**已写明的抱怨/不满/难用/没做到**节选已归纳**价高价不清缺斤短两难拆包**等时******带场景**的负面体验句写出****节选写明**以正向为主/负向不显著/无法归纳成类负向********§2.1 表前****一句**点明**摘录中负向有限或仅为个案****不得**凭词频/监测编造**部分竞品/普遍用户/多数人说**类负评**确需****12 **供后文动作落地**仅可****典型场景假设待原评/调研核实+ 场景化具体烦恼****仍须****人话里的难与烦****禁止**要统一标签**运营手段**当痛点****归纳依据的价/优惠/手价/满减**不得**写入痛点列见先读后写与监测分源
- **用户痛点与监测事实分源与上条配合**`structured_brief` 的价带列表价字段``price_promotion_signals``第六章促销/价差摘录等**在缺少 §8/节选 评论同主题时******作为用户痛点简述**根据******则可在痛点列**与动作列**配合书写如节选已写份量少//担心买贵
- **用户痛点简述 · 证据与策略分线硬性须严谨****痛点句须与摘录可核对**凡写进本格的**用户侧**陈述**** 能在 `report_matrix_group_evidence_md` **评论归纳负向/混合段或已给引句** 中找到**同向**依据**无依据即不写****禁止** **无摘录支撑** **部分竞品偏硬/不便/包装难用/难拆** **竞品事实** 来凑痛点行**找不到证据就不要这样写****价格呈现**到手价/标价/满减/希望透明是否对价差敏感**无评论同向主题** **经营与页面策略****** 写在策略动作具体怎么做如何验证** §****不得** 改写成**仿佛已被用户说出** 痛点 例如**对标价与到手价差异不敏感但希望价格透明** 这类 **在摘录中 依据 ** 心理/策略 混合句**不得** 出现在 痛点 **禁止** 无调研依据 **自我矛盾 用户心理** 当作 确定 陈述****同条内 **** 不敏感 **** 要透明 **** 节选中立论**典型场景假设待核实** **** **买家自身** 场景 //**** 夹带 数据 指涉 **竞品** 具体 缺陷
- **用户痛点简述 · 无据竞品归纳禁词硬性与产线后处理同口径****除非** 整句部分/多数+竞品/品牌对货架缺陷的**具体归纳**能在 `report_matrix_group_evidence_md` **逐句或同义**找到凭据**否则** 本格**不得** 出现用于**断言**竞品同行或他牌的下列用语**含同义变体中间夹形容词**`部分竞品``多数竞品``有的竞品``竞品中(一些|部分|许多)``**品牌**的普遍/多数/不少/一部分``同行()?()?普遍/多数/不少``其他品牌(普遍|多数|不少)``他牌()?问题``列表内商品/其余款式`+**可证伪质量缺陷** 上述内容若确为**策略推断**须写在策略动作/具体怎么做/如何验证/§5**而非** 痛点列**禁止** 用户希望但部分竞品**假装** 前半句是摘录后半句是监测事实**** 改为**仅写买家侧**之难//吃不准**** **典型场景假设待核实** **不写可证伪的竞品体特征**
- **§2.1 痛点列 · 先读后写防无依据价类套话硬性**在填写 §2.1 **之前**先在 `report_matrix_group_evidence_md` **通读** **评论文本 / §8 正或负向体验 / 混合评价** 相关的段落**独立判断**其中是否**已经写出** **//便宜/优惠/满减//到手/标价/透明/虚高/力度/性价比** 等之一为**用户或评论**关切的**主题句****不得**用第六章/价盘/促销**监测**段冒充有评论价主题 ******上述评论相关段落****没有**任一同向主题**用户痛点简述列禁止** **/优惠/透明/虚高/力度/手价/** 为核心作痛点概括**特别禁止**套话如**价格虚高****优惠不透明****价格感知模糊****优惠力度不透明**及同义改头换面**价促应对**只写在策略动作具体怎么做如何验证 **§******上列评论段落**已有**价高想更便宜活动难懂**负面体验**归纳******有场景有难受点**的短句写出**不得**夸大输出前****完成本先读再写 §2.1 痛点格
- 若某信号仅存在为**关注词/统计/价差行数/监测****** §8 对评论的**主题归纳****不得**用确定口吻写进痛点列**//呈现类应对**可写在后三列与 §
- **禁止**凭空发明痛点行配料相似卖点雷同作为**已监测结论****性价比一般**等仅当节选或 brief **确有**同类主题方可写入**评论归纳明确**/份量****为避写价而改写成与节选不符的别句
- **行级覆盖与章节分工不限制最多行数有真实痛点依据为纲**§2.1 行数****为凑行数而堆伪痛**每行**须符合上文**业务定义****具体困难/烦恼/负面体验+场景******明确标为**典型场景假设待原评/调研核实****禁止**用多类**正向/词频**主题硬拆成多行伪痛****节选已归纳**多条**不同负向/不便****分行写清**价促到手价呈现**无评论同向时放后三列/§**禁止**§3.1§4大段首写可执行主张而§2.1全表无**可对读**的落地动作行**交叉指代仍须在 §2.1 出现可执行句**
- **不得编造**销量GMV未在 `structured_brief` 与底稿中出现的占比或价格底稿与摘要中的数字须保持一致
- **店铺集中度**仅可依据 `structured_brief.concentration` 与底稿并区分**列表行****去重 SKU**第一大份额前三家合计等中文**不要用** CR1CR3
- **禁止编造**京东自营 SKU 占比自营超 X%等摘要中未给出的定量句
- **矩阵** `structured_brief` 含矩阵相关字段**呼应**细分类目与竞品矩阵结论不得无故删光
- **第八章文本挖掘探针 JSON `chapter8_text_mining_probe` 为真时**
- **禁止**关注词子串命中次数预设场景分组条数/占比当作评论侧主论据
- 用户洞察负向归因须与 **§8 文本挖掘** 及可选节选一致促销与券价差须与 `price_promotion_signals`报告**第六章** brief 已给字段一致**默认**无宿主报告内长文策略节选时**禁止**第九章已写为凭据编造具体规则**禁止**编造满减门槛或补贴比例
- 用户洞察负向归因须与 **§8 文本挖掘** 及可选节选一致**监测侧**券价差事实须与 `price_promotion_signals`报告**第六章** brief 已给字段一致**§8.3 成稿须含本品促销决策**满减/满折/到手价呈现等见系统提示促销专条**禁止**把未在输入出现的竞品规则写成已监测到的定论**允许****拟定**的本品档位并标注待运营确认
- **可选 `report_strategy_excerpt`****默认多为空**非空时战略方向与该节选不明显矛盾**不得**把节选与 `structured_brief` 均未出现的数字当作事实**为空时** `structured_brief``report_matrix_group_evidence_md`若有与底稿/表单为准**禁止**编造宿主报告策略章已断言的具体结论或虚假背书"""
STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**与业务侧填写的**决策字段**,把「规则底稿」写成**短、可执行**的策略 Markdown **独立成稿**。
@ -125,11 +149,12 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
- **读者测试**业务读者读完**摘要**任一大节后应能回答至少一项**团队/渠道在何触点针对哪类用户或哪条痛点采取什么动作如何验收或待验证什么**若某段只能回答市场/品类/价带是什么样**没有**紧随或嵌入的故本阶段须优先**动词句**须改写或删并**禁止**以形势描述段作为该节主体
- **背景上限****顾客是谁** 1.1 1.2 **禁止**扩写成第二份分析报告合计**至多约五句**结论性背景谁搜关心什么分细类一句价带分位样本量拆解词频/方法一句带过或写详见同任务竞品分析报告**禁止**多段连续铺陈数据
- **摘要**范围与样本用户侧**一句**可接受后**阶段重点**必须是 **12 条完整执行句**每条须含**可识别动作**如统一商详第几屏表述主图试点规格命名客服首句跟价/不跟价说明等之一**禁止**单独使用加强运营把握机会提升体验深化心智等无主体无触点无痛点指向的套话
- **§2.1 **监测已支撑**多个**痛点或细类维度时**至少两行**有实质内容非空非整格待填**策略动作****具体怎么做**两列须以**动词短语或短句**开头**禁止**两列长期只有形容词名词标签或泛化口号
- **§2.1 ****有依据的痛点/已标注的典型场景假设待核实**可一行或多行**不得**为凑行数写多行无来源伪痛表内******可执行**的策略与落地****摘录中负向有限+12 行场景假设痛**仍须**策略动作/具体怎么做/如何验证中写**如何解决假设中的难与烦******节选**本身**可归纳**多条**负向/不便****分行据实写**策略动作****具体怎么做****动词**为主**禁止**两列长期只有空泛口号
- **§2.1 行级与 §1.2 对齐**§1.2 **拟成策略**的维度 §2.1 **能指回****** 维度 节选 **** **好评/满意** 而非 **负向/不便****不要** 痛点 **硬造** 对应行**** §1.2/§3 **叙事** 优点**确需** §1.2 对位 **** 上文 **典型场景假设** 节选 负向 痛点 **禁止** 只在 后文 详写 §2.1 全空
- **§§**每一 numbered 小节 §6.2§7.x§8.x须含**至少一条**可指回 §2.1 某一行的落地动作可口头合并叙述**禁止**仅用强化品牌/优化体验/夯实基础等名词堆叠而无**谁做在哪做做哪一步**
- **反例禁止作为节内主要篇幅**当前品类呈现市场整体用户日益注重健康**纯判断句串**而无后续因此我方本阶段若保留背景**一句**后必须接执行句
**落实范围**上文全局禁止编造适用于**摘要一至十附录**的每一句话与表格每一格**不得**因章节不同而放宽
**落实范围**上文全局禁止编造**全文证据与表达分线**适用于**摘要一至十附录**的每一句话与表格每一格**不得**因章节不同而放宽**不得** §2.1 放宽 用户/竞品/心理 **可指回 依据** 要求
**对外成稿与禁止技术泄露硬性**
- 正文须为**可直接对业务或合作方阅读**的正式策略文档对外前仍须按需脱敏**禁止**出现反引号代码体JSON 键名英文字段名内部数据结构名源码或仓库路径类文件名任务 ID工作台规则骨架等系统痕迹**禁止**照抄底稿中以 *成稿**回答**占位**骨架* 开头的**元说明句**须改写为正式业务表述
@ -143,7 +168,8 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
- 仍须遵守全局禁止编造数字品牌店铺用户原话活动规则仅可来自输入依据无依据处用假设语气
**决策边界硬性**
- ** `strategy_decisions_substantive` true **业务已在 `strategy_decisions` 中填写的项角色**本阶段策略目标类型**时间成功标准战场一句话定位勾选竞争倾向四柱目标客群/对标/资源备注**营销策略****总体策略**视为**已定决策**成稿须**落实为具体执行句****不得**改写成相反结论或再要求用户请选择**本阶段策略目标类型在输入 JSON 中已给出非空文本**策略范围与前提表中该列须**直接采用该表述**可略作语序润色**不得**改判为另一类阶段目标
- ** `strategy_decisions_substantive` true **业务已在 `strategy_decisions` 中填写的项角色**本阶段策略目标类型**时间成功标准战场一句话定位勾选竞争倾向四柱与**战术相关**字段目标客群/对标/资源备注**营销策略****总体策略**视为**已定决策**成稿须**落实为具体执行句****不得**改写成相反结论或再要求用户请选择**本阶段策略目标类型在输入 JSON 中已给出非空文本**策略范围与前提表中该列须**直接采用该表述**可略作语序润色**不得**改判为另一类阶段目标
- **表单与 §§战术**`rules_draft_markdown` 表单锚点与 JSON **非空** `pillar_product``pillar_price``pillar_channel``pillar_comm``positioning_choice``tactic_promotion` 须分别体现在 **§ 品牌四线****§ 战术支柱** **对位**小节**不得**成稿时忽略架空或与表单**相反****`tactic_promotion` 非空** **§8.3** **优先承接**其意图可扩写为满减/满折/活动呈现**不得**仅用监测行数占比类句子顶替**`positioning_choice` 非空** **§8.2** 定价须与该价位取向**一致**用连贯叙述**禁止**在正文以内部选项名当小节标题或问卷式四列
- ** `strategy_decisions_substantive` true** 而部分表单项仍为空或占位结合监测摘要与节选**补全为可执行表述**与数据方向一致
- ** `strategy_decisions_substantive` false **适用上文业务决策未填写时的成稿义务**禁止**请先填表类表述搪塞全篇
- **成稿阶段避免**反复请业务决策不确定时在 §2.1 类目/细类+假设待业务确认**写清****禁止**只写泛化一句
@ -155,22 +181,22 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
**语气**面向业务读者避免 CR1心智等内部缩写**勿在成稿中反复强调对齐某报告第几章**以策略表述为主
**策略表述硬性痛点 怎么做须覆盖全书不得只写 §2§8 部分章节**
- **总原则**成稿**不是**第二份分析报告**不是**市场形势说明书每条重要内容应能回答**针对哪条用户痛点在哪条类目/细类下** **§2.1** 表对应**我们采取什么动作****在具体触点怎么做**商详/主图/短视频/客服/规格/价格呈现等**如何验证**若适用**是什么仅作每节不超过一两句的铺垫怎么做须占可策略论述篇幅的主体**
- **总原则**成稿**不是**第二份分析报告**不是**市场形势说明书每条重要内容应能回答**针对哪条用户痛点在哪条类目/细类下** **§2.1** 表对应**我们采取什么动作****在具体触点怎么做**商详/主图/短视频/客服/规格/价格呈现等**如何验证**若适用**是什么仅作每节不超过一两句的铺垫怎么做须占可策略论述篇幅的主体****§2.1用户痛点****业务定义**+**证据与策略分线****全文**用户/竞品/价促表述 亦见 **全文证据与表达分线****无据不写****** 用无据部分竞品/ 后三列/§
- **§2.1 针对痛点要怎么做**若底稿已有表头**填写实质内容**全稿**动作总锚** §2.1若无表须在 **§** **§** 用等价分条写清痛点动作落地验证
**分节要求与底稿章节一一对应勿省略**
- **策略范围与前提**回答**这份策略是针对什么做的**监测任务本品角色战场主推类目**本阶段目标类型**时间成功标准与业务表单及备注对齐`strategy_decisions_substantive` false 时须写清假设前提与推荐目标类型可含 AE 选项 true 时未填项不得与已填决策矛盾**禁止**与后文 §2.1§ 自相矛盾
- **摘要**除范围样本外**阶段重点**须含 12 **可执行动作**指向优先痛点非空泛加强运营须与上文策略范围与前提边界一致****在正文写回扣 §2承接上文等指导语
- **顾客是谁****禁止**重复报告中的细类词频分品类样本量展开文本挖掘方法 **少量结论句**谁搜关心什么决策场景**1.3 本品聚焦**须写清本期主攻人群/场景/细类**** §2.1 可对上类作者提示
- ****** §2.1** 一张表类目/细类本决策适用| 用户痛点简述| 策略动作 | 具体怎么做 | 如何验证须覆盖监测已支撑的主要维度**按类目分行**口感/质地分线分量/规格信任与价格等依数据取舍**类目列 + 痛点简述列**遵守§2 条款**禁止**再写独立痛点与证据表价值对表负向归因子节 § 重复的内容一律并入本表或删去
- ****** §3.1**标题与底稿一致为**购买者视角为何要选这一款依据与理由**全文须站在**购买者**一侧写其在浏览/比价时**为何值得把这一款放进购物车**解决什么具体问题相对同类获得感价位是否可接受信任点是什么可用用户/消费者作主语****保留或转述输入中已有**检索/样本与价带**作买家决策背景勿大段铺陈**随后**12 句落到**购买动机****禁止**用运营/品牌单方口吻替代买家逻辑适合××叙事切入策略上占位品类时机好作为收尾而不说买家得到什么**禁止**以只适用于整个品类的宏观句作为**唯一或最后**结论宏观背景若写**必须**收束到因此**买家**更愿为这一款付费的可验证点规格/配料/口感/价位等须与输入可对读可结合 brief 写价带锚点一句**禁止** §3.2转化障碍与应对若与购买相关的障碍与应对已在 §2.1 表内§3.1 **勿再复述**
- **顾客是谁****禁止**重复报告中的细类词频分品类样本量展开文本挖掘方法 **少量结论句**谁搜关心什么决策场景**1.3 本品聚焦**须写清本期主攻人群/场景/细类**** §2.1 可对上类作者提示**§1.2** **禁止**写入类目结构摘录 SKU/条数表核心关注点高频词含括号次数共现词对主题归纳依据 report_matrix_group_evidence_md**与竞品报告或矩阵节选同构**的字段化罗列该等内容仅在报告与 JSON 节选策略稿**至多两三句**定性差异即可必要时一句详见同任务竞品分析报告带过
- ****** §2.1** 一张表类目/细类本决策适用| 用户痛点简述| 策略动作 | 具体怎么做 | 如何验证**用户痛点简述**须写 **生活/工作/使用中的具体困难烦恼不便负面体验刚性未获满足****** 场景/时刻**** `STRATEGY_DATA_RULES` **业务定义****不是** 优点**不是** 策略空话**** 节选 负向 有限**** 该条 **表前说明 + 典型场景假设** 规则**价促/到手价 监测** 评论 主题 **** 后三列 **§****类目列** 遵守 上文 **行级覆盖** **禁止** 另设痛点与证据表 重复 子节并入 删去
- ****** §3.1**标题与底稿一致为**购买者视角为何要选这一款依据与理由**全文须站在**购买者**一侧写其在浏览/比价时**为何值得把这一款放进购物车**解决什么具体问题相对同类获得感价位是否可接受信任点是什么可用用户/消费者作主语**优先****对照**讲清理由相对**常规/普通同类**泛称如常见甜面包普通饼干**禁止**编造未出现的竞品品牌在哪些维度上更值得买可从**蛋白膳食纤维饱腹感GI 或糖负担口感质地配料表/清洁标签包装形态或控量**等角度**择输入已支撑**的项展开与监测摘要brief矩阵节选可对读**禁止**捏造营养成分数值检测结论或未出现的/低百分之几监测未提供可对读数据时允许写定性对照或待包装/检测与业务核对后再对外宣称**不得**用空洞品类口号代替买家逻辑****保留或转述输入中已有**检索/样本与价带**作买家决策背景勿大段铺陈**随后**落到**购买动机**不少于 **24 **实质内容避免仅一句带过**禁止**用运营/品牌单方口吻替代买家逻辑适合××叙事切入策略上占位品类时机好作为收尾而不说买家得到什么**禁止**以只适用于整个品类的宏观句作为**唯一或最后**结论宏观背景若写**必须**收束到因此**买家**更愿为这一款付费的可验证点规格/配料/口感/价位等须与输入可对读可结合 brief 写价带锚点一句**禁止** §3.1 **写入**与竞品报告同级的检索条数价带 min/max/中位数样本 n **字段式复述**该等数据仅在 `structured_brief`/报告策略 §3 只写购买者理由与对照不抄监测报表句**禁止**把历史规则里的检索结果量级价带摘录类标签或摘录体带进正文**禁止** §3.2转化障碍与应对若与购买相关的障碍与应对已在 §2.1 表内§3.1 **勿再复述**
- ****品牌承诺与调性须能落到**可感知触点**如商详第几屏包装客服首句避免只有形容词
- ****§5.2 差异化§5.3 竞争应对须写清**相对竞品多做什么/少做什么具体一步动作**
- ******禁止**另起对比对象摘录店铺分布品牌分布等与竞品分析报告同构的集中度长篇复述格局与份额**一句结论**详见报告即可**§5.1 差异化§5.2 竞争应对**须写清**相对竞品多做什么/少做什么具体一步动作**
- ****成功标准与 §6.2 路径须与 **§2.1** 动作**可对齐或合并叙述**营销/总体策略句须为**动词导向**
- ****品牌四线**每一条**至少一句**服务哪类痛点本周/本阶段具体做哪一步**
- ****四支柱**每一支柱**须回扣 **痛点动作落地**可与 §2.1 合并叙述避免重复堆砌
- ****在表单风险勾选之外**每条风险**尽量带**应对动作或验证计划**抽样核对规则勿只列风险标题
- ****下一步清单须为**可执行任务**可含负责人/时间占位 §2.1 § 优先级一致可含按类目核对主图/商详与 §2.1类项
- ****四支柱**每一支柱**须回扣 **痛点动作落地**可与 §2.1 合并叙述避免重复堆砌**§8.2** **禁止**以勾选问卷四行并列贴顶/卡腰/下探/另起带价位阵地取向表单式标题呈现已定 `positioning_choice` 与监测价带须**融入连贯定价叙述****禁止**表单勾选与监测价带可对读等内部提示语入正文
- ******每条风险**尽量带**应对动作或验证计划**抽样核对规则勿只列标题**禁止** `[ ]`/`[x]` 问卷式风险清单仅列问句不落地须用叙述句写风险假设与验证
- ****下一步须为**可执行任务**可含负责人/时间占位 §2.1 § 优先级一致**禁止** `- [ ]` 待办勾选排版用编号或分条**动作句**表述
**全书与 §2.1 类目列对齐防泛化与上条全文一致配套**
- **摘要**阶段重点中的可执行动作**尽量**点明适用类目或主推线
@ -184,22 +210,165 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
- 监测中**酥脆****松软**等可能**同时**高频通常对应**不同细类**如饼干 vs 面包/糕点或不同场景成稿须**按主推细类或分产品线**表述饼干线策略与酥脆/饱腹等对齐面包/糕点线与松软/早餐等对齐若多线并存须**分款分句****禁止**只写要做松软而忽略酥脆主导的细类除非 `structured_brief`表单或业务备注已明确****推该线
- **产品策略句**须能指回**本品是哪一类解决哪条口感预期**避免与数据里另一细类的主导词打架
**促销满减满折 不管 编造**
- **必须** **§.3 促销与活动策略**及必要时 §.3写清 `price_promotion_signals`报告第六章已归纳的**标价与到手价差常见活动形态**如何承接跟价节奏规则透明不与数据矛盾**禁止**没编出具体数字就整节不写促销
- **禁止编造**输入中未出现的**具体**满减门槛满额折扣每满减金额若摘要/报告未捕获某类机制须明确写**监测未捕获具体满减/满折规则上架前须与运营及后台活动对齐后再对外宣称**并可列**待补信息**是否参加跨店满减店铺券类型
- **区分**策略上跟券保到手价透明是成稿义务具体满 300 40只能来自已有数据
**促销§8.3 写决策不写报告统计硬性**
- **文体****§.3 必须以本品 / 本阶段的促销与活动决策为主**读者应能回答我们打算用什么满减什么折扣档到手价怎么跟竞品对齐**禁止** `strategy_price_promotion_brief_cn` `price_promotion_signals` 里的**行数占比中位数价差**整段照抄当 §8.3 正文那是报告第六章口径监测结论**至多一句**作依据例如监测显示列表侧普遍存在标价与到手价差竞品多叠券呈现
- **决策须落地**至少写出 **12 条可执行的促销主张**使用**决策句式**例如**本阶段拟设** [门槛] [面额]**拟设** [门槛] [折扣] 新人/分层价是否跟进及主图/商详如何写清规则**竞品侧**已出现的具体 99 9 **仅当**报告节选 / `report_matrix_group_evidence_md` / brief 其他字段**已出现**时可作对标复述**本品侧**具体数字若输入未给**仍须写出拟定结构**几档拉新 vs 提客单意图并标明**具体门槛与面额待运营按毛利与后台活动核定****禁止**待核定代替整条决策导致 §8.3 只有监测复述而无我们怎么做
- **稳定性**`strategy_price_promotion_brief_cn` 用于**边界核对****不得**与其矛盾地写未检测到满减/折扣未见列表侧价差当摘要已表明存在可对齐价差且券后低于标价样本时尤甚但该字段**不是** §8.3 成稿模板
- **三类信息分清**1**监测事实**仅用输入字段2**竞品页原文规则**仅当节选等已收录3**本品策略主张**§8.3 **必须有**可用建议**不得**3写成监测已证实
**输出** Markdown 正文不要 ``` 围栏须收束各小节与全文勿中途截断"""
STRATEGY_USER_PREFIX = (
"请基于以下 JSON 输出最终策略稿Markdown正文须为对外可读正式文档不得泄露 JSON 键名、字段名、源码路径或底稿中的编写提示语。\n"
"输出前自检:全文不得包含输入中未出现的具体数字、品牌/店铺名、用户引语与活动规则;不确定处须写「假设」「监测未体现」或「待业务核对」。\n"
"输出前自检:不得把输入未出现的**竞品侧**数字、品牌/店铺名、用户引语当作**已证实事实**;不确定处须写「假设」「待业务核对」。\n"
"输出前自检(**全书 证据****:除 §2.1 外,**摘要/一/三~十/附录** 是否 **未** 写 无 据 的「**部分/多数 竞品/用户**…」、**未** 将 **价/促/主图/渠道** 等 **本品策略** 假装 成 **用户或评论 已 提出 的 要求**?监测/价带 仅 作 背景 时 是否 **未** 写 成 **已证实 的 用户痛****若否**,先改再输出。\n"
"§8.3:以**本品促销决策**为主(满减/满折/到手价呈现等),勿复述监测行数占比;**禁止**与 `strategy_price_promotion_brief_cn` 矛盾地否认价差存在;"
"若具体门槛/面额输入未给,须写「拟…」并标明待运营核定,**勿**把拟定数字写成「监测已显示竞品满××减××」。\n"
"输出前自检(规划 §1.1):摘要「阶段重点」是否为 12 条含动作+触点或时间窗口的执行句第一章是否未写成长篇市场白皮书§2.1 是否至少两行实质且「动作/怎么做」列为动词句;§六~§八 每节是否至少一条可落地的「谁在哪做什么」。若否,先改再输出。\n"
"输出前自检§3.1):「为何要选这一款」是否优先从**买家**角度写了相对**常规同类**的对照或可验证差异(蛋白/纤维/饱腹/GI 或糖负担/口感/配料/包装等**仅在有依据时**),避免只有宏观品类句或运营口吻;若否,先改再输出。\n"
"输出前自检§2.1):在 §1.2 / brief / `strategy_hints` 已归纳**多类**有依据主题时§2.1 是否**未无故遗漏**配料/营养/信任等**任一类**强信号行(可合并,不可仅在 §3/§4 才首次详写)?**若否**,先增行、合并或补交叉指代后再输出。\n"
"输出前自检§2.1 痛点列 ① 定义)****「用户痛点(简述)」**格是否 条条 为 **有场景、可感受的 困难/烦恼/不便/负面体验/刚需未得满足****而** 非 优点/卖点/「存疑/可核/信任/认知」空话?**若否**,先改再输出。\n"
"输出前自检§2.1 痛点列 ② 证据)****每一**痛点句**是否**在 `report_matrix_group_evidence_md` 中有**可指回** 的评论/负向/混合依据?**是否** 未写 **无**据 的「**部分竞品**…」?**若** 无 评论 价/透明 同向 主题,**是否** 未 把 到手价/透明/对价差敏感 等 **策略** 当 成 用户痛 写在 本列?**若否**,先改再输出。\n"
"输出前自检§2.1 痛点列 ③ 负向 有限 时)****若** 摘录 以 正评 为 主,是否 已 在 **表前** 一句 说明 且 假设痛 行 带 **(典型场景假设…待核实)** 前缀?**若否**,先改再输出。\n"
"输出前自检(表单与 §7/§8**`strategy_decisions` 中已填 的 四柱、`positioning_choice`、`tactic_promotion` 等 是否 已 在 **§七~§八** 落为 可执行 叙述、**未** 与 底稿 表单锚点 矛盾 或 被 监测 复述 **顶替****若否**,先改再输出。\n"
"若 JSON 中 `strategy_decisions_substantive` 为 false你须基于监测摘要与细类报告节选**主动推断**完整策略草案(含 §2.1 多行实质内容),"
"在「策略范围与前提」标明假设前提,并对阶段目标给出 AE 类型选项及**推荐倾向**;禁止全文停留在待填占位。\n"
"若 `strategy_decisions_substantive` 为 true已填表单项视为已定须落实空项结合数据补全并与后文一致。\n\n"
)
# §2.1「用户痛点」列:模型常置若罔闻的「部分竞品…」无据归纳,产线侧删节,避免对外输出虚假监测事实。
_S21_PAIN_PLACEHOLDER = (
"(典型场景假设:选购同类代餐/控糖饼干时,易在口感、健康标签与包装使用上吃不准、怕买错,"
"待与评价摘录/调研补核。)"
)
_S21_PAIN_BANNED_MARKERS: tuple[str, ...] = (
"部分竞品",
"多数竞品",
"有的竞品",
"同行普遍",
"同行多数",
"其他品牌普遍",
"其他品牌多数",
)
def _is_s21_table_separator_row(stripped: str) -> bool:
if not stripped.startswith("|"):
return False
cells = [c.strip() for c in stripped.split("|") if c.strip() != ""]
if len(cells) < 2:
return False
return all(re.match(r"^:?-+$", c) is not None for c in cells)
def _strip_unsourced_competitor_phrases_in_s21_pain_cell(text: str) -> str:
"""
删除用户痛点简述格内无据的竞品泛化子句保留可单独成立的买家侧表述
不解析摘录全文做是否有据 NLP 判断仅对已知高风险句式机读去尾
"""
t = (text or "").strip()
if not t:
return t
# 「用户希望/担心…,但(部分|多数)竞品…」 整段后半为高风险
t = re.sub(
r"[,、;;]\s*但(部分|多数|有的)竞品[^|]*$",
"",
t,
)
t = re.sub(
r"[,、;;]\s*而(部分|多数|有的)竞品[^|]*$",
"",
t,
)
t = re.sub(
r"[,、;;]\s*(而)?(与|和)(部分|多数|有的)竞品(相比|相对)[^|]*$",
"",
t,
)
t = re.sub(
r"[,、;;]\s*相对(部分|多数)竞品[^|]*$",
"",
t,
)
t = re.sub(
r"[,、;;]\s*[^|,、;;]{0,8}(部分|多数)竞品(在|的|中)[^|]*$",
"",
t,
)
for marker in _S21_PAIN_BANNED_MARKERS:
while marker in t:
i = t.find(marker)
t = t[:i].rstrip()
t = re.sub(r"[,、;;]\s*([但而])+\s*$", "", t)
t = t.rstrip(",、;;但而 ")
t = re.sub(r"\s+", " ", t).strip(",、;; ")
if re.search(
r"(部分|多数|有的)竞品|其他品牌(普遍|多数)|同行(普遍|多数)", t
):
return _S21_PAIN_PLACEHOLDER
if len(t) < 4:
return _S21_PAIN_PLACEHOLDER
return t
def sanitize_strategy_s21_pain_column_md(markdown: str) -> str:
"""
在独立策略稿 Markdown 中定位 ``## 二、…`` 与 ``## 三、…`` 之间、含「用户痛点」表头的
管道表 **用户痛点** 列做 ``_strip_unsourced_competitor_phrases_in_s21_pain_cell``
无表或结构不识别时原样返回
"""
m = re.search(
r"(?ms)(^##\s*二[、,.\s](?:.|\n)*?)(?=^##\s*三[、,.\s])",
markdown,
)
if not m:
return markdown
section = m.group(1)
lines = section.splitlines(keepends=True)
out: list[str] = []
pain_idx: int | None = None
in_s21_table = False
for line in lines:
stripped = line.strip()
if (
stripped.startswith("|")
and "用户痛点" in stripped
and re.search(r"用户痛点|痛点[(]简述[)]", stripped)
):
parts_h = [p.strip() for p in stripped.split("|")]
for i, cell in enumerate(parts_h):
if "用户痛点" in cell:
pain_idx = i
in_s21_table = True
break
if in_s21_table and pain_idx is not None and stripped.startswith("|"):
is_sep_row = _is_s21_table_separator_row(stripped)
if is_sep_row or "用户痛点" in stripped:
pass
else:
sparts = line.rstrip("\n").split("|")
if len(sparts) > pain_idx and pain_idx >= 0:
inner = (sparts[pain_idx] or "").strip()
if inner and not re.match(r"^[-:\s]{2,}$", inner):
new_inner = _strip_unsourced_competitor_phrases_in_s21_pain_cell(
inner
)
if new_inner != inner:
sparts[pain_idx] = f" {new_inner} "
line = "|".join(sparts) + (
"\n" if line.endswith("\n") else ""
)
if (
in_s21_table
and stripped
and not stripped.startswith("|")
and not stripped.startswith("#")
):
in_s21_table = False
out.append(line)
new_sec = "".join(out)
return markdown[: m.start(1)] + new_sec + markdown[m.end(1) :]
def _build_strategy_draft_llm_payload_and_user(
*,
@ -234,6 +403,10 @@ def _build_strategy_draft_llm_payload_and_user(
rd = rules_md
else:
rd = _truncate_rules_draft_md(rules_md, rules_max)
pps = compact.get("price_promotion_signals")
promo_brief_cn = price_promotion_signals_strategy_brief_cn(
pps if isinstance(pps, dict) else {}
)
payload: dict[str, Any] = {
"job_id": job_id,
"keyword": keyword,
@ -244,6 +417,7 @@ def _build_strategy_draft_llm_payload_and_user(
),
"business_notes": business_notes,
"structured_brief": compact,
"strategy_price_promotion_brief_cn": promo_brief_cn,
"rules_draft_markdown": rd,
"report_strategy_excerpt": ex,
"report_matrix_group_evidence_md": gm,
@ -429,7 +603,10 @@ def generate_strategy_draft_markdown_llm(
report_matrix_group_evidence_md=report_matrix_group_evidence_md,
report_config=report_config,
)
return call_llm(STRATEGY_SYSTEM, user)
raw = call_llm(
STRATEGY_SYSTEM, user, temperature=_strategy_llm_temperature()
)
return sanitize_strategy_s21_pain_column_md(raw)
STRATEGY_OPPORTUNITIES_SYSTEM = (
@ -583,23 +760,31 @@ def generate_strategy_opportunities_llm(
payload["prior_chapter_llm_narratives"] = narratives
user = _user_from_payload(payload)
if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp):
return call_llm(sys_prompt, user)
return call_llm(
sys_prompt, user, temperature=_strategy_llm_temperature()
)
for cap_brief in (40_000, 32_000, 26_000, 20_000, 16_000, 14_000, 12_000, 10_000):
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
payload = {"keyword": keyword, "competitor_brief": compact}
user = _user_from_payload(payload)
if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp):
return call_llm(sys_prompt, user)
return call_llm(
sys_prompt, user, temperature=_strategy_llm_temperature()
)
for cap_brief in (14_000, 12_000, 10_000, 8_000):
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
payload = {"keyword": keyword, "competitor_brief": compact}
user = _user_from_payload(payload)
if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp_relaxed):
return call_llm(sys_prompt, user)
return call_llm(
sys_prompt, user, temperature=_strategy_llm_temperature()
)
compact = compact_brief_for_llm(brief, max_chars=8_000)
payload = {"keyword": keyword, "competitor_brief": compact}
user = _user_from_payload(payload)
return call_llm(sys_prompt, user)
return call_llm(
sys_prompt, user, temperature=_strategy_llm_temperature()
)

View File

@ -137,25 +137,7 @@ def markdown_summary_from_brief(brief: dict[str, Any]) -> str:
)
lines.append("")
ckw = brief.get("comment_focus_keywords") or []
if ckw:
lines.extend(["## 评价关注词Top", ""])
for item in ckw[:10]:
if isinstance(item, dict):
lines.append(
f"- **{item.get('word') or ''}**{_num(item.get('count'))}"
)
lines.append("")
usc = brief.get("usage_scenarios") or []
if usc:
lines.extend(["## 用途/场景预设词组Top", ""])
for item in usc[:8]:
if isinstance(item, dict):
lines.append(
f"- **{item.get('scenario') or ''}**{_num(item.get('count'))} 条(约 {_pct(item.get('share_of_text_units'))} 文本单元)"
)
lines.append("")
# 已废弃comment_focus_keywords / usage_scenarios 子串词表统计不再写入 brief要点摘录亦不再展示。
hints = brief.get("strategy_hints") or []
if hints:

View File

@ -6,6 +6,7 @@ from collections import Counter
from typing import Any
from pipeline.competitor_report.csv_io import _collect_prices
from pipeline.competitor_report.price_promo import _analyze_price_promotions
from pipeline.competitor_report.price_stats import _price_stats_extended
@ -224,12 +225,11 @@ def filter_brief_for_strategy_matrix_group(
if fb_one:
f0 = fb_one[0]
b["comment_focus_keywords"] = list(f0.get("focus_keyword_hits") or [])
scenarios_top = f0.get("scenarios_top") or []
b["usage_scenarios"] = list(scenarios_top)
denom = int(f0.get("effective_comment_text_units") or 0)
if denom <= 0:
denom = int(f0.get("comment_rows") or 0)
b["comment_focus_keywords"] = []
b["usage_scenarios"] = []
b["usage_scenarios_denominator"] = denom
else:
b["comment_focus_keywords"] = []
@ -285,7 +285,7 @@ def filter_brief_for_strategy_matrix_group(
else:
b["notes"] = [extra]
b["price_promotion_signals"] = []
b["price_promotion_signals"] = _analyze_price_promotions(rows_for_price)
b["strategy_hints"] = []
b["list_visibility_proxy"] = {

View File

@ -268,8 +268,7 @@ def generate_report_charts(
) -> list[str]:
"""生成扇形/条形 PNG。返回已写入的文件名列表不含路径
``report_config["chapter8_text_mining_probe"]`` 为真****生成 ``chart_focus_and_scenarios_bar__*.png``
与竞品报告 §8.2 文本挖掘探针互斥避免无效产出
****生成 ``chart_focus_and_scenarios_bar__*.png``预设关注词/场景子串统计已废弃
"""
_setup_matplotlib_cjk()
import matplotlib.pyplot as plt
@ -556,10 +555,7 @@ def generate_report_charts(
core = "group"
return f"i{index:02d}_{core}"
_skip_focus_scenario_combo = bool(
isinstance(report_config, dict)
and report_config.get("chapter8_text_mining_probe")
)
_skip_focus_scenario_combo = True
if _skip_focus_scenario_combo:
for fp in out_dir.glob("chart_focus_and_scenarios_bar__*.png"):
try:

View File

@ -15,12 +15,34 @@ def _strip_inline_md(s: str) -> str:
return s
_RE_TASK_CHECKED = re.compile(r"^\[x\]\s*", re.IGNORECASE)
_RE_TASK_UNCHECKED = re.compile(r"^\[ \]\s*")
# GFM 表头分隔行:每格为 :--- / ---: / :---: / ---------- 等(至少 3 个连字符)
_TABLE_SEP_CELL = re.compile(r"^:?-{3,}:?$")
def _strip_gfm_task_list_prefix(text: str) -> str:
"""去掉 ``- [x]`` / ``- [ ]`` 中的任务标记,导出时用普通项目符号,观感接近 MD 预览圆点。"""
t = text.strip()
m = _RE_TASK_CHECKED.match(t)
if m:
return t[m.end() :].strip()
m = _RE_TASK_UNCHECKED.match(t)
if m:
return t[m.end() :].strip()
return text
def _is_table_sep(line: str) -> bool:
t = line.strip()
if not t.startswith("|"):
"""GFM 表头与表体之间的分隔行(含任意长度连字符,如 ``|----------|``)。"""
row_line = line.strip()
if not row_line.startswith("|"):
return False
inner = t.strip("|").replace(" ", "")
return bool(inner) and all(p in ("", "---", ":---", "---:", ":---:") for p in t.split("|"))
cells = [c.strip() for c in row_line.strip("|").split("|")]
sep_cells = [c for c in cells if c]
if len(sep_cells) < 2:
return False
return all(_TABLE_SEP_CELL.match(c) is not None for c in sep_cells)
_RE_HEADING = re.compile(r"^(#{1,6})\s+(.+)$")
@ -32,6 +54,38 @@ _RE_HR = re.compile(r"^\s*(?:[-*_]\s*){3,}\s*$")
_img_line = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$")
def _join_md_soft_break_lines(lines: list[str]) -> str:
"""把编辑器/模型折行产生的多行合并为一段(等价于 CommonMark 软换行 → 空格)。"""
parts = [ln.strip() for ln in lines if ln and ln.strip()]
if not parts:
return ""
return " ".join(parts)
def _is_plain_markdown_line(s: str) -> bool:
"""是否可作为「正文折行」参与合并的一行(非标题/列表/表格等)。"""
t = s.strip()
if not t:
return False
if t.startswith("```"):
return False
if _RE_HR.match(s):
return False
if _match_heading(s) is not None:
return False
if _img_line.match(t):
return False
if t.startswith("|"):
return False
if _RE_UL.match(s):
return False
if _RE_OL.match(s):
return False
if _RE_BLOCKQUOTE.match(s):
return False
return True
def _match_heading(line: str) -> tuple[int, str] | None:
"""返回 (docx level 08, 标题文本) 或 None。"""
m = _RE_HEADING.match(line.strip())
@ -57,7 +111,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
pass
def _add_list_bullet(text: str) -> None:
t = _strip_inline_md(text)
t = _strip_gfm_task_list_prefix(_strip_inline_md(text))
try:
doc.add_paragraph(t, style="List Bullet")
except KeyError:
@ -73,9 +127,24 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
lines = (md or "").replace("\r\n", "\n").split("\n")
i = 0
in_fence = False
plain_buf: list[str] = []
def flush_plain() -> None:
if not plain_buf:
return
merged = _join_md_soft_break_lines(plain_buf)
plain_buf.clear()
if not merged:
return
p = doc.add_paragraph()
p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
p.add_run(_strip_inline_md(merged))
while i < len(lines):
raw = lines[i]
if raw.strip().startswith("```"):
if not in_fence:
flush_plain()
in_fence = not in_fence
i += 1
continue
@ -90,23 +159,27 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
line = raw.rstrip()
if not line.strip():
flush_plain()
doc.add_paragraph("")
i += 1
continue
if _RE_HR.match(line):
flush_plain()
doc.add_paragraph("")
i += 1
continue
hm = _match_heading(line)
if hm is not None:
flush_plain()
doc.add_heading(hm[1], level=hm[0])
i += 1
continue
mimg = _img_line.match(line.strip())
if mimg and asset_root is not None:
flush_plain()
rel = mimg.group(2).strip()
if not (rel.startswith("http://") or rel.startswith("https://")):
img_path = (asset_root / rel).resolve()
@ -121,6 +194,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
continue
if line.strip().startswith("|"):
flush_plain()
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
row_line = lines[i].strip()
@ -142,18 +216,21 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
mu = _RE_UL.match(line)
if mu:
flush_plain()
_add_list_bullet(mu.group(1))
i += 1
continue
mo = _RE_OL.match(line)
if mo:
flush_plain()
_add_list_number(mo.group(2))
i += 1
continue
mq = _RE_BLOCKQUOTE.match(line)
if mq:
flush_plain()
inner = mq.group(1).strip()
if inner:
p = doc.add_paragraph()
@ -162,12 +239,18 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
i += 1
continue
if _is_plain_markdown_line(line):
plain_buf.append(line)
i += 1
continue
flush_plain()
p = doc.add_paragraph()
p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
text = _strip_inline_md(line)
p.add_run(text)
p.add_run(_strip_inline_md(line))
i += 1
flush_plain()
bio = BytesIO()
doc.save(bio)
return bio.getvalue()
@ -262,19 +345,22 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
fontName=font_name,
fontSize=10,
leading=14,
spaceAfter=3,
)
h1s = ParagraphStyle(
name="H1CJK",
parent=body,
fontSize=16,
leading=20,
spaceAfter=8,
spaceBefore=0,
spaceAfter=10,
)
h2s = ParagraphStyle(
name="H2CJK",
parent=body,
fontSize=13,
leading=17,
spaceBefore=14,
spaceAfter=6,
)
h3s = ParagraphStyle(
@ -282,6 +368,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
parent=body,
fontSize=12,
leading=16,
spaceBefore=10,
spaceAfter=5,
)
h4s = ParagraphStyle(
@ -289,6 +376,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
parent=body,
fontSize=11,
leading=15,
spaceBefore=8,
spaceAfter=4,
)
h56s = ParagraphStyle(
@ -296,6 +384,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
parent=body,
fontSize=10.5,
leading=14,
spaceBefore=6,
spaceAfter=3,
)
quote_style = ParagraphStyle(
@ -305,25 +394,50 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
fontSize=9.5,
textColor=colors.HexColor("#444444"),
)
# 项目符号用 Helvetica 绘制:正文 CJK 字体常缺 U+2022「•」会落成方框似 ☐)
bullet_body = ParagraphStyle(
name="BulletBodyCJK",
parent=body,
leftIndent=18,
bulletIndent=8,
leftIndent=22,
bulletIndent=10,
firstLineIndent=0,
bulletFontName="Helvetica",
bulletFontSize=10,
wordWrap="CJK",
)
ol_body = ParagraphStyle(
name="OlBodyCJK",
parent=body,
leftIndent=18,
firstLineIndent=0,
wordWrap="CJK",
)
story: list[Any] = []
lines = (md or "").replace("\r\n", "\n").split("\n")
i = 0
in_fence = False
plain_buf: list[str] = []
def _para_cell(s: str, style: Any) -> Paragraph:
return Paragraph(xml_escape(_strip_inline_md(s)), style)
def flush_plain_pdf() -> None:
if not plain_buf:
return
merged = _join_md_soft_break_lines(plain_buf)
plain_buf.clear()
if not merged:
return
story.append(
Paragraph(xml_escape(_strip_inline_md(merged)), body)
)
while i < len(lines):
raw = lines[i]
if raw.strip().startswith("```"):
if not in_fence:
flush_plain_pdf()
in_fence = not in_fence
i += 1
continue
@ -334,17 +448,20 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
i += 1
continue
if not s.strip():
flush_plain_pdf()
story.append(Spacer(1, 0.15 * cm))
i += 1
continue
if _RE_HR.match(s):
flush_plain_pdf()
story.append(Spacer(1, 0.2 * cm))
i += 1
continue
hm = _match_heading(s)
if hm is not None:
flush_plain_pdf()
level, title = hm
title_esc = xml_escape(title)
if level == 0:
@ -362,6 +479,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
mimg = _img_line.match(s.strip())
if mimg and asset_root is not None:
flush_plain_pdf()
rel = mimg.group(2).strip()
if not (rel.startswith("http://") or rel.startswith("https://")):
img_path = (asset_root / rel).resolve()
@ -381,6 +499,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
continue
if s.strip().startswith("|"):
flush_plain_pdf()
rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"):
row_line = lines[i].strip()
@ -393,47 +512,51 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
if rows:
max_cols = max(len(r) for r in rows)
pad_rows = [r + [""] * (max_cols - len(r)) for r in rows]
usable_w = 13 * cm
col_w = usable_w / float(max_cols)
data: list[list[Any]] = []
for row in pad_rows:
data.append(
[_para_cell(c, body) for c in row]
usable_w = 17 * cm
if max_cols == 2:
col_widths = [4.2 * cm, usable_w - 4.2 * cm]
else:
col_widths = [usable_w / float(max_cols)] * max_cols
data = [[_para_cell(c, body) for c in row] for row in pad_rows]
t = Table(data, colWidths=col_widths, repeatRows=1)
tbl_cmds: list[tuple[Any, ...]] = [
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#c8c8c8")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
if pad_rows:
tbl_cmds.append(
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#ececec"))
)
t = Table(data, colWidths=[col_w] * max_cols)
t.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
t.setStyle(TableStyle(tbl_cmds))
story.append(t)
story.append(Spacer(1, 0.15 * cm))
story.append(Spacer(1, 0.2 * cm))
continue
mu = _RE_UL.match(s)
if mu:
txt = xml_escape(_strip_inline_md(mu.group(1)))
story.append(Paragraph(f"{txt}", bullet_body))
flush_plain_pdf()
inner = _strip_gfm_task_list_prefix(_strip_inline_md(mu.group(1)))
txt = xml_escape(inner)
story.append(Paragraph(txt, bullet_body, bulletText="\u2022"))
i += 1
continue
mo = _RE_OL.match(s)
if mo:
flush_plain_pdf()
n, rest = mo.group(1), mo.group(2)
txt = xml_escape(_strip_inline_md(rest))
story.append(Paragraph(f"{n}. {txt}", bullet_body))
story.append(Paragraph(f"{n}. {txt}", ol_body))
i += 1
continue
mq = _RE_BLOCKQUOTE.match(s)
if mq:
flush_plain_pdf()
inner = mq.group(1).strip()
if inner:
story.append(
@ -442,10 +565,16 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
i += 1
continue
plain = _strip_inline_md(s)
story.append(Paragraph(xml_escape(plain), body))
if _is_plain_markdown_line(s):
plain_buf.append(s)
i += 1
continue
flush_plain_pdf()
story.append(Paragraph(xml_escape(_strip_inline_md(s)), body))
i += 1
flush_plain_pdf()
buf = BytesIO()
doc = SimpleDocTemplate(
buf,

View File

@ -1,5 +1,5 @@
"""
市场策略 Markdown 草稿**规则骨架**占位 + 少量数据摘录供业务与大模型成稿对齐
市场策略 Markdown 草稿**规则骨架**占位 + 必要表单项不铺陈与报告同构的统计摘录供业务与大模型成稿对齐
- 决策在策略生成表单完成未填项由大模型结合摘要与报告节选补全
- 骨架刻意短可执行避免与成稿重复的假设 / 待验证套话
@ -9,29 +9,11 @@ from __future__ import annotations
import math
from typing import Any
from .brief_concentration import (
concentration_first_share,
concentration_top_three_share,
)
def _esc(s: Any) -> str:
t = "" if s is None else str(s).strip()
return t.replace("\r\n", "\n").replace("\r", "\n")
def _pct(x: Any) -> str:
if x is None:
return ""
try:
v = float(x)
if math.isnan(v) or math.isinf(v):
return ""
return f"{100 * v:.1f}%"
except (TypeError, ValueError):
return ""
def _num(x: Any) -> str:
if x is None:
return ""
@ -48,67 +30,6 @@ def _num(x: Any) -> str:
return str(x)
def _cr_narrative(
label: str,
cr1: Any,
cr3: Any,
top: Any,
*,
first_share_wording: tuple[str, str] | None = None,
) -> str | None:
"""从集中度生成一句策略向描述,无数据则返回 None正文避免英文缩写
``first_share_wording`` ``(第一大句前缀, 前三大句前缀)`` 时覆盖默认措辞用于矩阵收窄口径
避免误写列表行
"""
try:
c1 = float(cr1) if cr1 is not None else None
except (TypeError, ValueError):
c1 = None
if c1 is None and not (top or "").strip():
return None
top_s = _esc(top) or ""
if first_share_wording is not None:
w1, w3 = first_share_wording
elif "店铺" in label:
w1, w3 = "第一大店铺约占列表行的", "前三大店铺合计约占"
elif "品牌" in label:
w1, w3 = "第一大品牌约占", "前三大品牌合计约占"
else:
w1, w3 = "第一大主体约占", "前三大合计约占"
if c1 is not None:
if c1 >= 0.4:
tone = "偏高,头部资源集中"
elif c1 >= 0.25:
tone = "中等,存在可争夺空间"
else:
tone = "相对分散,差异化切入点可能更多"
return (
f"- **{label}**{w1} **{_pct(cr1)}**{w3} **{_pct(cr3)}**"
f"当前头部为「{top_s}」。*粗判:{tone}。*"
)
return f"- **{label}**:头部为「{top_s}」(缺少占比时可结合列表与商详数据补全)。"
def _shop_unique_sku_basis_lines(shops: dict[str, Any]) -> list[str]:
"""
``shops_from_list.unique_sku_basis`` 与竞品报告/摘要一致按去重 SKU 的店铺集中度对照口径
"""
usb = shops.get("unique_sku_basis") if isinstance(shops, dict) else None
if not isinstance(usb, dict) or not usb.get("n_unique_skus"):
return []
u1 = concentration_first_share(usb)
u3 = concentration_top_three_share(usb)
utop = _esc(usb.get("top_label") or "")
if u1 is None or not utop:
return []
return [
f"- **列表侧店铺(按去重 SKU**:共 **{_num(usb.get('n_unique_skus'))}** 个去重 SKU"
f"第一大店铺「{utop}」约占 **{_pct(u1)}**;前三合计 **{_pct(u3)}**。"
"*(与上行「按列表行」可能因同一 SKU 多行曝光而差异;非销量/市占。)*"
]
def _goal_bullet(label: str, user_val: str, placeholder: str) -> str:
v = _esc(user_val).strip()
if v:
@ -121,13 +42,117 @@ def _pillar_cell(user_val: str) -> str:
return v if v else "*待填*"
def _pos_mark(choice: str, key: str) -> str:
return "[x]" if choice == key else "[ ]"
def _price_position_llm_hint(pos: str) -> str:
"""给 LLM 的 §8.2 提示:单行取向,避免成稿复刻四选项勾选表单。"""
k = (pos or "").strip()
if k == "top":
core = "当前表单取向为贴顶:锚定中高位或头部价位带。"
elif k == "mid":
core = "当前表单取向为卡腰:围绕监测价带中位数一带。"
elif k == "entry":
core = "当前表单取向为下探:贴近监测价带区间下限。"
elif k == "different":
core = "当前表单取向为另起带:以规格、组合或服务形成差异化价位。"
else:
core = "表单未勾选价位取向;请结合 `structured_brief` 价带与业务判断补全。"
return (
f"- {core} 成稿 §8.2 仅用**连贯叙述句**展开定价逻辑;**禁止**四选项勾选清单、"
"并排「贴顶/卡腰/下探/另起带」问卷式排版、「(表单勾选)」及类似内部提示语。"
)
def _risk_line(checked: bool, text: str) -> str:
mark = "[x]" if checked else "[ ]"
return f"- {mark} {text}"
def _price_position_display_line(pos: str) -> str:
"""下载稿 §8.2:单行交待表单价位取向,不铺陈四勾选清单。"""
k = (pos or "").strip()
if k == "top":
core = "**贴顶**(锚定中高位或头部价位带)"
elif k == "mid":
core = "**卡腰**(围绕监测价带中位数一带)"
elif k == "entry":
core = "**下探**(贴近监测价带区间下限)"
elif k == "different":
core = "**另起带**(规格/组合或服务差异化价位)"
else:
core = "(表单未勾选;请结合价带与业务判断)"
return (
f"- **价位取向(表单)**{core}"
" 成稿 §8.2 用连贯叙述展开即可,不必复刻四选项勾选排版。"
)
def _nine_ten_markdown_blocks(
*,
rk: bool,
rp: bool,
rc: bool,
for_llm_input: bool,
) -> list[str]:
"""§九、§十:叙述式分条,避免勾选问卷体不利阅读。"""
items: list[tuple[str, str, bool]] = [
(
"评论与归纳口径",
"评论侧归纳是否存在以偏概全,宜结合原评论抽样核实。",
rk,
),
(
"价格带与清洗规则",
"价格带是否包含大促或异常挂价,宜核对数据清洗规则。",
rp,
),
(
"列表曝光与深入样本",
"列表侧集中度与深入样本中的品牌结构是否不一致,宜说明渠道或口径差异。",
rc,
),
]
out: list[str] = [
"## 九、风险、假设与待验证",
"",
]
if for_llm_input:
out.append(
"*§9 须用**短段落或分条叙述**写风险、假设与验证或应对;**禁止** `[ ]`/`[x]` 勾选、问卷式排版,"
"或仅堆疑问句而无动作。*"
)
out.append("")
else:
out.append(
"*成稿:每条风险带**应对动作或验证计划**;下列为业务表单关注点。*"
)
out.append("")
for title, body, checked in items:
tag = (
" *(业务已在表单中勾选「已知晓」,成稿须优先写清验证或应对。)*"
if checked
else ""
)
out.append(f"- **{title}**{body}{tag}")
out.append("")
if not for_llm_input:
out.extend(["*业务备注见下节。*", ""])
out.extend(
[
"## 十、下一步与节奏",
"",
]
)
if for_llm_input:
out.append(
"*§10 须列**可执行动作**(可补负责人/时间),与 §2.1 / §六 优先级一致;**禁止** `[ ]` 待办勾选格式。*"
)
out.append("")
else:
out.append("*成稿:可执行任务清单;可补负责人与时间。*")
out.append("")
out.extend(
[
"- 锁定主推款与对标,并完成法务与合规核对。",
"- 统一对外数据口径与话术。",
"- 下轮监测更新后迭代策略。",
"",
]
)
return out
def filter_strategy_hints_for_ch8_probe(hints: Any) -> list[str]:
@ -156,8 +181,8 @@ def report_uses_chapter8_text_mining_probe(report_config: dict[str, Any] | None)
"""
与任务 ``report_config`` ``chapter8_text_mining_probe`` 一致未显式设置时默认 ``True``
``jd.runner.get_default_report_config`` 一致
用于 §1.2 文案分支及对 ``strategy_hints`` 的过滤开启探针时与子串命中枚举相关的自动线索会被压掉
关闭时 §1.2 说明简报不附带预设关注词/场景子串统计评论侧以报告第八章探针若启用与原文为准
用于 §1.2 短指引分支及对 ``strategy_hints`` 的过滤开启探针时与子串命中枚举相关的自动线索会被压掉
关闭时 §1.2 提示简报不附带预设关注词/场景子串统计枚举
"""
if not isinstance(report_config, dict):
return True
@ -246,12 +271,12 @@ def build_strategy_draft_markdown(
)
_table_goal_type = _scope_cell(sgt, _goal_type_placeholder)
_summary_user_side = (
"- **用户侧***一两句结论即可:讨论焦点与负向主题;**按细类分句**归纳,**勿**混成「全站用户」一句;**勿**展开与报告重复的细类统计、词频。)*"
"- **用户侧***结论句:讨论焦点与负向主题;按细类分句;勿复述报告统计摘录。)*"
if not for_llm_input
else "- **用户侧**:—"
)
_summary_stage = (
"- **阶段重点***须含 12 条**可执行动作**回扣 §2 优先痛点;**尽量点明适用类目/主推线**;勿仅写「加强运营」。)*"
"- **阶段重点***12 条可执行动作,点明类目/主推线。)*"
if not for_llm_input
else "- **阶段重点**:—"
)
@ -308,7 +333,7 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*成稿须与 §2 一致:写清「谁在什么任务下检索、决策」及**主攻类目/细类**(与 §2.1 类目列可对上),为后文「针对痛点怎么做」埋伏笔。*",
"*成稿写清谁在何任务下检索与决策、主攻类目/细类。*",
"",
]
),
@ -317,30 +342,19 @@ def build_strategy_draft_markdown(
]
)
if use_ch8_probe:
if not for_llm_input:
lines.extend(
[
"*当前任务以**第八章评论侧文本挖掘**为主呈现时,此处**不**逐条罗列关注词子串命中次数。*",
"",
"- **饼干 / 糕点 / 面点等***(骨架占位;成稿**分细类**各一句归纳用户关心点,**勿**合并成模糊「全池」一句;**勿**复述 §8 词频与条数。)*",
"",
]
)
_sec12 = (
"*评论侧以报告**第八章文本挖掘**为准;成稿在此按**细类各一两句**写讨论焦点,勿铺陈词频次数、共现、类目条数表等与报告重复的摘录。*"
if for_llm_input
else "*评论侧见报告第八章文本挖掘;成稿按细类各一两句,勿铺陈词频、共现、类目条数表等摘录。*"
)
else:
if not for_llm_input:
lines.append(
"*简报中**不再**附带预设关注词/场景子串统计;评论侧请依据同任务《竞品分析报告》**第八章第二节**(文本挖掘探针,若已启用)及抽样原文撰写本节。*"
)
lines.append("")
mix = brief.get("category_mix_top") or []
if mix:
lines.append("### 类目结构(摘录)")
lines.append("")
for item in mix[:6]:
if isinstance(item, dict):
lines.append(f"- {_esc(item.get('label'))}{_num(item.get('count'))}")
lines.append("")
_sec12 = (
"*简报不附带预设关注词/场景子串统计枚举;评论侧见报告第八章及原文抽样;成稿按细类各一两句,勿铺陈统计摘录。*"
if for_llm_input
else "*简报中**不再**附带预设关注词/场景子串统计;评论侧见报告第八章;成稿按细类各一两句,勿铺陈与报告重复的统计摘录。*"
)
lines.append(_sec12)
lines.append("")
lines.extend(
[
@ -386,11 +400,7 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*本节**仅**用下表写清**针对痛点要怎么做****类目** + 痛点简述 + 动作 + 落地 + 验证)。**不再**单设「痛点表 / 价值对表 / 负向归因」子节,避免与 §三、§八重复。*",
"",
"*「用户痛点(简述)」须与 `structured_brief` / 策略线索 / 报告节选**可核对****禁止**编造「用户反馈『……』」式引语,除非原句已出现在上述输入中。*",
"",
"*「类目/细类」列:写明本行决策**适用于哪一类**(如饼干/面包/全检索池);多细类须**分行****禁止**用一句「全站」覆盖彼此冲突的策略;类目未定可写「待业务定类」并附分类假设。*",
"*本节仅 §2.1 一表;痛点与 brief/报告可核对;多类目分行;勿编造用户引语。*",
"",
]
),
@ -428,47 +438,40 @@ def build_strategy_draft_markdown(
"",
]
)
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
lines.append(
f"- **检索结果量级(需求侧参考,非销售额)**{_num(raw.get('result_count_consensus'))}(站内匹配条数量级)"
if for_llm_input
else f"- **检索结果量级(需求侧参考,非销售额)**{_num(raw.get('result_count_consensus'))}(列表 resultCount"
)
elif merged_n is not None:
lines.append(f"- **深入样本 SKU 数(监测范围)**{_num(merged_n)}")
if for_llm_input:
# 检索量级、价带统计已在 structured_brief/报告;勿写入 rules避免模型复述进策略正文。
lines.append("")
else:
lines.append(
"- **检索与样本尺度**:—"
if for_llm_input
else "- **检索与样本尺度***(成稿结合摘要与监测范围。)*"
)
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_disp}n = {_num(pst.get('n'))}"
f"区间 {_num(pst.get('min'))}{_num(pst.get('max'))};中位数 {_num(pst.get('median'))}",
"",
]
)
else:
if for_llm_input:
lines.append("- **价带摘录**:监测摘要中暂无统计表,可结合同任务报告补一句与购买理由相关的价位锚点。")
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
rc = _num(raw.get("result_count_consensus"))
lines.append(
f"- **站内检索匹配条数量级**{rc}(列表 resultCount非销售额口径"
)
elif merged_n is not None:
lines.append(f"- **深入监测样本 SKU 数**{_num(merged_n)}")
else:
lines.append("- **检索与样本尺度***(成稿结合摘要与监测范围。)*")
lines.append("")
if pst.get("n"):
src = _esc(brief.get("price_stats_source")) or ""
src_disp = src
lines.extend(
[
f"- **本批样本价带**:来源 {src_disp}n = {_num(pst.get('n'))}"
f"区间 {_num(pst.get('min'))}{_num(pst.get('max'))};中位数 {_num(pst.get('median'))}",
"",
]
)
else:
lines.append(
"*摘要中无价带统计,成稿可结合本批次价格数据在本节补一句价位锚点;**勿**重复 §2 已写的应对动作。*"
)
lines.append("")
lines.append(
"- **购买理由***(成稿:**购买者视角**——买家为何选这一款;承接上列依据与 §2 优先痛点;多细类则分句;**勿**只写品类风口或运营叙事;价带/规格动作已在 §2 表内则此处**勿再展开一遍**。)*"
)
lines.append("")
lines.append(
"- **购买理由(须站在购买者一侧写)**:用 12 句写清**买家为何愿意下单这一款**——解决什么顾虑、在货架上凭什么选它(获得感、可感知利益、价位是否值得等);上列检索/价带仅作背景,勿喧宾夺主。"
"可用「用户/消费者」作主语,**禁止**用纯运营口吻(如「适合××叙事切入」「策略上占位」)代替购买动机;**禁止**以品类宏观句收尾而不落到本品可验证点。"
if for_llm_input
else "- **购买理由***(成稿:**购买者视角**——买家为何选这一款;承接上列依据与 §2 优先痛点;多细类则分句;**勿**只写品类风口或运营叙事;价带/规格动作已在 §2 表内则此处**勿再展开一遍**。)*"
)
lines.append("")
lines.extend(
[
@ -484,7 +487,7 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*成稿:承诺与调性须能落到**触点**(商详/包装/客服首句等)上的**具体句子****若**多类目话术不同,按 §2.1 类目**分句**,勿仅形容词。*",
"*承诺与调性落到触点;多类目按 §2.1 分句。价位见 §8.2。*",
"",
]
),
@ -510,104 +513,42 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*本节写**用户为何信任、为何愿意选这个品牌**(承诺、证据、合规边界);**价位阵地**(表单勾选的四类取向)见 **§8.2 定价策略**,勿混写。*",
"*信任与证据;与 §8.2 价位叙述分开写。*",
"",
]
),
]
)
conc = brief.get("concentration") or {}
shops = conc.get("shops_from_list") or {}
dbrand = conc.get("detail_brand_among_merged") or {}
scope_ap = brief.get("strategy_scope_applied")
scoped_matrix = isinstance(scope_ap, dict) and bool(scope_ap.get("group"))
gname_scoped = _esc(scope_ap.get("group")) if scoped_matrix else ""
lines.extend(
[
"## 五、与其它品牌有何不同",
"",
"### 5.1 对比对象(摘录)",
"",
]
)
if scoped_matrix:
lines.append(
"*本任务已按矩阵细类收窄:**下列店铺/品牌占比均按该分组内「深入合并 SKU」条数统计**"
"与全关键词 **PC 搜索列表行** 集中度**不是同一口径**;亦非销量或市占。*"
if not for_llm_input
else "*集中度:按所选矩阵分组内合并 SKU 条数;非全站列表行。*"
)
lines.append("")
shop_label = (
f"店铺分布(「{gname_scoped}」内样本 SKU"
if scoped_matrix
else "列表侧店铺集中度"
)
brand_label = (
f"品牌分布(「{gname_scoped}」内样本 SKU"
if scoped_matrix
else "深入样本内品牌集中度"
)
scoped_wording: tuple[str, str] | None = (
("第一大店铺约占该分组样本 SKU 的", "前三大店铺合计约占")
if scoped_matrix
else None
)
scoped_brand_wording: tuple[str, str] | None = (
("第一大品牌约占该分组样本 SKU 的", "前三大品牌合计约占")
if scoped_matrix
else None
)
n_shop = _cr_narrative(
shop_label,
concentration_first_share(shops),
concentration_top_three_share(shops),
shops.get("top_label"),
first_share_wording=scoped_wording,
)
n_brand = _cr_narrative(
brand_label,
concentration_first_share(dbrand),
concentration_top_three_share(dbrand),
dbrand.get("top_label"),
first_share_wording=scoped_brand_wording,
)
if n_shop:
lines.append(n_shop)
for uline in _shop_unique_sku_basis_lines(shops):
lines.append(uline)
if n_brand:
lines.append(n_brand)
if not n_shop and not n_brand:
lines.append(
"- **竞争结构**:监测摘要未含集中度摘录。"
if for_llm_input
else "*本摘要未含集中度指标,请结合本批次竞争结构数据补全。*"
)
lines.extend(
[
(
"*竞争格局与店铺/品牌集中度见同任务《竞品分析报告》;规则骨架**不**铺陈摘录,成稿**勿**复述「对比对象(摘录)」「店铺分布」「品牌分布」等与报告同构的长段。*"
if for_llm_input
else "*竞争格局与集中度见报告;成稿写清与谁对比、差异化与应对,**勿**在此铺陈店铺/品牌占比摘录。*"
),
"",
*(
[]
if for_llm_input
else [
"",
"- **环境自测**:头部强势时是侧翼还是正面替代?格局分散时是否用细分场景切入?",
"",
]
),
"",
(
"### 5.2 差异化方向"
"### 5.1 差异化方向"
if for_llm_input
else "### 5.2 差异化方向(占位)"
else "### 5.1 差异化方向(占位)"
),
"",
*(
[]
if for_llm_input
else [
"*成稿:相对竞品**多做什么/少做什么**,写**可执行的一步****若**差异因细类而异,**分类目**写(非空泛「更好」)。*",
"*差异化写清相对竞品多做什么/少做什么;细类不同则分写。*",
"",
]
),
@ -621,12 +562,10 @@ def build_strategy_draft_markdown(
"",
]
)
lines.append("### 5.3 竞争应对")
lines.append("### 5.2 竞争应对")
lines.append("")
if not for_llm_input:
lines.append(
"*成稿:在表单倾向基础上,写清**跟价/不跟价时具体话术或机制**(一句即可)。*"
)
lines.append("*竞争应对:跟价/不跟价时的话术或机制(可简短)。*")
lines.append("")
stance = _esc(d.get("competitive_stance") or "").strip()
stance_line = {
@ -663,7 +602,7 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*成稿:路径须与 **§2.1 针对痛点要怎么做** 可对齐;营销/总体策略为**动词句**,回扣痛点;**多类目并行**时**分线**写目标或写清主线/副线。*",
"*路径与 §2.1 对齐;动词句为主。*",
"",
]
),
@ -690,6 +629,7 @@ def build_strategy_draft_markdown(
pr = str(d.get("pillar_price") or "")
pch = str(d.get("pillar_channel") or "")
pcm = str(d.get("pillar_comm") or "")
tp = str(d.get("tactic_promotion") or "")
lines.extend(
[
"## 七、品牌四线:建设 · 打造 · 运营 · 体验",
@ -698,8 +638,7 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*与表单「4P 策略支柱」对应:产品 / 定价 / 渠道 / 传播。)*",
"*成稿:**每条线**至少一句——服务哪类痛点、本阶段**具体做哪一步****尽量**与 §2.1「类目/细类」可对上,多类目则**分句**(勿四条同一泛化句)。*",
"*四线对应产品/定价/渠道/传播;每条至少一句落地动作。*",
"",
]
),
@ -725,16 +664,12 @@ def build_strategy_draft_markdown(
if use_ch8_probe and not for_llm_input:
pst_sig = brief.get("price_promotion_signals") or {}
has_promo = isinstance(pst_sig, dict) and bool(pst_sig)
promo_hint = "*促销与活动线索:须与摘要 `price_promotion_signals` 及第六章/第九章已有归纳一致;无则勿编造具体满减门槛。*"
lines.extend(
[
"",
promo_hint
if has_promo
else "*促销与价差:若摘要或价格信号有归纳则承接;无则勿编造。*",
"",
]
promo_one = (
"*促销线索须与摘要中的价格/活动信号一致;无则勿编造门槛。*"
if has_promo
else "*价差与活动:有则承接摘要;无则勿编造。*"
)
lines.extend(["", promo_one, ""])
lines.extend(
[
@ -744,7 +679,7 @@ def build_strategy_draft_markdown(
[]
if for_llm_input
else [
"*成稿:四支柱分别回扣 **痛点→动作→落地**(可与 §2.1 呼应,避免纯重复);**若**产品/定价/促销因类目策略不同,**分细类**写子条,勿一条盖全站。*",
"*四支柱回扣痛点→动作→落地;类目不同则分细类。*",
"",
]
),
@ -754,26 +689,37 @@ def build_strategy_draft_markdown(
"",
"### 8.2 定价策略",
"",
"**价位阵地取向(表单勾选;与监测价带可对读)**",
"",
f"- {_pos_mark(pos, 'top')} **贴顶**:中高位或头部价位带。",
f"- {_pos_mark(pos, 'mid')} **卡腰**:围绕中位数一带。",
f"- {_pos_mark(pos, 'entry')} **下探**:贴近区间下限。",
f"- {_pos_mark(pos, 'different')} **另起带**:规格/组合/服务差异化。",
"",
f"- *(表单价格支柱:{_pillar_cell(pr)}*",
"",
]
)
if for_llm_input:
lines.append(_price_position_llm_hint(pos))
lines.extend(["", f"- *(表单价格支柱:{_pillar_cell(pr)}*", ""])
else:
lines.extend(
[
_price_position_display_line(pos),
"",
f"- *(表单价格支柱:{_pillar_cell(pr)}*",
"",
]
)
lines.extend(
[
"### 8.3 促销与活动策略",
"",
*(
[]
[
"*成稿须写**本品**拟采用的满减/满折或到手价规则(决策句),监测至多一句带过;勿把摘要里的行数占比当正文。*",
"",
]
if for_llm_input
else [
"*须写促销**原则**(券/到手价/跟价节奏);**满减、满折、跨店**等:能引用的写清来源;监测未捕获具体门槛时写「待与运营/后台对齐」,**勿**整节留空,**勿**编造门槛数字。*",
"*与 `price_promotion_signals`、报告第六章一致;勿虚构活动。*",
"*写清本阶段促销**决策**(拟满减/折扣档、跟价原则);勿写成报告统计段落。*",
"",
]
),
f"- *(表单促销策略:{_pillar_cell(tp)}*",
"",
"### 8.4 渠道与传播",
"",
f"- *(渠道/传播:{_pillar_cell(pch)} / {_pillar_cell(pcm)}*",
@ -784,41 +730,7 @@ def build_strategy_draft_markdown(
rk = bool(d.get("ack_risk_keywords"))
rp = bool(d.get("ack_risk_price"))
rc = bool(d.get("ack_risk_concentration"))
rk_kw = "评论侧归纳是否以偏概全?(需原评论抽样)"
lines.extend(
[
"## 九、风险、假设与待验证",
"",
_risk_line(rk, rk_kw),
_risk_line(rp, "价格带是否含大促/异常挂价?(需核对清洗规则)"),
_risk_line(rc, "列表集中度与深入样本品牌是否不一致?(需解释渠道差异)"),
"",
*(
[]
if for_llm_input
else [
"*成稿:每条风险尽量带**应对动作或验证计划**(抽样、核对规则),勿只列标题。*",
"",
"*业务备注见下节。*",
"",
]
),
"## 十、下一步与节奏",
"",
*(
[]
if for_llm_input
else [
"*成稿:下列为**可执行任务**(可补负责人/时间);与 §2.1 / §六 优先级一致;可含「按类目核对主图/商详与 §2.1 表」类项。*",
"",
]
),
"- [ ] 锁定主推款与对标;过法务与合规。",
"- [ ] 统一对外数据口径与话术。",
"- [ ] 下轮监测更新后迭代策略。",
"",
]
)
lines.extend(_nine_ten_markdown_blocks(rk=rk, rp=rp, rc=rc, for_llm_input=for_llm_input))
notes = _esc(business_notes)
lines.extend(
@ -864,11 +776,9 @@ def build_strategy_draft_markdown(
if bits:
lines.append(f"- **采集参数快照**{'; '.join(bits)}")
raw = brief.get("pc_search_raw") or {}
if raw.get("result_count_consensus") is not None:
if raw.get("result_count_consensus") is not None and not for_llm_input:
lines.append(
f"- **平台申报检索规模**{_num(raw.get('result_count_consensus'))}"
if for_llm_input
else f"- **列表申报规模resultCount**{_num(raw.get('result_count_consensus'))}"
f"- **列表申报规模resultCount**{_num(raw.get('result_count_consensus'))}"
)
if for_llm_input:
lines.extend(

View File

@ -451,6 +451,9 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
pillar_comm = serializers.CharField(
required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False
)
tactic_promotion = serializers.CharField(
required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False
)
audience_segment = serializers.CharField(
required=False, allow_blank=True, default="", max_length=500, trim_whitespace=False
)

View File

@ -0,0 +1,65 @@
"""
策略制定表单写入 ``strategy_decisions`` 的字段名 ``StrategyDraftRequestSerializer`` 对应项一致
不含 ``business_notes````generator````strategy_matrix_group*``由接口另字段承载
"""
from __future__ import annotations
from typing import Any
# 与 ``JobStrategyDraftView`` 中 ``strategy_decisions`` 字符串/选项列一致
STRATEGY_DECISION_TEXT_FIELD_NAMES: tuple[str, ...] = (
"product_role",
"stage_goal_type",
"time_horizon",
"success_criteria",
"non_goals",
"battlefield_one_line",
"positioning_choice",
"competitive_stance",
"pillar_product",
"pillar_price",
"pillar_channel",
"pillar_comm",
"tactic_promotion",
"audience_segment",
"competitor_reference",
"resource_notes",
"marketing_strategy",
"general_strategy",
)
STRATEGY_DECISION_BOOL_FIELD_NAMES: tuple[str, ...] = (
"ack_risk_keywords",
"ack_risk_price",
"ack_risk_concentration",
)
STRATEGY_DECISION_FIELD_NAMES: tuple[str, ...] = (
*STRATEGY_DECISION_TEXT_FIELD_NAMES,
*STRATEGY_DECISION_BOOL_FIELD_NAMES,
)
# POST 中不并入 ``strategy_decisions``、但与策略制定请求一并提交的字段
STRATEGY_DRAFT_POST_NON_DECISION_FIELD_NAMES: frozenset[str] = frozenset(
{
"business_notes",
"generator",
"strategy_matrix_group",
"strategy_matrix_group_index",
}
)
def build_strategy_decisions_dict(validated: dict[str, Any]) -> dict[str, Any]:
"""由 ``StrategyDraftRequestSerializer`` 的 ``validated_data`` 组装与线上一致的 ``strategy_decisions``。"""
out: dict[str, Any] = {}
for k in STRATEGY_DECISION_TEXT_FIELD_NAMES:
out[k] = validated.get(k) or ""
for k in STRATEGY_DECISION_BOOL_FIELD_NAMES:
out[k] = bool(validated.get(k))
return out
def empty_strategy_decisions() -> dict[str, Any]:
return build_strategy_decisions_dict({})

View File

@ -36,13 +36,15 @@ class BriefStrategyScopeTests(SimpleTestCase):
"brand": "B",
"shop": "S2",
"category": "休闲食品 > 饼干 > 粗粮饼干",
"list_price_show": "20",
"标价": "100",
"券后到手价": "80",
},
{
"brand": "B",
"shop": "S2",
"category": "休闲食品 > 饼干 > 苏打饼干",
"list_price_show": "22",
"标价": "50",
"券后到手价": "50",
},
],
},
@ -52,27 +54,11 @@ class BriefStrategyScopeTests(SimpleTestCase):
"group": "饮料",
"comment_rows": 5,
"effective_comment_text_units": 5,
"focus_keyword_hits": [{"word": "", "count": 2}],
"scenarios_top": [
{
"scenario": "解渴",
"count": 2,
"share_of_text_units": 0.4,
}
],
},
{
"group": "饼干",
"comment_rows": 8,
"effective_comment_text_units": 8,
"focus_keyword_hits": [{"word": "", "count": 3}],
"scenarios_top": [
{
"scenario": "早餐",
"count": 4,
"share_of_text_units": 0.5,
}
],
},
],
"usage_scenarios_by_matrix_group": [
@ -120,7 +106,17 @@ class BriefStrategyScopeTests(SimpleTestCase):
self.assertEqual(out["matrix_by_group"][0]["group"], "饼干")
self.assertEqual(len(out["matrix_by_group"][0]["skus"]), 2)
self.assertEqual(len(out["consumer_feedback_by_matrix_group"]), 1)
self.assertEqual(out["comment_focus_keywords"][0]["word"], "")
self.assertEqual(out["comment_focus_keywords"], [])
self.assertEqual(out["price_stats_source"], "strategy_scope_matrix_group_skus")
self.assertIn("strategy_scope_applied", out)
self.assertEqual(out["strategy_scope_applied"]["group"], "饼干")
def test_filter_recomputes_price_promotion_signals(self) -> None:
b = self._sample_brief()
out = filter_brief_for_strategy_matrix_group(b, matrix_group_index=1)
pps = out.get("price_promotion_signals")
self.assertIsInstance(pps, dict)
assert isinstance(pps, dict)
self.assertEqual(pps.get("row_count"), 2)
self.assertEqual(pps.get("rows_with_both_list_and_coupon"), 2)
self.assertEqual(pps.get("rows_coupon_below_list_price"), 1)

View File

@ -0,0 +1,149 @@
"""
第九章策略与机会与痛点叙事的单测对齐**不修改** runner / jd_report 等生产链路
背景当前流水线里 ``llm_sentiment_md`` 未传入 ``generate_strategy_opportunities_llm``
若产品上要策略与痛点叙事强绑定需要在编排层把 8.3 等节选并入 ``chapter_llm_narratives``
本文件仅在**单测**中演示直接向 ``generate_strategy_opportunities_llm`` 传入含痛点锚点的节选
并断言 **发给大模型的 user JSON** 中原样携带该锚点 ``STRATEGY_OPPORTUNITIES_SYSTEM``
转化与体验须呼应 sec8_3_*的约定一致
真机产出是否复述痛点属模型行为此处只测**输入契约**强绑定
"""
from __future__ import annotations
import json
from unittest.mock import patch
from django.test import SimpleTestCase
from pipeline.llm.generate_strategy import generate_strategy_opportunities_llm
def _parse_strategy_user_json(user_prompt: str) -> dict[str, object]:
"""``STRATEGY_OPPORTUNITIES_USER_PREFIX`` 后为单行或多行 JSON。"""
i = user_prompt.find("{")
assert i >= 0, "user_prompt 中应有 JSON 对象"
return json.loads(user_prompt[i:])
def _minimal_brief() -> dict:
"""供 ``compact_brief_for_llm`` 的最小合法 competitor_brief。"""
return {
"schema_version": 1,
"keyword": "单测词",
"batch_label": "test-batch",
"scope": {
"merged_sku_count": 1,
"comment_flat_rows": 3,
"structure_source_rows": 5,
"uses_pc_search_list_export": False,
"category_mix_source": "keyword_pipeline_merged",
"category_mix_valid_matrix_sku_count": 1,
},
"matrix_by_group": [],
"consumer_feedback_by_matrix_group": [],
"notes": [],
}
class Ch9StrategyPainNarrativeBindingTests(SimpleTestCase):
"""痛点叙事通过 ``prior_chapter_llm_narratives`` 进入第九章请求体。"""
_ANCHOR = "PAIN_ANCHOR_CH9_BINDING_TEST_7f3a"
def _fake_llm(self, captured: dict[str, str]):
def _fn(system_prompt: str, user_prompt: str, **kwargs) -> str:
captured["user"] = user_prompt
return (
"#### 定价与价带\n假设:待验证。\n\n"
"#### 差异化与应对齐的优势\n假设:待验证。\n\n"
"#### 风险与避免项\n假设:待验证。\n\n"
"#### 促销与活动机制\n输入未体现。\n\n"
"#### 转化与体验\n假设:待验证。\n"
)
return _fn
def test_sec8_3_text_mining_probe_narrative_carries_pain_anchor_in_user_json(
self,
) -> None:
"""系统提示要求转化与体验呼应 ``sec8_3_text_mining_probe``;节选须进入请求 JSON。"""
captured: dict[str, str] = {}
narratives = {
"sec8_3_text_mining_probe": (
"#### 饼干\n"
f"负向体验归纳(单测锚点):用户集中抱怨「口感发干、保质期偏短」。锚点标记 {self._ANCHOR}"
),
}
with patch(
"pipeline.llm.generate_strategy.call_llm",
side_effect=self._fake_llm(captured),
):
out = generate_strategy_opportunities_llm(
_minimal_brief(),
keyword="单测词",
chapter_llm_narratives=narratives,
)
self.assertIn("转化与体验", out)
user = captured.get("user", "")
self.assertIn(self._ANCHOR, user)
obj = _parse_strategy_user_json(user)
narr = obj.get("prior_chapter_llm_narratives") or {}
self.assertIn(self._ANCHOR, narr.get("sec8_3_text_mining_probe", ""))
def test_sec8_3_comment_focus_summaries_carries_pain_anchor_in_user_json(
self,
) -> None:
"""与探针二选一时的第八章节选键;同样须进入请求 JSON。"""
captured: dict[str, str] = {}
narratives = {
"sec8_3_comment_focus_summaries": (
f"细类评论要点:复购障碍与「漏发」相关讨论较多。锚点 {self._ANCHOR}"
),
}
with patch(
"pipeline.llm.generate_strategy.call_llm",
side_effect=self._fake_llm(captured),
):
generate_strategy_opportunities_llm(
_minimal_brief(),
keyword="单测词",
chapter_llm_narratives=narratives,
)
user = captured.get("user", "")
self.assertIn(self._ANCHOR, user)
obj = _parse_strategy_user_json(user)
narr = obj.get("prior_chapter_llm_narratives") or {}
self.assertIn(self._ANCHOR, narr.get("sec8_3_comment_focus_summaries", ""))
def test_extra_narrative_key_sec8_sentiment_passed_through_for_alignment(
self,
) -> None:
"""
``generate_strategy_opportunities_llm`` 会把 ``chapter_llm_narratives``
所有非空字符串键并入 ``prior_chapter_llm_narratives``无白名单过滤
单测层可用额外键如模拟 8.3 全文节选与系统提示与各键定性主题方向一致形成契约
生产是否增加该键仅影响编排不需改本函数签名
"""
captured: dict[str, str] = {}
narratives = {
"sec8_3_text_mining_probe": "探针摘要略。",
"sec8_3_comment_sentiment_themes": (
f"#### 饼干\n负向主题:配送挤压导致碎裂。锚点 {self._ANCHOR}"
),
}
with patch(
"pipeline.llm.generate_strategy.call_llm",
side_effect=self._fake_llm(captured),
):
generate_strategy_opportunities_llm(
_minimal_brief(),
keyword="单测词",
chapter_llm_narratives=narratives,
)
obj = _parse_strategy_user_json(captured["user"])
narr = obj.get("prior_chapter_llm_narratives") or {}
self.assertIn("sec8_3_comment_sentiment_themes", narr)
self.assertIn(self._ANCHOR, narr["sec8_3_comment_sentiment_themes"])
# 截断后锚点仍在(锚点放在短文首段即可)
self.assertLess(len(narr["sec8_3_comment_sentiment_themes"]), 5000)

View File

@ -49,7 +49,8 @@ class BuildCompetitorBriefTests(SimpleTestCase):
semantic_pool_max=10,
)
self.assertIn("sample_reviews_semantic_pool", pl)
self.assertEqual(pl.get("sentiment_bucket_method"), "keyword_substring_heuristic")
self.assertNotIn("comment_sentiment_lexicon", pl)
self.assertNotIn("negative_lexeme_hits_top", pl)
self.assertGreaterEqual(len(pl["sample_reviews_semantic_pool"]), 1)
def test_comment_sentiment_score_then_lexeme(self) -> None:
@ -61,7 +62,11 @@ class BuildCompetitorBriefTests(SimpleTestCase):
self.assertEqual(lex.get("negative_only"), 1)
self.assertEqual(lex.get("neutral_or_empty"), 1)
pl = build_comment_sentiment_llm_payload(texts, scores=scores)
self.assertEqual(pl.get("sentiment_bucket_method"), "score_then_lexeme")
dist = pl.get("star_rating_distribution") or {}
self.assertEqual(dist.get("score_1_2"), 1)
self.assertEqual(dist.get("score_3"), 1)
self.assertEqual(dist.get("score_4_5"), 1)
self.assertNotIn("comment_sentiment_lexicon", pl)
def test_comment_sentiment_all_scores_missing_falls_back_keyword(self) -> None:
texts = ["好吃推荐", "差评"]
@ -69,7 +74,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
lex = _comment_sentiment_lexicon(texts, scores)
self.assertEqual(lex.get("method"), "keyword_lexicon")
def test_custom_focus_words_in_report_config(self) -> None:
def test_brief_omits_preset_comment_focus_keywords(self) -> None:
with tempfile.TemporaryDirectory() as td:
run_dir = Path(td)
(run_dir / "pc_search_raw").mkdir(parents=True)
@ -88,8 +93,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
report_config={"comment_focus_words": ["自定义词阿尔法"]},
)
words = {x["word"] for x in out["comment_focus_keywords"]}
self.assertIn("自定义词阿尔法", words)
self.assertEqual(out["comment_focus_keywords"], [])
def test_matrix_groups_require_detail_category_path(self) -> None:
sku_h = "SKU(skuId)"
@ -145,43 +149,6 @@ class BuildCompetitorBriefTests(SimpleTestCase):
self.assertIn("店铺:", lines[0])
self.assertIn("整体口感还差点意思", lines[0])
def test_scenario_groups_llm_payload_matches_chapter8_sec2_right_rail_counts(
self,
) -> None:
sku_h = "SKU(skuId)"
merged = [
{
sku_h: "111",
"detail_category_path": "食品饮料 > 休闲食品 > 饼干 > 粗粮饼干",
"标题(wareName)": "A饼",
"detail_shop_name": "店甲",
},
]
scen = (("早餐/代餐", ("早餐",)),)
fb = jcr._consumer_feedback_by_matrix_group(
merged_rows=merged,
comment_rows=[
{"sku": "111", "tagCommentContent": "早上当早餐吃还不错"},
],
sku_header=sku_h,
)
pl = jcr.build_scenario_groups_llm_payload(
feedback_groups=fb,
scenario_groups=scen,
merged_rows=merged,
sku_header=sku_h,
title_h="标题(wareName)",
)
self.assertIn("groups", pl)
self.assertIn("scenario_lexicon", pl)
g0 = pl["groups"][0]
self.assertEqual(g0["group"], "饼干")
self.assertEqual(g0["effective_text_count"], 1)
self.assertEqual(g0["scenario_distribution"][0]["mention_rows"], 1)
self.assertEqual(
g0["scenario_distribution"][0]["scenario"], "早餐/代餐"
)
def test_cn_volume_int_parses_total_sales_trailer(self) -> None:
self.assertEqual(
_cn_volume_int("已售50万+ | good:99%好评"), 500_000

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import unittest
from pipeline.llm.keyword_suggest import _parse_phrases_object, _parse_scenarios_object
from pipeline.llm.keyword_suggest import _parse_phrases_object
class ParsePhrasesTests(unittest.TestCase):
@ -16,20 +16,5 @@ class ParsePhrasesTests(unittest.TestCase):
self.assertEqual(_parse_phrases_object(raw), ["低糖"])
class ParseScenariosTests(unittest.TestCase):
def test_min_triggers_in_parser(self) -> None:
raw = '{"scenarios": [{"label": "早餐", "triggers": ["早上"]}]}'
out = _parse_scenarios_object(raw)
self.assertEqual(len(out), 1)
self.assertEqual(out[0]["label"], "早餐")
self.assertEqual(out[0]["triggers"], ["早上"])
def test_fenced(self) -> None:
raw = '```\n{"scenarios": [{"label": "露营", "triggers": ["户外", "野餐"]}]}\n```'
out = _parse_scenarios_object(raw)
self.assertEqual(len(out), 1)
self.assertEqual(out[0]["label"], "露营")
if __name__ == "__main__":
unittest.main()

View File

@ -1,7 +1,10 @@
"""Markdown → docx/pdf 导出防回归docx 主循环须递增行指针)。"""
from __future__ import annotations
import io
from django.test import SimpleTestCase
from docx import Document
from pipeline.reporting.md_document_export import (
markdown_to_docx_bytes,
@ -21,3 +24,35 @@ class MdDocumentExportTests(SimpleTestCase):
data = markdown_to_pdf_bytes(md)
self.assertGreater(len(data), 100)
self.assertTrue(data.startswith(b"%PDF"))
def test_docx_merges_soft_line_breaks_in_paragraph(self) -> None:
"""模型/编辑器折行不应被当成多个独立段落。"""
md = "统一商详第\n1 屏核心话术\n\n下一段"
data = markdown_to_docx_bytes(md)
doc = Document(io.BytesIO(data))
texts = [p.text.strip() for p in doc.paragraphs if p.text.strip()]
self.assertEqual(texts, ["统一商详第 1 屏核心话术", "下一段"])
def test_docx_task_list_strips_prefix_uses_normal_bullet(self) -> None:
"""``- [x]`` / ``- [ ]`` 去掉方括号标记,用普通列表符号,观感接近 MD 预览。"""
md = "- [x] 卡腰:围绕中位。\n- [ ] 贴顶:高位。\n- 普通列表项"
data = markdown_to_docx_bytes(md)
doc = Document(io.BytesIO(data))
texts = [p.text for p in doc.paragraphs if p.text.strip()]
joined = "\n".join(texts)
self.assertNotIn("", joined)
self.assertNotIn("", joined)
self.assertNotIn("[x]", joined)
self.assertNotIn("[ ]", joined)
self.assertIn("卡腰:围绕中位", joined)
self.assertIn("贴顶:高位", joined)
self.assertTrue(any("普通列表项" in t for t in texts))
def test_docx_table_skips_long_dash_separator_row(self) -> None:
"""`|----------|` 分隔行不得作为表格正文行导出。"""
md = "| 差异点 | 说明 |\n|----------|----------|\n| A | B |"
data = markdown_to_docx_bytes(md)
doc = Document(io.BytesIO(data))
self.assertEqual(len(doc.tables), 1)
self.assertEqual(len(doc.tables[0].rows), 2)
self.assertNotIn("----------", doc.tables[0].rows[1].cells[0].text)

View File

@ -0,0 +1,27 @@
"""price_promotion_signals 策略摘要句与统计一致。"""
from __future__ import annotations
from django.test import SimpleTestCase
from pipeline.competitor_report.price_promo import (
_analyze_price_promotions,
price_promotion_signals_strategy_brief_cn,
)
class PricePromoStrategyBriefCnTests(SimpleTestCase):
def test_brief_cn_mentions_alignment_when_both_prices(self) -> None:
rows = [
{"标价": "100", "券后到手价": "80"},
{"标价": "50", "券后到手价": "50"},
]
p = _analyze_price_promotions(rows)
t = price_promotion_signals_strategy_brief_cn(p)
self.assertIn("同时有标价与券后", t)
self.assertRegex(t, r"可对齐\*\* 的行 \*\*2\*\*")
self.assertRegex(t, r"严格低于\*\*标价的行 \*\*1\*\*")
self.assertIn("§8.3 请写清", t)
def test_empty_dict_message(self) -> None:
t = price_promotion_signals_strategy_brief_cn({})
self.assertIn("未携带", t)

View File

@ -1,14 +1,40 @@
"""市场策略草稿 Markdown规则无 LLM"""
from __future__ import annotations
import json
from pathlib import Path
from django.test import SimpleTestCase
from pipeline.llm.generate_strategy import _omit_ch8_probe_wordchart_fields
from pipeline.llm.generate_strategy import sanitize_strategy_s21_pain_column_md
from pipeline.llm.generate_strategy import strategy_decisions_substantive
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
class StrategyDraftTests(SimpleTestCase):
def test_sanitize_s21_pain_column_strips_partial_competitor(self) -> None:
md = """## 二、产品价值与用户痛点
### 2.1 针对痛点要怎么做
| 类目/细类本决策适用 | 用户痛点简述 | 策略动作 | 具体怎么做 | 如何验证 |
|------------------------|----------------|----------|------------|----------|
| 粗粮饼干 | 用户希望在控糖的同时获得良好的饱腹感与口感但部分竞品口感偏硬或缺乏层次感 | A | B | C |
| 酥性饼干 | 部分竞品包装差 | 推小包装 | D | E |
## 三、为什么要买
"""
out = sanitize_strategy_s21_pain_column_md(md)
self.assertNotIn("部分竞品", out)
self.assertIn("控糖", out)
self.assertIn("良好的饱腹感", out)
# 起首以「部分竞品…」时整格收敛为典型场景假设占位
self.assertIn("典型场景假设", out)
def test_sanitize_s21_passthrough_without_section2(self) -> None:
self.assertEqual("## 一\nx", sanitize_strategy_s21_pain_column_md("## 一\nx"))
def test_strategy_decisions_substantive(self) -> None:
self.assertFalse(strategy_decisions_substantive(None))
self.assertFalse(strategy_decisions_substantive({}))
@ -36,13 +62,36 @@ class StrategyDraftTests(SimpleTestCase):
md = build_strategy_draft_markdown(
job_id=7,
keyword="K",
brief=brief,
brief={
**brief,
"category_mix_top": [
{"label": "粗粮饼干", "count": 11},
{"label": "酥性饼干", "count": 10},
],
"pc_search_raw": {"result_count_consensus": 333619},
"price_stats": {
"n": 21,
"min": 14.38,
"max": 64.97,
"median": 27.97,
},
},
generated_at_iso="2026-01-01",
for_llm_input=True,
strategy_decisions={"positioning_choice": "mid"},
)
self.assertNotIn("任务 ID", md)
self.assertNotIn("generate_strategy.py", md)
self.assertNotIn("strategy_hints", md)
self.assertNotIn("333619", md)
self.assertNotIn("27.97", md)
self.assertNotIn("价位阵地取向(表单勾选", md)
self.assertNotIn("- [ ] **贴顶**", md)
self.assertNotIn("- [ ] 锁定主推款", md)
self.assertNotIn("类目结构(摘录)", md)
self.assertNotIn("粗粮饼干", md)
self.assertIn("**评论与归纳口径**", md)
self.assertIn("当前表单取向为卡腰", md)
self.assertIn("监测摘要自动线索", md)
self.assertIn("列表页约第 13 页", md)
self.assertNotIn("埋伏笔", md)
@ -50,7 +99,7 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("回扣 §2", md)
self.assertNotIn("(占位)", md)
self.assertIn("### 1.3 本品聚焦\n", md)
self.assertIn("### 5.2 差异化方向\n", md)
self.assertIn("### 5.1 差异化方向\n", md)
def test_build_contains_sections_and_notes(self) -> None:
brief = {
@ -122,21 +171,24 @@ class StrategyDraftTests(SimpleTestCase):
self.assertIn("**本品角色**:追赶型", md)
self.assertIn("**营销策略**:内容种草+搜索承接", md)
self.assertIn("**总体策略**:先腰后顶", md)
self.assertIn("- [x] **卡腰**", md)
self.assertIn("- [ ] **贴顶**", md)
self.assertIn("**卡腰**", md)
i4 = md.find("## 四、为什么要选")
i5 = md.find("## 五、与其它品牌")
self.assertGreater(i5, i4)
self.assertNotIn("贴顶", md[i4:i5])
i82 = md.find("### 8.2 定价策略")
self.assertGreater(i82, 0)
self.assertGreater(md.find("- [x] **卡腰**"), i82)
self.assertGreater(md.find("卡腰", i82), i82)
self.assertNotIn("- [ ] **贴顶**", md)
self.assertIn("侧翼切入", md)
self.assertIn("做低糖配方", md)
self.assertIn("### 7.1 品牌建设", md)
self.assertIn("- [x] 评论侧归纳是否以偏概全", md)
self.assertIn("- [ ] 价格带是否含大促", md)
self.assertIn("- [x] 列表集中度与深入样本品牌是否不一致", md)
self.assertIn("**评论与归纳口径**", md)
self.assertIn("**价格带与清洗规则**", md)
self.assertIn("**列表曝光与深入样本**", md)
self.assertIn("业务已在表单中勾选", md)
self.assertNotIn("- [x] 评论侧", md)
self.assertNotIn("- [ ] 锁定主推款", md)
def test_chapter8_probe_omits_focus_scenario_count_bullets(self) -> None:
brief = {
@ -185,8 +237,22 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("子串统计命中约 **501**", md)
self.assertNotIn("场景「控糖", md)
def test_matrix_scope_concentration_not_list_rows_wording(self) -> None:
"""收窄矩阵时 concentration 来自分组内 SKU§5.1 勿写「列表行」。"""
def test_download_draft_omits_category_mix_excerpt(self) -> None:
md = build_strategy_draft_markdown(
job_id=1,
keyword="K",
brief={
"schema_version": 1,
"batch_label": "b",
"category_mix_top": [{"label": "粗粮饼干", "count": 11}],
},
for_llm_input=False,
)
self.assertNotIn("类目结构(摘录)", md)
self.assertNotIn("粗粮饼干", md)
def test_section_five_omits_concentration_excerpt_block(self) -> None:
"""规则骨架不再含「对比对象(摘录)」及店铺/品牌集中度铺陈。"""
brief = {
"schema_version": 1,
"keyword": "低GI",
@ -205,13 +271,14 @@ class StrategyDraftTests(SimpleTestCase):
},
}
md = build_strategy_draft_markdown(job_id=1, keyword="低GI", brief=brief)
self.assertIn("与全关键词 **PC 搜索列表行** 集中度**不是同一口径**", md)
self.assertIn("店铺分布(「饼干」内样本 SKU", md)
self.assertIn("该分组样本 SKU 的", md)
self.assertNotIn("列表侧店铺集中度", md)
self.assertNotIn("第一大店铺约占列表行的", md)
self.assertNotIn("对比对象(摘录)", md)
self.assertNotIn("店铺分布(「饼干」内样本 SKU", md)
self.assertNotIn("碧翠园京东自营旗舰店", md)
self.assertNotIn("23.8%", md)
self.assertIn("### 5.1 差异化方向", md)
self.assertIn("### 5.2 竞争应对", md)
def test_shops_unique_sku_basis_rendered(self) -> None:
def test_concentration_not_rendered_even_with_unique_sku_basis(self) -> None:
brief = {
"schema_version": 1,
"keyword": "测试",
@ -235,9 +302,8 @@ class StrategyDraftTests(SimpleTestCase):
keyword="测试",
brief=brief,
)
self.assertIn("按去重 SKU", md)
self.assertIn("120", md)
self.assertIn("35.0%", md)
self.assertNotIn("按去重 SKU", md)
self.assertNotIn("120", md)
def test_chapter8_probe_filters_strategy_hints_focus_scenario_lines(self) -> None:
brief = {
@ -259,8 +325,7 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("评价文本中「口感", md)
self.assertNotIn("用途/场景中「控糖", md)
self.assertIn("样本内品牌较分散", md)
self.assertIn("price_promotion_signals", md)
self.assertIn("price_promotion_signals", md)
self.assertIn("促销线索须与摘要中的价格/活动信号一致", md)
def test_ch8_probe_omit_wordchart_nested_in_consumer_feedback(self) -> None:
compact = {
@ -286,3 +351,80 @@ class StrategyDraftTests(SimpleTestCase):
self.assertEqual(
compact["consumer_feedback_by_matrix_group"][0].get("comment_rows"), 10
)
def test_strategy_decisions_full_lowgi_biscuit_fixture(self) -> None:
from pipeline.demos.run_strategy_decisions_full_fixture_demo import load_full_fixture
from pipeline.llm.generate_strategy import strategy_decisions_substantive
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
sd = load_full_fixture()
self.assertTrue(strategy_decisions_substantive(sd))
self.assertIn("tactic_promotion", sd)
self.assertIn("满 99", (sd.get("tactic_promotion") or ""))
brief = {
"schema_version": 1,
"keyword": "低GI饼干",
"batch_label": "demo_fixture",
"scope": {"merged_sku_count": 2},
"strategy_hints": ["fixture 联调"],
"meta": {"page_start": 1, "page_to": 3, "max_skus_config": 100},
"category_mix_top": [
{"label": "粗粮饼干", "count": 11},
],
"pc_search_raw": {"result_count_consensus": 100000},
"price_stats": {"n": 10, "min": 1, "max": 99, "median": 30},
}
md = build_strategy_draft_markdown(
job_id=0,
keyword="低GI饼干",
brief=brief,
business_notes="",
generated_at_iso="2026-01-01T00:00:00+00:00",
strategy_decisions=sd,
for_llm_input=False,
)
self.assertIn("表单促销策略", md)
self.assertIn("满 99", md)
self.assertIn("卡位监测中位", md)
def test_strategy_decision_field_names_match_strategy_draft_serializer(self) -> None:
from pipeline.serializers import StrategyDraftRequestSerializer
from pipeline.strategy_decision_keys import (
STRATEGY_DECISION_FIELD_NAMES,
STRATEGY_DRAFT_POST_NON_DECISION_FIELD_NAMES,
)
ser = StrategyDraftRequestSerializer()
names = set(ser.fields.keys()) - STRATEGY_DRAFT_POST_NON_DECISION_FIELD_NAMES
self.assertEqual(
names,
set(STRATEGY_DECISION_FIELD_NAMES),
"序列化器与 strategy_decision_keys 不一致:增删字段时须同时改 strategy_decision_keys 与 demo fixture",
)
def test_empty_strategy_decisions_has_all_21_keys(self) -> None:
from pipeline.strategy_decision_keys import (
STRATEGY_DECISION_FIELD_NAMES,
build_strategy_decisions_dict,
)
d = build_strategy_decisions_dict({})
self.assertEqual(len(STRATEGY_DECISION_FIELD_NAMES), 21)
self.assertEqual(set(d.keys()), set(STRATEGY_DECISION_FIELD_NAMES))
self.assertEqual(d["tactic_promotion"], "")
self.assertFalse(d["ack_risk_keywords"])
def test_strategy_draft_request_full_fixture_matches_serializer(self) -> None:
"""``strategy_draft_request_full_*.json`` 含 HTTP POST 全字段(含 generator / 矩阵作用域等)。"""
from pipeline.serializers import StrategyDraftRequestSerializer
from pipeline.strategy_decision_keys import build_strategy_decisions_dict
root = Path(__file__).resolve().parent.parent
p = root / "demos" / "fixtures" / "strategy_draft_request_full_lowgi_biscuit.json"
self.assertTrue(p.is_file(), f"缺少固定样例: {p}")
data = json.loads(p.read_text(encoding="utf-8"))
ser = StrategyDraftRequestSerializer(data=data)
self.assertTrue(ser.is_valid(), ser.errors)
sd = build_strategy_decisions_dict(ser.validated_data)
only = {k: v for k, v in data.items() if k in sd}
self.assertEqual(sd, only)

View File

@ -35,6 +35,7 @@ from ..reporting.report_matrix_group_evidence import (
)
from ..reporting.report_strategy_excerpt import load_report_strategy_excerpt
from ..reporting.strategy_draft import build_strategy_draft_markdown
from ..strategy_decision_keys import build_strategy_decisions_dict
from ..serializers import (
MarketingDetailPackRequestSerializer,
PipelineJobSerializer,
@ -139,28 +140,7 @@ class JobStrategyDraftView(APIView):
ser.is_valid(raise_exception=True)
vd = ser.validated_data
notes = (vd.get("business_notes") or "").strip()
strategy_decisions = {
"product_role": vd.get("product_role") or "",
"stage_goal_type": vd.get("stage_goal_type") or "",
"time_horizon": vd.get("time_horizon") or "",
"success_criteria": vd.get("success_criteria") or "",
"non_goals": vd.get("non_goals") or "",
"battlefield_one_line": vd.get("battlefield_one_line") or "",
"positioning_choice": vd.get("positioning_choice") or "",
"competitive_stance": vd.get("competitive_stance") or "",
"pillar_product": vd.get("pillar_product") or "",
"pillar_price": vd.get("pillar_price") or "",
"pillar_channel": vd.get("pillar_channel") or "",
"pillar_comm": vd.get("pillar_comm") or "",
"audience_segment": vd.get("audience_segment") or "",
"competitor_reference": vd.get("competitor_reference") or "",
"resource_notes": vd.get("resource_notes") or "",
"marketing_strategy": vd.get("marketing_strategy") or "",
"general_strategy": vd.get("general_strategy") or "",
"ack_risk_keywords": bool(vd.get("ack_risk_keywords")),
"ack_risk_price": bool(vd.get("ack_risk_price")),
"ack_risk_concentration": bool(vd.get("ack_risk_concentration")),
}
strategy_decisions = build_strategy_decisions_dict(vd)
try:
brief = build_competitor_brief_for_job(
job.run_dir,

View File

@ -62,10 +62,8 @@
"n": 117,
"median": 16.9
},
"comment_focus_keywords": [{ "word": "口感", "count": 48 }],
"usage_scenarios": [
{ "scenario": "控糖/血糖相关", "count": 12, "share_of_text_units": 0.15 }
],
"comment_focus_keywords": [],
"usage_scenarios": [],
"strategy_hints": ["样本内…(待验证)"],
"matrix_by_group": [
{
@ -91,19 +89,14 @@
"consumer_feedback_by_matrix_group": [
{
"group": "饼干",
"matrix_group_index": 0,
"chart_slug": "i00_饼干",
"comment_rows": 40,
"focus_keyword_hits": [{ "word": "口感", "count": 10 }],
"scenarios_top": [
{
"scenario": "早餐/代餐",
"count": 5,
"share_of_text_units": 0.2
}
]
"effective_comment_text_units": 38
}
],
"notes": [
"与在线分析报告各章计数规则一致;主题词与场景为预设词表,非 NLP 主题模型。",
"与在线分析报告各章计数规则一致;评论侧主题以第八章文本挖掘(若启用)及语义归纳为准,不再以预设子串词表为主指标。",
"价格来自展示字段抽取,含促销与规格差异。"
]
}

View File

@ -7,18 +7,9 @@ defineEmits(['add-market', 'remove-market'])
<template>
<div>
<div class="rc-section">
<h4 class="rc-subtitle">1. 第八章评论分析</h4>
<p class="rc-help">
报告<strong>不再</strong>使用预设关注词 / 预设场景词组子串统计请在报告配置高级 JSON 或接口中维护
<code>chapter8_text_mining_probe</code>
等开关以生成开放词表词频与共现等文本挖掘内容可选
<code>llm_comment_sentiment</code>按矩阵细类分别调用模型在报告<strong>8.3</strong>生成/负向主题归纳与探针及细类评论要点归纳并列互不替代
</p>
</div>
<div class="rc-section">
<h4 class="rc-subtitle">2. 外部市场信息可选</h4>
<h4 class="rc-subtitle">1. 外部市场信息可选</h4>
<p class="rc-help">若手边有第三方市场规模增速等摘录可填在表里报告会多一节说明不需要可整表留空</p>
<div class="rc-market-wrap">
<table class="rc-market">

View File

@ -1,45 +1,47 @@
import { ref } from 'vue'
/** 触发词分隔:逗号、顿号、中文逗号、换行 */
const TRIGGER_SPLIT = /[,,、\n\r]+/u
function splitTriggers(text) {
if (!text || typeof text !== 'string') return []
return text
.split(TRIGGER_SPLIT)
.map((s) => s.trim())
.filter(Boolean)
}
/**
* 表单未展示的大模型/细类归纳等布尔项从任务读入后在保存时原样写回避免误清空
* 表单未单独展示的布尔项从任务读入后在保存时原样写回避免误清空
* backend ``validate_report_config_body`` 允许的键一致
*/
const REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS = [
'llm_comment_sentiment',
'llm_matrix_group_summaries',
'llm_price_group_summaries',
'llm_promo_group_summaries',
'llm_strategy_opportunities',
'llm_comment_group_summaries',
'llm_scenario_group_summaries',
'llm_group_summaries_chunk_by_matrix',
'chapter8_text_mining_probe',
'chapter8_text_mining_probe_live_llm',
'chapter8_text_mining_probe_llm_chunked',
'chapter8_text_mining_probe_wordcloud',
]
const REPORT_CONFIG_PASSTHROUGH_INT_KEYS = [
'chapter8_probe_min_texts',
'chapter8_probe_lda_topics',
'chapter8_probe_top_k_words',
'chapter8_probe_cooc_vocab',
'chapter8_probe_cooc_pairs',
'chapter8_probe_wordcloud_max',
]
/**
* 报告调参表单与后端 report_config 字段对应面向非技术用户
*/
export function useReportConfigForm() {
const focusWordRows = ref([{ text: '' }])
const scenarioGroups = ref([{ label: '', triggersText: '' }])
const marketRows = ref([
{ indicator: '', value_and_scope: '', source: '', year: '' },
])
/** 表单未编辑的布尔项,从任务配置读入后随保存写回 */
/** 表单未编辑的项,从任务配置读入后随保存写回 */
const passthroughBools = ref({})
const passthroughInts = ref({})
function resetToEmpty() {
focusWordRows.value = [{ text: '' }]
scenarioGroups.value = [{ label: '', triggersText: '' }]
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
passthroughBools.value = {}
passthroughInts.value = {}
}
/**
@ -51,40 +53,6 @@ export function useReportConfigForm() {
return
}
const w = cfg.comment_focus_words
if (Array.isArray(w) && w.length) {
focusWordRows.value = w
.map((x) => ({ text: String(x ?? '').trim() }))
.filter((r) => r.text)
if (!focusWordRows.value.length) focusWordRows.value = [{ text: '' }]
} else {
focusWordRows.value = [{ text: '' }]
}
const sg = cfg.comment_scenario_groups
if (Array.isArray(sg) && sg.length) {
scenarioGroups.value = sg.map((item) => {
let label = ''
let triggers = []
if (Array.isArray(item) && item.length >= 2) {
label = String(item[0] ?? '').trim()
const tr = item[1]
triggers = Array.isArray(tr) ? tr.map((t) => String(t ?? '').trim()).filter(Boolean) : []
} else if (item && typeof item === 'object' && !Array.isArray(item)) {
label = String(item.label ?? '').trim()
const tr = item.triggers
triggers = Array.isArray(tr) ? tr.map((t) => String(t ?? '').trim()).filter(Boolean) : []
}
return {
label,
triggersText: triggers.join('、'),
}
})
if (!scenarioGroups.value.length) scenarioGroups.value = [{ label: '', triggersText: '' }]
} else {
scenarioGroups.value = [{ label: '', triggersText: '' }]
}
const er = cfg.external_market_table_rows
if (Array.isArray(er) && er.length) {
marketRows.value = er.map((row) => {
@ -110,33 +78,26 @@ export function useReportConfigForm() {
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
}
const pass = {}
const passB = {}
for (const k of REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(cfg, k)) pass[k] = Boolean(cfg[k])
if (Object.prototype.hasOwnProperty.call(cfg, k)) passB[k] = Boolean(cfg[k])
}
passthroughBools.value = pass
passthroughBools.value = passB
const passI = {}
for (const k of REPORT_CONFIG_PASSTHROUGH_INT_KEYS) {
if (Object.prototype.hasOwnProperty.call(cfg, k)) {
const v = cfg[k]
if (typeof v === 'number' && Number.isFinite(v)) passI[k] = Math.trunc(v)
}
}
passthroughInts.value = passI
}
/** @returns {Record<string, unknown>} 可 PATCH 到后端的 report_config全空则为 {} */
function buildPayload() {
const out = {}
const words = focusWordRows.value.map((r) => (r.text || '').trim()).filter(Boolean)
if (words.length) out.comment_focus_words = words
const groups = scenarioGroups.value
.map((g) => ({
label: (g.label || '').trim(),
triggers: splitTriggers(g.triggersText || ''),
}))
.filter((g) => g.label && g.triggers.length)
if (groups.length) {
out.comment_scenario_groups = groups.map((g) => ({
label: g.label,
triggers: g.triggers,
}))
}
const rows = marketRows.value
.map((r) => ({
indicator: (r.indicator || '').trim(),
@ -155,28 +116,10 @@ export function useReportConfigForm() {
}
Object.assign(out, passthroughBools.value)
Object.assign(out, passthroughInts.value)
return out
}
function addFocusRow() {
focusWordRows.value.push({ text: '' })
}
function removeFocusRow(i) {
if (focusWordRows.value.length > 1) focusWordRows.value.splice(i, 1)
else focusWordRows.value[0].text = ''
}
function addScenarioRow() {
scenarioGroups.value.push({ label: '', triggersText: '' })
}
function removeScenarioRow(i) {
if (scenarioGroups.value.length > 1) scenarioGroups.value.splice(i, 1)
else {
scenarioGroups.value[0].label = ''
scenarioGroups.value[0].triggersText = ''
}
}
function addMarketRow() {
marketRows.value.push({
indicator: '',
@ -197,17 +140,12 @@ export function useReportConfigForm() {
}
return {
focusWordRows,
scenarioGroups,
marketRows,
passthroughBools,
passthroughInts,
resetToEmpty,
applyFromApiConfig,
buildPayload,
addFocusRow,
removeFocusRow,
addScenarioRow,
removeScenarioRow,
addMarketRow,
removeMarketRow,
}

View File

@ -0,0 +1,37 @@
/**
* 分析报告页报告正文与数据摘要的 localStorage 缓存供跨标签页通过 storage 事件同步
*/
export function analysisReportCacheKey(jobId) {
return `ma_analysis_report_${jobId}`
}
export function analysisBriefCacheKey(jobId) {
return `ma_analysis_brief_${jobId}`
}
/**
* @param {string | number} jobId
* @param {string} md
*/
export function persistAnalysisReportMd(jobId, md) {
if (typeof localStorage === 'undefined' || !jobId || md == null) return
try {
localStorage.setItem(analysisReportCacheKey(jobId), String(md))
} catch {
/* 配额 / 隐私模式 */
}
}
/**
* @param {string | number} jobId
* @param {unknown} briefObj JSON 序列化的摘要对象
*/
export function persistAnalysisBrief(jobId, briefObj) {
if (typeof localStorage === 'undefined' || !jobId || briefObj == null) return
try {
localStorage.setItem(analysisBriefCacheKey(jobId), JSON.stringify(briefObj))
} catch {
/* ignore */
}
}

View File

@ -0,0 +1,62 @@
/**
* 营销内容包marketing-detail-pack API 返回的 JSON持久化与策略稿分键存放
* 主存 localStorage strategyDraftStorage 相同迁移思路
*/
const PREFIX = 'ma_marketing_detail_pack_'
function key(jobId) {
return `${PREFIX}${jobId}`
}
/**
* @param {string} jobId
* @returns {Record<string, unknown> | null}
*/
export function loadMarketingDetailPackRecord(jobId) {
if (!jobId) return null
const k = key(jobId)
try {
let raw = localStorage.getItem(k)
if (!raw && typeof sessionStorage !== 'undefined') {
raw = sessionStorage.getItem(k)
if (raw) {
try {
localStorage.setItem(k, raw)
} catch {
/* */
}
}
}
if (!raw) return null
const o = JSON.parse(raw)
return o && typeof o === 'object' ? o : null
} catch {
return null
}
}
/**
* @param {string} jobId
* @param {Record<string, unknown>} pack
*/
export function saveMarketingDetailPackRecord(jobId, pack) {
if (!jobId || !pack || typeof pack !== 'object') return
const k = key(jobId)
const payload = JSON.stringify(pack)
try {
localStorage.setItem(k, payload)
} catch {
try {
sessionStorage.setItem(k, payload)
} catch {
/* */
}
return
}
try {
sessionStorage.removeItem(k)
} catch {
/* */
}
}

View File

@ -0,0 +1,85 @@
/**
* 任务列表搜索采集 / 报告 / 策略等页共享列表轮询只保留一路定时器
*/
import { defineStore } from 'pinia'
function jsonFetch(path, opts = {}) {
return fetch(path, {
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
}
function isActiveJobStatus(status) {
return status === 'pending' || status === 'running'
}
/** API 与路由里 id 可能是 number表单 v-model 常为 string */
function sameJobId(a, b) {
if (a == null || b == null) return false
return a === b || String(a) === String(b)
}
let jobsListPollTimer = null
function stopJobsListPoll() {
if (jobsListPollTimer != null) {
clearInterval(jobsListPollTimer)
jobsListPollTimer = null
}
}
export const useJobStore = defineStore('ma-jobs', {
state: () => ({
jobs: [],
}),
actions: {
_syncJobsListPoll() {
const hasActive = this.jobs.some((j) => isActiveJobStatus(j.status))
if (!hasActive) {
stopJobsListPoll()
return
}
if (jobsListPollTimer != null) return
jobsListPollTimer = setInterval(() => {
useJobStore().fetchJobsListQuietly()
}, 3000)
},
setJobs(list) {
this.jobs = Array.isArray(list) ? list : []
this._syncJobsListPoll()
},
/**
* 用单条任务详情写回列表 PATCH 轮询详情 GET 对齐
* @param {Record<string, unknown>} updated
*/
mergeJob(updated) {
if (!updated || updated.id == null) return
const idx = this.jobs.findIndex((x) => sameJobId(x.id, updated.id))
if (idx >= 0) {
this.jobs.splice(idx, 1, updated)
this._syncJobsListPoll()
}
},
async fetchJobsListQuietly() {
try {
const r = await jsonFetch('/api/jobs/')
if (r.ok) {
this.setJobs(await r.json())
}
} catch {
/* 忽略网络错误,下一轮再试 */
}
},
async refreshJobs() {
const r = await jsonFetch('/api/jobs/')
if (!r.ok) throw new Error(await r.text())
this.setJobs(await r.json())
},
},
})

View File

@ -8,6 +8,7 @@ import {
import { RouterLink } from 'vue-router'
import ReportConfigFormFields from '../../components/ReportConfigFormFields.vue'
import { refreshJobs, useJobs, api, reportConfigDefaultsUrl } from '../../composables/useJobs'
import { useJobStore } from '../../stores/jobs'
import { useReportConfigForm } from '../../composables/useReportConfigForm'
const { jobs } = useJobs()
@ -32,19 +33,8 @@ const regenBusyOtherTask = computed(
() => regenPendingJobId.value != null && regenPendingJobId.value !== selectedId.value,
)
const {
focusWordRows,
scenarioGroups,
marketRows,
applyFromApiConfig,
buildPayload,
addFocusRow,
removeFocusRow,
addScenarioRow,
removeScenarioRow,
addMarketRow,
removeMarketRow,
} = useReportConfigForm()
const { marketRows, applyFromApiConfig, buildPayload, addMarketRow, removeMarketRow } =
useReportConfigForm()
const reportConfigErr = ref('')
const reportConfigSaveLoading = ref(false)
@ -113,8 +103,7 @@ async function saveReportConfigToJob() {
return
}
const updated = JSON.parse(text)
const idx = jobs.value.findIndex((x) => x.id === updated.id)
if (idx >= 0) jobs.value[idx] = updated
useJobStore().mergeJob(updated)
syncReportConfigFromJob(updated)
} catch (e) {
reportConfigErr.value = String(e)
@ -182,8 +171,7 @@ async function regenerateReport() {
return
}
const updated = JSON.parse(text)
const idx = jobs.value.findIndex((x) => x.id === updated.id)
if (idx >= 0) jobs.value[idx] = updated
useJobStore().mergeJob(updated)
})
} catch (e) {
regenErr.value = String(e)
@ -200,8 +188,7 @@ watch(selectedId, async () => {
const r = await api(`/api/jobs/${id}/`)
if (r.ok) {
const j = await r.json()
const idx = jobs.value.findIndex((x) => x.id === j.id)
if (idx >= 0) jobs.value[idx] = j
useJobStore().mergeJob(j)
syncReportConfigFromJob(j)
}
} catch {
@ -272,11 +259,7 @@ watch(
</p>
<div v-if="selectedId" class="report-config-block">
<h3 class="report-config-title">报告里的评价统计怎么算</h3>
<p class="hint-top report-config-hint">
关注词场景词组外部市场表等<strong>可以不改</strong>留空并保存即沿用内置规则大模型相关布尔项
<code>llm_comment_sentiment</code>不再单独占勾选框若任务里已有会在保存时保留要改请展开高级 JSON
</p>
<h3 class="report-config-title">报告配置</h3>
<div class="report-config-actions">
<button
type="button"
@ -297,13 +280,7 @@ watch(
</div>
<ReportConfigFormFields
:focus-word-rows="focusWordRows"
:scenario-groups="scenarioGroups"
:market-rows="marketRows"
@add-focus="addFocusRow"
@remove-focus="removeFocusRow"
@add-scenario="addScenarioRow"
@remove-scenario="removeScenarioRow"
@add-market="addMarketRow"
@remove-market="removeMarketRow"
/>

View File

@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import MarkdownPreview from '../../components/MarkdownPreview.vue'
@ -136,6 +136,13 @@ import {
generationInFlightKey,
withGenerationInFlight,
} from '../../composables/useGenerationInFlight'
import {
analysisBriefCacheKey,
analysisReportCacheKey,
persistAnalysisBrief,
persistAnalysisReportMd,
} from '../../lib/analysisViewStorage'
import { useJobStore } from '../../stores/jobs'
const { jobs } = useJobs()
const selectedId = ref('')
@ -148,21 +155,30 @@ const briefErr = ref('')
const briefCopyOk = ref(false)
const packErr = ref('')
const exportDocErr = ref('')
/** 正在导出的格式docx | pdf | null */
const exportDocFmt = ref(null)
const genInFlight = generationInFlightKey()
const K_PREVIEW = 'preview-report:'
const K_BRIEF = 'competitor-brief:'
const K_PACK = 'brief-pack:'
const K_EXPORT = 'export-report:'
function isExporting(fmt) {
const id = selectedId.value
if (!id) return false
return genInFlight.value.includes(`${K_EXPORT}${id}:${fmt}`)
}
async function exportReportFmt(fmt) {
const id = selectedId.value
if (!id) return
exportDocErr.value = ''
exportDocFmt.value = fmt
try {
await exportReportDocument(id, fmt)
} catch (e) {
exportDocErr.value = String(e?.message || e)
} finally {
exportDocFmt.value = null
}
await withGenerationInFlight(`${K_EXPORT}${id}:${fmt}`, async () => {
try {
await exportReportDocument(id, fmt)
} catch (e) {
exportDocErr.value = String(e?.message || e)
}
})
}
/** 将 Markdown 中的 report_assets 相对路径转为可访问的 API URL在线预览插图 */
@ -178,10 +194,6 @@ const reportMdForPreview = computed(() =>
reportMdWithAssetUrls(reportMd.value, selectedId.value),
)
const genInFlight = generationInFlightKey()
const K_PREVIEW = 'preview-report:'
const K_BRIEF = 'competitor-brief:'
const K_PACK = 'brief-pack:'
function genKeyMatches(prefix) {
const id = selectedId.value
if (!id) return false
@ -194,9 +206,15 @@ const viewInFlightOtherJobId = computed(() => {
const sid = selectedId.value
if (!sid) return null
for (const k of genInFlight.value) {
const i = k.lastIndexOf(':')
if (i < 0) continue
const jid = k.slice(i + 1)
let jid = null
if (k.startsWith(K_PREVIEW)) jid = k.slice(K_PREVIEW.length)
else if (k.startsWith(K_BRIEF)) jid = k.slice(K_BRIEF.length)
else if (k.startsWith(K_PACK)) jid = k.slice(K_PACK.length)
else if (k.startsWith(K_EXPORT)) {
const rest = k.slice(K_EXPORT.length)
const m = /^(\d+):/.exec(rest)
if (m) jid = m[1]
}
if (jid && jid !== sid) return jid
}
return null
@ -238,7 +256,9 @@ async function loadReport() {
}
return
}
reportMd.value = await r.text()
const text = await r.text()
reportMd.value = text
persistAnalysisReportMd(id, text)
} catch (e) {
err.value = String(e)
}
@ -268,6 +288,7 @@ async function loadCompetitorBrief() {
const j = JSON.parse(text)
briefData.value = j
briefJson.value = JSON.stringify(j, null, 2)
persistAnalysisBrief(id, j)
} catch (e) {
briefErr.value = String(e)
}
@ -314,7 +335,31 @@ async function downloadBriefPack() {
})
}
onMounted(loadList)
function onAnalysisViewStorage(ev) {
if (!ev.key || ev.storageArea !== localStorage) return
const sid = selectedId.value
if (!sid) return
if (ev.key === analysisReportCacheKey(sid) && ev.newValue != null) {
reportMd.value = ev.newValue
}
if (ev.key === analysisBriefCacheKey(sid) && ev.newValue) {
try {
const j = JSON.parse(ev.newValue)
briefData.value = j
briefJson.value = JSON.stringify(j, null, 2)
} catch {
/* ignore */
}
}
}
onMounted(() => {
loadList()
window.addEventListener('storage', onAnalysisViewStorage)
})
onUnmounted(() => {
window.removeEventListener('storage', onAnalysisViewStorage)
})
watch(selectedId, async () => {
briefJson.value = ''
@ -327,8 +372,7 @@ watch(selectedId, async () => {
const r = await api(`/api/jobs/${id}/`)
if (r.ok) {
const j = await r.json()
const idx = jobs.value.findIndex((x) => x.id === j.id)
if (idx >= 0) jobs.value[idx] = j
useJobStore().mergeJob(j)
}
} catch {
/* ignore */
@ -381,18 +425,18 @@ watch(
<button
type="button"
class="ma-btn ma-btn-secondary"
:disabled="!selectedId || exportDocFmt || loading"
:disabled="!selectedId || isExporting('docx') || isExporting('pdf') || loading"
@click="exportReportFmt('docx')"
>
{{ exportDocFmt === 'docx' ? '导出中…' : '导出 Word' }}
{{ isExporting('docx') ? '导出中…' : '导出 Word' }}
</button>
<button
type="button"
class="ma-btn ma-btn-secondary"
:disabled="!selectedId || exportDocFmt || loading"
:disabled="!selectedId || isExporting('docx') || isExporting('pdf') || loading"
@click="exportReportFmt('pdf')"
>
{{ exportDocFmt === 'pdf' ? '导出中…' : '导出 PDF' }}
{{ isExporting('pdf') ? '导出中…' : '导出 PDF' }}
</button>
<button
type="button"

View File

@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import JobDatasetModal from '../../components/JobDatasetModal.vue'
import { api, refreshJobs, useJobs } from '../../composables/useJobs'
import { useJobStore } from '../../stores/jobs'
const { jobs } = useJobs()
const selectedId = ref('')
@ -36,8 +37,7 @@ async function refreshSelectedJob() {
const r = await api(`/api/jobs/${id}/`)
if (r.ok) {
const j = await r.json()
const idx = jobs.value.findIndex((x) => x.id === j.id)
if (idx >= 0) jobs.value[idx] = j
useJobStore().mergeJob(j)
}
} catch {
/* ignore */

View File

@ -1,6 +1,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { api, refreshJobs, useJobs, jobConfigHint, jobCancelUrl } from '../../composables/useJobs'
import { useJobStore } from '../../stores/jobs'
const { jobs } = useJobs()
const loadError = ref('')
@ -48,8 +49,7 @@ async function requestCancel(jobId) {
return
}
const updated = JSON.parse(text)
const idx = jobs.value.findIndex((x) => x.id === updated.id)
if (idx >= 0) jobs.value[idx] = updated
useJobStore().mergeJob(updated)
await refreshJobs()
} catch (e) {
cancelErr.value = String(e)

View File

@ -7,6 +7,7 @@ import {
withGenerationInFlight,
} from '../../composables/useGenerationInFlight'
import {
loadStrategyDraftRecord,
loadStrategyMatrixScope,
saveStrategyDraftRecord,
saveStrategyMatrixScope,
@ -60,6 +61,7 @@ const decisions = reactive({
pillar_price: '',
pillar_channel: '',
pillar_comm: '',
tactic_promotion: '',
audience_segment: '',
competitor_reference: '',
resource_notes: '',
@ -108,6 +110,7 @@ function buildPayload() {
pillar_price: decisions.pillar_price,
pillar_channel: decisions.pillar_channel,
pillar_comm: decisions.pillar_comm,
tactic_promotion: decisions.tactic_promotion,
audience_segment: decisions.audience_segment,
competitor_reference: decisions.competitor_reference,
resource_notes: decisions.resource_notes,
@ -122,6 +125,56 @@ function buildPayload() {
}
}
/** 与 backend ``pipeline/strategy_decision_keys.STRATEGY_DECISION_FIELD_NAMES`` 一致 */
const SAVED_DECISION_KEYS = [
'product_role',
'stage_goal_type',
'time_horizon',
'success_criteria',
'non_goals',
'battlefield_one_line',
'positioning_choice',
'competitive_stance',
'pillar_product',
'pillar_price',
'pillar_channel',
'pillar_comm',
'tactic_promotion',
'audience_segment',
'competitor_reference',
'resource_notes',
'marketing_strategy',
'general_strategy',
'ack_risk_keywords',
'ack_risk_price',
'ack_risk_concentration',
]
/**
* 从本任务上次已保存的生成请求恢复表单使用户决策与成稿/再次提交一致
*/
function applyDecisionsFromSavedRecord(jobId) {
if (!jobId) return
const rec = loadStrategyDraftRecord(String(jobId))
const lr = rec?.last_request
if (!lr || typeof lr !== 'object') return
for (const k of SAVED_DECISION_KEYS) {
if (!Object.prototype.hasOwnProperty.call(lr, k)) continue
if (k.startsWith('ack_')) {
decisions[k] = Boolean(lr[k])
} else {
const v = lr[k]
decisions[k] = v == null || typeof v === 'boolean' ? '' : String(v)
}
}
if (typeof lr.business_notes === 'string') {
businessNotes.value = lr.business_notes
}
if (lr.generator === 'rules' || lr.generator === 'llm') {
rulesOnlyThisRun.value = lr.generator === 'rules'
}
}
function formatJobOption(j) {
const t = j.created_at
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
@ -228,8 +281,16 @@ onUnmounted(() => {
}
})
watch(selectedId, (id) => {
loadMatrixGroupsForJob(id)
watch(selectedId, async (id) => {
await loadMatrixGroupsForJob(id)
if (id) {
applyDecisionsFromSavedRecord(String(id))
const rec = loadStrategyDraftRecord(String(id))
const mg = rec?.last_request?.strategy_matrix_group
if (typeof mg === 'string' && mg.trim() && matrixGroups.value.some((g) => g.group === mg)) {
strategyMatrixScope.value = mg
}
}
})
watch(strategyMatrixScope, (v) => {
@ -262,8 +323,7 @@ watch(
<section class="ma-card">
<h2>策略生成</h2>
<p class="hint-top">
选择<strong>已成功</strong>任务先选顶部<strong>矩阵细类</strong>主推类目与报告矩阵一致策略稿与矩阵选择保存在本机 <strong>localStorage</strong>同域名下可跨标签查看与其它页面的耗时任务通过全局任务锁同步下方字段按策略文档常见顺序排列成稿里的小节标题与编号由系统自动对应有关痛点购买理由品牌承诺等由监测与模型撰写本页主要收集<strong>业务决策与战术要点</strong>生成结果见
<RouterLink to="/jd/strategy-view">策略稿预览</RouterLink><strong>已填项</strong>进入底稿并由大模型落实<strong>未填项</strong>可由模型结合数据推断
选择<strong>已成功</strong>任务先选顶部<strong>矩阵细类</strong><strong>已填项</strong>进入底稿并由大模型落实<strong>未填项</strong>可由模型结合数据推断
</p>
@ -443,52 +503,69 @@ watch(
</fieldset>
<fieldset class="fieldset">
<legend>品牌四线与战术动作</legend>
<p class="fieldset-hint">
下列内容会在策略稿中用于<strong>品牌四线</strong><strong>战术支柱</strong>相关段落系统会自动落到对应小节价位阵地为单选促销与活动细节无单独表单项由监测与模型归纳品牌承诺与调性由模型依据数据撰写
</p>
<legend>品牌四线建设 · 打造 · 运营 · 体验</legend>
<label class="fld fld-block">
<span>产品</span>
<textarea
v-model="decisions.pillar_product"
rows="2"
placeholder="规格、配方或功能叙事、计划中的产品动作(可选)"
/>
<span>品牌建设</span>
<textarea v-model="decisions.pillar_product" rows="2" placeholder="选填" />
</label>
<label class="fld fld-block">
<span>价位阵地单选</span>
<select v-model="decisions.positioning_choice" class="job-select full">
<option v-for="o in positioningOptions" :key="o.value || 'empty'" :value="o.value">
{{ o.label }}
</option>
</select>
<span>品牌打造</span>
<textarea v-model="decisions.pillar_price" rows="2" placeholder="选填" />
</label>
<label class="fld fld-block">
<span>定价补充说明</span>
<textarea
v-model="decisions.pillar_price"
rows="2"
placeholder="在价位阵地之外:到手价呈现、跟价或避战原则、与大促关系等(可选)"
/>
<span>品牌运营</span>
<textarea v-model="decisions.pillar_channel" rows="2" placeholder="选填" />
</label>
<label class="fld fld-block">
<span>渠道与触点</span>
<textarea
v-model="decisions.pillar_channel"
rows="2"
placeholder="货架、店铺类型、站内路径、触点优先级等(可选)"
/>
</label>
<label class="fld fld-block">
<span>传播与内容</span>
<textarea
v-model="decisions.pillar_comm"
rows="2"
placeholder="内容形态、达人/自播、搜索承接与话术方向等(可选)"
/>
<span>品牌体验</span>
<textarea v-model="decisions.pillar_comm" rows="2" placeholder="选填" />
</label>
</fieldset>
<fieldset class="fieldset fieldset-tactic-pillars">
<legend>战术支柱</legend>
<div class="tactic-sec">
<h4 class="tactic-sec-t">产品策略</h4>
<label class="fld fld-block fld-tight">
<textarea v-model="decisions.pillar_product" rows="2" placeholder="选填" />
</label>
</div>
<div class="tactic-sec">
<h4 class="tactic-sec-t">定价策略</h4>
<label class="fld fld-block">
<span>价位阵地</span>
<select v-model="decisions.positioning_choice" class="job-select full">
<option v-for="o in positioningOptions" :key="o.value || 'empty'" :value="o.value">
{{ o.label }}
</option>
</select>
</label>
<label class="fld fld-block">
<span>补充说明</span>
<textarea v-model="decisions.pillar_price" rows="2" placeholder="选填" />
</label>
</div>
<div class="tactic-sec">
<h4 class="tactic-sec-t">促销与活动策略</h4>
<label class="fld fld-block fld-tight">
<textarea v-model="decisions.tactic_promotion" rows="2" placeholder="选填" />
</label>
</div>
<div class="tactic-sec">
<h4 class="tactic-sec-t">渠道与传播</h4>
<div class="tactic-ch-row">
<label class="fld fld-block">
<span>渠道</span>
<textarea v-model="decisions.pillar_channel" rows="2" placeholder="选填" />
</label>
<label class="fld fld-block">
<span>传播</span>
<textarea v-model="decisions.pillar_comm" rows="2" placeholder="选填" />
</label>
</div>
</div>
</fieldset>
<fieldset class="fieldset">
<legend>数据与样本风险确认知晓</legend>
<p class="fieldset-hint">
@ -685,4 +762,33 @@ watch(
.form-skip-note strong {
color: #334155;
}
.fieldset-tactic-pillars .tactic-sec {
margin-top: 0.65rem;
padding-top: 0.7rem;
border-top: 1px solid #e5e7eb;
}
.fieldset-tactic-pillars .tactic-sec:first-of-type {
margin-top: 0.25rem;
padding-top: 0;
border-top: none;
}
.tactic-sec-t {
margin: 0 0 0.4rem;
font-size: 0.88rem;
font-weight: 600;
color: #374151;
}
.tactic-ch-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1rem;
}
@media (max-width: 640px) {
.tactic-ch-row {
grid-template-columns: 1fr;
}
}
.fld-tight {
margin-top: 0.15rem;
}
</style>

View File

@ -9,6 +9,10 @@ import {
exportStrategyDocument,
} from '../../composables/useJobs'
import { generationInFlightKey, withGenerationInFlight } from '../../composables/useGenerationInFlight'
import {
loadMarketingDetailPackRecord,
saveMarketingDetailPackRecord,
} from '../../lib/marketingDetailPackStorage'
import { loadStrategyDraftRecord } from '../../lib/strategyDraftStorage'
import { marketingPackResultToMarkdown } from '../../lib/marketingPackMarkdown'
@ -26,6 +30,8 @@ const exportErr = ref('')
const marketingErr = ref('')
const marketingExportErr = ref('')
const marketingResult = ref(null)
/** 底部「预览」区:策略稿 vs 营销 Markdown与导出同源 */
const previewDoc = ref('strategy')
const exportBusy = computed(() => {
const id = selectedId.value
@ -113,6 +119,15 @@ const marketingPackTouchBlock = computed(() =>
]),
)
const marketingMd = computed(() => {
if (!marketingResult.value) return ''
try {
return marketingPackResultToMarkdown(marketingResult.value) || ''
} catch {
return ''
}
})
function loadDraft() {
const id = selectedId.value
if (!id) {
@ -139,6 +154,16 @@ function loadDraft() {
}
}
function loadMarketingPack() {
const id = selectedId.value
if (!id) {
marketingResult.value = null
return
}
const rec = loadMarketingDetailPackRecord(id)
marketingResult.value = rec && typeof rec === 'object' ? rec : null
}
async function loadList() {
try {
await refreshJobs()
@ -224,7 +249,14 @@ async function generateMarketingDetailPack() {
}
return
}
marketingResult.value = JSON.parse(text)
const body = JSON.parse(text)
marketingResult.value = body
try {
saveMarketingDetailPackRecord(id, body)
} catch {
/* 忽略存储失败 */
}
previewDoc.value = 'marketing'
})
} catch (e) {
marketingErr.value = String(e)
@ -270,18 +302,28 @@ function onStorageDraftSync(ev) {
if (jid === String(selectedId.value)) loadDraft()
}
function onStorageMarketingSync(ev) {
const prefix = 'ma_marketing_detail_pack_'
if (!ev.key || !ev.key.startsWith(prefix)) return
const jid = ev.key.slice(prefix.length)
if (jid === String(selectedId.value)) loadMarketingPack()
}
onMounted(async () => {
await loadList()
syncSelectionFromRouteAndJobs()
loadDraft()
loadMarketingPack()
if (typeof window !== 'undefined') {
window.addEventListener('storage', onStorageDraftSync)
window.addEventListener('storage', onStorageMarketingSync)
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', onStorageDraftSync)
window.removeEventListener('storage', onStorageMarketingSync)
}
})
@ -293,14 +335,16 @@ watch(
if (s !== selectedId.value) {
selectedId.value = s
loadDraft()
loadMarketingPack()
}
},
)
watch(selectedId, (id) => {
marketingResult.value = null
marketingExportErr.value = ''
previewDoc.value = 'strategy'
loadDraft()
loadMarketingPack()
const want = id ? String(id) : ''
if (String(route.query.job || '') !== want) {
router.replace({ path: '/jd/strategy-view', query: want ? { job: want } : {} })
@ -313,6 +357,7 @@ watch(successJobs, (list) => {
if (list.length) {
selectedId.value = String(list[0].id)
loadDraft()
loadMarketingPack()
}
})
</script>
@ -322,7 +367,7 @@ watch(successJobs, (list) => {
<section class="ma-card">
<h2>策略稿预览</h2>
<p class="hint-top">
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本机浏览器 <strong>localStorage</strong>同域名下可跨标签查看生成/导出/营销内容等耗时操作状态在全局任务锁中同步跨标签页可看到进行中需要改决策请回到
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本机浏览器 <strong>localStorage</strong>同域名下可跨标签查看需要改决策请回到
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
重新提交分析数据见
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>
@ -385,10 +430,7 @@ watch(successJobs, (list) => {
<p class="ma-muted marketing-pack-meta">
{{ marketingResult.generated_at }} · {{ marketingResult.source }}
</p>
<p class="ma-muted marketing-pack-disk">
服务端会将本包写入任务目录
<code>marketing/marketing_detail_pack_v1.json</code>与批次一并归档目录不可写时仅内存结果
</p>
<div class="toolbar marketing-pack-actions">
<button
type="button"
@ -441,9 +483,25 @@ watch(successJobs, (list) => {
</p>
</section>
<section v-if="draftMd" class="ma-card preview-card">
<section v-if="draftMd || marketingMd" class="ma-card preview-card">
<div class="preview-head">
<h2>预览</h2>
<div v-if="marketingMd" class="tabs doc-tabs">
<button
type="button"
:class="{ on: previewDoc === 'strategy' }"
@click="previewDoc = 'strategy'"
>
策略稿
</button>
<button
type="button"
:class="{ on: previewDoc === 'marketing' }"
@click="previewDoc = 'marketing'"
>
营销内容
</button>
</div>
<div class="tabs">
<button type="button" :class="{ on: viewMode === 'render' }" @click="viewMode = 'render'">
渲染
@ -453,10 +511,21 @@ watch(successJobs, (list) => {
</button>
</div>
</div>
<div v-if="viewMode === 'render'" class="md-box">
<MarkdownPreview :source="draftMd" />
</div>
<pre v-else class="raw-md">{{ draftMd }}</pre>
<template v-if="previewDoc === 'strategy' && draftMd">
<div v-if="viewMode === 'render'" class="md-box">
<MarkdownPreview :source="draftMd" />
</div>
<pre v-else class="raw-md">{{ draftMd }}</pre>
</template>
<template v-else-if="previewDoc === 'marketing' && marketingMd">
<div v-if="viewMode === 'render'" class="md-box">
<MarkdownPreview :source="marketingMd" />
</div>
<pre v-else class="raw-md">{{ marketingMd }}</pre>
</template>
<p v-else class="ma-muted preview-fallback">
暂无当前页面对应的文稿请先生成策略稿或营销内容
</p>
</section>
</div>
</template>
@ -535,6 +604,13 @@ watch(successJobs, (list) => {
.preview-head h2 {
margin: 0;
}
.doc-tabs {
margin-right: auto;
}
.preview-fallback {
margin: 0;
font-size: 0.9rem;
}
.tabs {
display: flex;
gap: 0.35rem;