diff --git a/backend/pipeline/demos/run_report_llm_chapters_demo.py b/backend/pipeline/demos/run_report_llm_chapters_demo.py index 071a1e7..53560ea 100644 --- a/backend/pipeline/demos/run_report_llm_chapters_demo.py +++ b/backend/pipeline/demos/run_report_llm_chapters_demo.py @@ -6,7 +6,7 @@ - §8.2:``generate_comment_sentiment_analysis_llm`` - §8末细类评价:``generate_comment_group_summaries_llm`` - §8.3 右栏后使用场景:``generate_scenario_group_summaries_llm`` -- §9 策略与机会:``generate_strategy_opportunities_llm``(输入为 ``build_competitor_brief`` 摘要) +- §9 策略与机会:``generate_strategy_opportunities_llm``(``build_competitor_brief`` + 可选 ``chapter_llm_narratives`` 与各章归纳对齐) - §8.5 类全文补充(独立长文):``generate_competitor_report_markdown_llm`` cd backend diff --git a/backend/pipeline/jd/runner.py b/backend/pipeline/jd/runner.py index ea90084..fd3a89e 100644 --- a/backend/pipeline/jd/runner.py +++ b/backend/pipeline/jd/runner.py @@ -718,11 +718,38 @@ def write_competitor_analysis_for_run_dir( try: from ..llm.generate import generate_strategy_opportunities_llm + _strategy_narratives: dict[str, str] = {} + if (llm_sentiment_md or "").strip(): + _strategy_narratives["sec8_2_sentiment_theme_attribution"] = ( + llm_sentiment_md + ) + if (llm_matrix_md or "").strip(): + _strategy_narratives["sec5_matrix_group_summaries"] = llm_matrix_md + if (llm_price_md or "").strip(): + _strategy_narratives["sec6_price_group_summaries"] = llm_price_md + if (llm_promo_md or "").strip(): + _strategy_narratives["sec6_promo_group_summaries"] = llm_promo_md + if (llm_scenario_gr_md or "").strip(): + _strategy_narratives["sec8_3_scenario_summaries"] = llm_scenario_gr_md + if use_ch8_probe and (chapter8_probe_embed_md or "").strip(): + _strategy_narratives["sec8_3_text_mining_probe"] = ( + chapter8_probe_embed_md + ) + elif (llm_comment_gr_md or "").strip(): + _strategy_narratives["sec8_3_comment_focus_summaries"] = ( + llm_comment_gr_md + ) + llm_strategy_opp_md = generate_strategy_opportunities_llm( - brief_final, keyword=kw + brief_final, + keyword=kw, + chapter_llm_narratives=_strategy_narratives or None, ) strategy_opp_llm_rec["ok"] = True strategy_opp_llm_rec["chars"] = len(llm_strategy_opp_md) + strategy_opp_llm_rec["prior_chapter_narrative_keys"] = sorted( + _strategy_narratives.keys() + ) except Exception as e: strategy_opp_llm_rec["ok"] = False strategy_opp_llm_rec["error"] = str(e) diff --git a/backend/pipeline/llm/generate.py b/backend/pipeline/llm/generate.py index 6701edb..70924ac 100644 --- a/backend/pipeline/llm/generate.py +++ b/backend/pipeline/llm/generate.py @@ -785,46 +785,90 @@ def generate_scenario_group_summaries_llm_chunked( return _join_chunked_group_markdown(parts) -STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JSON 含 ``keyword`` 与 ``competitor_brief``(与本任务规则报告同源的结构化摘要,可能经裁剪,并含 ``matrix_overview_for_llm``)。 +STRATEGY_OPPORTUNITIES_SYSTEM = """你是 B 端市场与增长顾问。输入 JSON 含 ``keyword``、``competitor_brief``(与本任务规则报告同源的结构化摘要,可能经裁剪,并含 ``matrix_overview_for_llm``),以及可选 ``prior_chapter_llm_narratives``(本报告 **§5~§8** 已生成的大模型归纳节选,与正文**同源**)。 请输出 **Markdown 正文**(不要用 ``` 围栏包裹),将**直接嵌入**宿主文档中**已存在章节标题之下**的小节,读者已知当前处于「策略与机会」相关章节。 +**与前文分析严格对齐(硬性,优先于自由发挥)**: +- **定性主题**(各细类讨论焦点、正负向体验、场景与关注词归纳、配料/卖点叙事、促销形态描述等)须与 ``prior_chapter_llm_narratives`` 中已出现的表述**方向一致**,**禁止**另写一套与节选**明显矛盾**的品类判断、品牌举例或用户痛点主题。 +- **定量与可核验事实**(价带分位数、店铺/品牌占比、条数、评论统计字段等)**以** ``competitor_brief`` **为准**;若节选与 brief 数字冲突,**采纳 brief**,且勿复述与数字冲突的节选句。 +- 若某键未出现在 ``prior_chapter_llm_narratives`` 或内容为空,则该维度**不得**编造与可能存在的报告其他章冲突的细节;仅依据 ``competitor_brief`` 或明确写「输入中未体现」。 +- **转化与体验**小节:正负向体验线索须**优先呼应** ``sec8_2_sentiment_theme_attribution`` 与 **§8.3 侧**节选(``sec8_3_comment_focus_summaries`` 或 ``sec8_3_text_mining_probe``,视何者存在);**禁止**将节选未提及的具体抱怨/品类问题写成**主要结论**;可写「假设:待结合业务验证」。 + **标题与措辞(硬性)**: - **禁止**在正文开头或任何位置重复宿主已有章名/小节名,包括但不限于:「第九章」「第9章」「九、」「策略与机会提示」「策略与机会建议」「策略与机会」等;**不要**自造 ``##`` 一级标题; - 小节标题**仅允许**使用业务主题式 ``####``(如下所列),从第一句起就进入实质内容。 **必须遵守**: -- **数字与事实**:价格分位数、集中度份额、条数、占比等**只能**来自输入 JSON 中已有字段;**禁止编造**未出现的品牌销量、具体 GMV、未给出的到手价; +- **数字与事实**:价格分位数、集中度份额、条数、占比等**只能**来自 ``competitor_brief`` 中已有字段;**禁止编造**未出现的品牌销量、具体 GMV、未给出的到手价; - **语气**:分节给出**可操作的假设性建议**(定价区间思路、应对齐的差异化观测点、应规避的风险、促销与机制设计线索、转化与详情页/评价侧改进方向),每条建议用「假设:」「待验证:」等标明不确定性; - **结构**:至少使用 ``####`` 组织以下主题(可合并子条,但须覆盖):**定价与价带**、**差异化与应对齐的优势**、**风险与避免项**、**促销与活动机制**、**转化与体验**; -- **促销与活动机制(硬性)**:该节**必须优先依据** ``competitor_brief.price_promotion_signals``(若存在):对 ``promo_keyword_row_hits_top`` 中**已出现**的活动话术类型(如满减、券、券后、百亿补贴、红包、秒杀、到手价等)逐类给出**假设性**机制建议(如何与标价/券后价差 ``share_coupon_below_list_when_both``、``median_discount_pct_when_coupon_below`` 等字段对照),并说明**待验证**的测试方式。**禁止**编造具体满减门槛、红包面额、补贴比例;**禁止**在输入中完全未出现任何列表侧活动话术或价差信号时,仍写一大段具体「要做满减发红包」而无「输入中未捕获此类信号」的说明。若 brief 中活动信号稀疏,须明确写出并转向「需补充列表/促销字段抓取」类建议。 -- **转化与体验(硬性)**:该节须**同时**写清 **正向体验**(如详情呈现、规格可读性、评价中反复被肯定的点、有助于信任与下单的线索,**仅依据** brief 中可见字段)与 **负向体验/摩擦**(如评价侧抱怨主题、体验短板、可能损害转化的信号及**待验证**的改进方向);不得只写一侧;无足够依据时写明「输入中信号不足」而非编造; +- **促销与活动机制(硬性)**:该节**必须优先依据** ``competitor_brief.price_promotion_signals``(若存在),并与 ``prior_chapter_llm_narratives.sec6_promo_group_summaries``(若有)**不矛盾**:对 ``promo_keyword_row_hits_top`` 中**已出现**的活动话术类型逐类给出**假设性**机制建议。**禁止**编造具体满减门槛、红包面额、补贴比例;**禁止**在输入中完全未出现任何列表侧活动话术或价差信号时,仍写一大段具体「要做满减发红包」而无「输入中未捕获此类信号」的说明。 +- **转化与体验(硬性)**:须**同时**写清正向与负向;**禁止**使用「占比均超过 130 次」等**语义不通或混用次数/占比**的表述;数字表述须与 ``competitor_brief`` 一致。 - **禁止**:不要写完整报告目录;不要复述「研究范围与方法」;不要使用 CR1/CR3 缩写(用「第一大……份额」「前三家合计」);不要输出与输入矛盾的价带描述。 篇幅约 **900~3200 字**(数据丰富可偏长)。""" STRATEGY_OPPORTUNITIES_USER_PREFIX = ( - "请根据以下 JSON 撰写策略归纳正文(Markdown)。宿主报告已含章节标题,**勿在输出中写第九章或「策略与机会」类标题**。\n\n" + "请根据以下 JSON 撰写策略归纳正文(Markdown)。" + "``competitor_brief`` 为结构化摘要;若含 ``prior_chapter_llm_narratives``,则为 §5~§8 大模型归纳节选,须与策略正文对齐。" + "宿主报告已含章节标题,**勿在输出中写第九章或「策略与机会」类标题**。\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 generate_strategy_opportunities_llm( - brief: dict[str, Any], *, keyword: str + brief: dict[str, Any], + *, + keyword: str, + chapter_llm_narratives: dict[str, str] | None = None, ) -> str: """ 基于 ``build_competitor_brief`` 全量摘要,生成策略与机会小节正文(不含章名,由宿主 Markdown 加标题)。 + + ``chapter_llm_narratives`` 为与本报告 §5~§8 同源的大模型正文节选,键名稳定(见 runner 传入),用于与策略段严格对齐。 """ - compact = compact_brief_for_llm(brief, max_chars=100_000) + narr_in = { + k: v + for k, v in (chapter_llm_narratives or {}).items() + if isinstance(v, str) and v.strip() + } + _TARGET = 118_000 + for cap_brief, cap_narr in ( + (100_000, 7_000), + (72_000, 5_000), + (52_000, 3_500), + (40_000, 2_500), + ): + 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 + raw = json.dumps(payload, ensure_ascii=False) + if len(raw) <= _TARGET: + user = STRATEGY_OPPORTUNITIES_USER_PREFIX + raw + return _call_llm(STRATEGY_OPPORTUNITIES_SYSTEM, user) + compact = compact_brief_for_llm(brief, max_chars=40_000) payload = {"keyword": keyword, "competitor_brief": compact} - raw = json.dumps(payload, ensure_ascii=False) - if len(raw) > 110_000: - compact = compact_brief_for_llm(brief, max_chars=65_000) - payload = {"keyword": keyword, "competitor_brief": compact} - raw = json.dumps(payload, ensure_ascii=False) - if len(raw) > 110_000: - compact = compact_brief_for_llm(brief, max_chars=40_000) - payload = {"keyword": keyword, "competitor_brief": compact} - raw = json.dumps(payload, ensure_ascii=False) - user = STRATEGY_OPPORTUNITIES_USER_PREFIX + raw + user = STRATEGY_OPPORTUNITIES_USER_PREFIX + json.dumps( + payload, ensure_ascii=False + ) return _call_llm(STRATEGY_OPPORTUNITIES_SYSTEM, user)