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

View File

@ -1,60 +1,9 @@
"""``report_config`` JSON → 关注词、场景组、外部市场表行。""" """``report_config`` JSON → 外部市场表行(不再解析预设关注词/场景词组)。"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from .constants import ( from .constants import EXTERNAL_MARKET_TABLE_ROWS
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
def _normalize_external_market_rows( def _normalize_external_market_rows(
@ -85,16 +34,11 @@ def _normalize_external_market_rows(
def resolve_report_tuning( def resolve_report_tuning(
report_config: dict[str, Any] | None, report_config: dict[str, Any] | None,
) -> tuple[ ) -> tuple[tuple[tuple[str, str, str, str], ...]]:
tuple[str, ...], """仅解析第三方市场摘录表;预设关注词/场景词组已废弃,不再参与报告或 brief。"""
tuple[tuple[str, tuple[str, ...]], ...],
tuple[tuple[str, str, str, str], ...],
]:
if not report_config: if not report_config:
return COMMENT_FOCUS_WORDS, COMMENT_SCENARIO_GROUPS, EXTERNAL_MARKET_TABLE_ROWS return (EXTERNAL_MARKET_TABLE_ROWS,)
return ( return (
_normalize_focus_words(report_config.get("comment_focus_words")),
_normalize_scenario_groups(report_config.get("comment_scenario_groups")),
_normalize_external_market_rows( _normalize_external_market_rows(
report_config.get("external_market_table_rows") report_config.get("external_market_table_rows")
), ),
@ -104,6 +48,4 @@ def resolve_report_tuning(
__all__ = [ __all__ = [
"resolve_report_tuning", "resolve_report_tuning",
"_normalize_external_market_rows", "_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], ...] = () 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__ = [ __all__ = [
"_COMMENT_CSV_BODY", "_COMMENT_CSV_BODY",
"_COMMENT_CSV_SCORE", "_COMMENT_CSV_SCORE",
"_COMMENT_CSV_SKU", "_COMMENT_CSV_SKU",
"COMMENT_FOCUS_WORDS",
"COMMENT_SCENARIO_GROUPS",
"EXTERNAL_MARKET_TABLE_ROWS", "EXTERNAL_MARKET_TABLE_ROWS",
"_COMMENT_FUZZ_KEYS", "_COMMENT_FUZZ_KEYS",
"_COMMENT_SCORE_NEG_MAX", "_COMMENT_SCORE_NEG_MAX",

View File

@ -1,12 +1,9 @@
"""按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价/场景)。""" """按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价摘录)。"""
from __future__ import annotations from __future__ import annotations
from collections import Counter
from typing import Any from typing import Any
from pipeline.csv.schema import JD_SEARCH_CSV_HEADERS, MERGED_FIELD_TO_CSV_HEADER from pipeline.csv.schema import JD_SEARCH_CSV_HEADERS, MERGED_FIELD_TO_CSV_HEADER
from .comment_sentiment import _comment_keyword_hits
from .constants import ( from .constants import (
_COMMENT_CSV_BODY, _COMMENT_CSV_BODY,
_COMMENT_CSV_SKU, _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 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: def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
title = _md_cell(_cell(row, title_h), 100) title = _md_cell(_cell(row, title_h), 100)
sp = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 120) 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 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__ = [ __all__ = [
"build_comment_groups_llm_payload", "build_comment_groups_llm_payload",
"build_matrix_groups_llm_payload", "build_matrix_groups_llm_payload",
"build_price_groups_llm_payload", "build_price_groups_llm_payload",
"build_promo_groups_llm_payload", "build_promo_groups_llm_payload",
"build_scenario_groups_llm_payload",
"_comment_scenario_counts",
"_group_keyword_hits",
"_listing_price_snippet_for_llm", "_listing_price_snippet_for_llm",
"_matrix_excerpt_line_for_llm", "_matrix_excerpt_line_for_llm",
"_promo_snippet_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]: def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]:
"""第六章第一节:优惠活动与价差信号(Markdown 行列表)。""" """第六章第一节:优惠活动与价差信号(Markdown 行列表)。"""
lines: list[str] = [ lines: list[str] = [
@ -144,4 +213,5 @@ def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]:
__all__ = [ __all__ = [
"_analyze_price_promotions", "_analyze_price_promotions",
"_markdown_price_promotion_section", "_markdown_price_promotion_section",
"price_promotion_signals_strategy_brief_cn",
] ]

View File

@ -1,4 +1,7 @@
"""报告 Markdown 片段:Mermaid、场景摘要、规则策略提示、插图路径与解读段落。""" """报告 Markdown 片段:规则策略提示、插图路径、矩阵图文件名等。
含若干**已废弃口径**的 Mermaid/场景摘要辅助函数(关注词子串、预设场景标签),当前主报告链路**不再调用**,保留仅为历史图表命名一致或后续清理。
"""
from __future__ import annotations from __future__ import annotations
import re import re
@ -54,12 +57,9 @@ def _strategy_hints(
*, *,
cr1: float | None, cr1: float | None,
pst: dict[str, Any], pst: dict[str, Any],
hits: Counter[str],
n_comments: int, n_comments: int,
scen_counts: Counter[str],
scen_n_texts: int,
) -> list[str]: ) -> list[str]:
"""基于规则的「提示性」结论,均标注待验证。""" """基于规则的「提示性」结论,均标注待验证(不含预设关注词/场景子串统计)。"""
hints: list[str] = [] hints: list[str] = []
if cr1 is not None and cr1 >= 0.45: if cr1 is not None and cr1 >= 0.45:
hints.append( hints.append(
@ -75,23 +75,10 @@ def _strategy_hints(
hints.append( 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: if n_comments < 5:
hints.append( hints.append(
"有效评价样本偏少,消费者洞察部分仅作方向参考,正式结论建议加大 SKU 数或评论分页。" "有效评价样本偏少,消费者洞察部分仅作方向参考,正式结论建议加大 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: if not hints:
hints.append( hints.append(
"当前样本下自动规则未触发强信号;请结合业务目标人工解读对比矩阵与原始 CSV。" "当前样本下自动规则未触发强信号;请结合业务目标人工解读对比矩阵与原始 CSV。"

View File

@ -17,6 +17,8 @@
输出:默认写入 ``<run_dir>/chapter8_text_mining_probe.md``。 输出:默认写入 ``<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`` 的 **第八章第二节(评论文本补充分析)**,替代原「关注词 + 场景」条图及对应两段大模型;**不再**嵌入原「评价正负面粗判」预设口语短语扇形图/条形图及同口径大模型块。 嵌入竞品报告:流水线默认开启(``get_default_report_config`` 中 ``chapter8_text_mining_probe``: true);若任务显式关闭则为 false。开启时会生成本稿并调用 ``markdown_embed_body_for_competitor_report`` 写入 ``competitor_analysis.md`` 的 **第八章第二节(评论文本补充分析)**,替代原「关注词 + 场景」条图及对应两段大模型;**不再**嵌入原「评价正负面粗判」预设口语短语扇形图/条形图及同口径大模型块。
""" """
from __future__ import annotations 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]] 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( def _run_probe_text_mining_llm(
payload: dict[str, Any], payload: dict[str, Any],
*, *,
chunked: bool, chunked: bool,
) -> str: ) -> 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"): if not payload.get("groups"):
return "> **补充分析 LLM 解读**:无分组数据,跳过。" return "> **补充分析 LLM 解读**:无分组数据,跳过。"
import time as _time
diag = _probe_llm_diag_enabled()
try: try:
if not chunked: if not chunked:
p = _truncate_probe_payload(payload) p = _truncate_probe_payload(payload)
@ -451,10 +468,35 @@ def _run_probe_text_mining_llm(
raw[:82_000] raw[:82_000]
+ "\n\n…(JSON 过长已截断,仅依据可见字段撰写。)\n" + "\n\n…(JSON 过长已截断,仅依据可见字段撰写。)\n"
) )
return _call_llm( if diag:
PROBE_TEXT_MINING_SYSTEM, print(
PROBE_TEXT_MINING_USER_PREFIX + raw, f"[probe-llm] mode=single json_chars={len(raw)}",
).strip() 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 "") kw = str(payload.get("keyword") or "")
note = str(payload.get("probe_note") or "") note = str(payload.get("probe_note") or "")
parts: list[str] = [] parts: list[str] = []
@ -477,12 +519,38 @@ def _run_probe_text_mining_llm(
raw = json.dumps(mini, ensure_ascii=False) raw = json.dumps(mini, ensure_ascii=False)
if len(raw) > 48_000: if len(raw) > 48_000:
raw = raw[:44_000] + "\n…\n" raw = raw[:44_000] + "\n…\n"
parts.append( if diag:
_call_llm( 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_SYSTEM,
PROBE_TEXT_MINING_USER_PREFIX + raw, PROBE_TEXT_MINING_USER_PREFIX + raw,
).strip() ).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) return "\n\n---\n\n".join(parts)
except Exception as e: except Exception as e:
return f"> **补充分析 LLM 解读**调用失败:{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]: def _strategy_decisions_empty() -> dict[str, Any]:
return { from pipeline.strategy_decision_keys import empty_strategy_decisions
"product_role": "",
"stage_goal_type": "", return empty_strategy_decisions()
"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]: 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": "关注血糖管理、减脂、健康早餐的20–45岁城市用户;决策路径多为搜索→列表比价→商详看配料与评价",
"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": "关注血糖管理、减脂、健康早餐的20–45岁城市用户;决策路径多为搜索→列表比价→商详看配料与评价",
"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) 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`` 字段**完全一致**的细类名作为小节标题;
- 每段约 **100~220 字**:归纳该细类用户**自述的使用场景/用途**结构(哪些场景标签相对突出、多场景叠加是否常见),可点到与其他细类的差异;**所有条数与占比须与 ``scenario_distribution``、``effective_text_count`` 一致**,禁止编造;
- 引用原话时须保留或复述摘录中的店铺/SKU/品名信息,勿虚构;
- **禁止** Markdown 表格、禁止复述全部摘录;若 ``effective_text_count`` 很小,写明「样本较少,归纳供启发」。
总输出约 **600~3200 字**。仅输出正文 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\n…(JSON 已截断以适配上下文;仅依据可见字段撰写。)\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``。 PRICE_GROUPS_SYSTEM = """你是定价与渠道顾问。输入为 JSON:``keyword`` 与 ``groups``。
每个 group 含 ``group``(细分类目名,与 第五章矩阵、第六章「按细类价盘」小节一致)、``sku_count``、``price_stats``(该细类可解析展示价的 min/max/median/mean/n,与第六章各细类 Markdown 分位数表同源)、 每个 group 含 ``group``(细分类目名,与 第五章矩阵、第六章「按细类价盘」小节一致)、``sku_count``、``price_stats``(该细类可解析展示价的 min/max/median/mean/n,与第六章各细类 Markdown 分位数表同源)、
``listing_snippets``(若干「标题|标价|券后|详情价」摘录,来自合并表字段,已截断)。 ``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 generate_comment_group_summaries_llm([g], keyword=keyword) for g in clean
] ]
return _join_chunked_group_markdown(parts) 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 方向早餐饼干(待业务定款)」),不得用「优质好物」「健康之选」等**无品类**的句子糊弄本条。 - **读者第一眼须知道在卖什么**:禁止通篇只有「价值感」「信任」「体验」而**不出现可识别的品类/形态**(如饼干、燕麦、奶粉、饮料等)。若输入未给出具体 SKU 名,仍须写清**类目 + 形态/规格层级**(如「低 GI 方向早餐饼干(待业务定款)」),不得用「优质好物」「健康之选」等**无品类**的句子糊弄本条。
- **对照式理由(不编造)**:当策略或备注能概括「普通/常规同类」的典型痛点(如升糖快、甜腻、纤维低、易饿)时,**why_this_product** 与 **differentiation_vs_alternatives** 须用**对照**写清「为何选本品」;**禁止**捏造未出现的品牌、检测值、具体「高/低百分之几」等。无对标素材时写本品独特点,并在 **open_points_for_business** 提示可补充的对照数据或检测依据(若无则空串)。
**JSON 键(须全部出现,值为字符串;无内容用空串)**: **JSON 键(须全部出现,值为字符串;无内容用空串)**:
- what_we_sell:**卖的是什么**(必填,建议 25~80 字)。写清**品类 + 主推形态/规格或适用场景**,让读者**不读策略稿**也能回答「你们在卖哪种货」。**仅可**综合策略稿、`strategy_decisions`(尤其 **pillar_product**、battlefield_one_line、audience_segment、marketing_strategy)、`business_notes` 与 `keyword` 监测语境中已出现的信息;若 `pillar_product` 非空须与之**不矛盾**。无具体商品名时须明确写「待业务补充主推 SKU/品名」,并保留类目词(可与关键词监测范围对读)。 - what_we_sell:**卖的是什么**(必填,建议 25~80 字)。写清**品类 + 主推形态/规格或适用场景**,让读者**不读策略稿**也能回答「你们在卖哪种货」。**仅可**综合策略稿、`strategy_decisions`(尤其 **pillar_product**、battlefield_one_line、audience_segment、marketing_strategy)、`business_notes` 与 `keyword` 监测语境中已出现的信息;若 `pillar_product` 非空须与之**不矛盾**。无具体商品名时须明确写「待业务补充主推 SKU/品名」,并保留类目词(可与关键词监测范围对读)。
- one_liner_value:一句话价值主张(买家能得到什么) - one_liner_value:一句话价值主张(买家能得到什么)
- buyer_job_to_be_done:购买者的任务或情境(一句) - buyer_job_to_be_done:购买者的任务或情境(一句)
- key_pain_or_desire:核心痛点或欲望(与策略一致) - key_pain_or_desire:核心痛点或欲望(与策略一致)
- why_this_product:为何要选这一款(相对同类,一句) - why_this_product:为何要选这一款(**优先** 1~2 句写相对**常规/普通同类**的核心理由,可用泛称如「普通甜面包」「常见饼干」;可从蛋白、膳食纤维、饱腹感、GI 或糖负担、口感、包装形态、配料表等**择输入已支持**的维度;无对照素材则写本品独特点)
- proof_or_trust_angle:信任或证明角度(无依据写「输入未体现」) - proof_or_trust_angle:信任或证明角度(无依据写「输入未体现」)
- differentiation_vs_alternatives:与替代方案相比的差异(一句) - differentiation_vs_alternatives:与替代方案相比的差异(**须含**与常规同类对照的一句话结论;营养数字、GI、每百克含量等**仅可**复述输入已有内容)
- price_value_framing:价位与价值感如何表述(与策略价位可对读;无则「待业务确认」) - price_value_framing:价位与价值感如何表述(与策略价位可对读;无则「待业务确认」)
- compliance_taboos:表述禁区摘要(来自业务备注或策略风险) - compliance_taboos:表述禁区摘要(来自业务备注或策略风险)
- open_points_for_business:待业务补充(无则空串) - open_points_for_business:待业务补充(无则空串)
@ -36,6 +37,7 @@ DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定
- 购买者视角,短句;禁止输出 JSON 键名英文给最终读者(值全部为中文**多触点上架**可用文案)。 - 购买者视角,短句;禁止输出 JSON 键名英文给最终读者(值全部为中文**多触点上架**可用文案)。
- 不要泄露「核心信息卡」「策略稿」等内部词。 - 不要泄露「核心信息卡」「策略稿」等内部词。
- **更丰富≠编造**:可增加条数与段落,但**每一条**须能从信息卡对应字段找到方向;无依据处写「输入未体现」「待业务核对」,**禁止**为凑字数新增数字、销量、认证、评价引语、具体竞品名。 - **更丰富≠编造**:可增加条数与段落,但**每一条**须能从信息卡对应字段找到方向;无依据处写「输入未体现」「待业务核对」,**禁止**为凑字数新增数字、销量、认证、评价引语、具体竞品名。
- **对照式表达(写厚但不编造)**:学习优质商详「先对比再购买」。**detail_headline** 在首句点明品类后,**至少 1 句**用「相对普通/常规同类(泛称,禁止编造品牌)」讲清差异或价值;无依据时用中性句或「具体对比数值待包装/检测与业务核对」。**selling_bullets** 中 **至少 3 条**须为**可感知的对照卖点**,从蛋白、膳食纤维、饱腹感、口感质地、配料/清洁标签、包装控量或便携、GI/糖负担等角度择信息卡**已支持**的项;信息卡未提的维度**不硬写**。**detail_mid_story_paragraphs** 中 **至少 1 段**用「为何不满足于普通同类」叙事,仍须紧扣信息卡,禁止新数字与编造用户故事。
- **每条 listing_titles、listing_subtitle、detail_headline、selling_bullets 的前两条**均须让读者能识别**在卖什么品类/什么货**(须与信息卡 **what_we_sell** 一致,可缩写但**禁止**偷换品类或只剩空洞形容词)。若信息卡 `what_we_sell` 已写品类,文案中**至少一处**直接出现该类目词或同义可识别表述。 - **每条 listing_titles、listing_subtitle、detail_headline、selling_bullets 的前两条**均须让读者能识别**在卖什么品类/什么货**(须与信息卡 **what_we_sell** 一致,可缩写但**禁止**偷换品类或只剩空洞形容词)。若信息卡 `what_we_sell` 已写品类,文案中**至少一处**直接出现该类目词或同义可识别表述。
- **文生图/文生视频提示词**:须为**可直接复制**到常见文生图、文生视频模型的**中文**描述;**仅可**依据信息卡已有事实与品类,**禁止**在提示词里写「策略稿」「信息卡」「JSON」等元话语;**禁止**要求生成未授权的具体品牌 Logo、真实包装上的可辨认商标、带疗效承诺的贴片字。 - **文生图/文生视频提示词**:须为**可直接复制**到常见文生图、文生视频模型的**中文**描述;**仅可**依据信息卡已有事实与品类,**禁止**在提示词里写「策略稿」「信息卡」「JSON」等元话语;**禁止**要求生成未授权的具体品牌 Logo、真实包装上的可辨认商标、带疗效承诺的贴片字。
- **文生图须「有货、有卖点画面」**(硬性): - **文生图须「有货、有卖点画面」**(硬性):
@ -45,12 +47,12 @@ DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定
- **配料/品类视觉**(如全麦):可写「麸皮颗粒隐约可见」「浅褐全麦外皮」等,**禁止**疗效字幕、血糖仪、前后对比治病画面。 - **配料/品类视觉**(如全麦):可写「麸皮颗粒隐约可见」「浅褐全麦外皮」等,**禁止**疗效字幕、血糖仪、前后对比治病画面。
**JSON 键(须全部出现)**: **JSON 键(须全部出现)**:
- listing_titles:字符串数组,**6~9** 条商品短标题备选(每条约 30 字内;**每条须含可识别品类或品名线索**,禁止多条全是空洞套话;可有 2~3 条侧重不同角度:场景/质地/配料/人群) - listing_titles:字符串数组,**6~9** 条商品短标题备选(每条约 30 字内;**每条须含可识别品类或品名线索**,禁止多条全是空洞套话;其中 **2~3 条**可在有依据时含「相对更…/更少…/不腻」等**对照**表述;其余侧重场景/质地/配料/人群)
- listing_subtitle:一条列表副文案(约 **60~90** 字内,信息不足则取下限) - listing_subtitle:一条列表副文案(约 **60~100** 字内,信息不足则取下限;**鼓励**含一句与常规同类对照的价值,无依据则省略)
- detail_headline:商品详情页首屏下 lead,**2~3 句**(**首句须点明卖的是什么货**,后接价值与差异;总长约 **80~160** 字) - detail_headline:商品详情页首屏下 lead,**2~4 句**(**首句须点明卖的是什么货**;**至少 1 句**为相对常规同类的对照或价值;总长约 **80~200** 字)
- selling_bullets:字符串数组,**8~12** 条卖点(每条约 **40 字内**;须覆盖:品类形态、口感/质地(若信息卡有)、配料/健康表述(合规)、场景、信任点、与同类差异等**不同角度**,**禁止** 12 条重复同一句话换说法) - selling_bullets:字符串数组,**8~12** 条卖点(每条约 **40 字内**;**至少 3 条**为「本品 vs 常规同类」式差异;整体须覆盖:品类形态、口感/质地(若信息卡有)、蛋白/纤维/饱腹/GI 或糖负担(**仅信息卡有则写**)、配料/健康表述(合规)、包装/规格(若信息卡有)、场景、信任点、与常规品差异等**不同角度**,**禁止** 12 条重复同一句话换说法)
- spec_sidebar_lines:字符串数组,**0~5** 条参数区旁短句(可空数组) - spec_sidebar_lines:字符串数组,**0~5** 条参数区旁短句(可空数组)
- faq:对象数组,每项含 question、answer 字符串,**5~8** 组;答句不得超出信息卡承诺;可含「怎么保存」「适合谁」「和××区别」(××用泛称除非信息卡有品牌) - faq:对象数组,每项含 question、answer 字符串,**5~8** 组;答句不得超出信息卡承诺;其中 **1~2** 组宜为「和普通/常规××有什么不同」类(××用泛称);可含「怎么保存」「适合谁」
- detail_mid_story_paragraphs:字符串数组,**2~4 段**详情页**首屏之后**的中段叙事;每段 **70~150** 字;**仅**展开信息卡已有卖点与 `what_we_sell`,可分段讲「适合谁—怎么吃—为何值得」;**禁止**新数字、新功效、编造用户故事 - detail_mid_story_paragraphs:字符串数组,**2~4 段**详情页**首屏之后**的中段叙事;每段 **70~150** 字;**仅**展开信息卡已有卖点与 `what_we_sell`,可分段讲「适合谁—怎么吃—为何值得」;**禁止**新数字、新功效、编造用户故事
- usage_and_pairing_tips:字符串数组,**2~5** 条食用场景、保存提示、搭配建议(如早餐配牛奶);信息卡未写保存条件则写「输入未体现具体保质期与保存要求,上架前请核对包装」类中性句,**禁止**编造保质期天数 - usage_and_pairing_tips:字符串数组,**2~5** 条食用场景、保存提示、搭配建议(如早餐配牛奶);信息卡未写保存条件则写「输入未体现具体保质期与保存要求,上架前请核对包装」类中性句,**禁止**编造保质期天数
- short_graphic_post_variants:字符串数组,**3~5** 条短图文/种草贴变体;每条 **45~110** 字;须**首句或次句**点明品类;适合复制到站内动态;**禁止**销量名次、虚假好评引语 - short_graphic_post_variants:字符串数组,**3~5** 条短图文/种草贴变体;每条 **45~110** 字;须**首句或次句**点明品类;适合复制到站内动态;**禁止**销量名次、虚假好评引语

View File

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

View File

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

View File

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

View File

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

View File

@ -15,12 +15,34 @@ def _strip_inline_md(s: str) -> str:
return s 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: def _is_table_sep(line: str) -> bool:
t = line.strip() """GFM 表头与表体之间的分隔行(含任意长度连字符,如 ``|----------|``)。"""
if not t.startswith("|"): row_line = line.strip()
if not row_line.startswith("|"):
return False return False
inner = t.strip("|").replace(" ", "") cells = [c.strip() for c in row_line.strip("|").split("|")]
return bool(inner) and all(p in ("", "---", ":---", "---:", ":---:") for p in t.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+(.+)$") _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*$") _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: def _match_heading(line: str) -> tuple[int, str] | None:
"""返回 (docx level 0–8, 标题文本) 或 None。""" """返回 (docx level 0–8, 标题文本) 或 None。"""
m = _RE_HEADING.match(line.strip()) m = _RE_HEADING.match(line.strip())
@ -57,7 +111,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
pass pass
def _add_list_bullet(text: str) -> None: def _add_list_bullet(text: str) -> None:
t = _strip_inline_md(text) t = _strip_gfm_task_list_prefix(_strip_inline_md(text))
try: try:
doc.add_paragraph(t, style="List Bullet") doc.add_paragraph(t, style="List Bullet")
except KeyError: 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") lines = (md or "").replace("\r\n", "\n").split("\n")
i = 0 i = 0
in_fence = False 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): while i < len(lines):
raw = lines[i] raw = lines[i]
if raw.strip().startswith("```"): if raw.strip().startswith("```"):
if not in_fence:
flush_plain()
in_fence = not in_fence in_fence = not in_fence
i += 1 i += 1
continue continue
@ -90,23 +159,27 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
line = raw.rstrip() line = raw.rstrip()
if not line.strip(): if not line.strip():
flush_plain()
doc.add_paragraph("") doc.add_paragraph("")
i += 1 i += 1
continue continue
if _RE_HR.match(line): if _RE_HR.match(line):
flush_plain()
doc.add_paragraph("") doc.add_paragraph("")
i += 1 i += 1
continue continue
hm = _match_heading(line) hm = _match_heading(line)
if hm is not None: if hm is not None:
flush_plain()
doc.add_heading(hm[1], level=hm[0]) doc.add_heading(hm[1], level=hm[0])
i += 1 i += 1
continue continue
mimg = _img_line.match(line.strip()) mimg = _img_line.match(line.strip())
if mimg and asset_root is not None: if mimg and asset_root is not None:
flush_plain()
rel = mimg.group(2).strip() rel = mimg.group(2).strip()
if not (rel.startswith("http://") or rel.startswith("https://")): if not (rel.startswith("http://") or rel.startswith("https://")):
img_path = (asset_root / rel).resolve() img_path = (asset_root / rel).resolve()
@ -121,6 +194,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
continue continue
if line.strip().startswith("|"): if line.strip().startswith("|"):
flush_plain()
rows: list[list[str]] = [] rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"): while i < len(lines) and lines[i].strip().startswith("|"):
row_line = lines[i].strip() 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) mu = _RE_UL.match(line)
if mu: if mu:
flush_plain()
_add_list_bullet(mu.group(1)) _add_list_bullet(mu.group(1))
i += 1 i += 1
continue continue
mo = _RE_OL.match(line) mo = _RE_OL.match(line)
if mo: if mo:
flush_plain()
_add_list_number(mo.group(2)) _add_list_number(mo.group(2))
i += 1 i += 1
continue continue
mq = _RE_BLOCKQUOTE.match(line) mq = _RE_BLOCKQUOTE.match(line)
if mq: if mq:
flush_plain()
inner = mq.group(1).strip() inner = mq.group(1).strip()
if inner: if inner:
p = doc.add_paragraph() p = doc.add_paragraph()
@ -162,12 +239,18 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
i += 1 i += 1
continue continue
if _is_plain_markdown_line(line):
plain_buf.append(line)
i += 1
continue
flush_plain()
p = doc.add_paragraph() p = doc.add_paragraph()
p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
text = _strip_inline_md(line) p.add_run(_strip_inline_md(line))
p.add_run(text)
i += 1 i += 1
flush_plain()
bio = BytesIO() bio = BytesIO()
doc.save(bio) doc.save(bio)
return bio.getvalue() return bio.getvalue()
@ -262,19 +345,22 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
fontName=font_name, fontName=font_name,
fontSize=10, fontSize=10,
leading=14, leading=14,
spaceAfter=3,
) )
h1s = ParagraphStyle( h1s = ParagraphStyle(
name="H1CJK", name="H1CJK",
parent=body, parent=body,
fontSize=16, fontSize=16,
leading=20, leading=20,
spaceAfter=8, spaceBefore=0,
spaceAfter=10,
) )
h2s = ParagraphStyle( h2s = ParagraphStyle(
name="H2CJK", name="H2CJK",
parent=body, parent=body,
fontSize=13, fontSize=13,
leading=17, leading=17,
spaceBefore=14,
spaceAfter=6, spaceAfter=6,
) )
h3s = ParagraphStyle( h3s = ParagraphStyle(
@ -282,6 +368,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
parent=body, parent=body,
fontSize=12, fontSize=12,
leading=16, leading=16,
spaceBefore=10,
spaceAfter=5, spaceAfter=5,
) )
h4s = ParagraphStyle( h4s = ParagraphStyle(
@ -289,6 +376,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
parent=body, parent=body,
fontSize=11, fontSize=11,
leading=15, leading=15,
spaceBefore=8,
spaceAfter=4, spaceAfter=4,
) )
h56s = ParagraphStyle( h56s = ParagraphStyle(
@ -296,6 +384,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
parent=body, parent=body,
fontSize=10.5, fontSize=10.5,
leading=14, leading=14,
spaceBefore=6,
spaceAfter=3, spaceAfter=3,
) )
quote_style = ParagraphStyle( quote_style = ParagraphStyle(
@ -305,25 +394,50 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
fontSize=9.5, fontSize=9.5,
textColor=colors.HexColor("#444444"), textColor=colors.HexColor("#444444"),
) )
# 项目符号用 Helvetica 绘制:正文 CJK 字体常缺 U+2022「•」,会落成方框(似 ☐)
bullet_body = ParagraphStyle( bullet_body = ParagraphStyle(
name="BulletBodyCJK", name="BulletBodyCJK",
parent=body, parent=body,
leftIndent=18, leftIndent=22,
bulletIndent=8, bulletIndent=10,
firstLineIndent=0, firstLineIndent=0,
bulletFontName="Helvetica",
bulletFontSize=10,
wordWrap="CJK",
)
ol_body = ParagraphStyle(
name="OlBodyCJK",
parent=body,
leftIndent=18,
firstLineIndent=0,
wordWrap="CJK",
) )
story: list[Any] = [] story: list[Any] = []
lines = (md or "").replace("\r\n", "\n").split("\n") lines = (md or "").replace("\r\n", "\n").split("\n")
i = 0 i = 0
in_fence = False in_fence = False
plain_buf: list[str] = []
def _para_cell(s: str, style: Any) -> Paragraph: def _para_cell(s: str, style: Any) -> Paragraph:
return Paragraph(xml_escape(_strip_inline_md(s)), style) 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): while i < len(lines):
raw = lines[i] raw = lines[i]
if raw.strip().startswith("```"): if raw.strip().startswith("```"):
if not in_fence:
flush_plain_pdf()
in_fence = not in_fence in_fence = not in_fence
i += 1 i += 1
continue continue
@ -334,17 +448,20 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
i += 1 i += 1
continue continue
if not s.strip(): if not s.strip():
flush_plain_pdf()
story.append(Spacer(1, 0.15 * cm)) story.append(Spacer(1, 0.15 * cm))
i += 1 i += 1
continue continue
if _RE_HR.match(s): if _RE_HR.match(s):
flush_plain_pdf()
story.append(Spacer(1, 0.2 * cm)) story.append(Spacer(1, 0.2 * cm))
i += 1 i += 1
continue continue
hm = _match_heading(s) hm = _match_heading(s)
if hm is not None: if hm is not None:
flush_plain_pdf()
level, title = hm level, title = hm
title_esc = xml_escape(title) title_esc = xml_escape(title)
if level == 0: 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()) mimg = _img_line.match(s.strip())
if mimg and asset_root is not None: if mimg and asset_root is not None:
flush_plain_pdf()
rel = mimg.group(2).strip() rel = mimg.group(2).strip()
if not (rel.startswith("http://") or rel.startswith("https://")): if not (rel.startswith("http://") or rel.startswith("https://")):
img_path = (asset_root / rel).resolve() img_path = (asset_root / rel).resolve()
@ -381,6 +499,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
continue continue
if s.strip().startswith("|"): if s.strip().startswith("|"):
flush_plain_pdf()
rows: list[list[str]] = [] rows: list[list[str]] = []
while i < len(lines) and lines[i].strip().startswith("|"): while i < len(lines) and lines[i].strip().startswith("|"):
row_line = lines[i].strip() row_line = lines[i].strip()
@ -393,47 +512,51 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
if rows: if rows:
max_cols = max(len(r) for r in rows) max_cols = max(len(r) for r in rows)
pad_rows = [r + [""] * (max_cols - len(r)) for r in rows] pad_rows = [r + [""] * (max_cols - len(r)) for r in rows]
usable_w = 13 * cm usable_w = 17 * cm
col_w = usable_w / float(max_cols) if max_cols == 2:
data: list[list[Any]] = [] col_widths = [4.2 * cm, usable_w - 4.2 * cm]
for row in pad_rows: else:
data.append( col_widths = [usable_w / float(max_cols)] * max_cols
[_para_cell(c, body) for c in row] 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(tbl_cmds))
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),
]
)
)
story.append(t) story.append(t)
story.append(Spacer(1, 0.15 * cm)) story.append(Spacer(1, 0.2 * cm))
continue continue
mu = _RE_UL.match(s) mu = _RE_UL.match(s)
if mu: if mu:
txt = xml_escape(_strip_inline_md(mu.group(1))) flush_plain_pdf()
story.append(Paragraph(f"• {txt}", bullet_body)) 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 i += 1
continue continue
mo = _RE_OL.match(s) mo = _RE_OL.match(s)
if mo: if mo:
flush_plain_pdf()
n, rest = mo.group(1), mo.group(2) n, rest = mo.group(1), mo.group(2)
txt = xml_escape(_strip_inline_md(rest)) 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 i += 1
continue continue
mq = _RE_BLOCKQUOTE.match(s) mq = _RE_BLOCKQUOTE.match(s)
if mq: if mq:
flush_plain_pdf()
inner = mq.group(1).strip() inner = mq.group(1).strip()
if inner: if inner:
story.append( story.append(
@ -442,10 +565,16 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
i += 1 i += 1
continue continue
plain = _strip_inline_md(s) if _is_plain_markdown_line(s):
story.append(Paragraph(xml_escape(plain), body)) plain_buf.append(s)
i += 1
continue
flush_plain_pdf()
story.append(Paragraph(xml_escape(_strip_inline_md(s)), body))
i += 1 i += 1
flush_plain_pdf()
buf = BytesIO() buf = BytesIO()
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buf, buf,

View File

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

View File

@ -451,6 +451,9 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
pillar_comm = serializers.CharField( pillar_comm = serializers.CharField(
required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False 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( audience_segment = serializers.CharField(
required=False, allow_blank=True, default="", max_length=500, trim_whitespace=False 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", "brand": "B",
"shop": "S2", "shop": "S2",
"category": "休闲食品 > 饼干 > 粗粮饼干", "category": "休闲食品 > 饼干 > 粗粮饼干",
"list_price_show": "20", "标价": "100",
"券后到手价": "80",
}, },
{ {
"brand": "B", "brand": "B",
"shop": "S2", "shop": "S2",
"category": "休闲食品 > 饼干 > 苏打饼干", "category": "休闲食品 > 饼干 > 苏打饼干",
"list_price_show": "22", "标价": "50",
"券后到手价": "50",
}, },
], ],
}, },
@ -52,27 +54,11 @@ class BriefStrategyScopeTests(SimpleTestCase):
"group": "饮料", "group": "饮料",
"comment_rows": 5, "comment_rows": 5,
"effective_comment_text_units": 5, "effective_comment_text_units": 5,
"focus_keyword_hits": [{"word": "甜", "count": 2}],
"scenarios_top": [
{
"scenario": "解渴",
"count": 2,
"share_of_text_units": 0.4,
}
],
}, },
{ {
"group": "饼干", "group": "饼干",
"comment_rows": 8, "comment_rows": 8,
"effective_comment_text_units": 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": [ "usage_scenarios_by_matrix_group": [
@ -120,7 +106,17 @@ class BriefStrategyScopeTests(SimpleTestCase):
self.assertEqual(out["matrix_by_group"][0]["group"], "饼干") self.assertEqual(out["matrix_by_group"][0]["group"], "饼干")
self.assertEqual(len(out["matrix_by_group"][0]["skus"]), 2) self.assertEqual(len(out["matrix_by_group"][0]["skus"]), 2)
self.assertEqual(len(out["consumer_feedback_by_matrix_group"]), 1) 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.assertEqual(out["price_stats_source"], "strategy_scope_matrix_group_skus")
self.assertIn("strategy_scope_applied", out) self.assertIn("strategy_scope_applied", out)
self.assertEqual(out["strategy_scope_applied"]["group"], "饼干") 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, semantic_pool_max=10,
) )
self.assertIn("sample_reviews_semantic_pool", pl) 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) self.assertGreaterEqual(len(pl["sample_reviews_semantic_pool"]), 1)
def test_comment_sentiment_score_then_lexeme(self) -> None: 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("negative_only"), 1)
self.assertEqual(lex.get("neutral_or_empty"), 1) self.assertEqual(lex.get("neutral_or_empty"), 1)
pl = build_comment_sentiment_llm_payload(texts, scores=scores) 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: def test_comment_sentiment_all_scores_missing_falls_back_keyword(self) -> None:
texts = ["好吃推荐", "差评"] texts = ["好吃推荐", "差评"]
@ -69,7 +74,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
lex = _comment_sentiment_lexicon(texts, scores) lex = _comment_sentiment_lexicon(texts, scores)
self.assertEqual(lex.get("method"), "keyword_lexicon") 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: with tempfile.TemporaryDirectory() as td:
run_dir = Path(td) run_dir = Path(td)
(run_dir / "pc_search_raw").mkdir(parents=True) (run_dir / "pc_search_raw").mkdir(parents=True)
@ -88,8 +93,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
report_config={"comment_focus_words": ["自定义词阿尔法"]}, report_config={"comment_focus_words": ["自定义词阿尔法"]},
) )
words = {x["word"] for x in out["comment_focus_keywords"]} self.assertEqual(out["comment_focus_keywords"], [])
self.assertIn("自定义词阿尔法", words)
def test_matrix_groups_require_detail_category_path(self) -> None: def test_matrix_groups_require_detail_category_path(self) -> None:
sku_h = "SKU(skuId)" sku_h = "SKU(skuId)"
@ -145,43 +149,6 @@ class BuildCompetitorBriefTests(SimpleTestCase):
self.assertIn("店铺:", lines[0]) self.assertIn("店铺:", lines[0])
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: def test_cn_volume_int_parses_total_sales_trailer(self) -> None:
self.assertEqual( self.assertEqual(
_cn_volume_int("已售50万+ | good:99%好评"), 500_000 _cn_volume_int("已售50万+ | good:99%好评"), 500_000

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import unittest 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): class ParsePhrasesTests(unittest.TestCase):
@ -16,20 +16,5 @@ class ParsePhrasesTests(unittest.TestCase):
self.assertEqual(_parse_phrases_object(raw), ["低糖"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -1,7 +1,10 @@
"""Markdown → docx/pdf 导出(防回归:docx 主循环须递增行指针)。""" """Markdown → docx/pdf 导出(防回归:docx 主循环须递增行指针)。"""
from __future__ import annotations from __future__ import annotations
import io
from django.test import SimpleTestCase from django.test import SimpleTestCase
from docx import Document
from pipeline.reporting.md_document_export import ( from pipeline.reporting.md_document_export import (
markdown_to_docx_bytes, markdown_to_docx_bytes,
@ -21,3 +24,35 @@ class MdDocumentExportTests(SimpleTestCase):
data = markdown_to_pdf_bytes(md) data = markdown_to_pdf_bytes(md)
self.assertGreater(len(data), 100) self.assertGreater(len(data), 100)
self.assertTrue(data.startswith(b"%PDF")) 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)。""" """市场策略草稿 Markdown(规则,无 LLM)。"""
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pipeline.llm.generate_strategy import _omit_ch8_probe_wordchart_fields 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.llm.generate_strategy import strategy_decisions_substantive
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
class StrategyDraftTests(SimpleTestCase): 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: def test_strategy_decisions_substantive(self) -> None:
self.assertFalse(strategy_decisions_substantive(None)) self.assertFalse(strategy_decisions_substantive(None))
self.assertFalse(strategy_decisions_substantive({})) self.assertFalse(strategy_decisions_substantive({}))
@ -36,13 +62,36 @@ class StrategyDraftTests(SimpleTestCase):
md = build_strategy_draft_markdown( md = build_strategy_draft_markdown(
job_id=7, job_id=7,
keyword="K", 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", generated_at_iso="2026-01-01",
for_llm_input=True, for_llm_input=True,
strategy_decisions={"positioning_choice": "mid"},
) )
self.assertNotIn("任务 ID", md) self.assertNotIn("任务 ID", md)
self.assertNotIn("generate_strategy.py", md) self.assertNotIn("generate_strategy.py", md)
self.assertNotIn("strategy_hints", 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("监测摘要自动线索", md)
self.assertIn("列表页约第 1~3 页", md) self.assertIn("列表页约第 1~3 页", md)
self.assertNotIn("埋伏笔", md) self.assertNotIn("埋伏笔", md)
@ -50,7 +99,7 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("回扣 §2", md) self.assertNotIn("回扣 §2", md)
self.assertNotIn("(占位)", md) self.assertNotIn("(占位)", md)
self.assertIn("### 1.3 本品聚焦\n", 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: def test_build_contains_sections_and_notes(self) -> None:
brief = { brief = {
@ -122,21 +171,24 @@ class StrategyDraftTests(SimpleTestCase):
self.assertIn("**本品角色**:追赶型", md) self.assertIn("**本品角色**:追赶型", md)
self.assertIn("**营销策略**:内容种草+搜索承接", md) self.assertIn("**营销策略**:内容种草+搜索承接", md)
self.assertIn("**总体策略**:先腰后顶", md) self.assertIn("**总体策略**:先腰后顶", md)
self.assertIn("- [x] **卡腰**", md) self.assertIn("**卡腰**", md)
self.assertIn("- [ ] **贴顶**", md)
i4 = md.find("## 四、为什么要选") i4 = md.find("## 四、为什么要选")
i5 = md.find("## 五、与其它品牌") i5 = md.find("## 五、与其它品牌")
self.assertGreater(i5, i4) self.assertGreater(i5, i4)
self.assertNotIn("贴顶", md[i4:i5]) self.assertNotIn("贴顶", md[i4:i5])
i82 = md.find("### 8.2 定价策略") i82 = md.find("### 8.2 定价策略")
self.assertGreater(i82, 0) 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("做低糖配方", md) self.assertIn("做低糖配方", md)
self.assertIn("### 7.1 品牌建设", md) self.assertIn("### 7.1 品牌建设", md)
self.assertIn("- [x] 评论侧归纳是否以偏概全", md) self.assertIn("**评论与归纳口径**", md)
self.assertIn("- [ ] 价格带是否含大促", md) self.assertIn("**价格带与清洗规则**", md)
self.assertIn("- [x] 列表集中度与深入样本品牌是否不一致", 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: def test_chapter8_probe_omits_focus_scenario_count_bullets(self) -> None:
brief = { brief = {
@ -185,8 +237,22 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("子串统计命中约 **501**", md) self.assertNotIn("子串统计命中约 **501**", md)
self.assertNotIn("场景「控糖", md) self.assertNotIn("场景「控糖", md)
def test_matrix_scope_concentration_not_list_rows_wording(self) -> None: def test_download_draft_omits_category_mix_excerpt(self) -> None:
"""收窄矩阵时 concentration 来自分组内 SKU,§5.1 勿写「列表行」。""" 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 = { brief = {
"schema_version": 1, "schema_version": 1,
"keyword": "低GI", "keyword": "低GI",
@ -205,13 +271,14 @@ class StrategyDraftTests(SimpleTestCase):
}, },
} }
md = build_strategy_draft_markdown(job_id=1, keyword="低GI", brief=brief) md = build_strategy_draft_markdown(job_id=1, keyword="低GI", brief=brief)
self.assertIn("与全关键词 **PC 搜索列表行** 集中度**不是同一口径**", md) self.assertNotIn("对比对象(摘录)", md)
self.assertIn("店铺分布(「饼干」内样本 SKU)", md) self.assertNotIn("店铺分布(「饼干」内样本 SKU)", md)
self.assertIn("该分组样本 SKU 的", md) self.assertNotIn("碧翠园京东自营旗舰店", md)
self.assertNotIn("列表侧店铺集中度", md) self.assertNotIn("23.8%", md)
self.assertNotIn("第一大店铺约占列表行的", 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 = { brief = {
"schema_version": 1, "schema_version": 1,
"keyword": "测试", "keyword": "测试",
@ -235,9 +302,8 @@ class StrategyDraftTests(SimpleTestCase):
keyword="测试", keyword="测试",
brief=brief, brief=brief,
) )
self.assertIn("按去重 SKU", md) self.assertNotIn("按去重 SKU", md)
self.assertIn("120", md) self.assertNotIn("120", md)
self.assertIn("35.0%", md)
def test_chapter8_probe_filters_strategy_hints_focus_scenario_lines(self) -> None: def test_chapter8_probe_filters_strategy_hints_focus_scenario_lines(self) -> None:
brief = { brief = {
@ -259,8 +325,7 @@ class StrategyDraftTests(SimpleTestCase):
self.assertNotIn("评价文本中「口感", md) self.assertNotIn("评价文本中「口感", md)
self.assertNotIn("用途/场景中「控糖", md) self.assertNotIn("用途/场景中「控糖", md)
self.assertIn("样本内品牌较分散", md) self.assertIn("样本内品牌较分散", md)
self.assertIn("price_promotion_signals", md) self.assertIn("促销线索须与摘要中的价格/活动信号一致", md)
self.assertIn("price_promotion_signals", md)
def test_ch8_probe_omit_wordchart_nested_in_consumer_feedback(self) -> None: def test_ch8_probe_omit_wordchart_nested_in_consumer_feedback(self) -> None:
compact = { compact = {
@ -286,3 +351,80 @@ class StrategyDraftTests(SimpleTestCase):
self.assertEqual( self.assertEqual(
compact["consumer_feedback_by_matrix_group"][0].get("comment_rows"), 10 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.report_strategy_excerpt import load_report_strategy_excerpt
from ..reporting.strategy_draft import build_strategy_draft_markdown from ..reporting.strategy_draft import build_strategy_draft_markdown
from ..strategy_decision_keys import build_strategy_decisions_dict
from ..serializers import ( from ..serializers import (
MarketingDetailPackRequestSerializer, MarketingDetailPackRequestSerializer,
PipelineJobSerializer, PipelineJobSerializer,
@ -139,28 +140,7 @@ class JobStrategyDraftView(APIView):
ser.is_valid(raise_exception=True) ser.is_valid(raise_exception=True)
vd = ser.validated_data vd = ser.validated_data
notes = (vd.get("business_notes") or "").strip() notes = (vd.get("business_notes") or "").strip()
strategy_decisions = { strategy_decisions = build_strategy_decisions_dict(vd)
"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")),
}
try: try:
brief = build_competitor_brief_for_job( brief = build_competitor_brief_for_job(
job.run_dir, job.run_dir,

View File

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

View File

@ -7,18 +7,9 @@ defineEmits(['add-market', 'remove-market'])
<template> <template>
<div> <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"> <div class="rc-section">
<h4 class="rc-subtitle">2. 外部市场信息(可选)</h4> <h4 class="rc-subtitle">1. 外部市场信息(可选)</h4>
<p class="rc-help">若手边有第三方市场规模、增速等摘录,可填在表里,报告会多一节说明;不需要可整表留空。</p> <p class="rc-help">若手边有第三方市场规模、增速等摘录,可填在表里,报告会多一节说明;不需要可整表留空。</p>
<div class="rc-market-wrap"> <div class="rc-market-wrap">
<table class="rc-market"> <table class="rc-market">

View File

@ -1,45 +1,47 @@
import { ref } from 'vue' 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`` 允许的键一致。) * (与 backend ``validate_report_config_body`` 允许的键一致。)
*/ */
const REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS = [ const REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS = [
'llm_comment_sentiment', 'llm_comment_sentiment',
'llm_matrix_group_summaries', 'llm_matrix_group_summaries',
'llm_price_group_summaries', 'llm_price_group_summaries',
'llm_promo_group_summaries',
'llm_strategy_opportunities',
'llm_comment_group_summaries', '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 字段对应),面向非技术用户。 * 报告调参表单(与后端 report_config 字段对应),面向非技术用户。
*/ */
export function useReportConfigForm() { export function useReportConfigForm() {
const focusWordRows = ref([{ text: '' }])
const scenarioGroups = ref([{ label: '', triggersText: '' }])
const marketRows = ref([ const marketRows = ref([
{ indicator: '', value_and_scope: '', source: '', year: '' }, { indicator: '', value_and_scope: '', source: '', year: '' },
]) ])
/** 表单未编辑的布尔项,从任务配置读入后随保存写回 */ /** 表单未编辑的项,从任务配置读入后随保存写回 */
const passthroughBools = ref({}) const passthroughBools = ref({})
const passthroughInts = ref({})
function resetToEmpty() { function resetToEmpty() {
focusWordRows.value = [{ text: '' }]
scenarioGroups.value = [{ label: '', triggersText: '' }]
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }] marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
passthroughBools.value = {} passthroughBools.value = {}
passthroughInts.value = {}
} }
/** /**
@ -51,40 +53,6 @@ export function useReportConfigForm() {
return 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 const er = cfg.external_market_table_rows
if (Array.isArray(er) && er.length) { if (Array.isArray(er) && er.length) {
marketRows.value = er.map((row) => { marketRows.value = er.map((row) => {
@ -110,33 +78,26 @@ export function useReportConfigForm() {
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }] marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
} }
const pass = {} const passB = {}
for (const k of REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS) { 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;全空则为 {} */ /** @returns {Record<string, unknown>} 可 PATCH 到后端的 report_config;全空则为 {} */
function buildPayload() { function buildPayload() {
const out = {} 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 const rows = marketRows.value
.map((r) => ({ .map((r) => ({
indicator: (r.indicator || '').trim(), indicator: (r.indicator || '').trim(),
@ -155,28 +116,10 @@ export function useReportConfigForm() {
} }
Object.assign(out, passthroughBools.value) Object.assign(out, passthroughBools.value)
Object.assign(out, passthroughInts.value)
return out 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() { function addMarketRow() {
marketRows.value.push({ marketRows.value.push({
indicator: '', indicator: '',
@ -197,17 +140,12 @@ export function useReportConfigForm() {
} }
return { return {
focusWordRows,
scenarioGroups,
marketRows, marketRows,
passthroughBools, passthroughBools,
passthroughInts,
resetToEmpty, resetToEmpty,
applyFromApiConfig, applyFromApiConfig,
buildPayload, buildPayload,
addFocusRow,
removeFocusRow,
addScenarioRow,
removeScenarioRow,
addMarketRow, addMarketRow,
removeMarketRow, 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 { RouterLink } from 'vue-router'
import ReportConfigFormFields from '../../components/ReportConfigFormFields.vue' import ReportConfigFormFields from '../../components/ReportConfigFormFields.vue'
import { refreshJobs, useJobs, api, reportConfigDefaultsUrl } from '../../composables/useJobs' import { refreshJobs, useJobs, api, reportConfigDefaultsUrl } from '../../composables/useJobs'
import { useJobStore } from '../../stores/jobs'
import { useReportConfigForm } from '../../composables/useReportConfigForm' import { useReportConfigForm } from '../../composables/useReportConfigForm'
const { jobs } = useJobs() const { jobs } = useJobs()
@ -32,19 +33,8 @@ const regenBusyOtherTask = computed(
() => regenPendingJobId.value != null && regenPendingJobId.value !== selectedId.value, () => regenPendingJobId.value != null && regenPendingJobId.value !== selectedId.value,
) )
const { const { marketRows, applyFromApiConfig, buildPayload, addMarketRow, removeMarketRow } =
focusWordRows, useReportConfigForm()
scenarioGroups,
marketRows,
applyFromApiConfig,
buildPayload,
addFocusRow,
removeFocusRow,
addScenarioRow,
removeScenarioRow,
addMarketRow,
removeMarketRow,
} = useReportConfigForm()
const reportConfigErr = ref('') const reportConfigErr = ref('')
const reportConfigSaveLoading = ref(false) const reportConfigSaveLoading = ref(false)
@ -113,8 +103,7 @@ async function saveReportConfigToJob() {
return return
} }
const updated = JSON.parse(text) const updated = JSON.parse(text)
const idx = jobs.value.findIndex((x) => x.id === updated.id) useJobStore().mergeJob(updated)
if (idx >= 0) jobs.value[idx] = updated
syncReportConfigFromJob(updated) syncReportConfigFromJob(updated)
} catch (e) { } catch (e) {
reportConfigErr.value = String(e) reportConfigErr.value = String(e)
@ -182,8 +171,7 @@ async function regenerateReport() {
return return
} }
const updated = JSON.parse(text) const updated = JSON.parse(text)
const idx = jobs.value.findIndex((x) => x.id === updated.id) useJobStore().mergeJob(updated)
if (idx >= 0) jobs.value[idx] = updated
}) })
} catch (e) { } catch (e) {
regenErr.value = String(e) regenErr.value = String(e)
@ -200,8 +188,7 @@ watch(selectedId, async () => {
const r = await api(`/api/jobs/${id}/`) const r = await api(`/api/jobs/${id}/`)
if (r.ok) { if (r.ok) {
const j = await r.json() const j = await r.json()
const idx = jobs.value.findIndex((x) => x.id === j.id) useJobStore().mergeJob(j)
if (idx >= 0) jobs.value[idx] = j
syncReportConfigFromJob(j) syncReportConfigFromJob(j)
} }
} catch { } catch {
@ -272,11 +259,7 @@ watch(
</p> </p>
<div v-if="selectedId" class="report-config-block"> <div v-if="selectedId" class="report-config-block">
<h3 class="report-config-title">报告里的评价统计怎么算</h3> <h3 class="report-config-title">报告配置</h3>
<p class="hint-top report-config-hint">
关注词、场景词组、外部市场表等<strong>可以不改</strong>:留空并保存即沿用内置规则。大模型相关布尔项(如
<code>llm_comment_sentiment</code>)不再单独占勾选框:若任务里已有,会在保存时保留;要改请展开「高级 JSON」。
</p>
<div class="report-config-actions"> <div class="report-config-actions">
<button <button
type="button" type="button"
@ -297,13 +280,7 @@ watch(
</div> </div>
<ReportConfigFormFields <ReportConfigFormFields
:focus-word-rows="focusWordRows"
:scenario-groups="scenarioGroups"
:market-rows="marketRows" :market-rows="marketRows"
@add-focus="addFocusRow"
@remove-focus="removeFocusRow"
@add-scenario="addScenarioRow"
@remove-scenario="removeScenarioRow"
@add-market="addMarketRow" @add-market="addMarketRow"
@remove-market="removeMarketRow" @remove-market="removeMarketRow"
/> />

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
withGenerationInFlight, withGenerationInFlight,
} from '../../composables/useGenerationInFlight' } from '../../composables/useGenerationInFlight'
import { import {
loadStrategyDraftRecord,
loadStrategyMatrixScope, loadStrategyMatrixScope,
saveStrategyDraftRecord, saveStrategyDraftRecord,
saveStrategyMatrixScope, saveStrategyMatrixScope,
@ -60,6 +61,7 @@ const decisions = reactive({
pillar_price: '', pillar_price: '',
pillar_channel: '', pillar_channel: '',
pillar_comm: '', pillar_comm: '',
tactic_promotion: '',
audience_segment: '', audience_segment: '',
competitor_reference: '', competitor_reference: '',
resource_notes: '', resource_notes: '',
@ -108,6 +110,7 @@ function buildPayload() {
pillar_price: decisions.pillar_price, pillar_price: decisions.pillar_price,
pillar_channel: decisions.pillar_channel, pillar_channel: decisions.pillar_channel,
pillar_comm: decisions.pillar_comm, pillar_comm: decisions.pillar_comm,
tactic_promotion: decisions.tactic_promotion,
audience_segment: decisions.audience_segment, audience_segment: decisions.audience_segment,
competitor_reference: decisions.competitor_reference, competitor_reference: decisions.competitor_reference,
resource_notes: decisions.resource_notes, 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) { function formatJobOption(j) {
const t = j.created_at const t = j.created_at
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : '' const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
@ -228,8 +281,16 @@ onUnmounted(() => {
} }
}) })
watch(selectedId, (id) => { watch(selectedId, async (id) => {
loadMatrixGroupsForJob(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) => { watch(strategyMatrixScope, (v) => {
@ -262,8 +323,7 @@ watch(
<section class="ma-card"> <section class="ma-card">
<h2>策略生成</h2> <h2>策略生成</h2>
<p class="hint-top"> <p class="hint-top">
选择<strong>已成功</strong>任务,先选顶部<strong>矩阵细类</strong>(主推类目,与报告矩阵一致)。策略稿与矩阵选择保存在本机 <strong>localStorage</strong>,同域名下可跨标签查看;与其它页面的耗时任务通过全局任务锁同步。下方字段按策略文档常见顺序排列;成稿里的小节标题与编号由系统自动对应。有关痛点、购买理由、品牌承诺等由监测与模型撰写,本页主要收集<strong>业务决策与战术要点</strong>。生成结果见 选择<strong>已成功</strong>任务,先选顶部<strong>矩阵细类</strong>。<strong>已填项</strong>进入底稿并由大模型落实;<strong>未填项</strong>可由模型结合数据推断。
<RouterLink to="/jd/strategy-view">策略稿预览</RouterLink>。<strong>已填项</strong>进入底稿并由大模型落实;<strong>未填项</strong>可由模型结合数据推断。
</p> </p>
@ -443,52 +503,69 @@ watch(
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend>品牌四线与战术动作</legend> <legend>品牌四线:建设 · 打造 · 运营 · 体验</legend>
<p class="fieldset-hint">
下列内容会在策略稿中用于<strong>品牌四线</strong>与<strong>战术支柱</strong>相关段落(系统会自动落到对应小节)。价位阵地为单选;促销与活动细节无单独表单项,由监测与模型归纳。品牌承诺与调性由模型依据数据撰写。
</p>
<label class="fld fld-block"> <label class="fld fld-block">
<span>产品</span> <span>品牌建设</span>
<textarea <textarea v-model="decisions.pillar_product" rows="2" placeholder="选填" />
v-model="decisions.pillar_product"
rows="2"
placeholder="规格、配方或功能叙事、计划中的产品动作(可选)"
/>
</label> </label>
<label class="fld fld-block"> <label class="fld fld-block">
<span>价位阵地(单选)</span> <span>品牌打造</span>
<select v-model="decisions.positioning_choice" class="job-select full"> <textarea v-model="decisions.pillar_price" rows="2" placeholder="选填" />
<option v-for="o in positioningOptions" :key="o.value || 'empty'" :value="o.value">
{{ o.label }}
</option>
</select>
</label> </label>
<label class="fld fld-block"> <label class="fld fld-block">
<span>定价(补充说明)</span> <span>品牌运营</span>
<textarea <textarea v-model="decisions.pillar_channel" rows="2" placeholder="选填" />
v-model="decisions.pillar_price"
rows="2"
placeholder="在价位阵地之外:到手价呈现、跟价或避战原则、与大促关系等(可选)"
/>
</label> </label>
<label class="fld fld-block"> <label class="fld fld-block">
<span>渠道与触点</span> <span>品牌体验</span>
<textarea <textarea v-model="decisions.pillar_comm" rows="2" placeholder="选填" />
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> </label>
</fieldset> </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"> <fieldset class="fieldset">
<legend>数据与样本风险(确认知晓)</legend> <legend>数据与样本风险(确认知晓)</legend>
<p class="fieldset-hint"> <p class="fieldset-hint">
@ -685,4 +762,33 @@ watch(
.form-skip-note strong { .form-skip-note strong {
color: #334155; 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> </style>

View File

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