diff --git a/backend/pipeline/llm/generate_strategy.py b/backend/pipeline/llm/generate_strategy.py index 709886b..16df065 100644 --- a/backend/pipeline/llm/generate_strategy.py +++ b/backend/pipeline/llm/generate_strategy.py @@ -6,7 +6,10 @@ import os from typing import Any from ..reporting.brief_compact import compact_brief_for_llm -from ..reporting.strategy_draft import build_strategy_draft_markdown +from ..reporting.strategy_draft import ( + build_strategy_draft_markdown, + report_uses_chapter8_text_mining_probe, +) from .llm_client import call_llm, estimate_chat_input_tokens, llm_context_window_size STRATEGY_SYSTEM = """你是市场策略顾问,根据**结构化监测摘要**与业务侧填写的**决策字段**,把「规则底稿」润色为可读的策略 Markdown。 @@ -33,6 +36,7 @@ def generate_strategy_draft_markdown_llm( 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``:与同任务宿主报告第九章「策略与机会」正文对齐的节选(见 @@ -45,6 +49,7 @@ def generate_strategy_draft_markdown_llm( 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 @@ -58,6 +63,15 @@ def generate_strategy_draft_markdown_llm( 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) ex = ( _truncate_strategy_narrative(excerpt_raw, excerpt_max) if excerpt_raw @@ -77,6 +91,11 @@ def generate_strategy_draft_markdown_llm( "rules_draft_markdown": rd, "report_strategy_excerpt": ex, } + if report_uses_chapter8_text_mining_probe(report_config): + payload["structured_brief_omission_note"] = ( + "已启用第八章文本挖掘:本 JSON 中的 structured_brief 已省略关注词/场景子串计数字段," + "避免与当前报告正文口径冲突;请依规则底稿第五节说明与报告 §8 自拟假设,勿逐条编造子串命中列表。" + ) raw = json.dumps(payload, ensure_ascii=False) if len(raw) > 500_000: payload["rules_draft_markdown"] = _truncate_rules_draft_md(rd, 200_000) diff --git a/backend/pipeline/reporting/strategy_draft.py b/backend/pipeline/reporting/strategy_draft.py index 017a894..5c1d3c2 100644 --- a/backend/pipeline/reporting/strategy_draft.py +++ b/backend/pipeline/reporting/strategy_draft.py @@ -98,6 +98,19 @@ def _risk_line(checked: bool, text: str) -> str: return f"- {mark} {text}" +def report_uses_chapter8_text_mining_probe(report_config: dict[str, Any] | None) -> bool: + """ + 与任务 ``report_config`` 中 ``chapter8_text_mining_probe`` 一致;未显式设置时默认 ``True`` + (与 ``jd.runner.get_default_report_config`` 一致)。 + 为 ``True`` 时,策略稿第五节不再逐条列举关注词/场景子串命中,以免与当前报告正文口径冲突。 + """ + if not isinstance(report_config, dict): + return True + if "chapter8_text_mining_probe" in report_config: + return bool(report_config.get("chapter8_text_mining_probe")) + return True + + def build_strategy_draft_markdown( *, job_id: int, @@ -106,8 +119,10 @@ def build_strategy_draft_markdown( business_notes: str = "", generated_at_iso: str = "", strategy_decisions: dict[str, Any] | None = None, + report_config: dict[str, Any] | None = None, ) -> str: """生成可下载的 Markdown:策略框架为主,附录为数据速览。""" + use_ch8_probe = report_uses_chapter8_text_mining_probe(report_config) d = strategy_decisions or {} pos = _esc(d.get("positioning_choice") or "").strip() kw = _esc(brief.get("keyword")) or _esc(keyword) or "—" @@ -253,31 +268,41 @@ def build_strategy_draft_markdown( ckw = brief.get("comment_focus_keywords") or [] usc = brief.get("usage_scenarios") or [] lines.extend(["## 五、用户需求与场景 — 可写成策略的假设", ""]) - lines.append( - "*下列由关注词/场景**计数**转化而来,是「待验证假设」而非结论;请结合评价原文抽样修订。*" - ) - lines.append("") - if ckw: - for item in ckw[:8]: - if isinstance(item, dict): - w = _esc(item.get("word")) - c = _num(item.get("count")) - lines.append( - f"- **假设**:用户决策中「{w}」被频繁提及(约 {c} 次统计命中)—— " - f"*可追问:本品故事是否正面回应?传播关键词是否覆盖?*" - ) - if usc: - for item in usc[:6]: - if isinstance(item, dict): - sc = _esc(item.get("scenario")) - cn = _num(item.get("count")) - sh = _pct(item.get("share_of_text_units")) - lines.append( - f"- **场景命题**:「{sc}」在预设场景中约 {cn} 条、约占 {sh} 文本单元—— " - f"*可追问:主图/详情/客服话术是否对齐该场景?*" - ) - if not ckw and not usc: - lines.append("*摘要中无关注词/场景组结果,请补全评论侧分析后再写本节。*") + if use_ch8_probe: + lines.extend( + [ + "*当前任务报告以**第八章评论侧文本挖掘**为主线(替代原关注词/场景条形图为主呈现)时,**请勿**再逐条照搬「子串命中次数」式假设列表——结构化摘要里仍可能含 `comment_focus_keywords` / `usage_scenarios` 等字段,**与当前报告正文展示口径可能不一致**。*", + "", + "*请结合报告中 **§8** 评论与情感归纳、文本挖掘小节,以及评价原文抽样,自拟若干条「待验证假设」(需可复核、可证伪)。*", + "", + ] + ) + else: + lines.append( + "*下列由关注词/场景**计数**转化而来,是「待验证假设」而非结论;请结合评价原文抽样修订。*" + ) + lines.append("") + if ckw: + for item in ckw[:8]: + if isinstance(item, dict): + w = _esc(item.get("word")) + c = _num(item.get("count")) + lines.append( + f"- **假设**:用户决策中「{w}」被频繁提及(约 {c} 次统计命中)—— " + f"*可追问:本品故事是否正面回应?传播关键词是否覆盖?*" + ) + if usc: + for item in usc[:6]: + if isinstance(item, dict): + sc = _esc(item.get("scenario")) + cn = _num(item.get("count")) + sh = _pct(item.get("share_of_text_units")) + lines.append( + f"- **场景命题**:「{sc}」在预设场景中约 {cn} 条、约占 {sh} 文本单元—— " + f"*可追问:主图/详情/客服话术是否对齐该场景?*" + ) + if not ckw and not usc: + lines.append("*摘要中无关注词/场景组结果,请补全评论侧分析后再写本节。*") lines.append("") hints = brief.get("strategy_hints") or [] @@ -316,11 +341,16 @@ 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 = ( + "评论侧量化/关键词归纳是否**以偏概全**?(需原评论抽样)" + if use_ch8_probe + else "关注词/场景是否**以偏概全**?(需原评论抽样)" + ) lines.extend( [ "## 七、风险与待证伪", "", - _risk_line(rk, "关注词/场景是否**以偏概全**?(需原评论抽样)"), + _risk_line(rk, rk_kw), _risk_line(rp, "价格带是否含大促/异常挂价?(需核对清洗规则)"), _risk_line(rc, "列表集中度与深入样本品牌是否**矛盾**?(需解释渠道差异)"), "", diff --git a/backend/pipeline/tests/test_strategy_draft.py b/backend/pipeline/tests/test_strategy_draft.py index e7c8fcc..59a1c89 100644 --- a/backend/pipeline/tests/test_strategy_draft.py +++ b/backend/pipeline/tests/test_strategy_draft.py @@ -45,6 +45,7 @@ class StrategyDraftTests(SimpleTestCase): keyword="K", brief=brief, strategy_decisions=decisions, + report_config={"chapter8_text_mining_probe": False}, ) self.assertIn("**本品角色**:追赶型", md) self.assertIn("- [x] **卡腰**", md) @@ -54,3 +55,48 @@ class StrategyDraftTests(SimpleTestCase): self.assertIn("- [x] 关注词/场景是否**以偏概全**", md) self.assertIn("- [ ] 价格带是否含大促", md) self.assertIn("- [x] 列表集中度与深入样本品牌是否**矛盾**", md) + + def test_chapter8_probe_omits_focus_scenario_count_bullets(self) -> None: + brief = { + "schema_version": 1, + "keyword": "低GI", + "comment_focus_keywords": [{"word": "口感", "count": 501}], + "usage_scenarios": [ + { + "scenario": "控糖/血糖相关", + "count": 305, + "share_of_text_units": 0.272, + } + ], + } + md = build_strategy_draft_markdown( + job_id=1, + keyword="低GI", + brief=brief, + report_config={"chapter8_text_mining_probe": True}, + ) + self.assertIn("文本挖掘", md) + self.assertNotIn("假设**:用户决策中「口感」", md) + self.assertNotIn("场景命题**:「控糖", md) + + def test_legacy_report_shows_focus_scenario_bullets(self) -> None: + brief = { + "schema_version": 1, + "keyword": "低GI", + "comment_focus_keywords": [{"word": "口感", "count": 501}], + "usage_scenarios": [ + { + "scenario": "控糖/血糖相关", + "count": 305, + "share_of_text_units": 0.272, + } + ], + } + md = build_strategy_draft_markdown( + job_id=1, + keyword="低GI", + brief=brief, + report_config={"chapter8_text_mining_probe": False}, + ) + self.assertIn("假设**:用户决策中「口感」", md) + self.assertIn("场景命题**:「控糖/血糖相关」", md) diff --git a/backend/pipeline/views/job_report_views.py b/backend/pipeline/views/job_report_views.py index 6733d5b..f927135 100644 --- a/backend/pipeline/views/job_report_views.py +++ b/backend/pipeline/views/job_report_views.py @@ -158,6 +158,7 @@ class JobStrategyDraftView(APIView): report_excerpt, excerpt_src = load_report_strategy_excerpt(job.run_dir) except OSError: report_excerpt, excerpt_src = "", "none" + rc_job = job.report_config if isinstance(job.report_config, dict) else None try: if generator == "llm": md = generate_strategy_draft_markdown_llm( @@ -168,6 +169,7 @@ class JobStrategyDraftView(APIView): generated_at_iso=gen_at, strategy_decisions=strategy_decisions, report_strategy_excerpt=report_excerpt, + report_config=rc_job, ) src = "llm_text_ai_crawler_v1" else: @@ -178,6 +180,7 @@ class JobStrategyDraftView(APIView): business_notes=notes, generated_at_iso=gen_at, strategy_decisions=strategy_decisions, + report_config=rc_job, ) src = "structured_summary_rules_v1" except ValueError as e: