mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(pipeline): 竞品简报与策略管线、报告导出及 JD 前端联动
补充策略决策键、fixture 与相关单测;分析/营销包本地存储与任务状态;竞品简报 schema 与 LLM 章节生成、促销与导出调整;并纳入 Cursor 工程规则 user-dev-style。 Made-with: Cursor
This commit is contained in:
parent
7b33af8a37
commit
614d631261
44
.cursor/rules/user-dev-style.mdc
Normal file
44
.cursor/rules/user-dev-style.mdc
Normal 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 规范。
|
||||
@ -1,4 +1,4 @@
|
||||
"""评价关键词命中、星级与口语词表、情感 lexicon、大模型情感 payload。"""
|
||||
"""评价文本单元迭代、星级解析、大模型情感载荷(语义池);保留旧版 lexicon 辅助函数供测试,不再进入报告主链路。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@ -405,30 +405,34 @@ def build_comment_sentiment_llm_payload(
|
||||
shuffle_seed: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
供大模型做正/负向语义归纳:附规则统计、按**评分优先或关键词**归类后的抽样,以及 **sample_reviews_semantic_pool**
|
||||
(全量去重后的评价句确定性洗牌抽样,供模型结合语境自行判断褒贬)。
|
||||
供大模型做正/负向**语义**归纳:**仅**提供去重后的 ``sample_reviews_semantic_pool``(洗牌抽样原文),
|
||||
以及可选 ``star_rating_distribution``(有有效评分列时 1~2 / 3 / 4~5 星条数,**非**预设子串词表)。
|
||||
|
||||
``sentiment_bucket_method``:有有效评分列时为 ``score_then_lexeme``,否则为 ``keyword_substring_heuristic``;
|
||||
``comment_sentiment_lexicon`` 与各象限计数一致(竞品报告与 brief **已不再**发布同口径图);正文归纳仍以整句语义为准。
|
||||
已移除:``comment_sentiment_lexicon``、预设口语短语命中、按关键词/词表机械分桶的样本列表(与报告已废弃口径一致)。
|
||||
参数 ``max_samples_*`` 保留签名以兼容旧调用方,**不再使用**。
|
||||
"""
|
||||
_ = (max_samples_positive, max_samples_negative, max_samples_mixed)
|
||||
use_score_column = bool(
|
||||
scores is not None
|
||||
and len(scores) == len(texts)
|
||||
and any(s is not None for s in scores)
|
||||
)
|
||||
pos_only_texts: list[str] = []
|
||||
neg_only_texts: list[str] = []
|
||||
mixed_texts: list[str] = []
|
||||
use_attr = (
|
||||
attributed_texts is not None
|
||||
and len(attributed_texts) == len(texts)
|
||||
)
|
||||
all_unique_disp: list[str] = []
|
||||
seen_unique: set[str] = set()
|
||||
text_unit_count = 0
|
||||
star_dist: dict[str, int] | None = None
|
||||
if use_score_column:
|
||||
star_dist = {"score_1_2": 0, "score_3": 0, "score_4_5": 0, "no_score": 0}
|
||||
|
||||
for i, t in enumerate(texts):
|
||||
s = (t or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
text_unit_count += 1
|
||||
disp = (
|
||||
(attributed_texts[i] or s).strip()
|
||||
if use_attr
|
||||
@ -437,14 +441,16 @@ def build_comment_sentiment_llm_payload(
|
||||
if disp and disp not in seen_unique:
|
||||
seen_unique.add(disp)
|
||||
all_unique_disp.append(disp)
|
||||
sc = scores[i] if use_score_column and scores is not None else None
|
||||
quad = _sentiment_quadrant_for_row(s, sc, use_score_column=use_score_column)
|
||||
if quad == "mixed":
|
||||
mixed_texts.append(disp)
|
||||
elif quad == "pos_only":
|
||||
pos_only_texts.append(disp)
|
||||
elif quad == "neg_only":
|
||||
neg_only_texts.append(disp)
|
||||
if star_dist is not None and scores is not None:
|
||||
sc = scores[i] if i < len(scores) else None
|
||||
if sc is None:
|
||||
star_dist["no_score"] += 1
|
||||
elif sc <= 2:
|
||||
star_dist["score_1_2"] += 1
|
||||
elif sc == 3:
|
||||
star_dist["score_3"] += 1
|
||||
else:
|
||||
star_dist["score_4_5"] += 1
|
||||
|
||||
def _semantic_pool(seq: list[str], cap: int) -> list[str]:
|
||||
"""去重列表的洗牌子样本;shuffle_seed 非空时按种子固定顺序以便同任务可复现。"""
|
||||
@ -467,39 +473,18 @@ def build_comment_sentiment_llm_payload(
|
||||
|
||||
semantic_pool = _semantic_pool(all_unique_disp, semantic_pool_max)
|
||||
|
||||
def _sample(seq: list[str], cap: int) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in seq:
|
||||
if raw in seen:
|
||||
continue
|
||||
seen.add(raw)
|
||||
if len(raw) > max_chars_per_review:
|
||||
out.append(raw[:max_chars_per_review] + "…")
|
||||
else:
|
||||
out.append(raw)
|
||||
if len(out) >= cap:
|
||||
break
|
||||
return out
|
||||
|
||||
lex = _comment_sentiment_lexicon(texts, scores)
|
||||
pos_h = lex.get("positive_tone_lexeme_hits") or []
|
||||
neg_h = lex.get("negative_tone_lexeme_hits") or []
|
||||
pos_h_top = [x for x in pos_h[:12] if isinstance(x, dict)]
|
||||
neg_h_top = [x for x in neg_h[:12] if isinstance(x, dict)]
|
||||
bucket_method = (
|
||||
"score_then_lexeme" if use_score_column else "keyword_substring_heuristic"
|
||||
)
|
||||
return {
|
||||
"comment_sentiment_lexicon": lex,
|
||||
"positive_lexeme_hits_top": pos_h_top,
|
||||
"negative_lexeme_hits_top": neg_h_top,
|
||||
"sentiment_bucket_method": bucket_method,
|
||||
out: dict[str, Any] = {
|
||||
"text_unit_count": text_unit_count,
|
||||
"unique_attributed_snippets_count": len(all_unique_disp),
|
||||
"sample_reviews_semantic_pool": semantic_pool,
|
||||
"sample_reviews_positive_biased": _sample(pos_only_texts, max_samples_positive),
|
||||
"sample_reviews_negative_biased": _sample(neg_only_texts, max_samples_negative),
|
||||
"sample_reviews_mixed_tone": _sample(mixed_texts, max_samples_mixed),
|
||||
"semantic_pool_note": (
|
||||
"为去重后的评价原文抽样(可含细类/SKU/店铺前缀)。请据**整句语义**归纳正向体验与负向抱怨;"
|
||||
"勿引用已废弃的预设子串词表、勿把星级分布等同于具体抱怨主题。"
|
||||
),
|
||||
}
|
||||
if star_dist is not None:
|
||||
out["star_rating_distribution"] = star_dist
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@ -1,60 +1,9 @@
|
||||
"""``report_config`` JSON → 关注词、场景组、外部市场表行。"""
|
||||
"""``report_config`` JSON → 外部市场表行(不再解析预设关注词/场景词组)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .constants import (
|
||||
COMMENT_FOCUS_WORDS,
|
||||
COMMENT_SCENARIO_GROUPS,
|
||||
EXTERNAL_MARKET_TABLE_ROWS,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_focus_words(raw: Any) -> tuple[str, ...]:
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return COMMENT_FOCUS_WORDS
|
||||
out: list[str] = []
|
||||
for x in raw[:120]:
|
||||
s = str(x).strip()
|
||||
if len(s) > 48:
|
||||
s = s[:48]
|
||||
if s:
|
||||
out.append(s)
|
||||
return tuple(out) if out else COMMENT_FOCUS_WORDS
|
||||
|
||||
|
||||
def _normalize_scenario_groups(
|
||||
raw: Any,
|
||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return COMMENT_SCENARIO_GROUPS
|
||||
parsed: list[tuple[str, tuple[str, ...]]] = []
|
||||
for item in raw[:40]:
|
||||
label = ""
|
||||
triggers: list[str] = []
|
||||
if isinstance(item, dict):
|
||||
label = str(item.get("label") or "").strip()[:80]
|
||||
tr = item.get("triggers")
|
||||
if isinstance(tr, list):
|
||||
for t in tr[:48]:
|
||||
s = str(t).strip()
|
||||
if len(s) > 48:
|
||||
s = s[:48]
|
||||
if s:
|
||||
triggers.append(s)
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
label = str(item[0]).strip()[:80]
|
||||
tr = item[1]
|
||||
if isinstance(tr, (list, tuple)):
|
||||
for t in tr[:48]:
|
||||
s = str(t).strip()
|
||||
if len(s) > 48:
|
||||
s = s[:48]
|
||||
if s:
|
||||
triggers.append(s)
|
||||
if label and triggers:
|
||||
parsed.append((label, tuple(triggers)))
|
||||
return tuple(parsed) if parsed else COMMENT_SCENARIO_GROUPS
|
||||
from .constants import EXTERNAL_MARKET_TABLE_ROWS
|
||||
|
||||
|
||||
def _normalize_external_market_rows(
|
||||
@ -85,16 +34,11 @@ def _normalize_external_market_rows(
|
||||
|
||||
def resolve_report_tuning(
|
||||
report_config: dict[str, Any] | None,
|
||||
) -> tuple[
|
||||
tuple[str, ...],
|
||||
tuple[tuple[str, tuple[str, ...]], ...],
|
||||
tuple[tuple[str, str, str, str], ...],
|
||||
]:
|
||||
) -> tuple[tuple[tuple[str, str, str, str], ...]]:
|
||||
"""仅解析第三方市场摘录表;预设关注词/场景词组已废弃,不再参与报告或 brief。"""
|
||||
if not report_config:
|
||||
return COMMENT_FOCUS_WORDS, COMMENT_SCENARIO_GROUPS, EXTERNAL_MARKET_TABLE_ROWS
|
||||
return (EXTERNAL_MARKET_TABLE_ROWS,)
|
||||
return (
|
||||
_normalize_focus_words(report_config.get("comment_focus_words")),
|
||||
_normalize_scenario_groups(report_config.get("comment_scenario_groups")),
|
||||
_normalize_external_market_rows(
|
||||
report_config.get("external_market_table_rows")
|
||||
),
|
||||
@ -104,6 +48,4 @@ def resolve_report_tuning(
|
||||
__all__ = [
|
||||
"resolve_report_tuning",
|
||||
"_normalize_external_market_rows",
|
||||
"_normalize_focus_words",
|
||||
"_normalize_scenario_groups",
|
||||
]
|
||||
|
||||
@ -71,50 +71,10 @@ _K_PROP_COL = JD_SEARCH_CSV_HEADERS["attributes"]
|
||||
|
||||
EXTERNAL_MARKET_TABLE_ROWS: tuple[tuple[str, str, str, str], ...] = ()
|
||||
|
||||
COMMENT_FOCUS_WORDS: tuple[str, ...] = (
|
||||
"口感",
|
||||
"甜",
|
||||
"糖",
|
||||
"血糖",
|
||||
"控糖",
|
||||
"低糖",
|
||||
"无糖",
|
||||
"饱腹",
|
||||
"升糖",
|
||||
"GI",
|
||||
"gi",
|
||||
"孕妇",
|
||||
"老人",
|
||||
"糖尿病",
|
||||
"价格",
|
||||
"贵",
|
||||
"便宜",
|
||||
"回购",
|
||||
"包装",
|
||||
"物流",
|
||||
"分量",
|
||||
"量少",
|
||||
"克重",
|
||||
)
|
||||
|
||||
COMMENT_SCENARIO_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("早餐/代餐", ("早餐", "代餐", "早饭", "当早餐", "当早饭", "早上吃", "晨起")),
|
||||
("零食/加餐/解馋", ("零食", "加餐", "嘴馋", "小零食", "解馋", "垫肚子", "饿了", "肚子饿", "两餐之间", "间食")),
|
||||
("控糖/血糖相关", ("控糖", "血糖高", "升糖", "糖友", "糖尿病", "孕期控糖", "妊娠糖", "血糖")),
|
||||
("孕期/育儿", ("孕期", "孕妇", "怀孕", "产妇", "坐月子", "哺乳", "给宝宝", "给娃", "孩子吃", "小孩吃", "宝宝吃")),
|
||||
("健身/减脂", ("减肥", "减脂", "瘦身", "健身", "卡路里", "热量低", "低脂")),
|
||||
("长辈/家庭", ("老人", "爸妈", "父母", "长辈", "爷爷奶奶", "给家里")),
|
||||
("办公/外出", ("办公室", "上班吃", "出门", "外出", "随身带", "包里", "便携")),
|
||||
("送礼/囤货", ("送礼", "送人", "囤货", "年货")),
|
||||
("夜宵/熬夜", ("夜宵", "熬夜", "晚上饿")),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_COMMENT_CSV_BODY",
|
||||
"_COMMENT_CSV_SCORE",
|
||||
"_COMMENT_CSV_SKU",
|
||||
"COMMENT_FOCUS_WORDS",
|
||||
"COMMENT_SCENARIO_GROUPS",
|
||||
"EXTERNAL_MARKET_TABLE_ROWS",
|
||||
"_COMMENT_FUZZ_KEYS",
|
||||
"_COMMENT_SCORE_NEG_MAX",
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
"""按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价/场景)。"""
|
||||
"""按细类矩阵分组的 LLM 载荷(矩阵/价盘/促销/评价摘录)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from pipeline.csv.schema import JD_SEARCH_CSV_HEADERS, MERGED_FIELD_TO_CSV_HEADER
|
||||
|
||||
from .comment_sentiment import _comment_keyword_hits
|
||||
from .constants import (
|
||||
_COMMENT_CSV_BODY,
|
||||
_COMMENT_CSV_SKU,
|
||||
@ -26,53 +23,6 @@ from .matrix_group import _competitor_matrix_group_key, _merged_rows_grouped_for
|
||||
from .price_stats import _price_stats_extended
|
||||
|
||||
|
||||
def _comment_scenario_counts(
|
||||
texts: list[str],
|
||||
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
) -> tuple[Counter[str], int]:
|
||||
"""每组统计「至少命中一个触发词」的条数。返回 (各组条数, 有效文本条数)。"""
|
||||
c: Counter[str] = Counter()
|
||||
n = len(texts)
|
||||
for blob in texts:
|
||||
for label, triggers in scenario_groups:
|
||||
if any(t in blob for t in triggers):
|
||||
c[label] += 1
|
||||
return c, n
|
||||
|
||||
|
||||
def _text_hits_scenario_triggers(
|
||||
text: str,
|
||||
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
) -> bool:
|
||||
blob = text or ""
|
||||
for _lbl, triggers in scenario_groups:
|
||||
if any(t in blob for t in triggers):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _group_keyword_hits(
|
||||
comment_rows_in_group: list[dict[str, str]],
|
||||
texts_fallback: list[str],
|
||||
*,
|
||||
focus_words: tuple[str, ...],
|
||||
) -> Counter[str]:
|
||||
h = _comment_keyword_hits(comment_rows_in_group, focus_words)
|
||||
if h:
|
||||
return h
|
||||
if not texts_fallback:
|
||||
return Counter()
|
||||
blob = "\n".join(texts_fallback)
|
||||
c: Counter[str] = Counter()
|
||||
for w in focus_words:
|
||||
if len(w) < 2:
|
||||
continue
|
||||
n = blob.count(w)
|
||||
if n:
|
||||
c[w] += n
|
||||
return c
|
||||
|
||||
|
||||
def _matrix_excerpt_line_for_llm(row: dict[str, str], title_h: str) -> str:
|
||||
title = _md_cell(_cell(row, title_h), 100)
|
||||
sp = _md_cell(_cell(row, _SELLING_POINT_KEY, _LEGACY_SELLING_POINT_KEY), 120)
|
||||
@ -278,119 +228,12 @@ def build_comment_groups_llm_payload(
|
||||
return out
|
||||
|
||||
|
||||
def build_scenario_groups_llm_payload(
|
||||
*,
|
||||
feedback_groups: list[tuple[str, list[dict[str, str]], list[str]]],
|
||||
scenario_groups: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
merged_rows: list[dict[str, str]],
|
||||
sku_header: str,
|
||||
title_h: str,
|
||||
) -> dict[str, Any]:
|
||||
"""供 ``generate_scenario_group_summaries_llm``;计数与 **§8.2** 关注词/场景路径下图右栏(场景)一致。"""
|
||||
if not feedback_groups:
|
||||
return {}
|
||||
sku_meta: dict[str, tuple[str, str, str]] = {}
|
||||
for row in merged_rows:
|
||||
sku = _cell(row, sku_header).strip()
|
||||
if not sku:
|
||||
continue
|
||||
gk = _competitor_matrix_group_key(row)
|
||||
if not gk:
|
||||
continue
|
||||
sku_meta[sku] = (
|
||||
gk,
|
||||
_cell(row, title_h),
|
||||
_cell(row, *_MERGED_SHOP_CELL_KEYS),
|
||||
)
|
||||
lexicon = [
|
||||
{"label": lbl, "trigger_examples": list(trigs[:12])}
|
||||
for lbl, trigs in scenario_groups
|
||||
]
|
||||
groups_out: list[dict[str, Any]] = []
|
||||
for gname, cr, tu in feedback_groups:
|
||||
if not tu and not cr:
|
||||
continue
|
||||
scen_g, scen_ng = _comment_scenario_counts(tu, scenario_groups)
|
||||
dist: list[dict[str, Any]] = []
|
||||
for lbl, n in scen_g.most_common():
|
||||
if n <= 0:
|
||||
continue
|
||||
dist.append(
|
||||
{
|
||||
"scenario": lbl,
|
||||
"mention_rows": int(n),
|
||||
"share_of_effective_texts": round(
|
||||
float(n) / float(scen_ng), 4
|
||||
)
|
||||
if scen_ng > 0
|
||||
else 0.0,
|
||||
}
|
||||
)
|
||||
snippets: list[str] = []
|
||||
for row in cr:
|
||||
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
||||
if not txt:
|
||||
continue
|
||||
if not _text_hits_scenario_triggers(txt, scenario_groups):
|
||||
continue
|
||||
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
||||
meta = sku_meta.get(sku)
|
||||
if meta:
|
||||
sg, tit, shop = meta
|
||||
prefix = (
|
||||
f"【细类:{sg}\uff5cSKU:{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
|
||||
f"店铺:{_md_cell(shop, 28)}】"
|
||||
)
|
||||
snippets.append(prefix + txt[:300])
|
||||
else:
|
||||
snippets.append(
|
||||
f"【细类:{gname}\uff5cSKU:{sku or '—'}】" + txt[:320]
|
||||
)
|
||||
if len(snippets) >= 16:
|
||||
break
|
||||
if len(snippets) < 5:
|
||||
for row in cr:
|
||||
txt = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
|
||||
if not txt:
|
||||
continue
|
||||
sku = _cell(row, _COMMENT_CSV_SKU, "sku").strip()
|
||||
meta = sku_meta.get(sku)
|
||||
if meta:
|
||||
sg, tit, shop = meta
|
||||
prefix = (
|
||||
f"【细类:{sg}\uff5cSKU:{sku}\uff5c品名:{_md_cell(tit, 60)}\uff5c"
|
||||
f"店铺:{_md_cell(shop, 28)}】"
|
||||
)
|
||||
snippets.append(prefix + txt[:260])
|
||||
else:
|
||||
snippets.append(
|
||||
f"【细类:{gname}\uff5cSKU:{sku or '—'}】" + txt[:280]
|
||||
)
|
||||
if len(snippets) >= 10:
|
||||
break
|
||||
groups_out.append(
|
||||
{
|
||||
"group": gname,
|
||||
"effective_text_count": int(scen_ng),
|
||||
"scenario_distribution": dist[:18],
|
||||
"sample_text_snippets": snippets,
|
||||
}
|
||||
)
|
||||
if not groups_out:
|
||||
return {}
|
||||
return {"scenario_lexicon": lexicon, "groups": groups_out}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_comment_groups_llm_payload",
|
||||
"build_matrix_groups_llm_payload",
|
||||
"build_price_groups_llm_payload",
|
||||
"build_promo_groups_llm_payload",
|
||||
"build_scenario_groups_llm_payload",
|
||||
"_comment_scenario_counts",
|
||||
"_group_keyword_hits",
|
||||
"_listing_price_snippet_for_llm",
|
||||
"_matrix_excerpt_line_for_llm",
|
||||
"_promo_snippet_for_llm",
|
||||
"_text_hits_scenario_triggers",
|
||||
]
|
||||
|
||||
@ -85,6 +85,75 @@ def _analyze_price_promotions(rows: list[dict[str, str]]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def price_promotion_signals_strategy_brief_cn(p: Any) -> str:
|
||||
"""
|
||||
与 ``_analyze_price_promotions`` 数字严格一致的中文摘要,供策略 LLM **固定**读取,
|
||||
避免同输入下 §8.3 在「有价差统计」与「未检测到促销」之间随机摇摆。
|
||||
"""
|
||||
if not isinstance(p, dict) or not p:
|
||||
return (
|
||||
"监测摘要未携带列表侧价差统计(price_promotion_signals 为空)。"
|
||||
"§8.3 勿编造「已监测到/未监测到」具体满减门槛或价差行数;可写结合商详与运营核对。"
|
||||
)
|
||||
n = int(p.get("row_count") or 0)
|
||||
wb = int(p.get("rows_with_both_list_and_coupon") or 0)
|
||||
cb = int(p.get("rows_coupon_below_list_price") or 0)
|
||||
wj = int(p.get("rows_with_list_price") or 0)
|
||||
wc = int(p.get("rows_with_coupon_price") or 0)
|
||||
sh = p.get("share_coupon_below_list_when_both")
|
||||
med = p.get("median_discount_pct_when_coupon_below")
|
||||
mean = p.get("mean_discount_pct_when_coupon_below")
|
||||
oa = int(p.get("rows_original_price_above_list_price") or 0)
|
||||
|
||||
parts: list[str] = [
|
||||
"(以下为**监测边界**摘要:用于核对 §8.3 **不得否认**下列事实;**禁止**把本段行数、占比抄进 §8.3 当正文。"
|
||||
"§8.3 正文须以**本品促销决策**为主——如拟采用的满减/满折档位、到手价呈现原则等。"
|
||||
"竞品页面上已出现的「满××减××」等**原文**仅当报告节选或 brief 已收录时可作对标复述;"
|
||||
"**本品主张**的具体门槛/折扣可写为「拟满…减…」「拟满…享…折」,无输入数字时须标注待运营按毛利与后台规则核定。)"
|
||||
]
|
||||
parts.append(
|
||||
f"监测行数 **{n}**;其中解析到标价字段 **{wj}** 行、券后/到手价字段 **{wc}** 行。"
|
||||
)
|
||||
if wb > 0:
|
||||
frag_sh = (
|
||||
f"占可对齐行的 **{100.0 * float(sh):.1f}%**"
|
||||
if isinstance(sh, (int, float))
|
||||
else ""
|
||||
).strip()
|
||||
line = (
|
||||
f"**同时有标价与券后/到手价且数值可对齐** 的行 **{wb}** 行;"
|
||||
f"其中展示券后/到手**严格低于**标价的行 **{cb}** 行"
|
||||
+ (f"({frag_sh})" if frag_sh else "")
|
||||
+ "。"
|
||||
)
|
||||
parts.append(line)
|
||||
if isinstance(med, (int, float)) and cb > 0:
|
||||
frag_mean = (
|
||||
f",平均相对标价约 **{float(mean):.1f}%**"
|
||||
if isinstance(mean, (int, float))
|
||||
else ""
|
||||
)
|
||||
parts.append(
|
||||
f"在「券后低于标价」子集中,展示价差的中位数约 **{float(med):.1f}%**(相对标价){frag_mean};"
|
||||
"通常对应列表上满减、券、限时价等叠加呈现。"
|
||||
)
|
||||
elif cb > 0:
|
||||
parts.append("存在券后低于标价的样本;条数较少故未给稳健分位数。")
|
||||
else:
|
||||
parts.append(
|
||||
"**缺少**「标价与券后/到手价同时可解析且可对齐」的有效行,不宜写「全样本普遍存在到手价差」;"
|
||||
"若报告第六章或节选有促销形态归纳,§8.3 应与之对齐。"
|
||||
)
|
||||
if oa > 0:
|
||||
parts.append(
|
||||
f"另有约 **{oa}** 行呈现「划线原价高于当前标价」类陈列(页面展示口径)。"
|
||||
)
|
||||
parts.append(
|
||||
"(再次提醒:上列数字**不得**作为 §8.3 主体段落;§8.3 请写清**我方**在促销上的决定或拟定方案。)"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]:
|
||||
"""第六章第一节:优惠活动与价差信号(Markdown 行列表)。"""
|
||||
lines: list[str] = [
|
||||
@ -144,4 +213,5 @@ def _markdown_price_promotion_section(p: dict[str, Any]) -> list[str]:
|
||||
__all__ = [
|
||||
"_analyze_price_promotions",
|
||||
"_markdown_price_promotion_section",
|
||||
"price_promotion_signals_strategy_brief_cn",
|
||||
]
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""报告 Markdown 片段:Mermaid、场景摘要、规则策略提示、插图路径与解读段落。"""
|
||||
"""报告 Markdown 片段:规则策略提示、插图路径、矩阵图文件名等。
|
||||
|
||||
含若干**已废弃口径**的 Mermaid/场景摘要辅助函数(关注词子串、预设场景标签),当前主报告链路**不再调用**,保留仅为历史图表命名一致或后续清理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@ -54,12 +57,9 @@ def _strategy_hints(
|
||||
*,
|
||||
cr1: float | None,
|
||||
pst: dict[str, Any],
|
||||
hits: Counter[str],
|
||||
n_comments: int,
|
||||
scen_counts: Counter[str],
|
||||
scen_n_texts: int,
|
||||
) -> list[str]:
|
||||
"""基于规则的「提示性」结论,均标注待验证。"""
|
||||
"""基于规则的「提示性」结论,均标注待验证(不含预设关注词/场景子串统计)。"""
|
||||
hints: list[str] = []
|
||||
if cr1 is not None and cr1 >= 0.45:
|
||||
hints.append(
|
||||
@ -75,23 +75,10 @@ def _strategy_hints(
|
||||
hints.append(
|
||||
"价格离散度较高,同时存在偏低价与偏高价陈列,可分别对标「性价比带」与「品质/功能带」竞品(**终端到手价受促销影响,非成本结构**)。"
|
||||
)
|
||||
if hits:
|
||||
top = hits.most_common(3)
|
||||
top_s = "、".join(w for w, _ in top)
|
||||
hints.append(
|
||||
f"评价文本中「{top_s}」等主题出现较多,可作为消费者沟通与产品卖点的假设输入(**非严格主题模型,建议人工抽样复核**)。"
|
||||
)
|
||||
if n_comments < 5:
|
||||
hints.append(
|
||||
"有效评价样本偏少,消费者洞察部分仅作方向参考,正式结论建议加大 SKU 数或评论分页。"
|
||||
)
|
||||
if scen_n_texts >= 5 and scen_counts:
|
||||
top_lbl, top_n = scen_counts.most_common(1)[0]
|
||||
share = top_n / scen_n_texts
|
||||
if share >= 0.25:
|
||||
hints.append(
|
||||
f"用途/场景中「{top_lbl}」在约 {100 * share:.0f}% 的有效评价自述中出现,可作为沟通场景与卖点的优先假设(**词组规则,建议抽样核对原句**)。"
|
||||
)
|
||||
if not hints:
|
||||
hints.append(
|
||||
"当前样本下自动规则未触发强信号;请结合业务目标人工解读对比矩阵与原始 CSV。"
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
|
||||
输出:默认写入 ``<run_dir>/chapter8_text_mining_probe.md``。
|
||||
|
||||
环境变量 ``MA_PROBE_LLM_DIAG=1``:向 stderr 打印每个 LLM 分块的 JSON 大小与耗时(定位「哪一类超时」)。
|
||||
|
||||
嵌入竞品报告:流水线默认开启(``get_default_report_config`` 中 ``chapter8_text_mining_probe``: true);若任务显式关闭则为 false。开启时会生成本稿并调用 ``markdown_embed_body_for_competitor_report`` 写入 ``competitor_analysis.md`` 的 **第八章第二节(评论文本补充分析)**,替代原「关注词 + 场景」条图及对应两段大模型;**不再**嵌入原「评价正负面粗判」预设口语短语扇形图/条形图及同口径大模型块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@ -434,14 +436,29 @@ def _merge_snippets_from_comment_groups(
|
||||
row["sample_text_snippets"] = [str(x)[:220] for x in sn[:8]]
|
||||
|
||||
|
||||
def _probe_llm_diag_enabled() -> bool:
|
||||
return os.environ.get("MA_PROBE_LLM_DIAG", "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def _run_probe_text_mining_llm(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
chunked: bool,
|
||||
) -> str:
|
||||
"""补充分析专用:``PROBE_TEXT_MINING_SYSTEM`` + 结构化 JSON;可选按细类拆分调用。"""
|
||||
"""补充分析专用:``PROBE_TEXT_MINING_SYSTEM`` + 结构化 JSON;可选按细类拆分调用。
|
||||
|
||||
- **分块模式**:每个 ``probe_status == ok`` 的细类**单独**请求;某一类失败时**保留**已成功类的正文,该类下追加失败说明(不再整段被外层 ``except`` 吃掉)。
|
||||
- 设置环境变量 ``MA_PROBE_LLM_DIAG=1`` 时向 **stderr** 打印每类 JSON 字符数与耗时,便于定位超时发生在哪一类。
|
||||
"""
|
||||
if not payload.get("groups"):
|
||||
return "> **补充分析 LLM 解读**:无分组数据,跳过。"
|
||||
import time as _time
|
||||
|
||||
diag = _probe_llm_diag_enabled()
|
||||
try:
|
||||
if not chunked:
|
||||
p = _truncate_probe_payload(payload)
|
||||
@ -451,10 +468,35 @@ def _run_probe_text_mining_llm(
|
||||
raw[:82_000]
|
||||
+ "\n\n…(JSON 过长已截断,仅依据可见字段撰写。)\n"
|
||||
)
|
||||
return _call_llm(
|
||||
PROBE_TEXT_MINING_SYSTEM,
|
||||
PROBE_TEXT_MINING_USER_PREFIX + raw,
|
||||
).strip()
|
||||
if diag:
|
||||
print(
|
||||
f"[probe-llm] mode=single json_chars={len(raw)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
t0 = _time.perf_counter()
|
||||
try:
|
||||
out = _call_llm(
|
||||
PROBE_TEXT_MINING_SYSTEM,
|
||||
PROBE_TEXT_MINING_USER_PREFIX + raw,
|
||||
).strip()
|
||||
except Exception as e:
|
||||
if diag:
|
||||
print(
|
||||
f"[probe-llm] mode=single FAIL after "
|
||||
f"{_time.perf_counter() - t0:.1f}s: {e}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return f"> **补充分析 LLM 解读**调用失败:{e}"
|
||||
if diag:
|
||||
print(
|
||||
f"[probe-llm] mode=single OK "
|
||||
f"{_time.perf_counter() - t0:.1f}s out_chars={len(out)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return out
|
||||
kw = str(payload.get("keyword") or "")
|
||||
note = str(payload.get("probe_note") or "")
|
||||
parts: list[str] = []
|
||||
@ -477,12 +519,38 @@ def _run_probe_text_mining_llm(
|
||||
raw = json.dumps(mini, ensure_ascii=False)
|
||||
if len(raw) > 48_000:
|
||||
raw = raw[:44_000] + "\n…\n"
|
||||
parts.append(
|
||||
_call_llm(
|
||||
if diag:
|
||||
print(
|
||||
f"[probe-llm] mode=chunked group={gname!r} json_chars={len(raw)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
t0 = _time.perf_counter()
|
||||
try:
|
||||
chunk_out = _call_llm(
|
||||
PROBE_TEXT_MINING_SYSTEM,
|
||||
PROBE_TEXT_MINING_USER_PREFIX + raw,
|
||||
).strip()
|
||||
)
|
||||
parts.append(chunk_out)
|
||||
if diag:
|
||||
print(
|
||||
f"[probe-llm] group={gname!r} OK "
|
||||
f"{_time.perf_counter() - t0:.1f}s out_chars={len(chunk_out)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
if diag:
|
||||
print(
|
||||
f"[probe-llm] group={gname!r} FAIL "
|
||||
f"{_time.perf_counter() - t0:.1f}s: {e}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
parts.append(
|
||||
f"#### {gname}\n\n"
|
||||
f"> **本细类补充分析 LLM 调用失败**:{e}"
|
||||
)
|
||||
return "\n\n---\n\n".join(parts)
|
||||
except Exception as e:
|
||||
return f"> **补充分析 LLM 解读**调用失败:{e}"
|
||||
|
||||
@ -34,28 +34,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
|
||||
def _strategy_decisions_empty() -> dict[str, Any]:
|
||||
return {
|
||||
"product_role": "",
|
||||
"stage_goal_type": "",
|
||||
"time_horizon": "",
|
||||
"success_criteria": "",
|
||||
"non_goals": "",
|
||||
"battlefield_one_line": "",
|
||||
"positioning_choice": "",
|
||||
"competitive_stance": "",
|
||||
"pillar_product": "",
|
||||
"pillar_price": "",
|
||||
"pillar_channel": "",
|
||||
"pillar_comm": "",
|
||||
"audience_segment": "",
|
||||
"competitor_reference": "",
|
||||
"resource_notes": "",
|
||||
"marketing_strategy": "",
|
||||
"general_strategy": "",
|
||||
"ack_risk_keywords": False,
|
||||
"ack_risk_price": False,
|
||||
"ack_risk_concentration": False,
|
||||
}
|
||||
from pipeline.strategy_decision_keys import empty_strategy_decisions
|
||||
|
||||
return empty_strategy_decisions()
|
||||
|
||||
|
||||
def _merge_decisions(base: dict[str, Any], overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
348
backend/pipeline/demos/dump_strategy_opportunities_llm_volume.py
Normal file
348
backend/pipeline/demos/dump_strategy_opportunities_llm_volume.py
Normal 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())
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
352
backend/pipeline/demos/probe_strategy_llm_stability.py
Normal file
352
backend/pipeline/demos/probe_strategy_llm_stability.py
Normal 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())
|
||||
@ -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())
|
||||
@ -155,158 +155,6 @@ def generate_comment_group_summaries_llm(
|
||||
return call_llm(COMMENT_GROUPS_SYSTEM, user)
|
||||
|
||||
|
||||
SCENARIO_GROUPS_SYSTEM = """你是用户研究与品类顾问。输入为 JSON:``keyword``、``scenario_lexicon``、``groups``。
|
||||
``scenario_lexicon`` 列出各场景标签及示例触发子串(与报告 **第八章第二节**(关注词与场景路径)右栏统计规则一致)。
|
||||
``groups`` 每项含 ``group``(与 第五章矩阵一致的细分类目名)、``effective_text_count``(有效评价文本条数)、
|
||||
``scenario_distribution``(各预设场景的 ``mention_rows`` 与 ``share_of_effective_texts``;**一条评价可计入多场景**;与 **第八章第二节** 图右栏同源)、
|
||||
``sample_text_snippets``(摘录行常含细类、SKU、品名、店铺等前缀的短引文,已截断)。
|
||||
统计为**子串命中**,不是语义主题模型。
|
||||
|
||||
请**为每个细类**输出一小段 Markdown(全部 groups 都要写,顺序与输入一致):
|
||||
- 以 ``#### `` + 与该条 ``group`` 字段**完全一致**的细类名作为小节标题;
|
||||
- 每段约 **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``。
|
||||
每个 group 含 ``group``(细分类目名,与 第五章矩阵、第六章「按细类价盘」小节一致)、``sku_count``、``price_stats``(该细类可解析展示价的 min/max/median/mean/n,与第六章各细类 Markdown 分位数表同源)、
|
||||
``listing_snippets``(若干「标题|标价|券后|详情价」摘录,来自合并表字段,已截断)。
|
||||
@ -446,24 +294,3 @@ def generate_comment_group_summaries_llm_chunked(
|
||||
generate_comment_group_summaries_llm([g], keyword=keyword) for g in clean
|
||||
]
|
||||
return _join_chunked_group_markdown(parts)
|
||||
|
||||
|
||||
def generate_scenario_group_summaries_llm_chunked(
|
||||
payload: dict[str, Any], *, keyword: str
|
||||
) -> str:
|
||||
"""``scenario_lexicon`` 每轮原样附带,``groups`` 每次只含一个细类。"""
|
||||
groups_in = [g for g in (payload.get("groups") or []) if isinstance(g, dict)]
|
||||
if not groups_in:
|
||||
return ""
|
||||
lex = payload.get("scenario_lexicon")
|
||||
base: dict[str, Any] = {
|
||||
"scenario_lexicon": lex if isinstance(lex, list) else [],
|
||||
}
|
||||
parts = [
|
||||
generate_scenario_group_summaries_llm(
|
||||
{**base, "groups": [g]},
|
||||
keyword=keyword,
|
||||
)
|
||||
for g in groups_in
|
||||
]
|
||||
return _join_chunked_group_markdown(parts)
|
||||
|
||||
@ -15,15 +15,16 @@ CORE_CARD_SYSTEM = """你是电商营销内容顾问。根据用户提供的「
|
||||
- 食品/健康相关:**禁止**治疗承诺与夸大疗效;无依据写「输入未体现」或「待法务确认」。
|
||||
- 句子短、可落地;兼顾**购买者决策**与**列表/商详/主图等多触点**上架可用性。
|
||||
- **读者第一眼须知道在卖什么**:禁止通篇只有「价值感」「信任」「体验」而**不出现可识别的品类/形态**(如饼干、燕麦、奶粉、饮料等)。若输入未给出具体 SKU 名,仍须写清**类目 + 形态/规格层级**(如「低 GI 方向早餐饼干(待业务定款)」),不得用「优质好物」「健康之选」等**无品类**的句子糊弄本条。
|
||||
- **对照式理由(不编造)**:当策略或备注能概括「普通/常规同类」的典型痛点(如升糖快、甜腻、纤维低、易饿)时,**why_this_product** 与 **differentiation_vs_alternatives** 须用**对照**写清「为何选本品」;**禁止**捏造未出现的品牌、检测值、具体「高/低百分之几」等。无对标素材时写本品独特点,并在 **open_points_for_business** 提示可补充的对照数据或检测依据(若无则空串)。
|
||||
|
||||
**JSON 键(须全部出现,值为字符串;无内容用空串)**:
|
||||
- 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:一句话价值主张(买家能得到什么)
|
||||
- buyer_job_to_be_done:购买者的任务或情境(一句)
|
||||
- key_pain_or_desire:核心痛点或欲望(与策略一致)
|
||||
- why_this_product:为何要选这一款(相对同类,一句)
|
||||
- why_this_product:为何要选这一款(**优先** 1~2 句写相对**常规/普通同类**的核心理由,可用泛称如「普通甜面包」「常见饼干」;可从蛋白、膳食纤维、饱腹感、GI 或糖负担、口感、包装形态、配料表等**择输入已支持**的维度;无对照素材则写本品独特点)
|
||||
- proof_or_trust_angle:信任或证明角度(无依据写「输入未体现」)
|
||||
- differentiation_vs_alternatives:与替代方案相比的差异(一句)
|
||||
- differentiation_vs_alternatives:与替代方案相比的差异(**须含**与常规同类对照的一句话结论;营养数字、GI、每百克含量等**仅可**复述输入已有内容)
|
||||
- price_value_framing:价位与价值感如何表述(与策略价位可对读;无则「待业务确认」)
|
||||
- compliance_taboos:表述禁区摘要(来自业务备注或策略风险)
|
||||
- open_points_for_business:待业务补充(无则空串)
|
||||
@ -36,6 +37,7 @@ DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定
|
||||
- 购买者视角,短句;禁止输出 JSON 键名英文给最终读者(值全部为中文**多触点上架**可用文案)。
|
||||
- 不要泄露「核心信息卡」「策略稿」等内部词。
|
||||
- **更丰富≠编造**:可增加条数与段落,但**每一条**须能从信息卡对应字段找到方向;无依据处写「输入未体现」「待业务核对」,**禁止**为凑字数新增数字、销量、认证、评价引语、具体竞品名。
|
||||
- **对照式表达(写厚但不编造)**:学习优质商详「先对比再购买」。**detail_headline** 在首句点明品类后,**至少 1 句**用「相对普通/常规同类(泛称,禁止编造品牌)」讲清差异或价值;无依据时用中性句或「具体对比数值待包装/检测与业务核对」。**selling_bullets** 中 **至少 3 条**须为**可感知的对照卖点**,从蛋白、膳食纤维、饱腹感、口感质地、配料/清洁标签、包装控量或便携、GI/糖负担等角度择信息卡**已支持**的项;信息卡未提的维度**不硬写**。**detail_mid_story_paragraphs** 中 **至少 1 段**用「为何不满足于普通同类」叙事,仍须紧扣信息卡,禁止新数字与编造用户故事。
|
||||
- **每条 listing_titles、listing_subtitle、detail_headline、selling_bullets 的前两条**均须让读者能识别**在卖什么品类/什么货**(须与信息卡 **what_we_sell** 一致,可缩写但**禁止**偷换品类或只剩空洞形容词)。若信息卡 `what_we_sell` 已写品类,文案中**至少一处**直接出现该类目词或同义可识别表述。
|
||||
- **文生图/文生视频提示词**:须为**可直接复制**到常见文生图、文生视频模型的**中文**描述;**仅可**依据信息卡已有事实与品类,**禁止**在提示词里写「策略稿」「信息卡」「JSON」等元话语;**禁止**要求生成未授权的具体品牌 Logo、真实包装上的可辨认商标、带疗效承诺的贴片字。
|
||||
- **文生图须「有货、有卖点画面」**(硬性):
|
||||
@ -45,12 +47,12 @@ DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定
|
||||
- **配料/品类视觉**(如全麦):可写「麸皮颗粒隐约可见」「浅褐全麦外皮」等,**禁止**疗效字幕、血糖仪、前后对比治病画面。
|
||||
|
||||
**JSON 键(须全部出现)**:
|
||||
- listing_titles:字符串数组,**6~9** 条商品短标题备选(每条约 30 字内;**每条须含可识别品类或品名线索**,禁止多条全是空洞套话;可有 2~3 条侧重不同角度:场景/质地/配料/人群)
|
||||
- listing_subtitle:一条列表副文案(约 **60~90** 字内,信息不足则取下限)
|
||||
- detail_headline:商品详情页首屏下 lead,**2~3 句**(**首句须点明卖的是什么货**,后接价值与差异;总长约 **80~160** 字)
|
||||
- selling_bullets:字符串数组,**8~12** 条卖点(每条约 **40 字内**;须覆盖:品类形态、口感/质地(若信息卡有)、配料/健康表述(合规)、场景、信任点、与同类差异等**不同角度**,**禁止** 12 条重复同一句话换说法)
|
||||
- listing_titles:字符串数组,**6~9** 条商品短标题备选(每条约 30 字内;**每条须含可识别品类或品名线索**,禁止多条全是空洞套话;其中 **2~3 条**可在有依据时含「相对更…/更少…/不腻」等**对照**表述;其余侧重场景/质地/配料/人群)
|
||||
- listing_subtitle:一条列表副文案(约 **60~100** 字内,信息不足则取下限;**鼓励**含一句与常规同类对照的价值,无依据则省略)
|
||||
- detail_headline:商品详情页首屏下 lead,**2~4 句**(**首句须点明卖的是什么货**;**至少 1 句**为相对常规同类的对照或价值;总长约 **80~200** 字)
|
||||
- selling_bullets:字符串数组,**8~12** 条卖点(每条约 **40 字内**;**至少 3 条**为「本品 vs 常规同类」式差异;整体须覆盖:品类形态、口感/质地(若信息卡有)、蛋白/纤维/饱腹/GI 或糖负担(**仅信息卡有则写**)、配料/健康表述(合规)、包装/规格(若信息卡有)、场景、信任点、与常规品差异等**不同角度**,**禁止** 12 条重复同一句话换说法)
|
||||
- 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`,可分段讲「适合谁—怎么吃—为何值得」;**禁止**新数字、新功效、编造用户故事
|
||||
- usage_and_pairing_tips:字符串数组,**2~5** 条食用场景、保存提示、搭配建议(如早餐配牛奶);信息卡未写保存条件则写「输入未体现具体保质期与保存要求,上架前请核对包装」类中性句,**禁止**编造保质期天数
|
||||
- short_graphic_post_variants:字符串数组,**3~5** 条短图文/种草贴变体;每条 **45~110** 字;须**首句或次句**点明品类;适合复制到站内动态;**禁止**销量名次、虚假好评引语
|
||||
|
||||
@ -8,60 +8,32 @@ from typing import Any
|
||||
from ..reporting.brief_compact import compact_brief_for_llm
|
||||
from .llm_client import call_llm
|
||||
|
||||
SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON 含:
|
||||
SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON **仅**含开放语义材料(**不含**预设子串词表、不含机械分桶样本列表):
|
||||
|
||||
- ``comment_sentiment_lexicon``:子串词表统计(与载荷内各计数字段**同一计数方式**;竞品报告**已不再**发布同口径扇形图/条形图;**仅作定量参考**;子串命中≠说话人态度)。
|
||||
- ``positive_lexeme_hits_top`` / ``negative_lexeme_hits_top``:短语级命中摘要(同源)。
|
||||
- ``sentiment_bucket_method``:``score_then_lexeme`` 表示**先按 1~5 星分桶**(无评分行再按关键词);``keyword_substring_heuristic`` 表示**仅关键词**分桶;与 ``comment_sentiment_lexicon`` 内四象限计数一致。``sample_reviews_positive_biased`` / ``negative`` / ``mixed_tone`` 按该规则**机械归类**的抽样,**可能与整句真实褒贬不一致**(例如「软硬适中」曾被误归负向)。
|
||||
- **``sample_reviews_semantic_pool``**(若有):本批评价经去重后的**随机/洗牌抽样**(来自全部有效条,不限于某一象限)。**归纳正/负向体验、引用「」短引文时,优先以此池与上述各列表中的原文为准,自行结合语境理解**:转折、对比(如「没那么甜」「软硬适中」)、先抑后扬/先扬后抑整句态度;**不得以子串是否命中负面词来断言该句为抱怨**。
|
||||
|
||||
每条样本通常以 ``【细类:…|SKU:…|品名:…|店铺:…】`` 开头,表示 **第五章细类、SKU、品名、店铺**;写归纳与「」引文时须能还原「哪家店、哪条 SKU、哪款品名」,或保留前缀,**禁止**无指代地写「用户普遍…」。
|
||||
- **``sample_reviews_semantic_pool``**:本批评价去重后的**洗牌抽样**原文(可含 ``【细类:…|SKU:…|品名:…|店铺:…】`` 前缀)。**归纳正/负向体验、写「」短引文时只依据本池与 JSON 中其它明文字段**,结合整句语境(转折、反讽、先抑后扬等);**禁止**凭单一敏感词断言整句为差评。
|
||||
- **``text_unit_count``** / **``unique_attributed_snippets_count``**:条数统计,勿编造。
|
||||
- **``star_rating_distribution``**(**若有**):有评价星级数据时,各档条数(``score_1_2`` / ``score_3`` / ``score_4_5`` / ``no_score``)。**仅作辅助**:低星多不自动等于「口感硬」等具体抱怨主题,须回到原文语义;**禁止**在输出中复述已废弃的「预设短语命中」「lexeme_hits」等口径。
|
||||
- **``semantic_pool_note``**:字段说明,遵循即可。
|
||||
|
||||
**硬性要求**:
|
||||
- **仅输出 Markdown 正文**(不要用 ``` 围栏包裹全文);
|
||||
- **不要编造**样本中未出现的具体事实、品牌、价格、医学功效;
|
||||
- **定量数字**(条数、占比、lexicon 各字段)须与 ``comment_sentiment_lexicon`` **一致**,勿编造;
|
||||
- **定性归纳**(满意点/抱怨点、引语是否算差评):以**整句语义**为准;若某句在语义上为褒义或中性描述,**不得**放入「质地差、口感硬」等负向归因;若词表归类结果与句意冲突,**以句意为准**,并在「使用注意」点明「关键词归类**仅反映子串计数,不作态度判断**」。
|
||||
- **负向主题优先级(硬性)**:写「主要」「集中」「突出」类抱怨前,**必须对照** ``negative_lexeme_hits_top`` 各短语的 ``texts_matched``:若「口感硬/咬不动/发硬」等**预设短语命中为 0 或明显低于**其它维度(如分量、少、物流),**不得**把质地硬写成首要负向主题;若抽样原文与语义池里**反复出现**「分量少、太少、不够吃」等而预设短语未列出,仍须**单独归纳**(用户常用生活化表述,不必与预设表完全一致)。
|
||||
- 若某措辞**未**出现在任一抽样原文(含前缀后正文)中,**禁止**用引号写成直接引语。
|
||||
- **不要**只复述「某词出现 N 次」——若业务侧仍配图表则由图展示;你的价值是**语义归纳**。
|
||||
- **定量**:若输入含 ``star_rating_distribution``,其中数字须与 JSON **一致**;``text_unit_count`` 与池子规模须自洽,勿编造;
|
||||
- **定性**:负向主题**只写**你在原文中读后能站稳的抱怨;若池中**几乎没有**明确批评句,须如实写「本批抽样内负向语义证据有限」,**禁止**为凑结构编造「口感硬」等未在引文中出现的典型抱怨。
|
||||
- 某措辞**未**出现在任一抽样原文(含前缀后正文)中,**禁止**用引号写成直接引语。
|
||||
- **不要**在输出里提及「预设词表」「子串命中」「lexeme」「关键词分桶」等已废弃机制。
|
||||
|
||||
**建议结构**(使用四级标题 ``####``):
|
||||
1. ``#### 正向体验主题``:3~6 条;概括满意点(口感、甜度、性价比等),**尽量**用「」引用 ``sample_reviews_semantic_pool`` 或其它样本中**语义确为正面**的短句(勿把对比褒义句当差评例子)。
|
||||
2. ``#### 负向评价主题归因``:**核心段落**。依据你读后判定为**确有不满**的句子,归纳 **4~8 个**问题维度(须覆盖**质地、分量/规格、价格、物流、包装**等中在原文中**实际出现**的类别,勿只写质地)。引文优先取自句意确为批评的原文(可来自任一档位键,不限于 ``sample_reviews_negative_biased``);引文须含 ``【细类…|…店铺…】`` 或同义店铺+品名/SKU。
|
||||
3. ``#### 混合评价中的典型张力``(可选):同一评价里褒贬并存时,说明在争什么;若无则略写。
|
||||
4. ``#### 使用注意``:关键词子串统计的局限、``sample_reviews_semantic_pool`` 与词表归类的差异、抽样截断、非医学结论。
|
||||
1. ``#### 正向体验主题``:3~6 条;尽量用「」引用池中**语义确为正面**的短句。
|
||||
2. ``#### 负向评价主题归因``:依据原文归纳;证据不足时简短说明,勿硬写。
|
||||
3. ``#### 混合评价中的典型张力``(可选):若无则略。
|
||||
4. ``#### 使用注意``:抽样截断、星级与语义可能不一致、非医学结论。
|
||||
|
||||
**篇幅**:若 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)
|
||||
**篇幅**:若 JSON 含 ``matrix_group_focus``,约 **500~1200 字**;否则约 **700~1600 字**。简体中文,语气客观。"""
|
||||
|
||||
|
||||
def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
|
||||
"""基于 lexicon 统计 + 语义池与按词表归类的抽样,生成评价情感归纳段落(Markdown);**默认不**嵌入竞品报告正文。"""
|
||||
"""基于开放语义池(及可选星级分布)生成评价正/负向主题归纳(Markdown)。"""
|
||||
p = dict(payload)
|
||||
scope_note = ""
|
||||
mg = p.get("matrix_group_focus")
|
||||
@ -72,23 +44,16 @@ def generate_comment_sentiment_analysis_llm(payload: dict[str, Any]) -> str:
|
||||
)
|
||||
raw = json.dumps(p, ensure_ascii=False)
|
||||
if len(raw) > 88_000:
|
||||
for k, cap, maxlen in (
|
||||
("sample_reviews_positive_biased", 6, 180),
|
||||
("sample_reviews_mixed_tone", 4, 180),
|
||||
("sample_reviews_negative_biased", 14, 200),
|
||||
("sample_reviews_semantic_pool", 30, 340),
|
||||
):
|
||||
lst = p.get(k)
|
||||
if isinstance(lst, list):
|
||||
p[k] = [str(x)[:maxlen] for x in lst[:cap]]
|
||||
lst = p.get("sample_reviews_semantic_pool")
|
||||
if isinstance(lst, list):
|
||||
p["sample_reviews_semantic_pool"] = [
|
||||
str(x)[:280] for x in lst[:24]
|
||||
]
|
||||
raw = json.dumps(p, ensure_ascii=False)
|
||||
if len(raw) > 88_000:
|
||||
raw = raw[:82_000] + "\n\n…(输入过长已截断,请勿编造截断外内容)\n"
|
||||
user = "请根据以下 JSON 按系统说明输出 Markdown:" + scope_note + "\n\n" + raw
|
||||
out = call_llm(SENTIMENT_LLM_SYSTEM, user)
|
||||
if isinstance(mg, str) and mg.strip():
|
||||
out = demote_sentiment_inner_h4_to_h5_for_matrix_group(out)
|
||||
return out
|
||||
return call_llm(SENTIMENT_LLM_SYSTEM, user)
|
||||
|
||||
|
||||
def split_competitor_report_for_bridges(
|
||||
|
||||
@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ..competitor_report.price_promo import price_promotion_signals_strategy_brief_cn
|
||||
from ..reporting.brief_compact import compact_brief_for_llm
|
||||
from ..reporting.strategy_draft import (
|
||||
build_strategy_draft_markdown,
|
||||
@ -12,6 +14,19 @@ from ..reporting.strategy_draft import (
|
||||
)
|
||||
from .llm_client import call_llm, estimate_chat_input_tokens, llm_context_window_size
|
||||
|
||||
|
||||
def _strategy_llm_temperature() -> float:
|
||||
"""
|
||||
独立策略稿与「策略与机会」嵌入块所用采样温度,默认略低于全库 ``chat_completion_text`` 的 0.2,
|
||||
以减轻同提示词多轮出稿的漂移。可用环境变量覆盖:``MA_STRATEGY_LLM_TEMPERATURE``(如 ``0``、``0.15``)。
|
||||
"""
|
||||
raw = (os.environ.get("MA_STRATEGY_LLM_TEMPERATURE") or "0.1").strip()
|
||||
try:
|
||||
t = float(raw)
|
||||
except ValueError:
|
||||
t = 0.1
|
||||
return max(0.0, min(2.0, t))
|
||||
|
||||
# 与策略生成表单 POST 字段一致:任一则视为业务已提供「实质决策」,否则由模型基于数据推断草案。
|
||||
_STRATEGY_DECISION_SUBSTANTIVE_KEYS: tuple[str, ...] = (
|
||||
"product_role",
|
||||
@ -27,6 +42,7 @@ _STRATEGY_DECISION_SUBSTANTIVE_KEYS: tuple[str, ...] = (
|
||||
"pillar_price",
|
||||
"pillar_channel",
|
||||
"pillar_comm",
|
||||
"tactic_promotion",
|
||||
"marketing_strategy",
|
||||
"general_strategy",
|
||||
"competitor_reference",
|
||||
@ -82,8 +98,9 @@ def _omit_ch8_probe_wordchart_fields(compact: dict[str, Any]) -> None:
|
||||
STRATEGY_DATA_RULES = """**全局禁止编造(硬性)**:下列条款**同时**适用于 ① **独立策略稿**全文;② 若任务仍生成的**宿主报告内** ``####`` 策略归纳块(JSON 含 ``competitor_brief``)。**默认产线**下报告内第九章大模型长文已关闭,**独立策略稿不以该块为默认事实源**。
|
||||
- **事实与数字**:销量、GMV、占比、价带、条数、份额、券面额、满减/满折门槛、到手价、店铺/品牌计数与排名、SKU 数、接口返回量等,**仅可**来自**本次调用输入 JSON** 已给出的字段。**独立策略稿**侧为:`structured_brief`、`rules_draft_markdown` 内摘录、**可选** `report_strategy_excerpt`(**默认多为空**,见 ``load_report_strategy_excerpt``)、**可选** `report_matrix_group_evidence_md`(与同任务报告第五~第八章细类归纳同源)、`strategy_decisions`、`business_notes`。**报告内嵌策略块**侧为 ``competitor_brief``、可选 ``prior_chapter_llm_narratives``。**禁止**凭空新增、改口径或写成「已监测证实」而无字段支撑。
|
||||
- **主体与名称**:**禁止**引入上述输入中**未出现**的**具体**品牌名、店铺名、SKU 名、商品标题作为**事实陈述**;若 `strategy_decisions`/备注/brief/节选已含则可写;否则用「头部/同类竞品」等泛称或「待业务指定对标」。
|
||||
- **用户侧表述**:**禁止**虚构评价原文、访谈引语、带引号的「用户说…」;细则见下文「§2 针对痛点要怎么做」表**痛点简述**列。
|
||||
- **促销与活动**:**禁止**编造活动名、具体规则、补贴比例;细则见下文促销与第八章探针相关条款。
|
||||
- **用户侧表述**:**禁止**虚构评价原文、访谈引语、带引号的「用户说…」;**用户/评论/竞品侧**之事实、心理、趋势在**全文**均须与**下文「全文证据与表达分线」**及**§2.1 各条**同严谨,**不**以「只在痛点表管」自宽。
|
||||
- **全文证据与表达分线(硬性,全稿与 §2.1 同严谨度)**:**摘要、一、三、四、五、六、七、八、九、十、附录**中凡写**用户/评论/竞品**之具体行为、缺陷、心理或行业判断,**须**可指回`report_matrix_group_evidence_md`、`structured_brief`、`business_notes`或本次**输入 JSON 已出现**的字段;**无依据即不写**或须标**「假设:」「待原评/调研核实:」**。**特别禁止**在**非 §2.1 段落**使用**无摘录支撑**的**「部分竞品/多数竞品/部分用户/用户普遍/行业通常」+可证伪细节**(与 §2.1 禁写「部分竞品…偏硬/不便/难拆」**同一标准**)。**本品策略**(价位、主图/商详、到手价、满减、拉新/分层、渠道)属**拟采取**,须用**「拟/建议/本阶段/待运营核定」**等,**不得**伪装为**用户或评论已提出之要求/抱怨**(**无**同向摘录时)。**监测与货架**(价带、列表价差、促销形态)**可作**「背景+故我方拟…」;**无**评论同向主题时**不得**转述为**确定句**的「**用户因××而痛**」。**禁止**全篇**无据**却用**肯定语气**写**自我矛盾**之心理/态度(**例**「对价差不敏感、又希望透明」**且**全篇**无**节支撑)。
|
||||
- **促销与活动**:**禁止**把**策略主张**写成「监测已证实××满减」的事实口吻;§8.3 须写**本品**拟采用的促销机制(见下文「促销」专条)。竞品侧具体规则**仅可**来自输入;本品侧档位可用「拟」「待核定」。
|
||||
- **策略动作与落地结果**:可写「建议」「假设」「待验证」的动作方向,**不得**编造「已执行」「已上线」「数据显示转化率/复购提升」等**无输入依据**的结果。
|
||||
- **信息不足**:须写「输入未体现」「待核对」「假设:」「待验证:」,**禁止**用确定语气掩盖缺失依据。
|
||||
- **与 §2.1「类目/细类」列一致(全文)**:除 §2.1 表格外,**摘要、一、三~八**凡写策略动作、阶段重点、资源分配、差异化或竞争应对,**优先**标明适用**类目/细类**;多细类策略冲突时**分条**写。**禁止**用「全站用户」「整体上一句」覆盖与 §2.1 已分行决策**矛盾**的表述。
|
||||
@ -97,15 +114,22 @@ STRATEGY_DATA_RULES = """**全局禁止编造(硬性)**:下列条款**同
|
||||
- **§2「针对痛点要怎么做」表(反捏造 + 分类目,硬性)**:
|
||||
- **「类目/细类(本决策适用)」列**:须与 `structured_brief` 中类目混排、矩阵分组、§1.2 细类讨论或 `strategy_decisions` 已选战场**可对上**;**禁止**编造未出现的类目名。**多细类并存**(如饼干 vs 面包)时,**必须分行**分策,**禁止**用「全站用户」「整体策略」等**泛化**一句覆盖彼此冲突的动作。**若**类目或主推线尚不确定,该行可写「待业务定类」或「假设:优先××线」,并说明**分类决策依据或待补信息**;仍须避免与数据明显矛盾。
|
||||
- **「用户痛点(简述)」列**:**禁止**书写「用户反馈『……』」「评价称『……』」等**带引号的逐字原话**,除非该片段在 `structured_brief`、`strategy_hints`、`report_strategy_excerpt` 或 `business_notes` 中**已出现相同或明显包含**的文本;否则一律**不得**用引号假装引用。
|
||||
- 若输入仅有主题级信号(关注词、负向归因方向、价差行数等),痛点简述应写**可追溯归纳**,例如「与 brief 中 ×× 字段一致」「与报告第八章/节选已归纳的 ×× 主题一致」「监测摘要见 `strategy_hints` 第 n 条」,或写「**待原评论抽样核实**」——**禁止**把合理推测写成「用户已明确说……」的事实口吻。
|
||||
- **禁止**凭空发明痛点行(如「配料相似」「卖点雷同」「性价比一般」)作为**已监测结论**;此类表述仅当 `structured_brief`、节选或备注中**确有同类主题或措辞**时方可写入,否则不写或标为待验证假设。
|
||||
- **「用户痛点(简述)」列 · 业务定义与合格内容(硬性)**:**用户痛点**指用户在**生活、工作、使用本品类/产品**过程中**具体感受到的困难、烦恼、不便、难受、麻烦**,或**长期未被满足且必要的刚性需求**未兑现;是**用户主观感受到的负面体验**或**强烈不满/焦虑**,**通常须能落到具体场景/时刻/情境**(**何时、何地、在何种任务下** 感到费劲、不踏实、难判断、用得不爽、不敢吃/不敢买 等),且**有「亟待被解决」的迫切感**。**不是** 产品优点、**不是** 卖点、**不是** 抽象「需求升级」、**不是** 纯策略/内部用语。**禁止** 用**单句**「可感知性存疑」「可核性」「信任需建立」「认知盲区」「与预期不一致但不说清糟在哪」等**无具体烦恼、无场景** 的空话冒充痛点(那是执行缺口描述,**不得** 单独占满痛点格)。**若** 节选/§8 **仅** 归纳了**满意、正向、复购、好吃** 等,**不** 能把它们改写成「伪痛」塞在本列;**优点与差异化** 放 **§3~§5**;本列**只** 放**可叙述的负面/不便/抱怨** 或 下文允许的**典型场景·假设**。
|
||||
- **与 `report_matrix_group_evidence_md` / §8 可对上(硬性)**:每一行**用户痛点**须能在节选里找到**同向**支撑——**首重**负向评价、混合评价、**已写明的抱怨/不满/难用/没做到**;节选已归纳**贵、价高、价不清、缺斤短两、难拆包**等时,**须**用**带场景**的负面体验句写出。**若**节选写明**以正向为主/负向不显著/无法归纳成类负向**,**须**在**§2.1 表前**用**一句**点明(如**摘录中负向有限或仅为个案**);**不得**凭词频/监测编造「**部分竞品/普遍用户/多数人说**」类负评;**确需**写**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` 与底稿中出现的占比或价格;底稿与摘要中的数字须保持一致。
|
||||
- **店铺集中度**仅可依据 `structured_brief.concentration` 与底稿,并区分**列表行**与**去重 SKU**;用「第一大……份额」「前三家合计」等中文,**不要用** CR1、CR3。
|
||||
- **禁止编造**「京东自营 SKU 占比」「自营超 X%」等摘要中未给出的定量句。
|
||||
- **矩阵**:若 `structured_brief` 含矩阵相关字段,须**呼应**细分类目与竞品矩阵结论,不得无故删光。
|
||||
- **第八章文本挖掘探针(当 JSON 中 `chapter8_text_mining_probe` 为真时)**:
|
||||
- **禁止**将「关注词子串命中次数」「预设场景分组条数/占比」当作评论侧主论据。
|
||||
- 用户洞察、负向归因须与 **§8 文本挖掘** 及可选节选一致;促销与券价差须与 `price_promotion_signals`、报告**第六章**及 brief 已给字段一致(**默认**无宿主报告内长文策略节选时,**禁止**以「第九章已写」为凭据编造具体规则);**禁止**编造满减门槛或补贴比例。
|
||||
- 用户洞察、负向归因须与 **§8 文本挖掘** 及可选节选一致;**监测侧**券价差事实须与 `price_promotion_signals`、报告**第六章**及 brief 已给字段一致。**§8.3 成稿须含「本品」促销决策**(满减/满折/到手价呈现等),见系统提示「促销」专条;**禁止**把未在输入出现的竞品规则写成「已监测到的定论」;**允许**写**拟定**的本品档位并标注待运营确认。
|
||||
- **可选 `report_strategy_excerpt`**:**默认多为空**。非空时战略方向与该节选不明显矛盾;**不得**把节选与 `structured_brief` 均未出现的数字当作事实。**为空时**以 `structured_brief`、`report_matrix_group_evidence_md`(若有)与底稿/表单为准,**禁止**编造「宿主报告策略章已断言的」具体结论或虚假背书。"""
|
||||
|
||||
STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**与业务侧填写的**决策字段**,把「规则底稿」写成**短、可执行**的策略 Markdown **独立成稿**。
|
||||
@ -125,11 +149,12 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
|
||||
- **读者测试**:业务读者读完**摘要、一、三~八**任一大节后,应能回答至少一项:**谁(团队/渠道)在何触点、针对哪类用户或哪条痛点、采取什么动作、如何验收或待验证什么**。若某段只能回答「市场/品类/价带是什么样」而**没有**紧随或嵌入的「故本阶段须…」「优先…」类**动词句**,须改写或删并,**禁止**以形势描述段作为该节主体。
|
||||
- **背景上限**:**一、顾客是谁** 的 1.1 与 1.2 **禁止**扩写成第二份分析报告:合计**至多约五句**结论性背景(谁搜、关心什么、分细类一句);价带分位、样本量拆解、词频/方法一句带过或写「详见同任务《竞品分析报告》」,**禁止**多段连续铺陈数据。
|
||||
- **摘要**:在「范围与样本」「用户侧」各**一句**可接受后,**阶段重点**必须是 **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 某一行的落地动作(可口头合并叙述);**禁止**仅用「强化品牌/优化体验/夯实基础」等名词堆叠而无**谁做、在哪做、做哪一步**。
|
||||
- **反例(禁止作为节内主要篇幅)**:「当前品类呈现…」「市场整体…」「用户日益注重健康」等**纯判断句串**而无后续「因此我方本阶段…」;若保留背景,**一句**后必须接执行句。
|
||||
|
||||
**落实范围**:上文「全局禁止编造」适用于**摘要、一至十、附录**的每一句话与表格每一格;**不得**因章节不同而放宽。
|
||||
**落实范围**:上文「全局禁止编造」**与「全文证据与表达分线」**适用于**摘要、一至十、附录**的每一句话与表格每一格;**不得**因章节不同而放宽、**不得** 因 非 §2.1 而 放宽 用户/竞品/心理 的 **可指回 依据** 要求。
|
||||
|
||||
**对外成稿与禁止技术泄露(硬性)**:
|
||||
- 正文须为**可直接对业务或合作方阅读**的正式策略文档(对外前仍须按需脱敏)。**禁止**出现:反引号代码体、JSON 键名、英文字段名、内部数据结构名、源码或仓库路径、类文件名、「任务 ID」「工作台」「规则骨架」等系统痕迹;**禁止**照抄底稿中以 *成稿:*、*回答:*、*占位*、*骨架* 开头的**元说明句**,须改写为正式业务表述。
|
||||
@ -143,7 +168,8 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
|
||||
- 仍须遵守全局禁止编造:数字、品牌、店铺、用户原话、活动规则仅可来自输入依据;无依据处用假设语气。
|
||||
|
||||
**决策边界(硬性)**:
|
||||
- **当 `strategy_decisions_substantive` 为 true 时**:业务已在 `strategy_decisions` 中填写的项(角色、**本阶段策略目标类型**、时间、成功标准、战场一句话、定位勾选、竞争倾向、四柱、目标客群/对标/资源备注、**营销策略**与**总体策略**等)视为**已定决策**:成稿须**落实为具体执行句**,**不得**改写成相反结论或再要求用户「请选择」。**若「本阶段策略目标类型」在输入 JSON 中已给出非空文本**,「策略范围与前提」表中该列须**直接采用该表述**(可略作语序润色),**不得**改判为另一类阶段目标。
|
||||
- **当 `strategy_decisions_substantive` 为 true 时**:业务已在 `strategy_decisions` 中填写的项(角色、**本阶段策略目标类型**、时间、成功标准、战场一句话、定位勾选、竞争倾向、四柱与**战术相关**字段、目标客群/对标/资源备注、**营销策略**与**总体策略**等)视为**已定决策**:成稿须**落实为具体执行句**,**不得**改写成相反结论或再要求用户「请选择」。**若「本阶段策略目标类型」在输入 JSON 中已给出非空文本**,「策略范围与前提」表中该列须**直接采用该表述**(可略作语序润色),**不得**改判为另一类阶段目标。
|
||||
- **表单与 §七、§八(战术)**:`rules_draft_markdown` 中「表单…」锚点与 JSON 里 **非空** 的 `pillar_product`、`pillar_price`、`pillar_channel`、`pillar_comm`、`positioning_choice`、`tactic_promotion` 须分别体现在 **§七 品牌四线**、**§八 战术支柱** 的**对位**小节,**不得**成稿时忽略、架空或与表单**相反**。**`tactic_promotion` 非空** 时 **§8.3** 须**优先承接**其意图(可扩写为满减/满折/活动呈现,**不得**仅用监测行数占比类句子顶替);**`positioning_choice` 非空** 时 **§8.2** 定价须与该价位取向**一致**(用连贯叙述,**禁止**在正文以内部选项名当小节标题或问卷式四列)。
|
||||
- **当 `strategy_decisions_substantive` 为 true** 而部分表单项仍为空或占位:结合监测摘要与节选**补全为可执行表述**,与数据方向一致。
|
||||
- **当 `strategy_decisions_substantive` 为 false 时**:适用上文「业务决策未填写时的成稿义务」,**禁止**以「请先填表」类表述搪塞全篇。
|
||||
- **成稿阶段避免**:反复「请业务决策」;不确定时在 §2.1 用「类目/细类」+「假设:」「待业务确认:」**写清**,**禁止**只写泛化一句。
|
||||
@ -155,22 +181,22 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
|
||||
**语气**:面向业务读者,避免 CR1、心智等内部缩写;**勿在成稿中反复强调「对齐某报告第几章」**,以策略表述为主。
|
||||
|
||||
**策略表述硬性(痛点 → 怎么做,须覆盖全书,不得只写 §2~§8 部分章节)**:
|
||||
- **总原则**:成稿**不是**第二份分析报告,也**不是**市场形势说明书。每条重要内容应能回答:**针对哪条用户痛点、在哪条类目/细类下**(与 **§2.1** 表对应)、**我们采取什么动作**、**在具体触点怎么做**(商详/主图/短视频/客服/规格/价格呈现等)、**如何验证**(若适用)。**「是什么」仅作每节不超过一两句的铺垫;「怎么做」须占可策略论述篇幅的主体。**
|
||||
- **总原则**:成稿**不是**第二份分析报告,也**不是**市场形势说明书。每条重要内容应能回答:**针对哪条用户痛点、在哪条类目/细类下**(与 **§2.1** 表对应)、**我们采取什么动作**、**在具体触点怎么做**(商详/主图/短视频/客服/规格/价格呈现等)、**如何验证**(若适用)。**「是什么」仅作每节不超过一两句的铺垫;「怎么做」须占可策略论述篇幅的主体。**(**§2.1《用户痛点》**见**业务定义**+**证据与策略分线**;**全文**用户/竞品/价促表述 亦见 **「全文证据与表达分线」**。**无据不写**、**不** 用无据「部分竞品…」、价/促 放 后三列/§八。)
|
||||
- **§2.1 针对痛点要怎么做**(若底稿已有表头)须**填写实质内容**;全稿**动作总锚**为 §2.1。若无表,须在 **§二** 或 **§八** 用等价分条写清「痛点—动作—落地—验证」。
|
||||
|
||||
**分节要求(与底稿章节一一对应,勿省略)**:
|
||||
- **策略范围与前提**:回答「**这份策略是针对什么做的**」(监测任务、本品角色、战场、主推类目、**本阶段目标类型**、时间、成功标准)。与业务表单及备注对齐;`strategy_decisions_substantive` 为 false 时须写清假设前提与推荐目标类型(可含 A~E 选项),为 true 时未填项不得与已填决策矛盾。**禁止**与后文 §2.1、§六 自相矛盾。
|
||||
- **摘要**:除范围样本外,**阶段重点**须含 1~2 条**可执行动作**,指向优先痛点(非空泛「加强运营」);须与上文「策略范围与前提」边界一致(**勿**在正文写「回扣 §2」「承接上文」等指导语)。
|
||||
- **一、顾客是谁**:**禁止**重复报告中的细类词频、分品类样本量展开、文本挖掘方法;用 **少量结论句**(谁搜、关心什么、决策场景);**1.3 本品聚焦**须写清本期主攻人群/场景/细类(**勿**写「与 §2.1 可对上」类作者提示)。
|
||||
- **二**:**仅 §2.1** 一张表:「类目/细类(本决策适用)| 用户痛点(简述)| 策略动作 | 具体怎么做 | 如何验证」。须覆盖监测已支撑的主要维度(**按类目分行**,口感/质地分线、分量/规格、信任与价格等,依数据取舍);**类目列 + 痛点简述列**遵守「§2 表」条款。**禁止**再写独立「痛点与证据表」「价值对表」「负向归因」子节(与 §二 重复的内容一律并入本表或删去)。
|
||||
- **三**:**仅 §3.1**,标题与底稿一致为**购买者视角:为何要选这一款(依据与理由)**。全文须站在**购买者**一侧:写其在浏览/比价时**为何值得把这一款放进购物车**(解决什么具体问题、相对同类获得感、价位是否可接受、信任点是什么),可用「用户/消费者」作主语。**先**保留或转述输入中已有**检索/样本与价带**(作买家决策背景,勿大段铺陈),**随后**用 1~2 句落到**购买动机**。**禁止**用运营/品牌单方口吻替代买家逻辑(如「适合××叙事切入」「策略上占位」「品类时机好」作为收尾而不说买家得到什么)。**禁止**以只适用于整个品类的宏观句作为**唯一或最后**结论;宏观背景若写,**必须**收束到「因此**买家**更愿为这一款付费」的可验证点(规格/配料/口感/价位等须与输入可对读)。可结合 brief 写价带锚点一句。**禁止**写 §3.2「转化障碍与应对」;若与购买相关的障碍与应对已在 §2.1 表内,§3.1 **勿再复述**。
|
||||
- **一、顾客是谁**:**禁止**重复报告中的细类词频、分品类样本量展开、文本挖掘方法;用 **少量结论句**(谁搜、关心什么、决策场景);**1.3 本品聚焦**须写清本期主攻人群/场景/细类(**勿**写「与 §2.1 可对上」类作者提示)。**§1.2** **禁止**写入「类目结构(摘录)」式 SKU/条数表、「核心关注点」「高频词(含括号次数)」「共现词对」「主题归纳」及「依据 report_matrix_group_evidence_md」等**与竞品报告或矩阵节选同构**的字段化罗列;该等内容仅在报告与 JSON 节选,策略稿**至多两三句**定性差异即可,必要时一句「详见同任务《竞品分析报告》」带过。
|
||||
- **二**:**仅 §2.1** 一张表:「类目/细类(本决策适用)| 用户痛点(简述)| 策略动作 | 具体怎么做 | 如何验证」。**「用户痛点(简述)」**须写 **生活/工作/使用中的具体困难、烦恼、不便、负面体验、刚性未获满足**,**带** 场景/时刻,**见** `STRATEGY_DATA_RULES` **业务定义**;**不是** 优点、**不是** 策略空话。**若** 节选 负向 有限,**见** 该条 下 **表前说明 + 典型场景假设** 规则;**价促/到手价 监测** 无 评论 同 主题 时 **只** 写 在 后三列 与 **§八**。**类目列** 遵守 上文 与 **行级覆盖** 款。**禁止** 另设「痛点与证据表」等 重复 子节(并入 本 表 或 删去)。
|
||||
- **三**:**仅 §3.1**,标题与底稿一致为**购买者视角:为何要选这一款(依据与理由)**。全文须站在**购买者**一侧:写其在浏览/比价时**为何值得把这一款放进购物车**(解决什么具体问题、相对同类获得感、价位是否可接受、信任点是什么),可用「用户/消费者」作主语。**优先**用**对照**讲清理由:相对**常规/普通同类**(泛称如「常见甜面包」「普通饼干」,**禁止**编造未出现的竞品品牌)在哪些维度上更值得买——可从**蛋白、膳食纤维、饱腹感、GI 或糖负担、口感质地、配料表/清洁标签、包装形态或控量**等角度**择输入已支撑**的项展开(与监测摘要、brief、矩阵节选可对读);**禁止**捏造营养成分数值、检测结论或未出现的「高/低百分之几」。监测未提供可对读数据时,允许写定性对照或「待包装/检测与业务核对后再对外宣称」,**不得**用空洞品类口号代替买家逻辑。**先**保留或转述输入中已有**检索/样本与价带**(作买家决策背景,勿大段铺陈),**随后**落到**购买动机**(不少于 **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** 动作**可对齐或合并叙述**;营销/总体策略句须为**动词导向**。
|
||||
- **七**:品牌四线**每一条**至少一句:**服务哪类痛点、本周/本阶段具体做哪一步**。
|
||||
- **八**:四支柱**每一支柱**须回扣 **痛点→动作→落地**(可与 §2.1 合并叙述,避免重复堆砌)。
|
||||
- **九**:在表单风险勾选之外,**每条风险**尽量带**应对动作或验证计划**(抽样、核对规则),勿只列风险标题。
|
||||
- **十**:下一步清单须为**可执行任务**(可含负责人/时间占位),与 §2.1 或 §六 优先级一致;可含「按类目核对主图/商详与 §2.1」类项。
|
||||
- **八**:四支柱**每一支柱**须回扣 **痛点→动作→落地**(可与 §2.1 合并叙述,避免重复堆砌)。**§8.2** **禁止**以勾选问卷、四行并列「贴顶/卡腰/下探/另起带」或「价位阵地取向(表单…)」式标题呈现;已定 `positioning_choice` 与监测价带须**融入连贯定价叙述**,**禁止**「表单勾选」「与监测价带可对读」等内部提示语入正文。
|
||||
- **九**:**每条风险**尽量带**应对动作或验证计划**(抽样、核对规则),勿只列标题。**禁止** `[ ]`/`[x]` 问卷式风险清单、仅列问句不落地;须用叙述句写风险、假设与验证。
|
||||
- **十**:下一步须为**可执行任务**(可含负责人/时间占位),与 §2.1 或 §六 优先级一致。**禁止** `- [ ]` 待办勾选排版;用编号或分条**动作句**表述。
|
||||
|
||||
**全书与 §2.1 类目列对齐(防泛化,与上条「全文一致」配套)**:
|
||||
- **摘要**:阶段重点中的可执行动作**尽量**点明适用类目或主推线。
|
||||
@ -184,22 +210,165 @@ STRATEGY_SYSTEM = f"""你是市场策略顾问,根据**结构化监测摘要**
|
||||
- 监测中「**酥脆**」与「**松软**」等可能**同时**高频,通常对应**不同细类**(如饼干 vs 面包/糕点)或不同场景。成稿须**按主推细类或分产品线**表述:饼干线策略与酥脆/饱腹等对齐,面包/糕点线与松软/早餐等对齐;若多线并存须**分款分句**,**禁止**只写「要做松软」而忽略酥脆主导的细类,除非 `structured_brief`、表单或业务备注已明确**仅**推该线。
|
||||
- **产品策略句**须能指回:**本品是哪一类、解决哪条口感预期**,避免与数据里另一细类的主导词打架。
|
||||
|
||||
**促销:满减、满折、券(≠ 不管;≠ 编造)**:
|
||||
- **必须**在 **§八.3 促销与活动策略**(及必要时 §七.3)写清:与 `price_promotion_signals`、报告第六章已归纳的**券、标价与到手价差、常见活动形态**如何承接(跟价节奏、规则透明、不与数据矛盾);**禁止**因「没编出具体数字」就整节不写促销。
|
||||
- **禁止编造**输入中未出现的**具体**满减门槛、满额折扣、每满减金额;若摘要/报告未捕获某类机制,须明确写「**监测未捕获具体满减/满折规则,上架前须与运营及后台活动对齐后再对外宣称**」,并可列**待补信息**(如:是否参加跨店满减、店铺券类型)。
|
||||
- **区分**:「策略上跟券、保到手价透明」是成稿义务;「具体满 300 减 40」只能来自已有数据。
|
||||
**促销:§8.3 写决策,不写报告统计(硬性)**:
|
||||
- **文体**:**§八.3 必须以「本品 / 本阶段」的促销与活动决策为主**——读者应能回答「我们打算用什么满减、什么折扣档、到手价怎么跟竞品对齐」。**禁止**把 `strategy_price_promotion_brief_cn` 或 `price_promotion_signals` 里的**行数、占比、中位数价差**整段照抄当 §8.3 正文(那是报告第六章口径);监测结论**至多一句**作依据,例如「监测显示列表侧普遍存在标价与到手价差,竞品多叠券呈现」。
|
||||
- **决策须落地**:至少写出 **1~2 条可执行的促销主张**,使用**决策句式**,例如:「**本阶段拟设**:满 [门槛] 减 [面额]」「**拟设**:满 [门槛] 享 [折扣] 折」「新人/分层价是否跟进及主图/商详如何写清规则」等。**竞品侧**已出现的具体「满 99 享 9 折」等**仅当**报告节选 / `report_matrix_group_evidence_md` / brief 其他字段**已出现**时可作对标复述;**本品侧**具体数字若输入未给,**仍须写出拟定结构**(几档、拉新 vs 提客单意图),并标明「**具体门槛与面额待运营按毛利与后台活动核定**」,**禁止**用「待核定」代替整条决策、导致 §8.3 只有监测复述而无「我们怎么做」。
|
||||
- **稳定性**:`strategy_price_promotion_brief_cn` 用于**边界核对**——**不得**与其矛盾地写「未检测到满减/折扣」「未见列表侧价差」等,当摘要已表明存在可对齐价差且券后低于标价样本时尤甚;但该字段**不是** §8.3 成稿模板。
|
||||
- **三类信息分清**:(1)**监测事实**——仅用输入字段;(2)**竞品页原文规则**——仅当节选等已收录;(3)**本品策略主张**——§8.3 **必须有**,可用「拟」「建议」,**不得**把(3)写成「监测已证实」。
|
||||
|
||||
**输出**:仅 Markdown 正文(不要 ``` 围栏);须收束各小节与全文,勿中途截断。"""
|
||||
|
||||
STRATEGY_USER_PREFIX = (
|
||||
"请基于以下 JSON 输出最终策略稿(Markdown),正文须为对外可读正式文档,不得泄露 JSON 键名、字段名、源码路径或底稿中的编写提示语。\n"
|
||||
"输出前自检:全文不得包含输入中未出现的具体数字、品牌/店铺名、用户引语与活动规则;不确定处须写「假设」「监测未体现」或「待业务核对」。\n"
|
||||
"输出前自检:不得把输入未出现的**竞品侧**数字、品牌/店铺名、用户引语当作**已证实事实**;不确定处须写「假设」「待业务核对」。\n"
|
||||
"输出前自检(**全书 证据**)**:除 §2.1 外,**摘要/一/三~十/附录** 是否 **未** 写 无 据 的「**部分/多数 竞品/用户**…」、**未** 将 **价/促/主图/渠道** 等 **本品策略** 假装 成 **用户或评论 已 提出 的 要求**?监测/价带 仅 作 背景 时 是否 **未** 写 成 **已证实 的 用户痛**?**若否**,先改再输出。\n"
|
||||
"§8.3:以**本品促销决策**为主(满减/满折/到手价呈现等),勿复述监测行数占比;**禁止**与 `strategy_price_promotion_brief_cn` 矛盾地否认价差存在;"
|
||||
"若具体门槛/面额输入未给,须写「拟…」并标明待运营核定,**勿**把拟定数字写成「监测已显示竞品满××减××」。\n"
|
||||
"输出前自检(规划 §1.1):摘要「阶段重点」是否为 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 多行实质内容),"
|
||||
"在「策略范围与前提」标明假设前提,并对阶段目标给出 A~E 类型选项及**推荐倾向**;禁止全文停留在待填占位。\n"
|
||||
"若 `strategy_decisions_substantive` 为 true:已填表单项视为已定须落实;空项结合数据补全,并与后文一致。\n\n"
|
||||
)
|
||||
|
||||
# §2.1「用户痛点」列:模型常置若罔闻的「部分竞品…」无据归纳,产线侧删节,避免对外输出虚假监测事实。
|
||||
_S21_PAIN_PLACEHOLDER = (
|
||||
"(典型场景假设:选购同类代餐/控糖饼干时,易在口感、健康标签与包装使用上吃不准、怕买错,"
|
||||
"待与评价摘录/调研补核。)"
|
||||
)
|
||||
_S21_PAIN_BANNED_MARKERS: tuple[str, ...] = (
|
||||
"部分竞品",
|
||||
"多数竞品",
|
||||
"有的竞品",
|
||||
"同行普遍",
|
||||
"同行多数",
|
||||
"其他品牌普遍",
|
||||
"其他品牌多数",
|
||||
)
|
||||
|
||||
|
||||
def _is_s21_table_separator_row(stripped: str) -> bool:
|
||||
if not stripped.startswith("|"):
|
||||
return False
|
||||
cells = [c.strip() for c in stripped.split("|") if c.strip() != ""]
|
||||
if len(cells) < 2:
|
||||
return False
|
||||
return all(re.match(r"^:?-+$", c) is not None for c in cells)
|
||||
|
||||
|
||||
def _strip_unsourced_competitor_phrases_in_s21_pain_cell(text: str) -> str:
|
||||
"""
|
||||
删除「用户痛点(简述)」格内无据的竞品泛化子句,保留可单独成立的买家侧表述。
|
||||
不解析摘录全文做「是否有据」的 NLP 判断;仅对已知高风险句式机读去尾。
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return t
|
||||
# 「用户希望/担心…,但(部分|多数)竞品…」 整段后半为高风险
|
||||
t = re.sub(
|
||||
r"[,,、;;]\s*但(部分|多数|有的)竞品[^|]*$",
|
||||
"",
|
||||
t,
|
||||
)
|
||||
t = re.sub(
|
||||
r"[,,、;;]\s*而(部分|多数|有的)竞品[^|]*$",
|
||||
"",
|
||||
t,
|
||||
)
|
||||
t = re.sub(
|
||||
r"[,,、;;]\s*(而)?(与|和)(部分|多数|有的)竞品(相比|相对)[^|]*$",
|
||||
"",
|
||||
t,
|
||||
)
|
||||
t = re.sub(
|
||||
r"[,,、;;]\s*相对(部分|多数)竞品[^|]*$",
|
||||
"",
|
||||
t,
|
||||
)
|
||||
t = re.sub(
|
||||
r"[,,、;;]\s*[^|,,、;;]{0,8}(部分|多数)竞品(在|的|中)[^|]*$",
|
||||
"",
|
||||
t,
|
||||
)
|
||||
for marker in _S21_PAIN_BANNED_MARKERS:
|
||||
while marker in t:
|
||||
i = t.find(marker)
|
||||
t = t[:i].rstrip()
|
||||
t = re.sub(r"[,,、;;]\s*([但而])+\s*$", "", t)
|
||||
t = t.rstrip(",,、;;但而 ")
|
||||
t = re.sub(r"\s+", " ", t).strip(",,、;; ")
|
||||
if re.search(
|
||||
r"(部分|多数|有的)竞品|其他品牌(普遍|多数)|同行(普遍|多数)", t
|
||||
):
|
||||
return _S21_PAIN_PLACEHOLDER
|
||||
if len(t) < 4:
|
||||
return _S21_PAIN_PLACEHOLDER
|
||||
return t
|
||||
|
||||
|
||||
def sanitize_strategy_s21_pain_column_md(markdown: str) -> str:
|
||||
"""
|
||||
在独立策略稿 Markdown 中定位 ``## 二、…`` 与 ``## 三、…`` 之间、含「用户痛点」表头的
|
||||
管道表,对 **用户痛点** 列做 ``_strip_unsourced_competitor_phrases_in_s21_pain_cell``。
|
||||
无表或结构不识别时原样返回。
|
||||
"""
|
||||
m = re.search(
|
||||
r"(?ms)(^##\s*二[、,..\s](?:.|\n)*?)(?=^##\s*三[、,..\s])",
|
||||
markdown,
|
||||
)
|
||||
if not m:
|
||||
return markdown
|
||||
section = m.group(1)
|
||||
lines = section.splitlines(keepends=True)
|
||||
out: list[str] = []
|
||||
pain_idx: int | None = None
|
||||
in_s21_table = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if (
|
||||
stripped.startswith("|")
|
||||
and "用户痛点" in stripped
|
||||
and re.search(r"用户痛点|痛点[((]简述[))]", stripped)
|
||||
):
|
||||
parts_h = [p.strip() for p in stripped.split("|")]
|
||||
for i, cell in enumerate(parts_h):
|
||||
if "用户痛点" in cell:
|
||||
pain_idx = i
|
||||
in_s21_table = True
|
||||
break
|
||||
if in_s21_table and pain_idx is not None and stripped.startswith("|"):
|
||||
is_sep_row = _is_s21_table_separator_row(stripped)
|
||||
if is_sep_row or "用户痛点" in stripped:
|
||||
pass
|
||||
else:
|
||||
sparts = line.rstrip("\n").split("|")
|
||||
if len(sparts) > pain_idx and pain_idx >= 0:
|
||||
inner = (sparts[pain_idx] or "").strip()
|
||||
if inner and not re.match(r"^[-:\s]{2,}$", inner):
|
||||
new_inner = _strip_unsourced_competitor_phrases_in_s21_pain_cell(
|
||||
inner
|
||||
)
|
||||
if new_inner != inner:
|
||||
sparts[pain_idx] = f" {new_inner} "
|
||||
line = "|".join(sparts) + (
|
||||
"\n" if line.endswith("\n") else ""
|
||||
)
|
||||
if (
|
||||
in_s21_table
|
||||
and stripped
|
||||
and not stripped.startswith("|")
|
||||
and not stripped.startswith("#")
|
||||
):
|
||||
in_s21_table = False
|
||||
out.append(line)
|
||||
new_sec = "".join(out)
|
||||
return markdown[: m.start(1)] + new_sec + markdown[m.end(1) :]
|
||||
|
||||
|
||||
def _build_strategy_draft_llm_payload_and_user(
|
||||
*,
|
||||
@ -234,6 +403,10 @@ def _build_strategy_draft_llm_payload_and_user(
|
||||
rd = rules_md
|
||||
else:
|
||||
rd = _truncate_rules_draft_md(rules_md, rules_max)
|
||||
pps = compact.get("price_promotion_signals")
|
||||
promo_brief_cn = price_promotion_signals_strategy_brief_cn(
|
||||
pps if isinstance(pps, dict) else {}
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"job_id": job_id,
|
||||
"keyword": keyword,
|
||||
@ -244,6 +417,7 @@ def _build_strategy_draft_llm_payload_and_user(
|
||||
),
|
||||
"business_notes": business_notes,
|
||||
"structured_brief": compact,
|
||||
"strategy_price_promotion_brief_cn": promo_brief_cn,
|
||||
"rules_draft_markdown": rd,
|
||||
"report_strategy_excerpt": ex,
|
||||
"report_matrix_group_evidence_md": gm,
|
||||
@ -429,7 +603,10 @@ def generate_strategy_draft_markdown_llm(
|
||||
report_matrix_group_evidence_md=report_matrix_group_evidence_md,
|
||||
report_config=report_config,
|
||||
)
|
||||
return call_llm(STRATEGY_SYSTEM, user)
|
||||
raw = call_llm(
|
||||
STRATEGY_SYSTEM, user, temperature=_strategy_llm_temperature()
|
||||
)
|
||||
return sanitize_strategy_s21_pain_column_md(raw)
|
||||
|
||||
|
||||
STRATEGY_OPPORTUNITIES_SYSTEM = (
|
||||
@ -583,23 +760,31 @@ def generate_strategy_opportunities_llm(
|
||||
payload["prior_chapter_llm_narratives"] = narratives
|
||||
user = _user_from_payload(payload)
|
||||
if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp):
|
||||
return call_llm(sys_prompt, user)
|
||||
return call_llm(
|
||||
sys_prompt, user, temperature=_strategy_llm_temperature()
|
||||
)
|
||||
|
||||
for cap_brief in (40_000, 32_000, 26_000, 20_000, 16_000, 14_000, 12_000, 10_000):
|
||||
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
|
||||
payload = {"keyword": keyword, "competitor_brief": compact}
|
||||
user = _user_from_payload(payload)
|
||||
if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp):
|
||||
return call_llm(sys_prompt, user)
|
||||
return call_llm(
|
||||
sys_prompt, user, temperature=_strategy_llm_temperature()
|
||||
)
|
||||
|
||||
for cap_brief in (14_000, 12_000, 10_000, 8_000):
|
||||
compact = compact_brief_for_llm(brief, max_chars=cap_brief)
|
||||
payload = {"keyword": keyword, "competitor_brief": compact}
|
||||
user = _user_from_payload(payload)
|
||||
if _strategy_prompt_ok_for_call(sys_prompt, user, min_completion_tokens=min_comp_relaxed):
|
||||
return call_llm(sys_prompt, user)
|
||||
return call_llm(
|
||||
sys_prompt, user, temperature=_strategy_llm_temperature()
|
||||
)
|
||||
|
||||
compact = compact_brief_for_llm(brief, max_chars=8_000)
|
||||
payload = {"keyword": keyword, "competitor_brief": compact}
|
||||
user = _user_from_payload(payload)
|
||||
return call_llm(sys_prompt, user)
|
||||
return call_llm(
|
||||
sys_prompt, user, temperature=_strategy_llm_temperature()
|
||||
)
|
||||
|
||||
@ -137,25 +137,7 @@ def markdown_summary_from_brief(brief: dict[str, Any]) -> str:
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
ckw = brief.get("comment_focus_keywords") or []
|
||||
if ckw:
|
||||
lines.extend(["## 评价关注词(Top)", ""])
|
||||
for item in ckw[:10]:
|
||||
if isinstance(item, dict):
|
||||
lines.append(
|
||||
f"- **{item.get('word') or '—'}**:{_num(item.get('count'))} 次"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
usc = brief.get("usage_scenarios") or []
|
||||
if usc:
|
||||
lines.extend(["## 用途/场景(预设词组,Top)", ""])
|
||||
for item in usc[:8]:
|
||||
if isinstance(item, dict):
|
||||
lines.append(
|
||||
f"- **{item.get('scenario') or '—'}**:{_num(item.get('count'))} 条(约 {_pct(item.get('share_of_text_units'))} 文本单元)"
|
||||
)
|
||||
lines.append("")
|
||||
# 已废弃:comment_focus_keywords / usage_scenarios 子串词表统计不再写入 brief,要点摘录亦不再展示。
|
||||
|
||||
hints = brief.get("strategy_hints") or []
|
||||
if hints:
|
||||
|
||||
@ -6,6 +6,7 @@ from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from pipeline.competitor_report.csv_io import _collect_prices
|
||||
from pipeline.competitor_report.price_promo import _analyze_price_promotions
|
||||
from pipeline.competitor_report.price_stats import _price_stats_extended
|
||||
|
||||
|
||||
@ -224,12 +225,11 @@ def filter_brief_for_strategy_matrix_group(
|
||||
|
||||
if fb_one:
|
||||
f0 = fb_one[0]
|
||||
b["comment_focus_keywords"] = list(f0.get("focus_keyword_hits") or [])
|
||||
scenarios_top = f0.get("scenarios_top") or []
|
||||
b["usage_scenarios"] = list(scenarios_top)
|
||||
denom = int(f0.get("effective_comment_text_units") or 0)
|
||||
if denom <= 0:
|
||||
denom = int(f0.get("comment_rows") or 0)
|
||||
b["comment_focus_keywords"] = []
|
||||
b["usage_scenarios"] = []
|
||||
b["usage_scenarios_denominator"] = denom
|
||||
else:
|
||||
b["comment_focus_keywords"] = []
|
||||
@ -285,7 +285,7 @@ def filter_brief_for_strategy_matrix_group(
|
||||
else:
|
||||
b["notes"] = [extra]
|
||||
|
||||
b["price_promotion_signals"] = []
|
||||
b["price_promotion_signals"] = _analyze_price_promotions(rows_for_price)
|
||||
b["strategy_hints"] = []
|
||||
|
||||
b["list_visibility_proxy"] = {
|
||||
|
||||
@ -268,8 +268,7 @@ def generate_report_charts(
|
||||
) -> list[str]:
|
||||
"""生成扇形/条形 PNG。返回已写入的文件名列表(不含路径)。
|
||||
|
||||
若 ``report_config["chapter8_text_mining_probe"]`` 为真,**不**生成 ``chart_focus_and_scenarios_bar__*.png``
|
||||
(与竞品报告 §8.2 文本挖掘探针互斥,避免无效产出)。
|
||||
**不**生成 ``chart_focus_and_scenarios_bar__*.png``(预设关注词/场景子串统计已废弃)。
|
||||
"""
|
||||
_setup_matplotlib_cjk()
|
||||
import matplotlib.pyplot as plt
|
||||
@ -556,10 +555,7 @@ def generate_report_charts(
|
||||
core = "group"
|
||||
return f"i{index:02d}_{core}"
|
||||
|
||||
_skip_focus_scenario_combo = bool(
|
||||
isinstance(report_config, dict)
|
||||
and report_config.get("chapter8_text_mining_probe")
|
||||
)
|
||||
_skip_focus_scenario_combo = True
|
||||
if _skip_focus_scenario_combo:
|
||||
for fp in out_dir.glob("chart_focus_and_scenarios_bar__*.png"):
|
||||
try:
|
||||
|
||||
@ -15,12 +15,34 @@ def _strip_inline_md(s: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
_RE_TASK_CHECKED = re.compile(r"^\[x\]\s*", re.IGNORECASE)
|
||||
_RE_TASK_UNCHECKED = re.compile(r"^\[ \]\s*")
|
||||
# GFM 表头分隔行:每格为 :--- / ---: / :---: / ---------- 等(至少 3 个连字符)
|
||||
_TABLE_SEP_CELL = re.compile(r"^:?-{3,}:?$")
|
||||
|
||||
|
||||
def _strip_gfm_task_list_prefix(text: str) -> str:
|
||||
"""去掉 ``- [x]`` / ``- [ ]`` 中的任务标记,导出时用普通项目符号,观感接近 MD 预览圆点。"""
|
||||
t = text.strip()
|
||||
m = _RE_TASK_CHECKED.match(t)
|
||||
if m:
|
||||
return t[m.end() :].strip()
|
||||
m = _RE_TASK_UNCHECKED.match(t)
|
||||
if m:
|
||||
return t[m.end() :].strip()
|
||||
return text
|
||||
|
||||
|
||||
def _is_table_sep(line: str) -> bool:
|
||||
t = line.strip()
|
||||
if not t.startswith("|"):
|
||||
"""GFM 表头与表体之间的分隔行(含任意长度连字符,如 ``|----------|``)。"""
|
||||
row_line = line.strip()
|
||||
if not row_line.startswith("|"):
|
||||
return False
|
||||
inner = t.strip("|").replace(" ", "")
|
||||
return bool(inner) and all(p in ("", "---", ":---", "---:", ":---:") for p in t.split("|"))
|
||||
cells = [c.strip() for c in row_line.strip("|").split("|")]
|
||||
sep_cells = [c for c in cells if c]
|
||||
if len(sep_cells) < 2:
|
||||
return False
|
||||
return all(_TABLE_SEP_CELL.match(c) is not None for c in sep_cells)
|
||||
|
||||
|
||||
_RE_HEADING = re.compile(r"^(#{1,6})\s+(.+)$")
|
||||
@ -32,6 +54,38 @@ _RE_HR = re.compile(r"^\s*(?:[-*_]\s*){3,}\s*$")
|
||||
_img_line = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$")
|
||||
|
||||
|
||||
def _join_md_soft_break_lines(lines: list[str]) -> str:
|
||||
"""把编辑器/模型折行产生的多行合并为一段(等价于 CommonMark 软换行 → 空格)。"""
|
||||
parts = [ln.strip() for ln in lines if ln and ln.strip()]
|
||||
if not parts:
|
||||
return ""
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _is_plain_markdown_line(s: str) -> bool:
|
||||
"""是否可作为「正文折行」参与合并的一行(非标题/列表/表格等)。"""
|
||||
t = s.strip()
|
||||
if not t:
|
||||
return False
|
||||
if t.startswith("```"):
|
||||
return False
|
||||
if _RE_HR.match(s):
|
||||
return False
|
||||
if _match_heading(s) is not None:
|
||||
return False
|
||||
if _img_line.match(t):
|
||||
return False
|
||||
if t.startswith("|"):
|
||||
return False
|
||||
if _RE_UL.match(s):
|
||||
return False
|
||||
if _RE_OL.match(s):
|
||||
return False
|
||||
if _RE_BLOCKQUOTE.match(s):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _match_heading(line: str) -> tuple[int, str] | None:
|
||||
"""返回 (docx level 0–8, 标题文本) 或 None。"""
|
||||
m = _RE_HEADING.match(line.strip())
|
||||
@ -57,7 +111,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
pass
|
||||
|
||||
def _add_list_bullet(text: str) -> None:
|
||||
t = _strip_inline_md(text)
|
||||
t = _strip_gfm_task_list_prefix(_strip_inline_md(text))
|
||||
try:
|
||||
doc.add_paragraph(t, style="List Bullet")
|
||||
except KeyError:
|
||||
@ -73,9 +127,24 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
lines = (md or "").replace("\r\n", "\n").split("\n")
|
||||
i = 0
|
||||
in_fence = False
|
||||
plain_buf: list[str] = []
|
||||
|
||||
def flush_plain() -> None:
|
||||
if not plain_buf:
|
||||
return
|
||||
merged = _join_md_soft_break_lines(plain_buf)
|
||||
plain_buf.clear()
|
||||
if not merged:
|
||||
return
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
|
||||
p.add_run(_strip_inline_md(merged))
|
||||
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
if raw.strip().startswith("```"):
|
||||
if not in_fence:
|
||||
flush_plain()
|
||||
in_fence = not in_fence
|
||||
i += 1
|
||||
continue
|
||||
@ -90,23 +159,27 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
|
||||
line = raw.rstrip()
|
||||
if not line.strip():
|
||||
flush_plain()
|
||||
doc.add_paragraph("")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if _RE_HR.match(line):
|
||||
flush_plain()
|
||||
doc.add_paragraph("")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
hm = _match_heading(line)
|
||||
if hm is not None:
|
||||
flush_plain()
|
||||
doc.add_heading(hm[1], level=hm[0])
|
||||
i += 1
|
||||
continue
|
||||
|
||||
mimg = _img_line.match(line.strip())
|
||||
if mimg and asset_root is not None:
|
||||
flush_plain()
|
||||
rel = mimg.group(2).strip()
|
||||
if not (rel.startswith("http://") or rel.startswith("https://")):
|
||||
img_path = (asset_root / rel).resolve()
|
||||
@ -121,6 +194,7 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
continue
|
||||
|
||||
if line.strip().startswith("|"):
|
||||
flush_plain()
|
||||
rows: list[list[str]] = []
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
row_line = lines[i].strip()
|
||||
@ -142,18 +216,21 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
|
||||
mu = _RE_UL.match(line)
|
||||
if mu:
|
||||
flush_plain()
|
||||
_add_list_bullet(mu.group(1))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
mo = _RE_OL.match(line)
|
||||
if mo:
|
||||
flush_plain()
|
||||
_add_list_number(mo.group(2))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
mq = _RE_BLOCKQUOTE.match(line)
|
||||
if mq:
|
||||
flush_plain()
|
||||
inner = mq.group(1).strip()
|
||||
if inner:
|
||||
p = doc.add_paragraph()
|
||||
@ -162,12 +239,18 @@ def markdown_to_docx_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if _is_plain_markdown_line(line):
|
||||
plain_buf.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
flush_plain()
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
|
||||
text = _strip_inline_md(line)
|
||||
p.add_run(text)
|
||||
p.add_run(_strip_inline_md(line))
|
||||
i += 1
|
||||
|
||||
flush_plain()
|
||||
bio = BytesIO()
|
||||
doc.save(bio)
|
||||
return bio.getvalue()
|
||||
@ -262,19 +345,22 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
fontName=font_name,
|
||||
fontSize=10,
|
||||
leading=14,
|
||||
spaceAfter=3,
|
||||
)
|
||||
h1s = ParagraphStyle(
|
||||
name="H1CJK",
|
||||
parent=body,
|
||||
fontSize=16,
|
||||
leading=20,
|
||||
spaceAfter=8,
|
||||
spaceBefore=0,
|
||||
spaceAfter=10,
|
||||
)
|
||||
h2s = ParagraphStyle(
|
||||
name="H2CJK",
|
||||
parent=body,
|
||||
fontSize=13,
|
||||
leading=17,
|
||||
spaceBefore=14,
|
||||
spaceAfter=6,
|
||||
)
|
||||
h3s = ParagraphStyle(
|
||||
@ -282,6 +368,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
parent=body,
|
||||
fontSize=12,
|
||||
leading=16,
|
||||
spaceBefore=10,
|
||||
spaceAfter=5,
|
||||
)
|
||||
h4s = ParagraphStyle(
|
||||
@ -289,6 +376,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
parent=body,
|
||||
fontSize=11,
|
||||
leading=15,
|
||||
spaceBefore=8,
|
||||
spaceAfter=4,
|
||||
)
|
||||
h56s = ParagraphStyle(
|
||||
@ -296,6 +384,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
parent=body,
|
||||
fontSize=10.5,
|
||||
leading=14,
|
||||
spaceBefore=6,
|
||||
spaceAfter=3,
|
||||
)
|
||||
quote_style = ParagraphStyle(
|
||||
@ -305,25 +394,50 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
fontSize=9.5,
|
||||
textColor=colors.HexColor("#444444"),
|
||||
)
|
||||
# 项目符号用 Helvetica 绘制:正文 CJK 字体常缺 U+2022「•」,会落成方框(似 ☐)
|
||||
bullet_body = ParagraphStyle(
|
||||
name="BulletBodyCJK",
|
||||
parent=body,
|
||||
leftIndent=18,
|
||||
bulletIndent=8,
|
||||
leftIndent=22,
|
||||
bulletIndent=10,
|
||||
firstLineIndent=0,
|
||||
bulletFontName="Helvetica",
|
||||
bulletFontSize=10,
|
||||
wordWrap="CJK",
|
||||
)
|
||||
ol_body = ParagraphStyle(
|
||||
name="OlBodyCJK",
|
||||
parent=body,
|
||||
leftIndent=18,
|
||||
firstLineIndent=0,
|
||||
wordWrap="CJK",
|
||||
)
|
||||
|
||||
story: list[Any] = []
|
||||
lines = (md or "").replace("\r\n", "\n").split("\n")
|
||||
i = 0
|
||||
in_fence = False
|
||||
plain_buf: list[str] = []
|
||||
|
||||
def _para_cell(s: str, style: Any) -> Paragraph:
|
||||
return Paragraph(xml_escape(_strip_inline_md(s)), style)
|
||||
|
||||
def flush_plain_pdf() -> None:
|
||||
if not plain_buf:
|
||||
return
|
||||
merged = _join_md_soft_break_lines(plain_buf)
|
||||
plain_buf.clear()
|
||||
if not merged:
|
||||
return
|
||||
story.append(
|
||||
Paragraph(xml_escape(_strip_inline_md(merged)), body)
|
||||
)
|
||||
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
if raw.strip().startswith("```"):
|
||||
if not in_fence:
|
||||
flush_plain_pdf()
|
||||
in_fence = not in_fence
|
||||
i += 1
|
||||
continue
|
||||
@ -334,17 +448,20 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
i += 1
|
||||
continue
|
||||
if not s.strip():
|
||||
flush_plain_pdf()
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if _RE_HR.match(s):
|
||||
flush_plain_pdf()
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
hm = _match_heading(s)
|
||||
if hm is not None:
|
||||
flush_plain_pdf()
|
||||
level, title = hm
|
||||
title_esc = xml_escape(title)
|
||||
if level == 0:
|
||||
@ -362,6 +479,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
|
||||
mimg = _img_line.match(s.strip())
|
||||
if mimg and asset_root is not None:
|
||||
flush_plain_pdf()
|
||||
rel = mimg.group(2).strip()
|
||||
if not (rel.startswith("http://") or rel.startswith("https://")):
|
||||
img_path = (asset_root / rel).resolve()
|
||||
@ -381,6 +499,7 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
continue
|
||||
|
||||
if s.strip().startswith("|"):
|
||||
flush_plain_pdf()
|
||||
rows: list[list[str]] = []
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
row_line = lines[i].strip()
|
||||
@ -393,47 +512,51 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
if rows:
|
||||
max_cols = max(len(r) for r in rows)
|
||||
pad_rows = [r + [""] * (max_cols - len(r)) for r in rows]
|
||||
usable_w = 13 * cm
|
||||
col_w = usable_w / float(max_cols)
|
||||
data: list[list[Any]] = []
|
||||
for row in pad_rows:
|
||||
data.append(
|
||||
[_para_cell(c, body) for c in row]
|
||||
usable_w = 17 * cm
|
||||
if max_cols == 2:
|
||||
col_widths = [4.2 * cm, usable_w - 4.2 * cm]
|
||||
else:
|
||||
col_widths = [usable_w / float(max_cols)] * max_cols
|
||||
data = [[_para_cell(c, body) for c in row] for row in pad_rows]
|
||||
t = Table(data, colWidths=col_widths, repeatRows=1)
|
||||
tbl_cmds: list[tuple[Any, ...]] = [
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#c8c8c8")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
if pad_rows:
|
||||
tbl_cmds.append(
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#ececec"))
|
||||
)
|
||||
t = Table(data, colWidths=[col_w] * max_cols)
|
||||
t.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
]
|
||||
)
|
||||
)
|
||||
t.setStyle(TableStyle(tbl_cmds))
|
||||
story.append(t)
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
continue
|
||||
|
||||
mu = _RE_UL.match(s)
|
||||
if mu:
|
||||
txt = xml_escape(_strip_inline_md(mu.group(1)))
|
||||
story.append(Paragraph(f"• {txt}", bullet_body))
|
||||
flush_plain_pdf()
|
||||
inner = _strip_gfm_task_list_prefix(_strip_inline_md(mu.group(1)))
|
||||
txt = xml_escape(inner)
|
||||
story.append(Paragraph(txt, bullet_body, bulletText="\u2022"))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
mo = _RE_OL.match(s)
|
||||
if mo:
|
||||
flush_plain_pdf()
|
||||
n, rest = mo.group(1), mo.group(2)
|
||||
txt = xml_escape(_strip_inline_md(rest))
|
||||
story.append(Paragraph(f"{n}. {txt}", bullet_body))
|
||||
story.append(Paragraph(f"{n}. {txt}", ol_body))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
mq = _RE_BLOCKQUOTE.match(s)
|
||||
if mq:
|
||||
flush_plain_pdf()
|
||||
inner = mq.group(1).strip()
|
||||
if inner:
|
||||
story.append(
|
||||
@ -442,10 +565,16 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
plain = _strip_inline_md(s)
|
||||
story.append(Paragraph(xml_escape(plain), body))
|
||||
if _is_plain_markdown_line(s):
|
||||
plain_buf.append(s)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
flush_plain_pdf()
|
||||
story.append(Paragraph(xml_escape(_strip_inline_md(s)), body))
|
||||
i += 1
|
||||
|
||||
flush_plain_pdf()
|
||||
buf = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
市场策略 Markdown 草稿:**规则骨架**(占位 + 少量数据摘录),供业务与大模型成稿对齐。
|
||||
市场策略 Markdown 草稿:**规则骨架**(占位 + 必要表单项;不铺陈与报告同构的统计摘录),供业务与大模型成稿对齐。
|
||||
|
||||
- 决策在「策略生成」表单完成;未填项由大模型结合摘要与报告节选补全。
|
||||
- 骨架刻意短、可执行;避免与成稿重复的「假设 / 待验证」套话。
|
||||
@ -9,29 +9,11 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from .brief_concentration import (
|
||||
concentration_first_share,
|
||||
concentration_top_three_share,
|
||||
)
|
||||
|
||||
|
||||
def _esc(s: Any) -> str:
|
||||
t = "" if s is None else str(s).strip()
|
||||
return t.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def _pct(x: Any) -> str:
|
||||
if x is None:
|
||||
return "—"
|
||||
try:
|
||||
v = float(x)
|
||||
if math.isnan(v) or math.isinf(v):
|
||||
return "—"
|
||||
return f"{100 * v:.1f}%"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _num(x: Any) -> str:
|
||||
if x is None:
|
||||
return "—"
|
||||
@ -48,67 +30,6 @@ def _num(x: Any) -> str:
|
||||
return str(x)
|
||||
|
||||
|
||||
def _cr_narrative(
|
||||
label: str,
|
||||
cr1: Any,
|
||||
cr3: Any,
|
||||
top: Any,
|
||||
*,
|
||||
first_share_wording: tuple[str, str] | None = None,
|
||||
) -> str | None:
|
||||
"""从集中度生成一句策略向描述,无数据则返回 None(正文避免英文缩写)。
|
||||
|
||||
``first_share_wording`` 为 ``(第一大句前缀, 前三大句前缀)`` 时覆盖默认措辞(用于矩阵收窄口径,
|
||||
避免误写「列表行」)。
|
||||
"""
|
||||
try:
|
||||
c1 = float(cr1) if cr1 is not None else None
|
||||
except (TypeError, ValueError):
|
||||
c1 = None
|
||||
if c1 is None and not (top or "").strip():
|
||||
return None
|
||||
top_s = _esc(top) or "—"
|
||||
if first_share_wording is not None:
|
||||
w1, w3 = first_share_wording
|
||||
elif "店铺" in label:
|
||||
w1, w3 = "第一大店铺约占列表行的", "前三大店铺合计约占"
|
||||
elif "品牌" in label:
|
||||
w1, w3 = "第一大品牌约占", "前三大品牌合计约占"
|
||||
else:
|
||||
w1, w3 = "第一大主体约占", "前三大合计约占"
|
||||
if c1 is not None:
|
||||
if c1 >= 0.4:
|
||||
tone = "偏高,头部资源集中"
|
||||
elif c1 >= 0.25:
|
||||
tone = "中等,存在可争夺空间"
|
||||
else:
|
||||
tone = "相对分散,差异化切入点可能更多"
|
||||
return (
|
||||
f"- **{label}**:{w1} **{_pct(cr1)}**,{w3} **{_pct(cr3)}**;"
|
||||
f"当前头部为「{top_s}」。*粗判:{tone}。*"
|
||||
)
|
||||
return f"- **{label}**:头部为「{top_s}」(缺少占比时可结合列表与商详数据补全)。"
|
||||
|
||||
|
||||
def _shop_unique_sku_basis_lines(shops: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
``shops_from_list.unique_sku_basis`` 与竞品报告/摘要一致:按去重 SKU 的店铺集中度对照口径。
|
||||
"""
|
||||
usb = shops.get("unique_sku_basis") if isinstance(shops, dict) else None
|
||||
if not isinstance(usb, dict) or not usb.get("n_unique_skus"):
|
||||
return []
|
||||
u1 = concentration_first_share(usb)
|
||||
u3 = concentration_top_three_share(usb)
|
||||
utop = _esc(usb.get("top_label") or "")
|
||||
if u1 is None or not utop:
|
||||
return []
|
||||
return [
|
||||
f"- **列表侧店铺(按去重 SKU)**:共 **{_num(usb.get('n_unique_skus'))}** 个去重 SKU;"
|
||||
f"第一大店铺「{utop}」约占 **{_pct(u1)}**;前三合计 **{_pct(u3)}**。"
|
||||
"*(与上行「按列表行」可能因同一 SKU 多行曝光而差异;非销量/市占。)*"
|
||||
]
|
||||
|
||||
|
||||
def _goal_bullet(label: str, user_val: str, placeholder: str) -> str:
|
||||
v = _esc(user_val).strip()
|
||||
if v:
|
||||
@ -121,13 +42,117 @@ def _pillar_cell(user_val: str) -> str:
|
||||
return v if v else "*待填*"
|
||||
|
||||
|
||||
def _pos_mark(choice: str, key: str) -> str:
|
||||
return "[x]" if choice == key else "[ ]"
|
||||
def _price_position_llm_hint(pos: str) -> str:
|
||||
"""给 LLM 的 §8.2 提示:单行取向,避免成稿复刻四选项勾选表单。"""
|
||||
k = (pos or "").strip()
|
||||
if k == "top":
|
||||
core = "当前表单取向为贴顶:锚定中高位或头部价位带。"
|
||||
elif k == "mid":
|
||||
core = "当前表单取向为卡腰:围绕监测价带中位数一带。"
|
||||
elif k == "entry":
|
||||
core = "当前表单取向为下探:贴近监测价带区间下限。"
|
||||
elif k == "different":
|
||||
core = "当前表单取向为另起带:以规格、组合或服务形成差异化价位。"
|
||||
else:
|
||||
core = "表单未勾选价位取向;请结合 `structured_brief` 价带与业务判断补全。"
|
||||
return (
|
||||
f"- {core} 成稿 §8.2 仅用**连贯叙述句**展开定价逻辑;**禁止**四选项勾选清单、"
|
||||
"并排「贴顶/卡腰/下探/另起带」问卷式排版、「(表单勾选)」及类似内部提示语。"
|
||||
)
|
||||
|
||||
|
||||
def _risk_line(checked: bool, text: str) -> str:
|
||||
mark = "[x]" if checked else "[ ]"
|
||||
return f"- {mark} {text}"
|
||||
def _price_position_display_line(pos: str) -> str:
|
||||
"""下载稿 §8.2:单行交待表单价位取向,不铺陈四勾选清单。"""
|
||||
k = (pos or "").strip()
|
||||
if k == "top":
|
||||
core = "**贴顶**(锚定中高位或头部价位带)"
|
||||
elif k == "mid":
|
||||
core = "**卡腰**(围绕监测价带中位数一带)"
|
||||
elif k == "entry":
|
||||
core = "**下探**(贴近监测价带区间下限)"
|
||||
elif k == "different":
|
||||
core = "**另起带**(规格/组合或服务差异化价位)"
|
||||
else:
|
||||
core = "(表单未勾选;请结合价带与业务判断)"
|
||||
return (
|
||||
f"- **价位取向(表单)**:{core}。"
|
||||
" 成稿 §8.2 用连贯叙述展开即可,不必复刻四选项勾选排版。"
|
||||
)
|
||||
|
||||
|
||||
def _nine_ten_markdown_blocks(
|
||||
*,
|
||||
rk: bool,
|
||||
rp: bool,
|
||||
rc: bool,
|
||||
for_llm_input: bool,
|
||||
) -> list[str]:
|
||||
"""§九、§十:叙述式分条,避免勾选问卷体不利阅读。"""
|
||||
items: list[tuple[str, str, bool]] = [
|
||||
(
|
||||
"评论与归纳口径",
|
||||
"评论侧归纳是否存在以偏概全,宜结合原评论抽样核实。",
|
||||
rk,
|
||||
),
|
||||
(
|
||||
"价格带与清洗规则",
|
||||
"价格带是否包含大促或异常挂价,宜核对数据清洗规则。",
|
||||
rp,
|
||||
),
|
||||
(
|
||||
"列表曝光与深入样本",
|
||||
"列表侧集中度与深入样本中的品牌结构是否不一致,宜说明渠道或口径差异。",
|
||||
rc,
|
||||
),
|
||||
]
|
||||
out: list[str] = [
|
||||
"## 九、风险、假设与待验证",
|
||||
"",
|
||||
]
|
||||
if for_llm_input:
|
||||
out.append(
|
||||
"*§9 须用**短段落或分条叙述**写风险、假设与验证或应对;**禁止** `[ ]`/`[x]` 勾选、问卷式排版,"
|
||||
"或仅堆疑问句而无动作。*"
|
||||
)
|
||||
out.append("")
|
||||
else:
|
||||
out.append(
|
||||
"*成稿:每条风险带**应对动作或验证计划**;下列为业务表单关注点。*"
|
||||
)
|
||||
out.append("")
|
||||
for title, body, checked in items:
|
||||
tag = (
|
||||
" *(业务已在表单中勾选「已知晓」,成稿须优先写清验证或应对。)*"
|
||||
if checked
|
||||
else ""
|
||||
)
|
||||
out.append(f"- **{title}**:{body}{tag}")
|
||||
out.append("")
|
||||
if not for_llm_input:
|
||||
out.extend(["*业务备注见下节。*", ""])
|
||||
out.extend(
|
||||
[
|
||||
"## 十、下一步与节奏",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if for_llm_input:
|
||||
out.append(
|
||||
"*§10 须列**可执行动作**(可补负责人/时间),与 §2.1 / §六 优先级一致;**禁止** `[ ]` 待办勾选格式。*"
|
||||
)
|
||||
out.append("")
|
||||
else:
|
||||
out.append("*成稿:可执行任务清单;可补负责人与时间。*")
|
||||
out.append("")
|
||||
out.extend(
|
||||
[
|
||||
"- 锁定主推款与对标,并完成法务与合规核对。",
|
||||
"- 统一对外数据口径与话术。",
|
||||
"- 下轮监测更新后迭代策略。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def filter_strategy_hints_for_ch8_probe(hints: Any) -> list[str]:
|
||||
@ -156,8 +181,8 @@ def report_uses_chapter8_text_mining_probe(report_config: dict[str, Any] | None)
|
||||
"""
|
||||
与任务 ``report_config`` 中 ``chapter8_text_mining_probe`` 一致;未显式设置时默认 ``True``
|
||||
(与 ``jd.runner.get_default_report_config`` 一致)。
|
||||
用于 §1.2 文案分支及对 ``strategy_hints`` 的过滤:开启探针时与子串命中枚举相关的自动线索会被压掉;
|
||||
关闭时 §1.2 仍说明「简报不附带预设关注词/场景子串统计」,评论侧以报告第八章探针(若启用)与原文为准。
|
||||
用于 §1.2 短指引分支及对 ``strategy_hints`` 的过滤:开启探针时与子串命中枚举相关的自动线索会被压掉;
|
||||
关闭时 §1.2 仍提示「简报不附带预设关注词/场景子串统计枚举」。
|
||||
"""
|
||||
if not isinstance(report_config, dict):
|
||||
return True
|
||||
@ -246,12 +271,12 @@ def build_strategy_draft_markdown(
|
||||
)
|
||||
_table_goal_type = _scope_cell(sgt, _goal_type_placeholder)
|
||||
_summary_user_side = (
|
||||
"- **用户侧**:*(一两句结论即可:讨论焦点与负向主题;**按细类分句**归纳,**勿**混成「全站用户」一句;**勿**展开与报告重复的细类统计、词频。)*"
|
||||
"- **用户侧**:*(结论句:讨论焦点与负向主题;按细类分句;勿复述报告统计摘录。)*"
|
||||
if not for_llm_input
|
||||
else "- **用户侧**:—"
|
||||
)
|
||||
_summary_stage = (
|
||||
"- **阶段重点**:*(须含 1~2 条**可执行动作**,回扣 §2 优先痛点;**尽量点明适用类目/主推线**;勿仅写「加强运营」。)*"
|
||||
"- **阶段重点**:*(1~2 条可执行动作,点明类目/主推线。)*"
|
||||
if not for_llm_input
|
||||
else "- **阶段重点**:—"
|
||||
)
|
||||
@ -308,7 +333,7 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿须与 §2 一致:写清「谁在什么任务下检索、决策」及**主攻类目/细类**(与 §2.1 类目列可对上),为后文「针对痛点怎么做」埋伏笔。*",
|
||||
"*成稿写清谁在何任务下检索与决策、主攻类目/细类。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -317,30 +342,19 @@ def build_strategy_draft_markdown(
|
||||
]
|
||||
)
|
||||
if use_ch8_probe:
|
||||
if not for_llm_input:
|
||||
lines.extend(
|
||||
[
|
||||
"*当前任务以**第八章评论侧文本挖掘**为主呈现时,此处**不**逐条罗列关注词子串命中次数。*",
|
||||
"",
|
||||
"- **饼干 / 糕点 / 面点等**:*(骨架占位;成稿**分细类**各一句归纳用户关心点,**勿**合并成模糊「全池」一句;**勿**复述 §8 词频与条数。)*",
|
||||
"",
|
||||
]
|
||||
)
|
||||
_sec12 = (
|
||||
"*评论侧以报告**第八章文本挖掘**为准;成稿在此按**细类各一两句**写讨论焦点,勿铺陈词频次数、共现、类目条数表等与报告重复的摘录。*"
|
||||
if for_llm_input
|
||||
else "*评论侧见报告第八章文本挖掘;成稿按细类各一两句,勿铺陈词频、共现、类目条数表等摘录。*"
|
||||
)
|
||||
else:
|
||||
if not for_llm_input:
|
||||
lines.append(
|
||||
"*简报中**不再**附带预设关注词/场景子串统计;评论侧请依据同任务《竞品分析报告》**第八章第二节**(文本挖掘探针,若已启用)及抽样原文撰写本节。*"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
mix = brief.get("category_mix_top") or []
|
||||
if mix:
|
||||
lines.append("### 类目结构(摘录)")
|
||||
lines.append("")
|
||||
for item in mix[:6]:
|
||||
if isinstance(item, dict):
|
||||
lines.append(f"- {_esc(item.get('label'))}:{_num(item.get('count'))}")
|
||||
lines.append("")
|
||||
_sec12 = (
|
||||
"*简报不附带预设关注词/场景子串统计枚举;评论侧见报告第八章及原文抽样;成稿按细类各一两句,勿铺陈统计摘录。*"
|
||||
if for_llm_input
|
||||
else "*简报中**不再**附带预设关注词/场景子串统计;评论侧见报告第八章;成稿按细类各一两句,勿铺陈与报告重复的统计摘录。*"
|
||||
)
|
||||
lines.append(_sec12)
|
||||
lines.append("")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
@ -386,11 +400,7 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*本节**仅**用下表写清**针对痛点要怎么做**(**类目** + 痛点简述 + 动作 + 落地 + 验证)。**不再**单设「痛点表 / 价值对表 / 负向归因」子节,避免与 §三、§八重复。*",
|
||||
"",
|
||||
"*「用户痛点(简述)」须与 `structured_brief` / 策略线索 / 报告节选**可核对**;**禁止**编造「用户反馈『……』」式引语,除非原句已出现在上述输入中。*",
|
||||
"",
|
||||
"*「类目/细类」列:写明本行决策**适用于哪一类**(如饼干/面包/全检索池);多细类须**分行**,**禁止**用一句「全站」覆盖彼此冲突的策略;类目未定可写「待业务定类」并附分类假设。*",
|
||||
"*本节仅 §2.1 一表;痛点与 brief/报告可核对;多类目分行;勿编造用户引语。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -428,47 +438,40 @@ def build_strategy_draft_markdown(
|
||||
"",
|
||||
]
|
||||
)
|
||||
raw = brief.get("pc_search_raw") or {}
|
||||
if raw.get("result_count_consensus") is not None:
|
||||
lines.append(
|
||||
f"- **检索结果量级(需求侧参考,非销售额)**:{_num(raw.get('result_count_consensus'))}(站内匹配条数量级)"
|
||||
if for_llm_input
|
||||
else f"- **检索结果量级(需求侧参考,非销售额)**:{_num(raw.get('result_count_consensus'))}(列表 resultCount)"
|
||||
)
|
||||
elif merged_n is not None:
|
||||
lines.append(f"- **深入样本 SKU 数(监测范围)**:{_num(merged_n)}")
|
||||
if for_llm_input:
|
||||
# 检索量级、价带统计已在 structured_brief/报告;勿写入 rules,避免模型复述进策略正文。
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(
|
||||
"- **检索与样本尺度**:—"
|
||||
if for_llm_input
|
||||
else "- **检索与样本尺度**:*(成稿结合摘要与监测范围。)*"
|
||||
)
|
||||
lines.append("")
|
||||
if pst.get("n"):
|
||||
src = _esc(brief.get("price_stats_source")) or "—"
|
||||
src_disp = "本监测样本" if for_llm_input and src == "strategy_scope_matrix_group_skus" else src
|
||||
lines.extend(
|
||||
[
|
||||
f"- **价带摘录(支撑购买理由与价位锚点)**:来源 {src_disp},n = {_num(pst.get('n'))};"
|
||||
f"区间 {_num(pst.get('min'))}~{_num(pst.get('max'))};中位数 {_num(pst.get('median'))}。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
else:
|
||||
if for_llm_input:
|
||||
lines.append("- **价带摘录**:监测摘要中暂无统计表,可结合同任务报告补一句与购买理由相关的价位锚点。")
|
||||
raw = brief.get("pc_search_raw") or {}
|
||||
if raw.get("result_count_consensus") is not None:
|
||||
rc = _num(raw.get("result_count_consensus"))
|
||||
lines.append(
|
||||
f"- **站内检索匹配条数量级**:{rc}(列表 resultCount,非销售额口径)。"
|
||||
)
|
||||
elif merged_n is not None:
|
||||
lines.append(f"- **深入监测样本 SKU 数**:{_num(merged_n)}。")
|
||||
else:
|
||||
lines.append("- **检索与样本尺度**:*(成稿结合摘要与监测范围。)*")
|
||||
lines.append("")
|
||||
if pst.get("n"):
|
||||
src = _esc(brief.get("price_stats_source")) or "—"
|
||||
src_disp = src
|
||||
lines.extend(
|
||||
[
|
||||
f"- **本批样本价带**:来源 {src_disp},n = {_num(pst.get('n'))};"
|
||||
f"区间 {_num(pst.get('min'))}~{_num(pst.get('max'))};中位数 {_num(pst.get('median'))}。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
"*摘要中无价带统计,成稿可结合本批次价格数据在本节补一句价位锚点;**勿**重复 §2 已写的应对动作。*"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"- **购买理由**:*(成稿:**购买者视角**——买家为何选这一款;承接上列依据与 §2 优先痛点;多细类则分句;**勿**只写品类风口或运营叙事;价带/规格动作已在 §2 表内则此处**勿再展开一遍**。)*"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"- **购买理由(须站在购买者一侧写)**:用 1~2 句写清**买家为何愿意下单这一款**——解决什么顾虑、在货架上凭什么选它(获得感、可感知利益、价位是否值得等);上列检索/价带仅作背景,勿喧宾夺主。"
|
||||
"可用「用户/消费者」作主语,**禁止**用纯运营口吻(如「适合××叙事切入」「策略上占位」)代替购买动机;**禁止**以品类宏观句收尾而不落到本品可验证点。"
|
||||
if for_llm_input
|
||||
else "- **购买理由**:*(成稿:**购买者视角**——买家为何选这一款;承接上列依据与 §2 优先痛点;多细类则分句;**勿**只写品类风口或运营叙事;价带/规格动作已在 §2 表内则此处**勿再展开一遍**。)*"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
@ -484,7 +487,7 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿:承诺与调性须能落到**触点**(商详/包装/客服首句等)上的**具体句子**;**若**多类目话术不同,按 §2.1 类目**分句**,勿仅形容词。*",
|
||||
"*承诺与调性落到触点;多类目按 §2.1 分句。价位见 §8.2。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -510,104 +513,42 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*本节写**用户为何信任、为何愿意选这个品牌**(承诺、证据、合规边界);**价位阵地**(表单勾选的四类取向)见 **§8.2 定价策略**,勿混写。*",
|
||||
"*信任与证据;与 §8.2 价位叙述分开写。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
conc = brief.get("concentration") or {}
|
||||
shops = conc.get("shops_from_list") or {}
|
||||
dbrand = conc.get("detail_brand_among_merged") or {}
|
||||
scope_ap = brief.get("strategy_scope_applied")
|
||||
scoped_matrix = isinstance(scope_ap, dict) and bool(scope_ap.get("group"))
|
||||
gname_scoped = _esc(scope_ap.get("group")) if scoped_matrix else ""
|
||||
lines.extend(
|
||||
[
|
||||
"## 五、与其它品牌有何不同",
|
||||
"",
|
||||
"### 5.1 对比对象(摘录)",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if scoped_matrix:
|
||||
lines.append(
|
||||
"*本任务已按矩阵细类收窄:**下列店铺/品牌占比均按该分组内「深入合并 SKU」条数统计**,"
|
||||
"与全关键词 **PC 搜索列表行** 集中度**不是同一口径**;亦非销量或市占。*"
|
||||
if not for_llm_input
|
||||
else "*集中度:按所选矩阵分组内合并 SKU 条数;非全站列表行。*"
|
||||
)
|
||||
lines.append("")
|
||||
shop_label = (
|
||||
f"店铺分布(「{gname_scoped}」内样本 SKU)"
|
||||
if scoped_matrix
|
||||
else "列表侧店铺集中度"
|
||||
)
|
||||
brand_label = (
|
||||
f"品牌分布(「{gname_scoped}」内样本 SKU)"
|
||||
if scoped_matrix
|
||||
else "深入样本内品牌集中度"
|
||||
)
|
||||
scoped_wording: tuple[str, str] | None = (
|
||||
("第一大店铺约占该分组样本 SKU 的", "前三大店铺合计约占")
|
||||
if scoped_matrix
|
||||
else None
|
||||
)
|
||||
scoped_brand_wording: tuple[str, str] | None = (
|
||||
("第一大品牌约占该分组样本 SKU 的", "前三大品牌合计约占")
|
||||
if scoped_matrix
|
||||
else None
|
||||
)
|
||||
n_shop = _cr_narrative(
|
||||
shop_label,
|
||||
concentration_first_share(shops),
|
||||
concentration_top_three_share(shops),
|
||||
shops.get("top_label"),
|
||||
first_share_wording=scoped_wording,
|
||||
)
|
||||
n_brand = _cr_narrative(
|
||||
brand_label,
|
||||
concentration_first_share(dbrand),
|
||||
concentration_top_three_share(dbrand),
|
||||
dbrand.get("top_label"),
|
||||
first_share_wording=scoped_brand_wording,
|
||||
)
|
||||
if n_shop:
|
||||
lines.append(n_shop)
|
||||
for uline in _shop_unique_sku_basis_lines(shops):
|
||||
lines.append(uline)
|
||||
if n_brand:
|
||||
lines.append(n_brand)
|
||||
if not n_shop and not n_brand:
|
||||
lines.append(
|
||||
"- **竞争结构**:监测摘要未含集中度摘录。"
|
||||
if for_llm_input
|
||||
else "*本摘要未含集中度指标,请结合本批次竞争结构数据补全。*"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
(
|
||||
"*竞争格局与店铺/品牌集中度见同任务《竞品分析报告》;规则骨架**不**铺陈摘录,成稿**勿**复述「对比对象(摘录)」「店铺分布」「品牌分布」等与报告同构的长段。*"
|
||||
if for_llm_input
|
||||
else "*竞争格局与集中度见报告;成稿写清与谁对比、差异化与应对,**勿**在此铺陈店铺/品牌占比摘录。*"
|
||||
),
|
||||
"",
|
||||
*(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"",
|
||||
"- **环境自测**:头部强势时是侧翼还是正面替代?格局分散时是否用细分场景切入?",
|
||||
"",
|
||||
]
|
||||
),
|
||||
"",
|
||||
(
|
||||
"### 5.2 差异化方向"
|
||||
"### 5.1 差异化方向"
|
||||
if for_llm_input
|
||||
else "### 5.2 差异化方向(占位)"
|
||||
else "### 5.1 差异化方向(占位)"
|
||||
),
|
||||
"",
|
||||
*(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿:相对竞品**多做什么/少做什么**,写**可执行的一步**;**若**差异因细类而异,**分类目**写(非空泛「更好」)。*",
|
||||
"*差异化写清相对竞品多做什么/少做什么;细类不同则分写。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -621,12 +562,10 @@ def build_strategy_draft_markdown(
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.append("### 5.3 竞争应对")
|
||||
lines.append("### 5.2 竞争应对")
|
||||
lines.append("")
|
||||
if not for_llm_input:
|
||||
lines.append(
|
||||
"*成稿:在表单倾向基础上,写清**跟价/不跟价时具体话术或机制**(一句即可)。*"
|
||||
)
|
||||
lines.append("*竞争应对:跟价/不跟价时的话术或机制(可简短)。*")
|
||||
lines.append("")
|
||||
stance = _esc(d.get("competitive_stance") or "").strip()
|
||||
stance_line = {
|
||||
@ -663,7 +602,7 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿:路径须与 **§2.1 针对痛点要怎么做** 可对齐;营销/总体策略为**动词句**,回扣痛点;**多类目并行**时**分线**写目标或写清主线/副线。*",
|
||||
"*路径与 §2.1 对齐;动词句为主。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -690,6 +629,7 @@ def build_strategy_draft_markdown(
|
||||
pr = str(d.get("pillar_price") or "")
|
||||
pch = str(d.get("pillar_channel") or "")
|
||||
pcm = str(d.get("pillar_comm") or "")
|
||||
tp = str(d.get("tactic_promotion") or "")
|
||||
lines.extend(
|
||||
[
|
||||
"## 七、品牌四线:建设 · 打造 · 运营 · 体验",
|
||||
@ -698,8 +638,7 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*(与表单「4P 策略支柱」对应:产品 / 定价 / 渠道 / 传播。)*",
|
||||
"*成稿:**每条线**至少一句——服务哪类痛点、本阶段**具体做哪一步**;**尽量**与 §2.1「类目/细类」可对上,多类目则**分句**(勿四条同一泛化句)。*",
|
||||
"*四线对应产品/定价/渠道/传播;每条至少一句落地动作。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -725,16 +664,12 @@ def build_strategy_draft_markdown(
|
||||
if use_ch8_probe and not for_llm_input:
|
||||
pst_sig = brief.get("price_promotion_signals") or {}
|
||||
has_promo = isinstance(pst_sig, dict) and bool(pst_sig)
|
||||
promo_hint = "*促销与活动线索:须与摘要 `price_promotion_signals` 及第六章/第九章已有归纳一致;无则勿编造具体满减门槛。*"
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
promo_hint
|
||||
if has_promo
|
||||
else "*促销与价差:若摘要或价格信号有归纳则承接;无则勿编造。*",
|
||||
"",
|
||||
]
|
||||
promo_one = (
|
||||
"*促销线索须与摘要中的价格/活动信号一致;无则勿编造门槛。*"
|
||||
if has_promo
|
||||
else "*价差与活动:有则承接摘要;无则勿编造。*"
|
||||
)
|
||||
lines.extend(["", promo_one, ""])
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
@ -744,7 +679,7 @@ def build_strategy_draft_markdown(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿:四支柱分别回扣 **痛点→动作→落地**(可与 §2.1 呼应,避免纯重复);**若**产品/定价/促销因类目策略不同,**分细类**写子条,勿一条盖全站。*",
|
||||
"*四支柱回扣痛点→动作→落地;类目不同则分细类。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
@ -754,26 +689,37 @@ def build_strategy_draft_markdown(
|
||||
"",
|
||||
"### 8.2 定价策略",
|
||||
"",
|
||||
"**价位阵地取向(表单勾选;与监测价带可对读)**",
|
||||
"",
|
||||
f"- {_pos_mark(pos, 'top')} **贴顶**:中高位或头部价位带。",
|
||||
f"- {_pos_mark(pos, 'mid')} **卡腰**:围绕中位数一带。",
|
||||
f"- {_pos_mark(pos, 'entry')} **下探**:贴近区间下限。",
|
||||
f"- {_pos_mark(pos, 'different')} **另起带**:规格/组合/服务差异化。",
|
||||
"",
|
||||
f"- *(表单价格支柱:{_pillar_cell(pr)})*",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if for_llm_input:
|
||||
lines.append(_price_position_llm_hint(pos))
|
||||
lines.extend(["", f"- *(表单价格支柱:{_pillar_cell(pr)})*", ""])
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
_price_position_display_line(pos),
|
||||
"",
|
||||
f"- *(表单价格支柱:{_pillar_cell(pr)})*",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"### 8.3 促销与活动策略",
|
||||
"",
|
||||
*(
|
||||
[]
|
||||
[
|
||||
"*成稿须写**本品**拟采用的满减/满折或到手价规则(决策句),监测至多一句带过;勿把摘要里的行数占比当正文。*",
|
||||
"",
|
||||
]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*须写促销**原则**(券/到手价/跟价节奏);**满减、满折、跨店**等:能引用的写清来源;监测未捕获具体门槛时写「待与运营/后台对齐」,**勿**整节留空,**勿**编造门槛数字。*",
|
||||
"*与 `price_promotion_signals`、报告第六章一致;勿虚构活动。*",
|
||||
"*写清本阶段促销**决策**(拟满减/折扣档、跟价原则);勿写成报告统计段落。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
f"- *(表单促销策略:{_pillar_cell(tp)})*",
|
||||
"",
|
||||
"### 8.4 渠道与传播",
|
||||
"",
|
||||
f"- *(渠道/传播:{_pillar_cell(pch)} / {_pillar_cell(pcm)})*",
|
||||
@ -784,41 +730,7 @@ def build_strategy_draft_markdown(
|
||||
rk = bool(d.get("ack_risk_keywords"))
|
||||
rp = bool(d.get("ack_risk_price"))
|
||||
rc = bool(d.get("ack_risk_concentration"))
|
||||
rk_kw = "评论侧归纳是否以偏概全?(需原评论抽样)"
|
||||
lines.extend(
|
||||
[
|
||||
"## 九、风险、假设与待验证",
|
||||
"",
|
||||
_risk_line(rk, rk_kw),
|
||||
_risk_line(rp, "价格带是否含大促/异常挂价?(需核对清洗规则)"),
|
||||
_risk_line(rc, "列表集中度与深入样本品牌是否不一致?(需解释渠道差异)"),
|
||||
"",
|
||||
*(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿:每条风险尽量带**应对动作或验证计划**(抽样、核对规则),勿只列标题。*",
|
||||
"",
|
||||
"*业务备注见下节。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
"## 十、下一步与节奏",
|
||||
"",
|
||||
*(
|
||||
[]
|
||||
if for_llm_input
|
||||
else [
|
||||
"*成稿:下列为**可执行任务**(可补负责人/时间);与 §2.1 / §六 优先级一致;可含「按类目核对主图/商详与 §2.1 表」类项。*",
|
||||
"",
|
||||
]
|
||||
),
|
||||
"- [ ] 锁定主推款与对标;过法务与合规。",
|
||||
"- [ ] 统一对外数据口径与话术。",
|
||||
"- [ ] 下轮监测更新后迭代策略。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(_nine_ten_markdown_blocks(rk=rk, rp=rp, rc=rc, for_llm_input=for_llm_input))
|
||||
|
||||
notes = _esc(business_notes)
|
||||
lines.extend(
|
||||
@ -864,11 +776,9 @@ def build_strategy_draft_markdown(
|
||||
if bits:
|
||||
lines.append(f"- **采集参数快照**:{'; '.join(bits)}")
|
||||
raw = brief.get("pc_search_raw") or {}
|
||||
if raw.get("result_count_consensus") is not None:
|
||||
if raw.get("result_count_consensus") is not None and not for_llm_input:
|
||||
lines.append(
|
||||
f"- **平台申报检索规模**:{_num(raw.get('result_count_consensus'))}"
|
||||
if for_llm_input
|
||||
else f"- **列表申报规模(resultCount)**:{_num(raw.get('result_count_consensus'))}"
|
||||
f"- **列表申报规模(resultCount)**:{_num(raw.get('result_count_consensus'))}"
|
||||
)
|
||||
if for_llm_input:
|
||||
lines.extend(
|
||||
|
||||
@ -451,6 +451,9 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
|
||||
pillar_comm = serializers.CharField(
|
||||
required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False
|
||||
)
|
||||
tactic_promotion = serializers.CharField(
|
||||
required=False, allow_blank=True, default="", max_length=800, trim_whitespace=False
|
||||
)
|
||||
audience_segment = serializers.CharField(
|
||||
required=False, allow_blank=True, default="", max_length=500, trim_whitespace=False
|
||||
)
|
||||
|
||||
65
backend/pipeline/strategy_decision_keys.py
Normal file
65
backend/pipeline/strategy_decision_keys.py
Normal 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({})
|
||||
@ -36,13 +36,15 @@ class BriefStrategyScopeTests(SimpleTestCase):
|
||||
"brand": "B",
|
||||
"shop": "S2",
|
||||
"category": "休闲食品 > 饼干 > 粗粮饼干",
|
||||
"list_price_show": "20",
|
||||
"标价": "100",
|
||||
"券后到手价": "80",
|
||||
},
|
||||
{
|
||||
"brand": "B",
|
||||
"shop": "S2",
|
||||
"category": "休闲食品 > 饼干 > 苏打饼干",
|
||||
"list_price_show": "22",
|
||||
"标价": "50",
|
||||
"券后到手价": "50",
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -52,27 +54,11 @@ class BriefStrategyScopeTests(SimpleTestCase):
|
||||
"group": "饮料",
|
||||
"comment_rows": 5,
|
||||
"effective_comment_text_units": 5,
|
||||
"focus_keyword_hits": [{"word": "甜", "count": 2}],
|
||||
"scenarios_top": [
|
||||
{
|
||||
"scenario": "解渴",
|
||||
"count": 2,
|
||||
"share_of_text_units": 0.4,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"group": "饼干",
|
||||
"comment_rows": 8,
|
||||
"effective_comment_text_units": 8,
|
||||
"focus_keyword_hits": [{"word": "脆", "count": 3}],
|
||||
"scenarios_top": [
|
||||
{
|
||||
"scenario": "早餐",
|
||||
"count": 4,
|
||||
"share_of_text_units": 0.5,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"usage_scenarios_by_matrix_group": [
|
||||
@ -120,7 +106,17 @@ class BriefStrategyScopeTests(SimpleTestCase):
|
||||
self.assertEqual(out["matrix_by_group"][0]["group"], "饼干")
|
||||
self.assertEqual(len(out["matrix_by_group"][0]["skus"]), 2)
|
||||
self.assertEqual(len(out["consumer_feedback_by_matrix_group"]), 1)
|
||||
self.assertEqual(out["comment_focus_keywords"][0]["word"], "脆")
|
||||
self.assertEqual(out["comment_focus_keywords"], [])
|
||||
self.assertEqual(out["price_stats_source"], "strategy_scope_matrix_group_skus")
|
||||
self.assertIn("strategy_scope_applied", out)
|
||||
self.assertEqual(out["strategy_scope_applied"]["group"], "饼干")
|
||||
|
||||
def test_filter_recomputes_price_promotion_signals(self) -> None:
|
||||
b = self._sample_brief()
|
||||
out = filter_brief_for_strategy_matrix_group(b, matrix_group_index=1)
|
||||
pps = out.get("price_promotion_signals")
|
||||
self.assertIsInstance(pps, dict)
|
||||
assert isinstance(pps, dict)
|
||||
self.assertEqual(pps.get("row_count"), 2)
|
||||
self.assertEqual(pps.get("rows_with_both_list_and_coupon"), 2)
|
||||
self.assertEqual(pps.get("rows_coupon_below_list_price"), 1)
|
||||
|
||||
@ -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)
|
||||
@ -49,7 +49,8 @@ class BuildCompetitorBriefTests(SimpleTestCase):
|
||||
semantic_pool_max=10,
|
||||
)
|
||||
self.assertIn("sample_reviews_semantic_pool", pl)
|
||||
self.assertEqual(pl.get("sentiment_bucket_method"), "keyword_substring_heuristic")
|
||||
self.assertNotIn("comment_sentiment_lexicon", pl)
|
||||
self.assertNotIn("negative_lexeme_hits_top", pl)
|
||||
self.assertGreaterEqual(len(pl["sample_reviews_semantic_pool"]), 1)
|
||||
|
||||
def test_comment_sentiment_score_then_lexeme(self) -> None:
|
||||
@ -61,7 +62,11 @@ class BuildCompetitorBriefTests(SimpleTestCase):
|
||||
self.assertEqual(lex.get("negative_only"), 1)
|
||||
self.assertEqual(lex.get("neutral_or_empty"), 1)
|
||||
pl = build_comment_sentiment_llm_payload(texts, scores=scores)
|
||||
self.assertEqual(pl.get("sentiment_bucket_method"), "score_then_lexeme")
|
||||
dist = pl.get("star_rating_distribution") or {}
|
||||
self.assertEqual(dist.get("score_1_2"), 1)
|
||||
self.assertEqual(dist.get("score_3"), 1)
|
||||
self.assertEqual(dist.get("score_4_5"), 1)
|
||||
self.assertNotIn("comment_sentiment_lexicon", pl)
|
||||
|
||||
def test_comment_sentiment_all_scores_missing_falls_back_keyword(self) -> None:
|
||||
texts = ["好吃推荐", "差评"]
|
||||
@ -69,7 +74,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
|
||||
lex = _comment_sentiment_lexicon(texts, scores)
|
||||
self.assertEqual(lex.get("method"), "keyword_lexicon")
|
||||
|
||||
def test_custom_focus_words_in_report_config(self) -> None:
|
||||
def test_brief_omits_preset_comment_focus_keywords(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
run_dir = Path(td)
|
||||
(run_dir / "pc_search_raw").mkdir(parents=True)
|
||||
@ -88,8 +93,7 @@ class BuildCompetitorBriefTests(SimpleTestCase):
|
||||
report_config={"comment_focus_words": ["自定义词阿尔法"]},
|
||||
)
|
||||
|
||||
words = {x["word"] for x in out["comment_focus_keywords"]}
|
||||
self.assertIn("自定义词阿尔法", words)
|
||||
self.assertEqual(out["comment_focus_keywords"], [])
|
||||
|
||||
def test_matrix_groups_require_detail_category_path(self) -> None:
|
||||
sku_h = "SKU(skuId)"
|
||||
@ -145,43 +149,6 @@ class BuildCompetitorBriefTests(SimpleTestCase):
|
||||
self.assertIn("店铺:", lines[0])
|
||||
self.assertIn("整体口感还差点意思", lines[0])
|
||||
|
||||
def test_scenario_groups_llm_payload_matches_chapter8_sec2_right_rail_counts(
|
||||
self,
|
||||
) -> None:
|
||||
sku_h = "SKU(skuId)"
|
||||
merged = [
|
||||
{
|
||||
sku_h: "111",
|
||||
"detail_category_path": "食品饮料 > 休闲食品 > 饼干 > 粗粮饼干",
|
||||
"标题(wareName)": "A饼",
|
||||
"detail_shop_name": "店甲",
|
||||
},
|
||||
]
|
||||
scen = (("早餐/代餐", ("早餐",)),)
|
||||
fb = jcr._consumer_feedback_by_matrix_group(
|
||||
merged_rows=merged,
|
||||
comment_rows=[
|
||||
{"sku": "111", "tagCommentContent": "早上当早餐吃还不错"},
|
||||
],
|
||||
sku_header=sku_h,
|
||||
)
|
||||
pl = jcr.build_scenario_groups_llm_payload(
|
||||
feedback_groups=fb,
|
||||
scenario_groups=scen,
|
||||
merged_rows=merged,
|
||||
sku_header=sku_h,
|
||||
title_h="标题(wareName)",
|
||||
)
|
||||
self.assertIn("groups", pl)
|
||||
self.assertIn("scenario_lexicon", pl)
|
||||
g0 = pl["groups"][0]
|
||||
self.assertEqual(g0["group"], "饼干")
|
||||
self.assertEqual(g0["effective_text_count"], 1)
|
||||
self.assertEqual(g0["scenario_distribution"][0]["mention_rows"], 1)
|
||||
self.assertEqual(
|
||||
g0["scenario_distribution"][0]["scenario"], "早餐/代餐"
|
||||
)
|
||||
|
||||
def test_cn_volume_int_parses_total_sales_trailer(self) -> None:
|
||||
self.assertEqual(
|
||||
_cn_volume_int("已售50万+ | good:99%好评"), 500_000
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from pipeline.llm.keyword_suggest import _parse_phrases_object, _parse_scenarios_object
|
||||
from pipeline.llm.keyword_suggest import _parse_phrases_object
|
||||
|
||||
|
||||
class ParsePhrasesTests(unittest.TestCase):
|
||||
@ -16,20 +16,5 @@ class ParsePhrasesTests(unittest.TestCase):
|
||||
self.assertEqual(_parse_phrases_object(raw), ["低糖"])
|
||||
|
||||
|
||||
class ParseScenariosTests(unittest.TestCase):
|
||||
def test_min_triggers_in_parser(self) -> None:
|
||||
raw = '{"scenarios": [{"label": "早餐", "triggers": ["早上"]}]}'
|
||||
out = _parse_scenarios_object(raw)
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["label"], "早餐")
|
||||
self.assertEqual(out[0]["triggers"], ["早上"])
|
||||
|
||||
def test_fenced(self) -> None:
|
||||
raw = '```\n{"scenarios": [{"label": "露营", "triggers": ["户外", "野餐"]}]}\n```'
|
||||
out = _parse_scenarios_object(raw)
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["label"], "露营")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
"""Markdown → docx/pdf 导出(防回归:docx 主循环须递增行指针)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from docx import Document
|
||||
|
||||
from pipeline.reporting.md_document_export import (
|
||||
markdown_to_docx_bytes,
|
||||
@ -21,3 +24,35 @@ class MdDocumentExportTests(SimpleTestCase):
|
||||
data = markdown_to_pdf_bytes(md)
|
||||
self.assertGreater(len(data), 100)
|
||||
self.assertTrue(data.startswith(b"%PDF"))
|
||||
|
||||
def test_docx_merges_soft_line_breaks_in_paragraph(self) -> None:
|
||||
"""模型/编辑器折行不应被当成多个独立段落。"""
|
||||
md = "统一商详第\n1 屏核心话术\n\n下一段"
|
||||
data = markdown_to_docx_bytes(md)
|
||||
doc = Document(io.BytesIO(data))
|
||||
texts = [p.text.strip() for p in doc.paragraphs if p.text.strip()]
|
||||
self.assertEqual(texts, ["统一商详第 1 屏核心话术", "下一段"])
|
||||
|
||||
def test_docx_task_list_strips_prefix_uses_normal_bullet(self) -> None:
|
||||
"""``- [x]`` / ``- [ ]`` 去掉方括号标记,用普通列表符号,观感接近 MD 预览。"""
|
||||
md = "- [x] 卡腰:围绕中位。\n- [ ] 贴顶:高位。\n- 普通列表项"
|
||||
data = markdown_to_docx_bytes(md)
|
||||
doc = Document(io.BytesIO(data))
|
||||
texts = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
joined = "\n".join(texts)
|
||||
self.assertNotIn("☑", joined)
|
||||
self.assertNotIn("☐", joined)
|
||||
self.assertNotIn("[x]", joined)
|
||||
self.assertNotIn("[ ]", joined)
|
||||
self.assertIn("卡腰:围绕中位", joined)
|
||||
self.assertIn("贴顶:高位", joined)
|
||||
self.assertTrue(any("普通列表项" in t for t in texts))
|
||||
|
||||
def test_docx_table_skips_long_dash_separator_row(self) -> None:
|
||||
"""`|----------|` 分隔行不得作为表格正文行导出。"""
|
||||
md = "| 差异点 | 说明 |\n|----------|----------|\n| A | B |"
|
||||
data = markdown_to_docx_bytes(md)
|
||||
doc = Document(io.BytesIO(data))
|
||||
self.assertEqual(len(doc.tables), 1)
|
||||
self.assertEqual(len(doc.tables[0].rows), 2)
|
||||
self.assertNotIn("----------", doc.tables[0].rows[1].cells[0].text)
|
||||
|
||||
27
backend/pipeline/tests/test_price_promo_strategy_brief_cn.py
Normal file
27
backend/pipeline/tests/test_price_promo_strategy_brief_cn.py
Normal 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)
|
||||
@ -1,14 +1,40 @@
|
||||
"""市场策略草稿 Markdown(规则,无 LLM)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from pipeline.llm.generate_strategy import _omit_ch8_probe_wordchart_fields
|
||||
from pipeline.llm.generate_strategy import sanitize_strategy_s21_pain_column_md
|
||||
from pipeline.llm.generate_strategy import strategy_decisions_substantive
|
||||
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
|
||||
|
||||
|
||||
class StrategyDraftTests(SimpleTestCase):
|
||||
def test_sanitize_s21_pain_column_strips_partial_competitor(self) -> None:
|
||||
md = """## 二、产品价值与用户痛点
|
||||
|
||||
### 2.1 针对痛点要怎么做
|
||||
|
||||
| 类目/细类(本决策适用) | 用户痛点(简述) | 策略动作 | 具体怎么做 | 如何验证 |
|
||||
|------------------------|----------------|----------|------------|----------|
|
||||
| 粗粮饼干 | 用户希望在控糖的同时获得良好的饱腹感与口感,但部分竞品口感偏硬或缺乏层次感 | A | B | C |
|
||||
| 酥性饼干 | 部分竞品包装差 | 推小包装 | D | E |
|
||||
|
||||
## 三、为什么要买
|
||||
"""
|
||||
out = sanitize_strategy_s21_pain_column_md(md)
|
||||
self.assertNotIn("部分竞品", out)
|
||||
self.assertIn("控糖", out)
|
||||
self.assertIn("良好的饱腹感", out)
|
||||
# 起首以「部分竞品…」时整格收敛为典型场景假设占位
|
||||
self.assertIn("典型场景假设", out)
|
||||
|
||||
def test_sanitize_s21_passthrough_without_section2(self) -> None:
|
||||
self.assertEqual("## 一\nx", sanitize_strategy_s21_pain_column_md("## 一\nx"))
|
||||
|
||||
def test_strategy_decisions_substantive(self) -> None:
|
||||
self.assertFalse(strategy_decisions_substantive(None))
|
||||
self.assertFalse(strategy_decisions_substantive({}))
|
||||
@ -36,13 +62,36 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
md = build_strategy_draft_markdown(
|
||||
job_id=7,
|
||||
keyword="K",
|
||||
brief=brief,
|
||||
brief={
|
||||
**brief,
|
||||
"category_mix_top": [
|
||||
{"label": "粗粮饼干", "count": 11},
|
||||
{"label": "酥性饼干", "count": 10},
|
||||
],
|
||||
"pc_search_raw": {"result_count_consensus": 333619},
|
||||
"price_stats": {
|
||||
"n": 21,
|
||||
"min": 14.38,
|
||||
"max": 64.97,
|
||||
"median": 27.97,
|
||||
},
|
||||
},
|
||||
generated_at_iso="2026-01-01",
|
||||
for_llm_input=True,
|
||||
strategy_decisions={"positioning_choice": "mid"},
|
||||
)
|
||||
self.assertNotIn("任务 ID", md)
|
||||
self.assertNotIn("generate_strategy.py", md)
|
||||
self.assertNotIn("strategy_hints", md)
|
||||
self.assertNotIn("333619", md)
|
||||
self.assertNotIn("27.97", md)
|
||||
self.assertNotIn("价位阵地取向(表单勾选", md)
|
||||
self.assertNotIn("- [ ] **贴顶**", md)
|
||||
self.assertNotIn("- [ ] 锁定主推款", md)
|
||||
self.assertNotIn("类目结构(摘录)", md)
|
||||
self.assertNotIn("粗粮饼干", md)
|
||||
self.assertIn("**评论与归纳口径**", md)
|
||||
self.assertIn("当前表单取向为卡腰", md)
|
||||
self.assertIn("监测摘要自动线索", md)
|
||||
self.assertIn("列表页约第 1~3 页", md)
|
||||
self.assertNotIn("埋伏笔", md)
|
||||
@ -50,7 +99,7 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
self.assertNotIn("回扣 §2", md)
|
||||
self.assertNotIn("(占位)", md)
|
||||
self.assertIn("### 1.3 本品聚焦\n", md)
|
||||
self.assertIn("### 5.2 差异化方向\n", md)
|
||||
self.assertIn("### 5.1 差异化方向\n", md)
|
||||
|
||||
def test_build_contains_sections_and_notes(self) -> None:
|
||||
brief = {
|
||||
@ -122,21 +171,24 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
self.assertIn("**本品角色**:追赶型", md)
|
||||
self.assertIn("**营销策略**:内容种草+搜索承接", md)
|
||||
self.assertIn("**总体策略**:先腰后顶", md)
|
||||
self.assertIn("- [x] **卡腰**", md)
|
||||
self.assertIn("- [ ] **贴顶**", md)
|
||||
self.assertIn("**卡腰**", md)
|
||||
i4 = md.find("## 四、为什么要选")
|
||||
i5 = md.find("## 五、与其它品牌")
|
||||
self.assertGreater(i5, i4)
|
||||
self.assertNotIn("贴顶", md[i4:i5])
|
||||
i82 = md.find("### 8.2 定价策略")
|
||||
self.assertGreater(i82, 0)
|
||||
self.assertGreater(md.find("- [x] **卡腰**"), i82)
|
||||
self.assertGreater(md.find("卡腰", i82), i82)
|
||||
self.assertNotIn("- [ ] **贴顶**", md)
|
||||
self.assertIn("侧翼切入", md)
|
||||
self.assertIn("做低糖配方", md)
|
||||
self.assertIn("### 7.1 品牌建设", md)
|
||||
self.assertIn("- [x] 评论侧归纳是否以偏概全", md)
|
||||
self.assertIn("- [ ] 价格带是否含大促", md)
|
||||
self.assertIn("- [x] 列表集中度与深入样本品牌是否不一致", md)
|
||||
self.assertIn("**评论与归纳口径**", md)
|
||||
self.assertIn("**价格带与清洗规则**", md)
|
||||
self.assertIn("**列表曝光与深入样本**", md)
|
||||
self.assertIn("业务已在表单中勾选", md)
|
||||
self.assertNotIn("- [x] 评论侧", md)
|
||||
self.assertNotIn("- [ ] 锁定主推款", md)
|
||||
|
||||
def test_chapter8_probe_omits_focus_scenario_count_bullets(self) -> None:
|
||||
brief = {
|
||||
@ -185,8 +237,22 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
self.assertNotIn("子串统计命中约 **501**", md)
|
||||
self.assertNotIn("场景「控糖", md)
|
||||
|
||||
def test_matrix_scope_concentration_not_list_rows_wording(self) -> None:
|
||||
"""收窄矩阵时 concentration 来自分组内 SKU,§5.1 勿写「列表行」。"""
|
||||
def test_download_draft_omits_category_mix_excerpt(self) -> None:
|
||||
md = build_strategy_draft_markdown(
|
||||
job_id=1,
|
||||
keyword="K",
|
||||
brief={
|
||||
"schema_version": 1,
|
||||
"batch_label": "b",
|
||||
"category_mix_top": [{"label": "粗粮饼干", "count": 11}],
|
||||
},
|
||||
for_llm_input=False,
|
||||
)
|
||||
self.assertNotIn("类目结构(摘录)", md)
|
||||
self.assertNotIn("粗粮饼干", md)
|
||||
|
||||
def test_section_five_omits_concentration_excerpt_block(self) -> None:
|
||||
"""规则骨架不再含「对比对象(摘录)」及店铺/品牌集中度铺陈。"""
|
||||
brief = {
|
||||
"schema_version": 1,
|
||||
"keyword": "低GI",
|
||||
@ -205,13 +271,14 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
},
|
||||
}
|
||||
md = build_strategy_draft_markdown(job_id=1, keyword="低GI", brief=brief)
|
||||
self.assertIn("与全关键词 **PC 搜索列表行** 集中度**不是同一口径**", md)
|
||||
self.assertIn("店铺分布(「饼干」内样本 SKU)", md)
|
||||
self.assertIn("该分组样本 SKU 的", md)
|
||||
self.assertNotIn("列表侧店铺集中度", md)
|
||||
self.assertNotIn("第一大店铺约占列表行的", md)
|
||||
self.assertNotIn("对比对象(摘录)", md)
|
||||
self.assertNotIn("店铺分布(「饼干」内样本 SKU)", md)
|
||||
self.assertNotIn("碧翠园京东自营旗舰店", md)
|
||||
self.assertNotIn("23.8%", md)
|
||||
self.assertIn("### 5.1 差异化方向", md)
|
||||
self.assertIn("### 5.2 竞争应对", md)
|
||||
|
||||
def test_shops_unique_sku_basis_rendered(self) -> None:
|
||||
def test_concentration_not_rendered_even_with_unique_sku_basis(self) -> None:
|
||||
brief = {
|
||||
"schema_version": 1,
|
||||
"keyword": "测试",
|
||||
@ -235,9 +302,8 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
keyword="测试",
|
||||
brief=brief,
|
||||
)
|
||||
self.assertIn("按去重 SKU", md)
|
||||
self.assertIn("120", md)
|
||||
self.assertIn("35.0%", md)
|
||||
self.assertNotIn("按去重 SKU", md)
|
||||
self.assertNotIn("120", md)
|
||||
|
||||
def test_chapter8_probe_filters_strategy_hints_focus_scenario_lines(self) -> None:
|
||||
brief = {
|
||||
@ -259,8 +325,7 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
self.assertNotIn("评价文本中「口感", md)
|
||||
self.assertNotIn("用途/场景中「控糖", md)
|
||||
self.assertIn("样本内品牌较分散", md)
|
||||
self.assertIn("price_promotion_signals", md)
|
||||
self.assertIn("price_promotion_signals", md)
|
||||
self.assertIn("促销线索须与摘要中的价格/活动信号一致", md)
|
||||
|
||||
def test_ch8_probe_omit_wordchart_nested_in_consumer_feedback(self) -> None:
|
||||
compact = {
|
||||
@ -286,3 +351,80 @@ class StrategyDraftTests(SimpleTestCase):
|
||||
self.assertEqual(
|
||||
compact["consumer_feedback_by_matrix_group"][0].get("comment_rows"), 10
|
||||
)
|
||||
|
||||
def test_strategy_decisions_full_lowgi_biscuit_fixture(self) -> None:
|
||||
from pipeline.demos.run_strategy_decisions_full_fixture_demo import load_full_fixture
|
||||
from pipeline.llm.generate_strategy import strategy_decisions_substantive
|
||||
from pipeline.reporting.strategy_draft import build_strategy_draft_markdown
|
||||
|
||||
sd = load_full_fixture()
|
||||
self.assertTrue(strategy_decisions_substantive(sd))
|
||||
self.assertIn("tactic_promotion", sd)
|
||||
self.assertIn("满 99", (sd.get("tactic_promotion") or ""))
|
||||
brief = {
|
||||
"schema_version": 1,
|
||||
"keyword": "低GI饼干",
|
||||
"batch_label": "demo_fixture",
|
||||
"scope": {"merged_sku_count": 2},
|
||||
"strategy_hints": ["fixture 联调"],
|
||||
"meta": {"page_start": 1, "page_to": 3, "max_skus_config": 100},
|
||||
"category_mix_top": [
|
||||
{"label": "粗粮饼干", "count": 11},
|
||||
],
|
||||
"pc_search_raw": {"result_count_consensus": 100000},
|
||||
"price_stats": {"n": 10, "min": 1, "max": 99, "median": 30},
|
||||
}
|
||||
md = build_strategy_draft_markdown(
|
||||
job_id=0,
|
||||
keyword="低GI饼干",
|
||||
brief=brief,
|
||||
business_notes="",
|
||||
generated_at_iso="2026-01-01T00:00:00+00:00",
|
||||
strategy_decisions=sd,
|
||||
for_llm_input=False,
|
||||
)
|
||||
self.assertIn("表单促销策略", md)
|
||||
self.assertIn("满 99", md)
|
||||
self.assertIn("卡位监测中位", md)
|
||||
|
||||
def test_strategy_decision_field_names_match_strategy_draft_serializer(self) -> None:
|
||||
from pipeline.serializers import StrategyDraftRequestSerializer
|
||||
from pipeline.strategy_decision_keys import (
|
||||
STRATEGY_DECISION_FIELD_NAMES,
|
||||
STRATEGY_DRAFT_POST_NON_DECISION_FIELD_NAMES,
|
||||
)
|
||||
|
||||
ser = StrategyDraftRequestSerializer()
|
||||
names = set(ser.fields.keys()) - STRATEGY_DRAFT_POST_NON_DECISION_FIELD_NAMES
|
||||
self.assertEqual(
|
||||
names,
|
||||
set(STRATEGY_DECISION_FIELD_NAMES),
|
||||
"序列化器与 strategy_decision_keys 不一致:增删字段时须同时改 strategy_decision_keys 与 demo fixture",
|
||||
)
|
||||
|
||||
def test_empty_strategy_decisions_has_all_21_keys(self) -> None:
|
||||
from pipeline.strategy_decision_keys import (
|
||||
STRATEGY_DECISION_FIELD_NAMES,
|
||||
build_strategy_decisions_dict,
|
||||
)
|
||||
|
||||
d = build_strategy_decisions_dict({})
|
||||
self.assertEqual(len(STRATEGY_DECISION_FIELD_NAMES), 21)
|
||||
self.assertEqual(set(d.keys()), set(STRATEGY_DECISION_FIELD_NAMES))
|
||||
self.assertEqual(d["tactic_promotion"], "")
|
||||
self.assertFalse(d["ack_risk_keywords"])
|
||||
|
||||
def test_strategy_draft_request_full_fixture_matches_serializer(self) -> None:
|
||||
"""``strategy_draft_request_full_*.json`` 含 HTTP POST 全字段(含 generator / 矩阵作用域等)。"""
|
||||
from pipeline.serializers import StrategyDraftRequestSerializer
|
||||
from pipeline.strategy_decision_keys import build_strategy_decisions_dict
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
p = root / "demos" / "fixtures" / "strategy_draft_request_full_lowgi_biscuit.json"
|
||||
self.assertTrue(p.is_file(), f"缺少固定样例: {p}")
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
ser = StrategyDraftRequestSerializer(data=data)
|
||||
self.assertTrue(ser.is_valid(), ser.errors)
|
||||
sd = build_strategy_decisions_dict(ser.validated_data)
|
||||
only = {k: v for k, v in data.items() if k in sd}
|
||||
self.assertEqual(sd, only)
|
||||
|
||||
@ -35,6 +35,7 @@ from ..reporting.report_matrix_group_evidence import (
|
||||
)
|
||||
from ..reporting.report_strategy_excerpt import load_report_strategy_excerpt
|
||||
from ..reporting.strategy_draft import build_strategy_draft_markdown
|
||||
from ..strategy_decision_keys import build_strategy_decisions_dict
|
||||
from ..serializers import (
|
||||
MarketingDetailPackRequestSerializer,
|
||||
PipelineJobSerializer,
|
||||
@ -139,28 +140,7 @@ class JobStrategyDraftView(APIView):
|
||||
ser.is_valid(raise_exception=True)
|
||||
vd = ser.validated_data
|
||||
notes = (vd.get("business_notes") or "").strip()
|
||||
strategy_decisions = {
|
||||
"product_role": vd.get("product_role") or "",
|
||||
"stage_goal_type": vd.get("stage_goal_type") or "",
|
||||
"time_horizon": vd.get("time_horizon") or "",
|
||||
"success_criteria": vd.get("success_criteria") or "",
|
||||
"non_goals": vd.get("non_goals") or "",
|
||||
"battlefield_one_line": vd.get("battlefield_one_line") or "",
|
||||
"positioning_choice": vd.get("positioning_choice") or "",
|
||||
"competitive_stance": vd.get("competitive_stance") or "",
|
||||
"pillar_product": vd.get("pillar_product") or "",
|
||||
"pillar_price": vd.get("pillar_price") or "",
|
||||
"pillar_channel": vd.get("pillar_channel") or "",
|
||||
"pillar_comm": vd.get("pillar_comm") or "",
|
||||
"audience_segment": vd.get("audience_segment") or "",
|
||||
"competitor_reference": vd.get("competitor_reference") or "",
|
||||
"resource_notes": vd.get("resource_notes") or "",
|
||||
"marketing_strategy": vd.get("marketing_strategy") or "",
|
||||
"general_strategy": vd.get("general_strategy") or "",
|
||||
"ack_risk_keywords": bool(vd.get("ack_risk_keywords")),
|
||||
"ack_risk_price": bool(vd.get("ack_risk_price")),
|
||||
"ack_risk_concentration": bool(vd.get("ack_risk_concentration")),
|
||||
}
|
||||
strategy_decisions = build_strategy_decisions_dict(vd)
|
||||
try:
|
||||
brief = build_competitor_brief_for_job(
|
||||
job.run_dir,
|
||||
|
||||
@ -62,10 +62,8 @@
|
||||
"n": 117,
|
||||
"median": 16.9
|
||||
},
|
||||
"comment_focus_keywords": [{ "word": "口感", "count": 48 }],
|
||||
"usage_scenarios": [
|
||||
{ "scenario": "控糖/血糖相关", "count": 12, "share_of_text_units": 0.15 }
|
||||
],
|
||||
"comment_focus_keywords": [],
|
||||
"usage_scenarios": [],
|
||||
"strategy_hints": ["样本内…(待验证)"],
|
||||
"matrix_by_group": [
|
||||
{
|
||||
@ -91,19 +89,14 @@
|
||||
"consumer_feedback_by_matrix_group": [
|
||||
{
|
||||
"group": "饼干",
|
||||
"matrix_group_index": 0,
|
||||
"chart_slug": "i00_饼干",
|
||||
"comment_rows": 40,
|
||||
"focus_keyword_hits": [{ "word": "口感", "count": 10 }],
|
||||
"scenarios_top": [
|
||||
{
|
||||
"scenario": "早餐/代餐",
|
||||
"count": 5,
|
||||
"share_of_text_units": 0.2
|
||||
}
|
||||
]
|
||||
"effective_comment_text_units": 38
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"与在线分析报告各章计数规则一致;主题词与场景为预设词表,非 NLP 主题模型。",
|
||||
"与在线分析报告各章计数规则一致;评论侧主题以第八章文本挖掘(若启用)及语义归纳为准,不再以预设子串词表为主指标。",
|
||||
"价格来自展示字段抽取,含促销与规格差异。"
|
||||
]
|
||||
}
|
||||
|
||||
@ -7,18 +7,9 @@ defineEmits(['add-market', 'remove-market'])
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="rc-section">
|
||||
<h4 class="rc-subtitle">1. 第八章评论分析</h4>
|
||||
<p class="rc-help">
|
||||
报告<strong>不再</strong>使用「预设关注词 / 预设场景词组」子串统计。请在报告配置(高级 JSON 或接口)中维护
|
||||
<code>chapter8_text_mining_probe</code>
|
||||
等开关,以生成开放词表、词频与共现等文本挖掘内容。可选
|
||||
<code>llm_comment_sentiment</code>:按矩阵细类分别调用模型,在报告<strong>8.3</strong>生成「正/负向主题」归纳,与探针及「细类评论要点归纳」并列、互不替代。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rc-section">
|
||||
<h4 class="rc-subtitle">2. 外部市场信息(可选)</h4>
|
||||
<h4 class="rc-subtitle">1. 外部市场信息(可选)</h4>
|
||||
<p class="rc-help">若手边有第三方市场规模、增速等摘录,可填在表里,报告会多一节说明;不需要可整表留空。</p>
|
||||
<div class="rc-market-wrap">
|
||||
<table class="rc-market">
|
||||
|
||||
@ -1,45 +1,47 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 触发词分隔:逗号、顿号、中文逗号、换行 */
|
||||
const TRIGGER_SPLIT = /[,,、\n\r]+/u
|
||||
|
||||
function splitTriggers(text) {
|
||||
if (!text || typeof text !== 'string') return []
|
||||
return text
|
||||
.split(TRIGGER_SPLIT)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单未展示的大模型/细类归纳等布尔项:从任务读入后在「保存」时原样写回,避免误清空。
|
||||
* 表单未单独展示的布尔项:从任务读入后在「保存」时原样写回,避免误清空。
|
||||
* (与 backend ``validate_report_config_body`` 允许的键一致。)
|
||||
*/
|
||||
const REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS = [
|
||||
'llm_comment_sentiment',
|
||||
'llm_matrix_group_summaries',
|
||||
'llm_price_group_summaries',
|
||||
'llm_promo_group_summaries',
|
||||
'llm_strategy_opportunities',
|
||||
'llm_comment_group_summaries',
|
||||
'llm_scenario_group_summaries',
|
||||
'llm_group_summaries_chunk_by_matrix',
|
||||
'chapter8_text_mining_probe',
|
||||
'chapter8_text_mining_probe_live_llm',
|
||||
'chapter8_text_mining_probe_llm_chunked',
|
||||
'chapter8_text_mining_probe_wordcloud',
|
||||
]
|
||||
|
||||
const REPORT_CONFIG_PASSTHROUGH_INT_KEYS = [
|
||||
'chapter8_probe_min_texts',
|
||||
'chapter8_probe_lda_topics',
|
||||
'chapter8_probe_top_k_words',
|
||||
'chapter8_probe_cooc_vocab',
|
||||
'chapter8_probe_cooc_pairs',
|
||||
'chapter8_probe_wordcloud_max',
|
||||
]
|
||||
|
||||
/**
|
||||
* 报告调参表单(与后端 report_config 字段对应),面向非技术用户。
|
||||
*/
|
||||
export function useReportConfigForm() {
|
||||
const focusWordRows = ref([{ text: '' }])
|
||||
const scenarioGroups = ref([{ label: '', triggersText: '' }])
|
||||
const marketRows = ref([
|
||||
{ indicator: '', value_and_scope: '', source: '', year: '' },
|
||||
])
|
||||
/** 表单未编辑的布尔项,从任务配置读入后随保存写回 */
|
||||
/** 表单未编辑的项,从任务配置读入后随保存写回 */
|
||||
const passthroughBools = ref({})
|
||||
const passthroughInts = ref({})
|
||||
|
||||
function resetToEmpty() {
|
||||
focusWordRows.value = [{ text: '' }]
|
||||
scenarioGroups.value = [{ label: '', triggersText: '' }]
|
||||
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
|
||||
passthroughBools.value = {}
|
||||
passthroughInts.value = {}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,40 +53,6 @@ export function useReportConfigForm() {
|
||||
return
|
||||
}
|
||||
|
||||
const w = cfg.comment_focus_words
|
||||
if (Array.isArray(w) && w.length) {
|
||||
focusWordRows.value = w
|
||||
.map((x) => ({ text: String(x ?? '').trim() }))
|
||||
.filter((r) => r.text)
|
||||
if (!focusWordRows.value.length) focusWordRows.value = [{ text: '' }]
|
||||
} else {
|
||||
focusWordRows.value = [{ text: '' }]
|
||||
}
|
||||
|
||||
const sg = cfg.comment_scenario_groups
|
||||
if (Array.isArray(sg) && sg.length) {
|
||||
scenarioGroups.value = sg.map((item) => {
|
||||
let label = ''
|
||||
let triggers = []
|
||||
if (Array.isArray(item) && item.length >= 2) {
|
||||
label = String(item[0] ?? '').trim()
|
||||
const tr = item[1]
|
||||
triggers = Array.isArray(tr) ? tr.map((t) => String(t ?? '').trim()).filter(Boolean) : []
|
||||
} else if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
label = String(item.label ?? '').trim()
|
||||
const tr = item.triggers
|
||||
triggers = Array.isArray(tr) ? tr.map((t) => String(t ?? '').trim()).filter(Boolean) : []
|
||||
}
|
||||
return {
|
||||
label,
|
||||
triggersText: triggers.join('、'),
|
||||
}
|
||||
})
|
||||
if (!scenarioGroups.value.length) scenarioGroups.value = [{ label: '', triggersText: '' }]
|
||||
} else {
|
||||
scenarioGroups.value = [{ label: '', triggersText: '' }]
|
||||
}
|
||||
|
||||
const er = cfg.external_market_table_rows
|
||||
if (Array.isArray(er) && er.length) {
|
||||
marketRows.value = er.map((row) => {
|
||||
@ -110,33 +78,26 @@ export function useReportConfigForm() {
|
||||
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
|
||||
}
|
||||
|
||||
const pass = {}
|
||||
const passB = {}
|
||||
for (const k of REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, k)) pass[k] = Boolean(cfg[k])
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, k)) passB[k] = Boolean(cfg[k])
|
||||
}
|
||||
passthroughBools.value = pass
|
||||
passthroughBools.value = passB
|
||||
|
||||
const passI = {}
|
||||
for (const k of REPORT_CONFIG_PASSTHROUGH_INT_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(cfg, k)) {
|
||||
const v = cfg[k]
|
||||
if (typeof v === 'number' && Number.isFinite(v)) passI[k] = Math.trunc(v)
|
||||
}
|
||||
}
|
||||
passthroughInts.value = passI
|
||||
}
|
||||
|
||||
/** @returns {Record<string, unknown>} 可 PATCH 到后端的 report_config;全空则为 {} */
|
||||
function buildPayload() {
|
||||
const out = {}
|
||||
|
||||
const words = focusWordRows.value.map((r) => (r.text || '').trim()).filter(Boolean)
|
||||
if (words.length) out.comment_focus_words = words
|
||||
|
||||
const groups = scenarioGroups.value
|
||||
.map((g) => ({
|
||||
label: (g.label || '').trim(),
|
||||
triggers: splitTriggers(g.triggersText || ''),
|
||||
}))
|
||||
.filter((g) => g.label && g.triggers.length)
|
||||
if (groups.length) {
|
||||
out.comment_scenario_groups = groups.map((g) => ({
|
||||
label: g.label,
|
||||
triggers: g.triggers,
|
||||
}))
|
||||
}
|
||||
|
||||
const rows = marketRows.value
|
||||
.map((r) => ({
|
||||
indicator: (r.indicator || '').trim(),
|
||||
@ -155,28 +116,10 @@ export function useReportConfigForm() {
|
||||
}
|
||||
|
||||
Object.assign(out, passthroughBools.value)
|
||||
Object.assign(out, passthroughInts.value)
|
||||
return out
|
||||
}
|
||||
|
||||
function addFocusRow() {
|
||||
focusWordRows.value.push({ text: '' })
|
||||
}
|
||||
function removeFocusRow(i) {
|
||||
if (focusWordRows.value.length > 1) focusWordRows.value.splice(i, 1)
|
||||
else focusWordRows.value[0].text = ''
|
||||
}
|
||||
|
||||
function addScenarioRow() {
|
||||
scenarioGroups.value.push({ label: '', triggersText: '' })
|
||||
}
|
||||
function removeScenarioRow(i) {
|
||||
if (scenarioGroups.value.length > 1) scenarioGroups.value.splice(i, 1)
|
||||
else {
|
||||
scenarioGroups.value[0].label = ''
|
||||
scenarioGroups.value[0].triggersText = ''
|
||||
}
|
||||
}
|
||||
|
||||
function addMarketRow() {
|
||||
marketRows.value.push({
|
||||
indicator: '',
|
||||
@ -197,17 +140,12 @@ export function useReportConfigForm() {
|
||||
}
|
||||
|
||||
return {
|
||||
focusWordRows,
|
||||
scenarioGroups,
|
||||
marketRows,
|
||||
passthroughBools,
|
||||
passthroughInts,
|
||||
resetToEmpty,
|
||||
applyFromApiConfig,
|
||||
buildPayload,
|
||||
addFocusRow,
|
||||
removeFocusRow,
|
||||
addScenarioRow,
|
||||
removeScenarioRow,
|
||||
addMarketRow,
|
||||
removeMarketRow,
|
||||
}
|
||||
|
||||
37
frontend/src/lib/analysisViewStorage.js
Normal file
37
frontend/src/lib/analysisViewStorage.js
Normal 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 */
|
||||
}
|
||||
}
|
||||
62
frontend/src/lib/marketingDetailPackStorage.js
Normal file
62
frontend/src/lib/marketingDetailPackStorage.js
Normal 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 {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
85
frontend/src/stores/jobs.js
Normal file
85
frontend/src/stores/jobs.js
Normal 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())
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import { RouterLink } from 'vue-router'
|
||||
import ReportConfigFormFields from '../../components/ReportConfigFormFields.vue'
|
||||
import { refreshJobs, useJobs, api, reportConfigDefaultsUrl } from '../../composables/useJobs'
|
||||
import { useJobStore } from '../../stores/jobs'
|
||||
import { useReportConfigForm } from '../../composables/useReportConfigForm'
|
||||
|
||||
const { jobs } = useJobs()
|
||||
@ -32,19 +33,8 @@ const regenBusyOtherTask = computed(
|
||||
() => regenPendingJobId.value != null && regenPendingJobId.value !== selectedId.value,
|
||||
)
|
||||
|
||||
const {
|
||||
focusWordRows,
|
||||
scenarioGroups,
|
||||
marketRows,
|
||||
applyFromApiConfig,
|
||||
buildPayload,
|
||||
addFocusRow,
|
||||
removeFocusRow,
|
||||
addScenarioRow,
|
||||
removeScenarioRow,
|
||||
addMarketRow,
|
||||
removeMarketRow,
|
||||
} = useReportConfigForm()
|
||||
const { marketRows, applyFromApiConfig, buildPayload, addMarketRow, removeMarketRow } =
|
||||
useReportConfigForm()
|
||||
|
||||
const reportConfigErr = ref('')
|
||||
const reportConfigSaveLoading = ref(false)
|
||||
@ -113,8 +103,7 @@ async function saveReportConfigToJob() {
|
||||
return
|
||||
}
|
||||
const updated = JSON.parse(text)
|
||||
const idx = jobs.value.findIndex((x) => x.id === updated.id)
|
||||
if (idx >= 0) jobs.value[idx] = updated
|
||||
useJobStore().mergeJob(updated)
|
||||
syncReportConfigFromJob(updated)
|
||||
} catch (e) {
|
||||
reportConfigErr.value = String(e)
|
||||
@ -182,8 +171,7 @@ async function regenerateReport() {
|
||||
return
|
||||
}
|
||||
const updated = JSON.parse(text)
|
||||
const idx = jobs.value.findIndex((x) => x.id === updated.id)
|
||||
if (idx >= 0) jobs.value[idx] = updated
|
||||
useJobStore().mergeJob(updated)
|
||||
})
|
||||
} catch (e) {
|
||||
regenErr.value = String(e)
|
||||
@ -200,8 +188,7 @@ watch(selectedId, async () => {
|
||||
const r = await api(`/api/jobs/${id}/`)
|
||||
if (r.ok) {
|
||||
const j = await r.json()
|
||||
const idx = jobs.value.findIndex((x) => x.id === j.id)
|
||||
if (idx >= 0) jobs.value[idx] = j
|
||||
useJobStore().mergeJob(j)
|
||||
syncReportConfigFromJob(j)
|
||||
}
|
||||
} catch {
|
||||
@ -272,11 +259,7 @@ watch(
|
||||
</p>
|
||||
|
||||
<div v-if="selectedId" class="report-config-block">
|
||||
<h3 class="report-config-title">报告里的评价统计怎么算</h3>
|
||||
<p class="hint-top report-config-hint">
|
||||
关注词、场景词组、外部市场表等<strong>可以不改</strong>:留空并保存即沿用内置规则。大模型相关布尔项(如
|
||||
<code>llm_comment_sentiment</code>)不再单独占勾选框:若任务里已有,会在保存时保留;要改请展开「高级 JSON」。
|
||||
</p>
|
||||
<h3 class="report-config-title">报告配置</h3>
|
||||
<div class="report-config-actions">
|
||||
<button
|
||||
type="button"
|
||||
@ -297,13 +280,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<ReportConfigFormFields
|
||||
:focus-word-rows="focusWordRows"
|
||||
:scenario-groups="scenarioGroups"
|
||||
:market-rows="marketRows"
|
||||
@add-focus="addFocusRow"
|
||||
@remove-focus="removeFocusRow"
|
||||
@add-scenario="addScenarioRow"
|
||||
@remove-scenario="removeScenarioRow"
|
||||
@add-market="addMarketRow"
|
||||
@remove-market="removeMarketRow"
|
||||
/>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import MarkdownPreview from '../../components/MarkdownPreview.vue'
|
||||
|
||||
@ -136,6 +136,13 @@ import {
|
||||
generationInFlightKey,
|
||||
withGenerationInFlight,
|
||||
} from '../../composables/useGenerationInFlight'
|
||||
import {
|
||||
analysisBriefCacheKey,
|
||||
analysisReportCacheKey,
|
||||
persistAnalysisBrief,
|
||||
persistAnalysisReportMd,
|
||||
} from '../../lib/analysisViewStorage'
|
||||
import { useJobStore } from '../../stores/jobs'
|
||||
|
||||
const { jobs } = useJobs()
|
||||
const selectedId = ref('')
|
||||
@ -148,21 +155,30 @@ const briefErr = ref('')
|
||||
const briefCopyOk = ref(false)
|
||||
const packErr = ref('')
|
||||
const exportDocErr = ref('')
|
||||
/** 正在导出的格式:docx | pdf | null */
|
||||
const exportDocFmt = ref(null)
|
||||
|
||||
const genInFlight = generationInFlightKey()
|
||||
const K_PREVIEW = 'preview-report:'
|
||||
const K_BRIEF = 'competitor-brief:'
|
||||
const K_PACK = 'brief-pack:'
|
||||
const K_EXPORT = 'export-report:'
|
||||
|
||||
function isExporting(fmt) {
|
||||
const id = selectedId.value
|
||||
if (!id) return false
|
||||
return genInFlight.value.includes(`${K_EXPORT}${id}:${fmt}`)
|
||||
}
|
||||
|
||||
async function exportReportFmt(fmt) {
|
||||
const id = selectedId.value
|
||||
if (!id) return
|
||||
exportDocErr.value = ''
|
||||
exportDocFmt.value = fmt
|
||||
try {
|
||||
await exportReportDocument(id, fmt)
|
||||
} catch (e) {
|
||||
exportDocErr.value = String(e?.message || e)
|
||||
} finally {
|
||||
exportDocFmt.value = null
|
||||
}
|
||||
await withGenerationInFlight(`${K_EXPORT}${id}:${fmt}`, async () => {
|
||||
try {
|
||||
await exportReportDocument(id, fmt)
|
||||
} catch (e) {
|
||||
exportDocErr.value = String(e?.message || e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 将 Markdown 中的 report_assets 相对路径转为可访问的 API URL(在线预览插图) */
|
||||
@ -178,10 +194,6 @@ const reportMdForPreview = computed(() =>
|
||||
reportMdWithAssetUrls(reportMd.value, selectedId.value),
|
||||
)
|
||||
|
||||
const genInFlight = generationInFlightKey()
|
||||
const K_PREVIEW = 'preview-report:'
|
||||
const K_BRIEF = 'competitor-brief:'
|
||||
const K_PACK = 'brief-pack:'
|
||||
function genKeyMatches(prefix) {
|
||||
const id = selectedId.value
|
||||
if (!id) return false
|
||||
@ -194,9 +206,15 @@ const viewInFlightOtherJobId = computed(() => {
|
||||
const sid = selectedId.value
|
||||
if (!sid) return null
|
||||
for (const k of genInFlight.value) {
|
||||
const i = k.lastIndexOf(':')
|
||||
if (i < 0) continue
|
||||
const jid = k.slice(i + 1)
|
||||
let jid = null
|
||||
if (k.startsWith(K_PREVIEW)) jid = k.slice(K_PREVIEW.length)
|
||||
else if (k.startsWith(K_BRIEF)) jid = k.slice(K_BRIEF.length)
|
||||
else if (k.startsWith(K_PACK)) jid = k.slice(K_PACK.length)
|
||||
else if (k.startsWith(K_EXPORT)) {
|
||||
const rest = k.slice(K_EXPORT.length)
|
||||
const m = /^(\d+):/.exec(rest)
|
||||
if (m) jid = m[1]
|
||||
}
|
||||
if (jid && jid !== sid) return jid
|
||||
}
|
||||
return null
|
||||
@ -238,7 +256,9 @@ async function loadReport() {
|
||||
}
|
||||
return
|
||||
}
|
||||
reportMd.value = await r.text()
|
||||
const text = await r.text()
|
||||
reportMd.value = text
|
||||
persistAnalysisReportMd(id, text)
|
||||
} catch (e) {
|
||||
err.value = String(e)
|
||||
}
|
||||
@ -268,6 +288,7 @@ async function loadCompetitorBrief() {
|
||||
const j = JSON.parse(text)
|
||||
briefData.value = j
|
||||
briefJson.value = JSON.stringify(j, null, 2)
|
||||
persistAnalysisBrief(id, j)
|
||||
} catch (e) {
|
||||
briefErr.value = String(e)
|
||||
}
|
||||
@ -314,7 +335,31 @@ async function downloadBriefPack() {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
function onAnalysisViewStorage(ev) {
|
||||
if (!ev.key || ev.storageArea !== localStorage) return
|
||||
const sid = selectedId.value
|
||||
if (!sid) return
|
||||
if (ev.key === analysisReportCacheKey(sid) && ev.newValue != null) {
|
||||
reportMd.value = ev.newValue
|
||||
}
|
||||
if (ev.key === analysisBriefCacheKey(sid) && ev.newValue) {
|
||||
try {
|
||||
const j = JSON.parse(ev.newValue)
|
||||
briefData.value = j
|
||||
briefJson.value = JSON.stringify(j, null, 2)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
window.addEventListener('storage', onAnalysisViewStorage)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', onAnalysisViewStorage)
|
||||
})
|
||||
|
||||
watch(selectedId, async () => {
|
||||
briefJson.value = ''
|
||||
@ -327,8 +372,7 @@ watch(selectedId, async () => {
|
||||
const r = await api(`/api/jobs/${id}/`)
|
||||
if (r.ok) {
|
||||
const j = await r.json()
|
||||
const idx = jobs.value.findIndex((x) => x.id === j.id)
|
||||
if (idx >= 0) jobs.value[idx] = j
|
||||
useJobStore().mergeJob(j)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@ -381,18 +425,18 @@ watch(
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || exportDocFmt || loading"
|
||||
:disabled="!selectedId || isExporting('docx') || isExporting('pdf') || loading"
|
||||
@click="exportReportFmt('docx')"
|
||||
>
|
||||
{{ exportDocFmt === 'docx' ? '导出中…' : '导出 Word' }}
|
||||
{{ isExporting('docx') ? '导出中…' : '导出 Word' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || exportDocFmt || loading"
|
||||
:disabled="!selectedId || isExporting('docx') || isExporting('pdf') || loading"
|
||||
@click="exportReportFmt('pdf')"
|
||||
>
|
||||
{{ exportDocFmt === 'pdf' ? '导出中…' : '导出 PDF' }}
|
||||
{{ isExporting('pdf') ? '导出中…' : '导出 PDF' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import JobDatasetModal from '../../components/JobDatasetModal.vue'
|
||||
import { api, refreshJobs, useJobs } from '../../composables/useJobs'
|
||||
import { useJobStore } from '../../stores/jobs'
|
||||
|
||||
const { jobs } = useJobs()
|
||||
const selectedId = ref('')
|
||||
@ -36,8 +37,7 @@ async function refreshSelectedJob() {
|
||||
const r = await api(`/api/jobs/${id}/`)
|
||||
if (r.ok) {
|
||||
const j = await r.json()
|
||||
const idx = jobs.value.findIndex((x) => x.id === j.id)
|
||||
if (idx >= 0) jobs.value[idx] = j
|
||||
useJobStore().mergeJob(j)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, refreshJobs, useJobs, jobConfigHint, jobCancelUrl } from '../../composables/useJobs'
|
||||
import { useJobStore } from '../../stores/jobs'
|
||||
|
||||
const { jobs } = useJobs()
|
||||
const loadError = ref('')
|
||||
@ -48,8 +49,7 @@ async function requestCancel(jobId) {
|
||||
return
|
||||
}
|
||||
const updated = JSON.parse(text)
|
||||
const idx = jobs.value.findIndex((x) => x.id === updated.id)
|
||||
if (idx >= 0) jobs.value[idx] = updated
|
||||
useJobStore().mergeJob(updated)
|
||||
await refreshJobs()
|
||||
} catch (e) {
|
||||
cancelErr.value = String(e)
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
withGenerationInFlight,
|
||||
} from '../../composables/useGenerationInFlight'
|
||||
import {
|
||||
loadStrategyDraftRecord,
|
||||
loadStrategyMatrixScope,
|
||||
saveStrategyDraftRecord,
|
||||
saveStrategyMatrixScope,
|
||||
@ -60,6 +61,7 @@ const decisions = reactive({
|
||||
pillar_price: '',
|
||||
pillar_channel: '',
|
||||
pillar_comm: '',
|
||||
tactic_promotion: '',
|
||||
audience_segment: '',
|
||||
competitor_reference: '',
|
||||
resource_notes: '',
|
||||
@ -108,6 +110,7 @@ function buildPayload() {
|
||||
pillar_price: decisions.pillar_price,
|
||||
pillar_channel: decisions.pillar_channel,
|
||||
pillar_comm: decisions.pillar_comm,
|
||||
tactic_promotion: decisions.tactic_promotion,
|
||||
audience_segment: decisions.audience_segment,
|
||||
competitor_reference: decisions.competitor_reference,
|
||||
resource_notes: decisions.resource_notes,
|
||||
@ -122,6 +125,56 @@ function buildPayload() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 与 backend ``pipeline/strategy_decision_keys.STRATEGY_DECISION_FIELD_NAMES`` 一致 */
|
||||
const SAVED_DECISION_KEYS = [
|
||||
'product_role',
|
||||
'stage_goal_type',
|
||||
'time_horizon',
|
||||
'success_criteria',
|
||||
'non_goals',
|
||||
'battlefield_one_line',
|
||||
'positioning_choice',
|
||||
'competitive_stance',
|
||||
'pillar_product',
|
||||
'pillar_price',
|
||||
'pillar_channel',
|
||||
'pillar_comm',
|
||||
'tactic_promotion',
|
||||
'audience_segment',
|
||||
'competitor_reference',
|
||||
'resource_notes',
|
||||
'marketing_strategy',
|
||||
'general_strategy',
|
||||
'ack_risk_keywords',
|
||||
'ack_risk_price',
|
||||
'ack_risk_concentration',
|
||||
]
|
||||
|
||||
/**
|
||||
* 从本任务上次已保存的「生成请求」恢复表单,使用户决策与成稿/再次提交一致。
|
||||
*/
|
||||
function applyDecisionsFromSavedRecord(jobId) {
|
||||
if (!jobId) return
|
||||
const rec = loadStrategyDraftRecord(String(jobId))
|
||||
const lr = rec?.last_request
|
||||
if (!lr || typeof lr !== 'object') return
|
||||
for (const k of SAVED_DECISION_KEYS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(lr, k)) continue
|
||||
if (k.startsWith('ack_')) {
|
||||
decisions[k] = Boolean(lr[k])
|
||||
} else {
|
||||
const v = lr[k]
|
||||
decisions[k] = v == null || typeof v === 'boolean' ? '' : String(v)
|
||||
}
|
||||
}
|
||||
if (typeof lr.business_notes === 'string') {
|
||||
businessNotes.value = lr.business_notes
|
||||
}
|
||||
if (lr.generator === 'rules' || lr.generator === 'llm') {
|
||||
rulesOnlyThisRun.value = lr.generator === 'rules'
|
||||
}
|
||||
}
|
||||
|
||||
function formatJobOption(j) {
|
||||
const t = j.created_at
|
||||
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
|
||||
@ -228,8 +281,16 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedId, (id) => {
|
||||
loadMatrixGroupsForJob(id)
|
||||
watch(selectedId, async (id) => {
|
||||
await loadMatrixGroupsForJob(id)
|
||||
if (id) {
|
||||
applyDecisionsFromSavedRecord(String(id))
|
||||
const rec = loadStrategyDraftRecord(String(id))
|
||||
const mg = rec?.last_request?.strategy_matrix_group
|
||||
if (typeof mg === 'string' && mg.trim() && matrixGroups.value.some((g) => g.group === mg)) {
|
||||
strategyMatrixScope.value = mg
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(strategyMatrixScope, (v) => {
|
||||
@ -262,8 +323,7 @@ watch(
|
||||
<section class="ma-card">
|
||||
<h2>策略生成</h2>
|
||||
<p class="hint-top">
|
||||
选择<strong>已成功</strong>任务,先选顶部<strong>矩阵细类</strong>(主推类目,与报告矩阵一致)。策略稿与矩阵选择保存在本机 <strong>localStorage</strong>,同域名下可跨标签查看;与其它页面的耗时任务通过全局任务锁同步。下方字段按策略文档常见顺序排列;成稿里的小节标题与编号由系统自动对应。有关痛点、购买理由、品牌承诺等由监测与模型撰写,本页主要收集<strong>业务决策与战术要点</strong>。生成结果见
|
||||
<RouterLink to="/jd/strategy-view">策略稿预览</RouterLink>。<strong>已填项</strong>进入底稿并由大模型落实;<strong>未填项</strong>可由模型结合数据推断。
|
||||
选择<strong>已成功</strong>任务,先选顶部<strong>矩阵细类</strong>。<strong>已填项</strong>进入底稿并由大模型落实;<strong>未填项</strong>可由模型结合数据推断。
|
||||
</p>
|
||||
|
||||
|
||||
@ -443,52 +503,69 @@ watch(
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend>品牌四线与战术动作</legend>
|
||||
<p class="fieldset-hint">
|
||||
下列内容会在策略稿中用于<strong>品牌四线</strong>与<strong>战术支柱</strong>相关段落(系统会自动落到对应小节)。价位阵地为单选;促销与活动细节无单独表单项,由监测与模型归纳。品牌承诺与调性由模型依据数据撰写。
|
||||
</p>
|
||||
<legend>品牌四线:建设 · 打造 · 运营 · 体验</legend>
|
||||
<label class="fld fld-block">
|
||||
<span>产品</span>
|
||||
<textarea
|
||||
v-model="decisions.pillar_product"
|
||||
rows="2"
|
||||
placeholder="规格、配方或功能叙事、计划中的产品动作(可选)"
|
||||
/>
|
||||
<span>品牌建设</span>
|
||||
<textarea v-model="decisions.pillar_product" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
<label class="fld fld-block">
|
||||
<span>价位阵地(单选)</span>
|
||||
<select v-model="decisions.positioning_choice" class="job-select full">
|
||||
<option v-for="o in positioningOptions" :key="o.value || 'empty'" :value="o.value">
|
||||
{{ o.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span>品牌打造</span>
|
||||
<textarea v-model="decisions.pillar_price" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
<label class="fld fld-block">
|
||||
<span>定价(补充说明)</span>
|
||||
<textarea
|
||||
v-model="decisions.pillar_price"
|
||||
rows="2"
|
||||
placeholder="在价位阵地之外:到手价呈现、跟价或避战原则、与大促关系等(可选)"
|
||||
/>
|
||||
<span>品牌运营</span>
|
||||
<textarea v-model="decisions.pillar_channel" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
<label class="fld fld-block">
|
||||
<span>渠道与触点</span>
|
||||
<textarea
|
||||
v-model="decisions.pillar_channel"
|
||||
rows="2"
|
||||
placeholder="货架、店铺类型、站内路径、触点优先级等(可选)"
|
||||
/>
|
||||
</label>
|
||||
<label class="fld fld-block">
|
||||
<span>传播与内容</span>
|
||||
<textarea
|
||||
v-model="decisions.pillar_comm"
|
||||
rows="2"
|
||||
placeholder="内容形态、达人/自播、搜索承接与话术方向等(可选)"
|
||||
/>
|
||||
<span>品牌体验</span>
|
||||
<textarea v-model="decisions.pillar_comm" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset fieldset-tactic-pillars">
|
||||
<legend>战术支柱</legend>
|
||||
<div class="tactic-sec">
|
||||
<h4 class="tactic-sec-t">产品策略</h4>
|
||||
<label class="fld fld-block fld-tight">
|
||||
<textarea v-model="decisions.pillar_product" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="tactic-sec">
|
||||
<h4 class="tactic-sec-t">定价策略</h4>
|
||||
<label class="fld fld-block">
|
||||
<span>价位阵地</span>
|
||||
<select v-model="decisions.positioning_choice" class="job-select full">
|
||||
<option v-for="o in positioningOptions" :key="o.value || 'empty'" :value="o.value">
|
||||
{{ o.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fld fld-block">
|
||||
<span>补充说明</span>
|
||||
<textarea v-model="decisions.pillar_price" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="tactic-sec">
|
||||
<h4 class="tactic-sec-t">促销与活动策略</h4>
|
||||
<label class="fld fld-block fld-tight">
|
||||
<textarea v-model="decisions.tactic_promotion" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="tactic-sec">
|
||||
<h4 class="tactic-sec-t">渠道与传播</h4>
|
||||
<div class="tactic-ch-row">
|
||||
<label class="fld fld-block">
|
||||
<span>渠道</span>
|
||||
<textarea v-model="decisions.pillar_channel" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
<label class="fld fld-block">
|
||||
<span>传播</span>
|
||||
<textarea v-model="decisions.pillar_comm" rows="2" placeholder="选填" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend>数据与样本风险(确认知晓)</legend>
|
||||
<p class="fieldset-hint">
|
||||
@ -685,4 +762,33 @@ watch(
|
||||
.form-skip-note strong {
|
||||
color: #334155;
|
||||
}
|
||||
.fieldset-tactic-pillars .tactic-sec {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.fieldset-tactic-pillars .tactic-sec:first-of-type {
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.tactic-sec-t {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.tactic-ch-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.tactic-ch-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.fld-tight {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,6 +9,10 @@ import {
|
||||
exportStrategyDocument,
|
||||
} from '../../composables/useJobs'
|
||||
import { generationInFlightKey, withGenerationInFlight } from '../../composables/useGenerationInFlight'
|
||||
import {
|
||||
loadMarketingDetailPackRecord,
|
||||
saveMarketingDetailPackRecord,
|
||||
} from '../../lib/marketingDetailPackStorage'
|
||||
import { loadStrategyDraftRecord } from '../../lib/strategyDraftStorage'
|
||||
import { marketingPackResultToMarkdown } from '../../lib/marketingPackMarkdown'
|
||||
|
||||
@ -26,6 +30,8 @@ const exportErr = ref('')
|
||||
const marketingErr = ref('')
|
||||
const marketingExportErr = ref('')
|
||||
const marketingResult = ref(null)
|
||||
/** 底部「预览」区:策略稿 vs 营销 Markdown(与导出同源) */
|
||||
const previewDoc = ref('strategy')
|
||||
|
||||
const exportBusy = computed(() => {
|
||||
const id = selectedId.value
|
||||
@ -113,6 +119,15 @@ const marketingPackTouchBlock = computed(() =>
|
||||
]),
|
||||
)
|
||||
|
||||
const marketingMd = computed(() => {
|
||||
if (!marketingResult.value) return ''
|
||||
try {
|
||||
return marketingPackResultToMarkdown(marketingResult.value) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function loadDraft() {
|
||||
const id = selectedId.value
|
||||
if (!id) {
|
||||
@ -139,6 +154,16 @@ function loadDraft() {
|
||||
}
|
||||
}
|
||||
|
||||
function loadMarketingPack() {
|
||||
const id = selectedId.value
|
||||
if (!id) {
|
||||
marketingResult.value = null
|
||||
return
|
||||
}
|
||||
const rec = loadMarketingDetailPackRecord(id)
|
||||
marketingResult.value = rec && typeof rec === 'object' ? rec : null
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
await refreshJobs()
|
||||
@ -224,7 +249,14 @@ async function generateMarketingDetailPack() {
|
||||
}
|
||||
return
|
||||
}
|
||||
marketingResult.value = JSON.parse(text)
|
||||
const body = JSON.parse(text)
|
||||
marketingResult.value = body
|
||||
try {
|
||||
saveMarketingDetailPackRecord(id, body)
|
||||
} catch {
|
||||
/* 忽略存储失败 */
|
||||
}
|
||||
previewDoc.value = 'marketing'
|
||||
})
|
||||
} catch (e) {
|
||||
marketingErr.value = String(e)
|
||||
@ -270,18 +302,28 @@ function onStorageDraftSync(ev) {
|
||||
if (jid === String(selectedId.value)) loadDraft()
|
||||
}
|
||||
|
||||
function onStorageMarketingSync(ev) {
|
||||
const prefix = 'ma_marketing_detail_pack_'
|
||||
if (!ev.key || !ev.key.startsWith(prefix)) return
|
||||
const jid = ev.key.slice(prefix.length)
|
||||
if (jid === String(selectedId.value)) loadMarketingPack()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadList()
|
||||
syncSelectionFromRouteAndJobs()
|
||||
loadDraft()
|
||||
loadMarketingPack()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', onStorageDraftSync)
|
||||
window.addEventListener('storage', onStorageMarketingSync)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', onStorageDraftSync)
|
||||
window.removeEventListener('storage', onStorageMarketingSync)
|
||||
}
|
||||
})
|
||||
|
||||
@ -293,14 +335,16 @@ watch(
|
||||
if (s !== selectedId.value) {
|
||||
selectedId.value = s
|
||||
loadDraft()
|
||||
loadMarketingPack()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedId, (id) => {
|
||||
marketingResult.value = null
|
||||
marketingExportErr.value = ''
|
||||
previewDoc.value = 'strategy'
|
||||
loadDraft()
|
||||
loadMarketingPack()
|
||||
const want = id ? String(id) : ''
|
||||
if (String(route.query.job || '') !== want) {
|
||||
router.replace({ path: '/jd/strategy-view', query: want ? { job: want } : {} })
|
||||
@ -313,6 +357,7 @@ watch(successJobs, (list) => {
|
||||
if (list.length) {
|
||||
selectedId.value = String(list[0].id)
|
||||
loadDraft()
|
||||
loadMarketingPack()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -322,7 +367,7 @@ watch(successJobs, (list) => {
|
||||
<section class="ma-card">
|
||||
<h2>策略稿预览</h2>
|
||||
<p class="hint-top">
|
||||
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本机浏览器 <strong>localStorage</strong>,同域名下可跨标签查看)。生成/导出/营销内容等耗时操作状态在全局任务锁中同步,跨标签页可看到进行中。需要改决策请回到
|
||||
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本机浏览器 <strong>localStorage</strong>,同域名下可跨标签查看)。需要改决策请回到
|
||||
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
|
||||
重新提交。分析数据见
|
||||
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>。
|
||||
@ -385,10 +430,7 @@ watch(successJobs, (list) => {
|
||||
<p class="ma-muted marketing-pack-meta">
|
||||
{{ marketingResult.generated_at }} · {{ marketingResult.source }}
|
||||
</p>
|
||||
<p class="ma-muted marketing-pack-disk">
|
||||
服务端会将本包写入任务目录
|
||||
<code>marketing/marketing_detail_pack_v1.json</code>(与批次一并归档;目录不可写时仅内存结果)。
|
||||
</p>
|
||||
|
||||
<div class="toolbar marketing-pack-actions">
|
||||
<button
|
||||
type="button"
|
||||
@ -441,9 +483,25 @@ watch(successJobs, (list) => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-if="draftMd" class="ma-card preview-card">
|
||||
<section v-if="draftMd || marketingMd" class="ma-card preview-card">
|
||||
<div class="preview-head">
|
||||
<h2>预览</h2>
|
||||
<div v-if="marketingMd" class="tabs doc-tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: previewDoc === 'strategy' }"
|
||||
@click="previewDoc = 'strategy'"
|
||||
>
|
||||
策略稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: previewDoc === 'marketing' }"
|
||||
@click="previewDoc = 'marketing'"
|
||||
>
|
||||
营销内容
|
||||
</button>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ on: viewMode === 'render' }" @click="viewMode = 'render'">
|
||||
渲染
|
||||
@ -453,10 +511,21 @@ watch(successJobs, (list) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="viewMode === 'render'" class="md-box">
|
||||
<MarkdownPreview :source="draftMd" />
|
||||
</div>
|
||||
<pre v-else class="raw-md">{{ draftMd }}</pre>
|
||||
<template v-if="previewDoc === 'strategy' && draftMd">
|
||||
<div v-if="viewMode === 'render'" class="md-box">
|
||||
<MarkdownPreview :source="draftMd" />
|
||||
</div>
|
||||
<pre v-else class="raw-md">{{ draftMd }}</pre>
|
||||
</template>
|
||||
<template v-else-if="previewDoc === 'marketing' && marketingMd">
|
||||
<div v-if="viewMode === 'render'" class="md-box">
|
||||
<MarkdownPreview :source="marketingMd" />
|
||||
</div>
|
||||
<pre v-else class="raw-md">{{ marketingMd }}</pre>
|
||||
</template>
|
||||
<p v-else class="ma-muted preview-fallback">
|
||||
暂无当前页面对应的文稿(请先生成策略稿或营销内容)。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@ -535,6 +604,13 @@ watch(successJobs, (list) => {
|
||||
.preview-head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.doc-tabs {
|
||||
margin-right: auto;
|
||||
}
|
||||
.preview-fallback {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user