diff --git a/.cursor/rules/user-dev-style.mdc b/.cursor/rules/user-dev-style.mdc new file mode 100644 index 0000000..337c5d1 --- /dev/null +++ b/.cursor/rules/user-dev-style.mdc @@ -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 规范。 diff --git a/backend/pipeline/competitor_report/comment_sentiment.py b/backend/pipeline/competitor_report/comment_sentiment.py index 9148ad0..347b2ff 100644 --- a/backend/pipeline/competitor_report/comment_sentiment.py +++ b/backend/pipeline/competitor_report/comment_sentiment.py @@ -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__ = [ diff --git a/backend/pipeline/competitor_report/config.py b/backend/pipeline/competitor_report/config.py index 3053c0c..00e0c53 100644 --- a/backend/pipeline/competitor_report/config.py +++ b/backend/pipeline/competitor_report/config.py @@ -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", ] diff --git a/backend/pipeline/competitor_report/constants.py b/backend/pipeline/competitor_report/constants.py index 2954ae7..4a613fd 100644 --- a/backend/pipeline/competitor_report/constants.py +++ b/backend/pipeline/competitor_report/constants.py @@ -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", diff --git a/backend/pipeline/competitor_report/llm_group_payloads.py b/backend/pipeline/competitor_report/llm_group_payloads.py index 3cc7fa0..983a0d4 100644 --- a/backend/pipeline/competitor_report/llm_group_payloads.py +++ b/backend/pipeline/competitor_report/llm_group_payloads.py @@ -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", ] diff --git a/backend/pipeline/competitor_report/price_promo.py b/backend/pipeline/competitor_report/price_promo.py index c24e453..ff228c1 100644 --- a/backend/pipeline/competitor_report/price_promo.py +++ b/backend/pipeline/competitor_report/price_promo.py @@ -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", ] diff --git a/backend/pipeline/competitor_report/report_md_helpers.py b/backend/pipeline/competitor_report/report_md_helpers.py index 03b92d8..9f7fb43 100644 --- a/backend/pipeline/competitor_report/report_md_helpers.py +++ b/backend/pipeline/competitor_report/report_md_helpers.py @@ -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。" diff --git a/backend/pipeline/demos/chapter8_text_mining_probe.py b/backend/pipeline/demos/chapter8_text_mining_probe.py index 0b3ee29..949a301 100644 --- a/backend/pipeline/demos/chapter8_text_mining_probe.py +++ b/backend/pipeline/demos/chapter8_text_mining_probe.py @@ -17,6 +17,8 @@ 输出:默认写入 ``/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}" diff --git a/backend/pipeline/demos/dump_strategy_llm_input_md.py b/backend/pipeline/demos/dump_strategy_llm_input_md.py index 1bc775a..189dd21 100644 --- a/backend/pipeline/demos/dump_strategy_llm_input_md.py +++ b/backend/pipeline/demos/dump_strategy_llm_input_md.py @@ -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]: diff --git a/backend/pipeline/demos/dump_strategy_opportunities_llm_volume.py b/backend/pipeline/demos/dump_strategy_opportunities_llm_volume.py new file mode 100644 index 0000000..28dfc63 --- /dev/null +++ b/backend/pipeline/demos/dump_strategy_opportunities_llm_volume.py @@ -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()) diff --git a/backend/pipeline/demos/fixtures/strategy_decisions_full_lowgi_biscuit.json b/backend/pipeline/demos/fixtures/strategy_decisions_full_lowgi_biscuit.json new file mode 100644 index 0000000..2080560 --- /dev/null +++ b/backend/pipeline/demos/fixtures/strategy_decisions_full_lowgi_biscuit.json @@ -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 +} diff --git a/backend/pipeline/demos/fixtures/strategy_draft_request_full_lowgi_biscuit.json b/backend/pipeline/demos/fixtures/strategy_draft_request_full_lowgi_biscuit.json new file mode 100644 index 0000000..f173235 --- /dev/null +++ b/backend/pipeline/demos/fixtures/strategy_draft_request_full_lowgi_biscuit.json @@ -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 +} diff --git a/backend/pipeline/demos/probe_strategy_llm_stability.py b/backend/pipeline/demos/probe_strategy_llm_stability.py new file mode 100644 index 0000000..5b5b92c --- /dev/null +++ b/backend/pipeline/demos/probe_strategy_llm_stability.py @@ -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()) diff --git a/backend/pipeline/demos/run_strategy_decisions_full_fixture_demo.py b/backend/pipeline/demos/run_strategy_decisions_full_fixture_demo.py new file mode 100644 index 0000000..8fee13f --- /dev/null +++ b/backend/pipeline/demos/run_strategy_decisions_full_fixture_demo.py @@ -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()) diff --git a/backend/pipeline/llm/generate_group_summaries.py b/backend/pipeline/llm/generate_group_summaries.py index c9662bc..f2954cf 100644 --- a/backend/pipeline/llm/generate_group_summaries.py +++ b/backend/pipeline/llm/generate_group_summaries.py @@ -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) diff --git a/backend/pipeline/llm/generate_marketing_detail.py b/backend/pipeline/llm/generate_marketing_detail.py index 6ed6077..6b46b09 100644 --- a/backend/pipeline/llm/generate_marketing_detail.py +++ b/backend/pipeline/llm/generate_marketing_detail.py @@ -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** 字;须**首句或次句**点明品类;适合复制到站内动态;**禁止**销量名次、虚假好评引语 diff --git a/backend/pipeline/llm/generate_sections.py b/backend/pipeline/llm/generate_sections.py index 3d002ee..7d7375f 100644 --- a/backend/pipeline/llm/generate_sections.py +++ b/backend/pipeline/llm/generate_sections.py @@ -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( diff --git a/backend/pipeline/llm/generate_strategy.py b/backend/pipeline/llm/generate_strategy.py index c1b212f..4578c55 100644 --- a/backend/pipeline/llm/generate_strategy.py +++ b/backend/pipeline/llm/generate_strategy.py @@ -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() + ) diff --git a/backend/pipeline/reporting/brief_pack.py b/backend/pipeline/reporting/brief_pack.py index 987e37e..7e6dc4c 100644 --- a/backend/pipeline/reporting/brief_pack.py +++ b/backend/pipeline/reporting/brief_pack.py @@ -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: diff --git a/backend/pipeline/reporting/brief_strategy_scope.py b/backend/pipeline/reporting/brief_strategy_scope.py index eefdcb3..a7bc339 100644 --- a/backend/pipeline/reporting/brief_strategy_scope.py +++ b/backend/pipeline/reporting/brief_strategy_scope.py @@ -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"] = { diff --git a/backend/pipeline/reporting/charts.py b/backend/pipeline/reporting/charts.py index 561b2a3..4c152bf 100644 --- a/backend/pipeline/reporting/charts.py +++ b/backend/pipeline/reporting/charts.py @@ -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: diff --git a/backend/pipeline/reporting/md_document_export.py b/backend/pipeline/reporting/md_document_export.py index 1e408da..4e48114 100644 --- a/backend/pipeline/reporting/md_document_export.py +++ b/backend/pipeline/reporting/md_document_export.py @@ -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, diff --git a/backend/pipeline/reporting/strategy_draft.py b/backend/pipeline/reporting/strategy_draft.py index b8bfc8c..5ebb775 100644 --- a/backend/pipeline/reporting/strategy_draft.py +++ b/backend/pipeline/reporting/strategy_draft.py @@ -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( diff --git a/backend/pipeline/serializers.py b/backend/pipeline/serializers.py index 4c7e189..8977f8c 100644 --- a/backend/pipeline/serializers.py +++ b/backend/pipeline/serializers.py @@ -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 ) diff --git a/backend/pipeline/strategy_decision_keys.py b/backend/pipeline/strategy_decision_keys.py new file mode 100644 index 0000000..5762d1b --- /dev/null +++ b/backend/pipeline/strategy_decision_keys.py @@ -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({}) diff --git a/backend/pipeline/tests/test_brief_strategy_scope.py b/backend/pipeline/tests/test_brief_strategy_scope.py index 47eaaa0..b70a493 100644 --- a/backend/pipeline/tests/test_brief_strategy_scope.py +++ b/backend/pipeline/tests/test_brief_strategy_scope.py @@ -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) diff --git a/backend/pipeline/tests/test_ch9_strategy_pain_narrative_binding.py b/backend/pipeline/tests/test_ch9_strategy_pain_narrative_binding.py new file mode 100644 index 0000000..57cc148 --- /dev/null +++ b/backend/pipeline/tests/test_ch9_strategy_pain_narrative_binding.py @@ -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) diff --git a/backend/pipeline/tests/test_competitor_brief.py b/backend/pipeline/tests/test_competitor_brief.py index d53310a..4fba4db 100644 --- a/backend/pipeline/tests/test_competitor_brief.py +++ b/backend/pipeline/tests/test_competitor_brief.py @@ -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 diff --git a/backend/pipeline/tests/test_llm_keyword_suggest.py b/backend/pipeline/tests/test_llm_keyword_suggest.py index 011febe..229fc70 100644 --- a/backend/pipeline/tests/test_llm_keyword_suggest.py +++ b/backend/pipeline/tests/test_llm_keyword_suggest.py @@ -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() diff --git a/backend/pipeline/tests/test_md_document_export.py b/backend/pipeline/tests/test_md_document_export.py index d10bb8c..b9bfe29 100644 --- a/backend/pipeline/tests/test_md_document_export.py +++ b/backend/pipeline/tests/test_md_document_export.py @@ -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) diff --git a/backend/pipeline/tests/test_price_promo_strategy_brief_cn.py b/backend/pipeline/tests/test_price_promo_strategy_brief_cn.py new file mode 100644 index 0000000..5e124db --- /dev/null +++ b/backend/pipeline/tests/test_price_promo_strategy_brief_cn.py @@ -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) diff --git a/backend/pipeline/tests/test_strategy_draft.py b/backend/pipeline/tests/test_strategy_draft.py index 809e402..0c5d322 100644 --- a/backend/pipeline/tests/test_strategy_draft.py +++ b/backend/pipeline/tests/test_strategy_draft.py @@ -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) diff --git a/backend/pipeline/views/job_report_views.py b/backend/pipeline/views/job_report_views.py index 5463daf..c690c6f 100644 --- a/backend/pipeline/views/job_report_views.py +++ b/backend/pipeline/views/job_report_views.py @@ -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, diff --git a/docs/examples/competitor_brief_schema_v1.json b/docs/examples/competitor_brief_schema_v1.json index 7a21ff0..9d25584 100644 --- a/docs/examples/competitor_brief_schema_v1.json +++ b/docs/examples/competitor_brief_schema_v1.json @@ -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 主题模型。", + "与在线分析报告各章计数规则一致;评论侧主题以第八章文本挖掘(若启用)及语义归纳为准,不再以预设子串词表为主指标。", "价格来自展示字段抽取,含促销与规格差异。" ] } diff --git a/frontend/src/components/ReportConfigFormFields.vue b/frontend/src/components/ReportConfigFormFields.vue index 50d9ee8..c455ca2 100644 --- a/frontend/src/components/ReportConfigFormFields.vue +++ b/frontend/src/components/ReportConfigFormFields.vue @@ -7,18 +7,9 @@ defineEmits(['add-market', 'remove-market']) @@ -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;