mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
- 规则底稿:过滤 strategy_hints 中与关注词/预设场景条形图同源的句子;§7 增加促销与活动对齐说明(price_promotion_signals、§6/§9)。 - LLM:payload 增加 chapter8_text_mining_probe;structured_brief 同步过滤 hints;系统提示与 omission_note 明确禁止用词频为据、须承接第九章与促销信号。 Made-with: Cursor
333 lines
18 KiB
Python
333 lines
18 KiB
Python
"""策略稿润色与第九章策略机会归纳。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from typing import Any
|
||
|
||
from ..reporting.brief_compact import compact_brief_for_llm
|
||
from ..reporting.strategy_draft import (
|
||
build_strategy_draft_markdown,
|
||
filter_strategy_hints_for_ch8_probe,
|
||
report_uses_chapter8_text_mining_probe,
|
||
)
|
||
from .llm_client import call_llm, estimate_chat_input_tokens, llm_context_window_size
|
||
|
||
STRATEGY_SYSTEM = """你是市场策略顾问,根据**结构化监测摘要**与业务侧填写的**决策字段**,把「规则底稿」写成**短、可执行**的策略 Markdown 成稿。
|
||
|
||
**输入**:`rules_draft_markdown`(规则骨架,与同任务数据一致)、`structured_brief`(摘要子集)、`strategy_decisions`、`business_notes`;可选 `report_strategy_excerpt`(与同任务宿主报告**第九章**「策略与机会」正文同源)。
|
||
|
||
**决策边界(硬性)**:
|
||
- **业务已在 `strategy_decisions` 中填写的项**(角色、时间、成功标准、战场一句话、定位勾选、竞争倾向、四柱、目标客群/对标/资源备注、**营销策略**与**总体策略**等)视为**已定决策**:成稿须**落实为具体执行句**,**不得**改写成相反结论或再要求用户「请选择」。
|
||
- **禁止编造店铺结构**:不得写出「京东自营 SKU 占比」「自营超 X%」等**摘要中未给出**的定量句;店铺集中度仅可依据 `structured_brief.concentration` 与底稿,并区分列表行 / 去重 SKU 口径。
|
||
- **表单中为空或占位(如 *待填*、*骨架占位*)的项**:由你结合 `structured_brief`、`report_strategy_excerpt`(若非空)与数据摘录**补全为可执行表述**;补全须与数据方向一致,**不得**编造输入中不存在的销量、GMV、未给出的价格或占比。
|
||
- **成稿阶段禁止**:再写一轮「请业务决策」「待确认后再定」「假设:待验证」等**二次决策话术**;不确定性用一句带过即可(如「需下周用原评论抽样核对」),勿重复堆砌。
|
||
|
||
**与报告第九章对齐(当 `report_strategy_excerpt` 非空时)**:战略方向与主要判断须与该节选**一致**,不得明显矛盾;若 `business_notes` 或表单与节选冲突,正文中简短点明「与报告第九章归纳差异见业务备注」,且**不得**把节选与 `structured_brief` 均未出现的数字当作事实。
|
||
|
||
**当 `report_strategy_excerpt` 为空**:仅依据底稿与摘要润色,**不得编造**报告第九章结论。
|
||
|
||
**数据**:不得编造销量、占比、价格数字;底稿与摘要中的数字须保持一致;集中度用「第一大……份额」「前三家合计」等中文,**不要用** CR1、CR3。
|
||
|
||
**矩阵**:若 `structured_brief` 含矩阵相关字段,须**呼应**细分类目与竞品矩阵结论,不得无故删光。
|
||
|
||
**第八章文本挖掘探针(当 JSON 中 `chapter8_text_mining_probe` 为真时,硬性)**:
|
||
- **禁止**将「关注词子串命中次数」「预设场景分组条数/占比」当作评论侧或需求侧的主要论据(`structured_brief` 已省略这些字段;与报告正文 §8 主口径一致)。
|
||
- 用户洞察、场景与传播结论须与宿主报告 **§8 文本挖掘** 及 `report_strategy_excerpt`(第九章)一致。
|
||
- **促销与活动**:若 `report_strategy_excerpt` 或 `structured_brief.price_promotion_signals` 中出现满减、券、到手价差、促销形态等,成稿须在**价格/营销/渠道**相关段落**承接**,与报告 **第六章、第九章** 建议一致,**不得无故省略**;**禁止**编造具体满减门槛或补贴比例(仅复述输入中已有线索)。
|
||
|
||
**输出**:仅 Markdown 正文(不要 ``` 围栏);须收束各小节与全文,勿中途截断。"""
|
||
|
||
STRATEGY_USER_PREFIX = """请基于以下 JSON 输出最终策略稿(Markdown)。\n\n"""
|
||
|
||
|
||
def generate_strategy_draft_markdown_llm(
|
||
*,
|
||
job_id: int,
|
||
keyword: str,
|
||
brief: dict[str, Any],
|
||
business_notes: str,
|
||
generated_at_iso: str,
|
||
strategy_decisions: dict[str, Any],
|
||
report_strategy_excerpt: str | None = None,
|
||
report_config: dict[str, Any] | None = None,
|
||
) -> str:
|
||
"""
|
||
``report_strategy_excerpt``:与同任务宿主报告第九章「策略与机会」正文对齐的节选(见
|
||
``reporting.report_strategy_excerpt.load_report_strategy_excerpt``);空字符串表示未生成或未重跑第九章大模型。
|
||
"""
|
||
rules_md = build_strategy_draft_markdown(
|
||
job_id=job_id,
|
||
keyword=keyword,
|
||
brief=brief,
|
||
business_notes=business_notes,
|
||
generated_at_iso=generated_at_iso,
|
||
strategy_decisions=strategy_decisions,
|
||
report_config=report_config,
|
||
)
|
||
excerpt_raw = (report_strategy_excerpt or "").strip()
|
||
sys_prompt = STRATEGY_SYSTEM
|
||
min_comp = _min_strategy_completion_tokens()
|
||
min_comp_relaxed = max(256, min_comp // 2)
|
||
|
||
def _payload_and_user(
|
||
*,
|
||
compact_max: int,
|
||
excerpt_max: int,
|
||
rules_max: int | None,
|
||
) -> str:
|
||
compact = compact_brief_for_llm(brief, max_chars=compact_max)
|
||
if report_uses_chapter8_text_mining_probe(report_config):
|
||
compact = dict(compact)
|
||
for k in (
|
||
"comment_focus_keywords",
|
||
"usage_scenarios",
|
||
"usage_scenarios_denominator",
|
||
"usage_scenarios_by_matrix_group",
|
||
):
|
||
compact.pop(k, None)
|
||
if isinstance(compact.get("strategy_hints"), list):
|
||
compact["strategy_hints"] = filter_strategy_hints_for_ch8_probe(
|
||
compact["strategy_hints"]
|
||
)
|
||
ex = (
|
||
_truncate_strategy_narrative(excerpt_raw, excerpt_max)
|
||
if excerpt_raw
|
||
else ""
|
||
)
|
||
if rules_max is None:
|
||
rd = rules_md
|
||
else:
|
||
rd = _truncate_rules_draft_md(rules_md, rules_max)
|
||
payload: dict[str, Any] = {
|
||
"job_id": job_id,
|
||
"keyword": keyword,
|
||
"generated_at_iso": generated_at_iso,
|
||
"strategy_decisions": strategy_decisions,
|
||
"business_notes": business_notes,
|
||
"structured_brief": compact,
|
||
"rules_draft_markdown": rd,
|
||
"report_strategy_excerpt": ex,
|
||
"chapter8_text_mining_probe": bool(
|
||
report_uses_chapter8_text_mining_probe(report_config)
|
||
),
|
||
}
|
||
if report_uses_chapter8_text_mining_probe(report_config):
|
||
payload["structured_brief_omission_note"] = (
|
||
"已启用第八章文本挖掘(探针为主):structured_brief 已省略「关注词/场景子串计数」及「与条形图同源的 strategy_hints 句子」,"
|
||
"避免与报告 §8 主口径冲突。**不得**再以词频或预设场景占比作为论据。"
|
||
"用户与评论侧须依报告 §8 文本挖掘归纳;**促销、满减、券价差**须与报告第六章、`price_promotion_signals` 及下方 `report_strategy_excerpt`(第九章)对齐,不得省略报告已写明的活动建议。"
|
||
)
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
if len(raw) > 500_000:
|
||
payload["rules_draft_markdown"] = _truncate_rules_draft_md(rd, 200_000)
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
return STRATEGY_USER_PREFIX + raw
|
||
|
||
for cap_brief, cap_excerpt, cap_rules in (
|
||
(80_000, 24_000, None),
|
||
(64_000, 20_000, None),
|
||
(48_000, 17_000, None),
|
||
(36_000, 14_000, None),
|
||
(28_000, 11_000, None),
|
||
(22_000, 9_000, None),
|
||
(18_000, 7_000, None),
|
||
(14_000, 5_000, None),
|
||
(12_000, 4_000, 220_000),
|
||
(10_000, 3_500, 180_000),
|
||
(10_000, 3_000, 150_000),
|
||
(9_000, 2_500, 120_000),
|
||
(8_000, 2_000, 100_000),
|
||
(8_000, 2_000, 70_000),
|
||
):
|
||
user = _payload_and_user(
|
||
compact_max=cap_brief,
|
||
excerpt_max=cap_excerpt,
|
||
rules_max=cap_rules,
|
||
)
|
||
if _strategy_prompt_ok_for_call(
|
||
sys_prompt, user, min_completion_tokens=min_comp
|
||
):
|
||
return call_llm(sys_prompt, user)
|
||
|
||
for cap_brief, cap_excerpt, cap_rules in (
|
||
(10_000, 2_000, 55_000),
|
||
(8_000, 1_500, 45_000),
|
||
(7_000, 1_200, 35_000),
|
||
):
|
||
user = _payload_and_user(
|
||
compact_max=cap_brief,
|
||
excerpt_max=cap_excerpt,
|
||
rules_max=cap_rules,
|
||
)
|
||
if _strategy_prompt_ok_for_call(
|
||
sys_prompt, user, min_completion_tokens=min_comp_relaxed
|
||
):
|
||
return call_llm(sys_prompt, user)
|
||
|
||
user = _payload_and_user(compact_max=6_000, excerpt_max=1_000, rules_max=28_000)
|
||
return call_llm(sys_prompt, user)
|
||
|
||
|
||
STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JSON 含 ``keyword``、``competitor_brief``(与本任务规则报告同源的结构化摘要,可能经裁剪,并含 ``matrix_overview_for_llm``),以及可选 ``prior_chapter_llm_narratives``(本报告 **第五至第八章** 已生成的大模型归纳节选,与正文**同源**)。
|
||
|
||
请输出 **Markdown 正文**(不要用 ``` 围栏包裹),将**直接嵌入**宿主文档中**已存在章节标题之下**的小节,读者已知当前处于「策略与机会」相关章节。
|
||
|
||
**与前文分析严格对齐(硬性,优先于自由发挥)**:
|
||
- **定性主题**(各细类讨论焦点、正负向体验、场景与关注词归纳、配料/卖点叙事、促销形态描述等)须与 ``prior_chapter_llm_narratives`` 中已出现的表述**方向一致**,**禁止**另写一套与节选**明显矛盾**的品类判断、品牌举例或用户痛点主题。
|
||
- **定量与可核验事实**(价带分位数、店铺/品牌占比、条数、评论统计字段等)**以** ``competitor_brief`` **为准**;若节选与 brief 数字冲突,**采纳 brief**,且勿复述与数字冲突的节选句。
|
||
- 若某键未出现在 ``prior_chapter_llm_narratives`` 或内容为空,则该维度**不得**编造与可能存在的报告其他章冲突的细节;仅依据 ``competitor_brief`` 或明确写「输入中未体现」。
|
||
- **转化与体验**小节:正负向体验线索须**优先呼应** ``sec8_2_sentiment_theme_attribution`` 与 **第八章第三节 侧**节选(``sec8_3_comment_focus_summaries`` 或 ``sec8_3_text_mining_probe``,视何者存在);**禁止**将节选未提及的具体抱怨/品类问题写成**主要结论**;可写「假设:待结合业务验证」。
|
||
|
||
**标题与措辞(硬性)**:
|
||
- **禁止**在正文开头或任何位置重复宿主已有章名/小节名,包括但不限于:「第九章」「第9章」「九、」「策略与机会提示」「策略与机会建议」「策略与机会」等;**不要**自造 ``##`` 一级标题;
|
||
- 小节标题**仅允许**使用业务主题式 ``####``(如下所列),从第一句起就进入实质内容。
|
||
|
||
**必须遵守**:
|
||
- **数字与事实**:价格分位数、集中度份额、条数、占比等**只能**来自 ``competitor_brief`` 中已有字段;**禁止编造**未出现的品牌销量、具体 GMV、未给出的到手价;
|
||
- **店铺类型占比(硬性)**:**禁止**编造「京东自营 SKU 占比」「自营款数占比超 X%」等表述,除非 ``competitor_brief`` 中 ``concentration.shops_from_list`` / ``list_shop_mix_top`` 等字段**已出现**对应店铺名与计数;若写第一大店铺份额,须与 ``shops_from_list`` 一致,并区分 **列表行** 与 **去重 SKU**(``unique_sku_basis``),**禁止**写成全渠道市占或模糊「SKU 占比」。
|
||
- **语气**:分节给出**可操作的假设性建议**(定价区间思路、应对齐的差异化观测点、应规避的风险、促销与机制设计线索、转化与详情页/评价侧改进方向),每条建议用「假设:」「待验证:」等标明不确定性;
|
||
- **结构**:至少使用 ``####`` 组织以下主题(可合并子条,但须覆盖):**定价与价带**、**差异化与应对齐的优势**、**风险与避免项**、**促销与活动机制**、**转化与体验**;
|
||
- **促销与活动机制(硬性)**:该节**必须优先依据** ``competitor_brief.price_promotion_signals``(券后/标价、价差等,若存在),并与 ``prior_chapter_llm_narratives.sec6_promo_group_summaries``(若有)**不矛盾**,给出**假设性**机制建议。**禁止**编造具体满减门槛、红包面额、补贴比例;**禁止**在输入中完全未出现任何列表侧价差或促销归纳信号时,仍写一大段具体「要做满减发红包」而无「输入中未捕获此类信号」的说明。
|
||
- **转化与体验(硬性)**:须**同时**写清正向与负向;**禁止**使用「占比均超过 130 次」等**语义不通或混用次数/占比**的表述;数字表述须与 ``competitor_brief`` 一致。
|
||
- **禁止**:不要写完整报告目录;不要复述「研究范围与方法」;不要使用 CR1/CR3 缩写(用「第一大……份额」「前三家合计」);不要输出与输入矛盾的价带描述。
|
||
|
||
篇幅约 **900~3200 字**(数据丰富可偏长)。"""
|
||
|
||
|
||
STRATEGY_OPPORTUNITIES_USER_PREFIX = (
|
||
"请根据以下 JSON 撰写策略归纳正文(Markdown)。"
|
||
"``competitor_brief`` 为结构化摘要;若含 ``prior_chapter_llm_narratives``,则为 第五至第八章 大模型归纳节选,须与策略正文对齐。"
|
||
"宿主报告已含章节标题,**勿在输出中写第九章或「策略与机会」类标题**。\n\n"
|
||
)
|
||
|
||
|
||
def _truncate_rules_draft_md(text: str, max_chars: int) -> str:
|
||
"""规则策略底稿过长时截断,避免 JSON 与 completion 预算挤占输出。"""
|
||
s = (text or "").strip()
|
||
if not s:
|
||
return ""
|
||
if len(s) <= max_chars:
|
||
return s
|
||
return (
|
||
s[: max_chars - 80].rstrip()
|
||
+ "\n\n…(规则底稿已截断,请勿编造截断后内容。)\n"
|
||
)
|
||
|
||
|
||
def _truncate_strategy_narrative(text: str, max_chars: int) -> str:
|
||
s = (text or "").strip()
|
||
if not s:
|
||
return ""
|
||
if len(s) <= max_chars:
|
||
return s
|
||
return (
|
||
s[: max_chars - 80].rstrip()
|
||
+ "\n\n…(前文各章归纳节选已截断;请勿编造截断后内容。)\n"
|
||
)
|
||
|
||
|
||
def _strategy_prompt_fits_context(system: str, user: str) -> bool:
|
||
"""若为 False,``chat_completion_text`` 会在发请求前因过长而抛错。"""
|
||
est = estimate_chat_input_tokens(system, user)
|
||
ctx = llm_context_window_size()
|
||
buf = 256
|
||
return est < ctx - buf - 256
|
||
|
||
|
||
def _strategy_completion_avail_tokens(system: str, user: str) -> int:
|
||
"""
|
||
与 ``AI_crawler.chat_completion_text`` 中 ``avail = context_window - input_est - buf`` 一致,
|
||
即本次调用实际可用于 **completion** 的上限(随后还会与 ``max_tokens`` 取 min)。
|
||
若该值过小,长文会在句中被截断(例如「转化与体验」末段不完整)。
|
||
"""
|
||
est = estimate_chat_input_tokens(system, user)
|
||
ctx = llm_context_window_size()
|
||
buf = 256
|
||
return ctx - est - buf
|
||
|
||
|
||
def _min_strategy_completion_tokens() -> int:
|
||
raw = (os.environ.get("MA_STRATEGY_MIN_COMPLETION_TOKENS") or "2048").strip()
|
||
try:
|
||
return max(256, int(raw))
|
||
except ValueError:
|
||
return 2048
|
||
|
||
|
||
def _strategy_prompt_ok_for_call(system: str, user: str, *, min_completion_tokens: int) -> bool:
|
||
return _strategy_prompt_fits_context(
|
||
system, user
|
||
) and _strategy_completion_avail_tokens(system, user) >= min_completion_tokens
|
||
|
||
|
||
def generate_strategy_opportunities_llm(
|
||
brief: dict[str, Any],
|
||
*,
|
||
keyword: str,
|
||
chapter_llm_narratives: dict[str, str] | None = None,
|
||
) -> str:
|
||
"""
|
||
基于 ``build_competitor_brief`` 全量摘要,生成策略与机会小节正文(不含章名,由宿主 Markdown 加标题)。
|
||
|
||
``chapter_llm_narratives`` 为与本报告 第五至第八章 同源的大模型正文节选,键名稳定(见 runner 传入),用于与策略段严格对齐。
|
||
"""
|
||
narr_in = {
|
||
k: v
|
||
for k, v in (chapter_llm_narratives or {}).items()
|
||
if isinstance(v, str) and v.strip()
|
||
}
|
||
sys_prompt = STRATEGY_OPPORTUNITIES_SYSTEM
|
||
|
||
def _user_from_payload(p: dict[str, Any]) -> str:
|
||
return STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps(p, ensure_ascii=False)
|
||
|
||
min_comp = _min_strategy_completion_tokens()
|
||
min_comp_relaxed = max(256, min_comp // 2)
|
||
|
||
for cap_brief, cap_narr in (
|
||
(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),
|
||
):
|
||
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": keyword,
|
||
"competitor_brief": compact,
|
||
}
|
||
if narratives:
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|