From 7b9009bd90f817b12a208605fc4d5865e4d5f1c4 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Wed, 22 Apr 2026 10:25:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(marketing):=20=E7=AD=96=E7=95=A5=E7=A8=BF?= =?UTF-8?q?=E4=B8=A4=E6=AD=A5=E7=94=9F=E6=88=90=E5=95=86=E8=AF=A6=E5=8C=85?= =?UTF-8?q?=20API=20=E4=B8=8E=E9=A2=84=E8=A7=88=E9=A1=B5=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../pipeline/llm/generate_marketing_detail.py | 128 ++++++++++++++++++ backend/pipeline/serializers.py | 35 ++++- .../tests/test_marketing_detail_pack.py | 56 ++++++++ backend/pipeline/urls.py | 5 + backend/pipeline/views/__init__.py | 2 + backend/pipeline/views/job_report_views.py | 61 ++++++++- frontend/src/views/jd/JdStrategyBuildView.vue | 1 + frontend/src/views/jd/JdStrategyView.vue | 123 ++++++++++++++++- 8 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 backend/pipeline/llm/generate_marketing_detail.py create mode 100644 backend/pipeline/tests/test_marketing_detail_pack.py diff --git a/backend/pipeline/llm/generate_marketing_detail.py b/backend/pipeline/llm/generate_marketing_detail.py new file mode 100644 index 0000000..ca8e9b8 --- /dev/null +++ b/backend/pipeline/llm/generate_marketing_detail.py @@ -0,0 +1,128 @@ +"""策略稿 → 核心信息卡 → 商详文案包(两步 LLM,JSON 输出)。""" +from __future__ import annotations + +import json +from typing import Any + +from .llm_client import call_llm + +_MAX_STRATEGY_CHARS = 28_000 + +CORE_CARD_SYSTEM = """你是电商商详与营销文案顾问。根据用户提供的「策略稿全文」与结构化决策、业务备注,输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏,不要前后说明文字)。 + +**硬性**: +- 事实、数字、功效、检测结论、销量、评价原文:**仅可**来自输入;**禁止**编造未出现的品牌名、数据、「用户说」引语。 +- 食品/健康相关:**禁止**治疗承诺与夸大疗效;无依据写「输入未体现」或「待法务确认」。 +- 句子短、可落地;兼顾**购买者决策**与商详写手可用性。 + +**JSON 键(须全部出现,值为字符串;无内容用空串)**: +- one_liner_value:一句话价值主张(买家能得到什么) +- buyer_job_to_be_done:购买者的任务或情境(一句) +- key_pain_or_desire:核心痛点或欲望(与策略一致) +- why_this_product:为何要选这一款(相对同类,一句) +- proof_or_trust_angle:信任或证明角度(无依据写「输入未体现」) +- differentiation_vs_alternatives:与替代方案相比的差异(一句) +- price_value_framing:价位与价值感如何表述(与策略价位可对读;无则「待业务确认」) +- compliance_taboos:表述禁区摘要(来自业务备注或策略风险) +- open_points_for_business:待业务补充(无则空串) +""" + +DETAIL_PACK_SYSTEM = """你是京东商详向文案手。输入为已定稿的「核心信息卡」JSON 与关键词。请输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏)。 + +**硬性**: +- **仅可**依据核心信息卡展开;**禁止**新增数字、功效、认证、评价引语、竞品具体名(除非信息卡里已有)。 +- 购买者视角,短句;禁止输出 JSON 键名英文给最终读者(值全部为中文商详可用文案)。 +- 不要泄露「核心信息卡」「策略稿」等内部词。 + +**JSON 键(须全部出现)**: +- listing_titles:字符串数组,4~6 条商品短标题备选(每条约 30 字内) +- listing_subtitle:一条列表副文案(约 60 字内) +- detail_headline:商详首屏下 lead,1~2 句 +- selling_bullets:字符串数组,5~8 条卖点(每条约 40 字内) +- spec_sidebar_lines:字符串数组,0~3 条参数区旁短句(可空数组) +- faq:对象数组,每项含 question、answer 字符串,3~5 组;答句不得超出信息卡承诺 +""" + + +def _truncate_strategy(md: str) -> tuple[str, bool]: + t = (md or "").strip() + if len(t) <= _MAX_STRATEGY_CHARS: + return t, False + return t[: _MAX_STRATEGY_CHARS].rstrip() + "\n\n…(策略正文已截断,以下同)\n", True + + +def _parse_llm_json(raw: str) -> dict[str, Any]: + s = (raw or "").strip() + if not s: + raise ValueError("大模型返回为空") + try: + out = json.loads(s) + except json.JSONDecodeError: + i = s.find("{") + j = s.rfind("}") + if i >= 0 and j > i: + out = json.loads(s[i : j + 1]) + else: + raise ValueError("大模型返回不是合法 JSON") from None + if not isinstance(out, dict): + raise ValueError("大模型 JSON 须为对象") + return out + + +def generate_core_info_card( + *, + keyword: str, + strategy_markdown: str, + strategy_decisions: dict[str, Any] | None, + business_notes: str, +) -> dict[str, Any]: + md, truncated = _truncate_strategy(strategy_markdown) + payload = { + "keyword": keyword, + "strategy_markdown": md, + "strategy_markdown_truncated": truncated, + "strategy_decisions": strategy_decisions or {}, + "business_notes": (business_notes or "").strip(), + } + user = ( + "请根据以下 JSON 输出核心信息卡(仅 JSON 对象):\n" + + json.dumps(payload, ensure_ascii=False) + ) + raw = call_llm(CORE_CARD_SYSTEM, user) + return _parse_llm_json(raw) + + +def generate_detail_page_pack( + *, + keyword: str, + core_info_card: dict[str, Any], +) -> dict[str, Any]: + payload = { + "keyword": keyword, + "core_info_card": core_info_card, + } + user = "请根据以下 JSON 输出商详包(仅 JSON 对象):\n" + json.dumps( + payload, ensure_ascii=False + ) + raw = call_llm(DETAIL_PACK_SYSTEM, user) + return _parse_llm_json(raw) + + +def generate_marketing_detail_pack( + *, + keyword: str, + strategy_markdown: str, + strategy_decisions: dict[str, Any] | None = None, + business_notes: str = "", +) -> dict[str, Any]: + core = generate_core_info_card( + keyword=keyword, + strategy_markdown=strategy_markdown, + strategy_decisions=strategy_decisions, + business_notes=business_notes, + ) + pack = generate_detail_page_pack(keyword=keyword, core_info_card=core) + return { + "core_info_card": core, + "detail_page_pack": pack, + } diff --git a/backend/pipeline/serializers.py b/backend/pipeline/serializers.py index d71631a..5c09fae 100644 --- a/backend/pipeline/serializers.py +++ b/backend/pipeline/serializers.py @@ -22,7 +22,6 @@ _REPORT_CONFIG_ALLOWED_KEYS = frozenset( "llm_promo_group_summaries", "llm_strategy_opportunities", "llm_comment_group_summaries", - "llm_scenario_group_summaries", "llm_group_summaries_chunk_by_matrix", "chapter8_text_mining_probe", "chapter8_text_mining_probe_live_llm", @@ -34,8 +33,6 @@ _REPORT_CONFIG_ALLOWED_KEYS = frozenset( "chapter8_probe_cooc_vocab", "chapter8_probe_cooc_pairs", "chapter8_probe_wordcloud_max", - "comment_focus_words", - "comment_scenario_groups", "external_market_table_rows", } ) @@ -46,6 +43,10 @@ def validate_report_config_body(value: dict) -> dict: raise serializers.ValidationError("须为 JSON 对象") value = dict(value) value.pop("llm_section_bridges", None) + # 已废弃字段:静默丢弃,兼容旧任务 JSON + value.pop("comment_focus_words", None) + value.pop("comment_scenario_groups", None) + value.pop("llm_scenario_group_summaries", None) extra = set(value.keys()) - _REPORT_CONFIG_ALLOWED_KEYS if extra: raise serializers.ValidationError( @@ -60,7 +61,6 @@ def validate_report_config_body(value: dict) -> dict: "llm_promo_group_summaries", "llm_strategy_opportunities", "llm_comment_group_summaries", - "llm_scenario_group_summaries", "llm_group_summaries_chunk_by_matrix", "chapter8_text_mining_probe", "chapter8_text_mining_probe_live_llm", @@ -473,3 +473,30 @@ class StrategyDraftRequestSerializer(serializers.Serializer): max_length=200, trim_whitespace=True, ) + + +class MarketingDetailPackRequestSerializer(serializers.Serializer): + """策略稿正文 → 核心信息卡 → 商详包(两步 LLM)。""" + + strategy_markdown = serializers.CharField( + required=True, + allow_blank=False, + min_length=20, + max_length=600_000, + trim_whitespace=False, + ) + business_notes = serializers.CharField( + required=False, + allow_blank=True, + default="", + max_length=20_000, + trim_whitespace=False, + ) + strategy_decisions = serializers.JSONField(required=False, allow_null=True) + + def validate_strategy_decisions(self, value): + if value is None: + return {} + if not isinstance(value, dict): + raise serializers.ValidationError("须为 JSON 对象") + return value diff --git a/backend/pipeline/tests/test_marketing_detail_pack.py b/backend/pipeline/tests/test_marketing_detail_pack.py new file mode 100644 index 0000000..1431533 --- /dev/null +++ b/backend/pipeline/tests/test_marketing_detail_pack.py @@ -0,0 +1,56 @@ +"""商详包:两步 JSON LLM 与解析。""" +from __future__ import annotations + +import json +from unittest.mock import patch + +from django.test import SimpleTestCase + +from pipeline.llm.generate_marketing_detail import ( + _parse_llm_json, + generate_marketing_detail_pack, +) + + +class MarketingDetailPackTests(SimpleTestCase): + def test_parse_llm_json_strips_wrapped(self) -> None: + raw = '前缀 {"a": 1, "b": "x"} 后缀' + d = _parse_llm_json(raw) + self.assertEqual(d, {"a": 1, "b": "x"}) + + def test_parse_llm_json_rejects_non_object(self) -> None: + with self.assertRaises(ValueError): + _parse_llm_json("[1,2]") + + def test_generate_marketing_detail_pack_two_calls(self) -> None: + core = { + "one_liner_value": "低负担早餐选择", + "buyer_job_to_be_done": "控糖加餐", + "key_pain_or_desire": "怕升糖", + "why_this_product": "配方可核对", + "proof_or_trust_angle": "输入未体现", + "differentiation_vs_alternatives": "同价带更少添加糖", + "price_value_framing": "中端", + "compliance_taboos": "不涉疗效", + "open_points_for_business": "", + } + pack = { + "listing_titles": ["标题A", "标题B"], + "listing_subtitle": "副文案", + "detail_headline": "首屏一句", + "selling_bullets": ["卖点1"], + "spec_sidebar_lines": [], + "faq": [{"question": "是否低GI?", "answer": "以包装与检测为准。"}], + } + with patch( + "pipeline.llm.generate_marketing_detail.call_llm", + side_effect=[json.dumps(core, ensure_ascii=False), json.dumps(pack, ensure_ascii=False)], + ): + out = generate_marketing_detail_pack( + keyword="低GI", + strategy_markdown="# 策略\n\n- 要点", + strategy_decisions={"product_role": "新品"}, + business_notes="", + ) + self.assertEqual(out["core_info_card"]["one_liner_value"], "低负担早餐选择") + self.assertEqual(out["detail_page_pack"]["listing_titles"][0], "标题A") diff --git a/backend/pipeline/urls.py b/backend/pipeline/urls.py index f0f1978..e6c391a 100644 --- a/backend/pipeline/urls.py +++ b/backend/pipeline/urls.py @@ -77,6 +77,11 @@ urlpatterns = [ views.JobStrategyDraftView.as_view(), name="job-strategy-draft", ), + path( + "jobs//marketing-detail-pack/", + views.JobMarketingDetailPackView.as_view(), + name="job-marketing-detail-pack", + ), path( "jobs//export-document/", views.JobExportDocumentView.as_view(), diff --git a/backend/pipeline/views/__init__.py b/backend/pipeline/views/__init__.py index 113a713..1398742 100644 --- a/backend/pipeline/views/__init__.py +++ b/backend/pipeline/views/__init__.py @@ -14,6 +14,7 @@ from .job_report_views import ( JobCompetitorBriefPackView, JobCompetitorBriefView, JobExportDocumentView, + JobMarketingDetailPackView, JobReportAssetView, JobStrategyDraftView, ) @@ -46,6 +47,7 @@ __all__ = [ "JobCompetitorBriefPackView", "JobCompetitorBriefView", "JobExportDocumentView", + "JobMarketingDetailPackView", "JobReportAssetView", "JobStrategyDraftView", "JobCancelView", diff --git a/backend/pipeline/views/job_report_views.py b/backend/pipeline/views/job_report_views.py index 71b975a..2009f7e 100644 --- a/backend/pipeline/views/job_report_views.py +++ b/backend/pipeline/views/job_report_views.py @@ -20,6 +20,7 @@ from ..jd.runner import ( regenerate_competitor_report, ) from ..llm.generate import generate_strategy_draft_markdown_llm +from ..llm.generate_marketing_detail import generate_marketing_detail_pack from ..models import JobStatus, PipelineJob from ..reporting.brief_pack import build_brief_pack_zip_bytes from ..reporting.brief_strategy_scope import ( @@ -33,7 +34,11 @@ 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 ..serializers import PipelineJobSerializer, StrategyDraftRequestSerializer +from ..serializers import ( + MarketingDetailPackRequestSerializer, + PipelineJobSerializer, + StrategyDraftRequestSerializer, +) from .common import job_run_dir_usable @@ -258,6 +263,60 @@ class JobStrategyDraftView(APIView): return Response(body) +@method_decorator(csrf_exempt, name="dispatch") +class JobMarketingDetailPackView(APIView): + """ + 根据浏览器会话中的策略稿 Markdown,经「核心信息卡」再派生**商详包**(JSON)。 + 两步均走 ``call_llm``;事实约束见提示词。 + """ + + def post(self, request, pk: int): + if not (settings.LOW_GI_PROJECT_ROOT or "").strip(): + return Response( + {"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + job = PipelineJob.objects.filter(pk=pk).first() + if not job: + raise Http404() + if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip(): + return Response( + {"detail": "仅可对已成功且含 run_dir 的任务生成商详包"}, + status=status.HTTP_400_BAD_REQUEST, + ) + ser = MarketingDetailPackRequestSerializer(data=request.data or {}) + ser.is_valid(raise_exception=True) + vd = ser.validated_data + md = (vd.get("strategy_markdown") or "").strip() + notes = (vd.get("business_notes") or "").strip() + raw_sd = vd.get("strategy_decisions") + strategy_decisions = raw_sd if isinstance(raw_sd, dict) else {} + gen_at = timezone.now().isoformat() + try: + inner = generate_marketing_detail_pack( + keyword=job.keyword, + strategy_markdown=md, + strategy_decisions=strategy_decisions, + business_notes=notes, + ) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY) + except requests.RequestException as e: + return Response( + {"detail": f"大模型网关错误:{e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + body: dict[str, object] = { + "schema_version": 1, + "job_id": job.id, + "keyword": job.keyword, + "generated_at": gen_at, + "source": "llm_marketing_detail_pack_v1", + **inner, + } + return Response(body) + + @method_decorator(csrf_exempt, name="dispatch") class JobExportDocumentView(APIView): """ diff --git a/frontend/src/views/jd/JdStrategyBuildView.vue b/frontend/src/views/jd/JdStrategyBuildView.vue index 51290cd..84f237c 100644 --- a/frontend/src/views/jd/JdStrategyBuildView.vue +++ b/frontend/src/views/jd/JdStrategyBuildView.vue @@ -190,6 +190,7 @@ async function generateAndGoPreview() { markdown: j.markdown || '', keyword: j.keyword || '', generated_at: j.generated_at || '', + last_request: buildPayload(), }), ) router.push({ path: '/jd/strategy-view', query: { job: id } }) diff --git a/frontend/src/views/jd/JdStrategyView.vue b/frontend/src/views/jd/JdStrategyView.vue index eaa0cd2..738ccb1 100644 --- a/frontend/src/views/jd/JdStrategyView.vue +++ b/frontend/src/views/jd/JdStrategyView.vue @@ -2,7 +2,12 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter, RouterLink } from 'vue-router' import MarkdownPreview from '../../components/MarkdownPreview.vue' -import { refreshJobs, useJobs, exportStrategyDocument } from '../../composables/useJobs' +import { + api, + refreshJobs, + useJobs, + exportStrategyDocument, +} from '../../composables/useJobs' const route = useRoute() const router = useRouter() @@ -14,6 +19,25 @@ const draftMeta = ref(null) const viewMode = ref('render') const exportErr = ref('') const exportBusy = ref(false) +const marketingBusy = ref(false) +const marketingErr = ref('') +const marketingResult = ref(null) + +function payloadForMarketing(lastRequest) { + if (!lastRequest || typeof lastRequest !== 'object') { + return { business_notes: '', strategy_decisions: {} } + } + const { + generator: _g, + business_notes: bn, + strategy_matrix_group: _mg, + ...rest + } = lastRequest + return { + business_notes: (bn || '').trim(), + strategy_decisions: rest, + } +} const STORAGE_KEY = (id) => `ma_strategy_draft_${id}` @@ -44,6 +68,7 @@ function loadDraft() { draftMeta.value = { keyword: o.keyword || '', generated_at: o.generated_at || '', + last_request: o.last_request || null, } } catch { draftMd.value = '' @@ -73,6 +98,41 @@ function downloadDraftMd() { URL.revokeObjectURL(u) } +async function generateMarketingDetailPack() { + if (!draftMd.value || !selectedId.value) return + marketingErr.value = '' + marketingResult.value = null + marketingBusy.value = true + const { business_notes, strategy_decisions } = payloadForMarketing( + draftMeta.value?.last_request, + ) + try { + const r = await api(`/api/jobs/${selectedId.value}/marketing-detail-pack/`, { + method: 'POST', + body: JSON.stringify({ + strategy_markdown: draftMd.value, + business_notes, + strategy_decisions, + }), + }) + const text = await r.text() + if (!r.ok) { + try { + const j = JSON.parse(text) + marketingErr.value = j.detail || text + } catch { + marketingErr.value = text || `HTTP ${r.status}` + } + return + } + marketingResult.value = JSON.parse(text) + } catch (e) { + marketingErr.value = String(e) + } finally { + marketingBusy.value = false + } +} + async function exportStrategyFmt(fmt) { if (!draftMd.value || !selectedId.value) return exportErr.value = '' @@ -146,7 +206,7 @@ watch(successJobs, (list) => {

策略稿预览

- 选择在策略生成页已生成过的任务查看文稿(保存在本浏览器会话内)。需要改决策请回到 + 选择在策略生成页已生成过的任务查看文稿(保存在本浏览器会话内)。可点击生成商详包:先抽核心信息卡,再派生标题与卖点等(两次大模型调用)。需要改决策请回到 策略生成 重新提交。分析数据见 报告查看。 @@ -187,6 +247,14 @@ watch(successJobs, (list) => { +

@@ -194,6 +262,21 @@ watch(successJobs, (list) => {

{{ exportErr }}

+

{{ marketingErr }}

+
+

商详包

+

+ {{ marketingResult.generated_at }} · {{ marketingResult.source }} +

+
+ 核心信息卡 +
{{ JSON.stringify(marketingResult.core_info_card, null, 2) }}
+
+
+ 商详字段 +
{{ JSON.stringify(marketingResult.detail_page_pack, null, 2) }}
+
+

任务目录:{{ selectedJob.run_dir }}

@@ -335,4 +418,40 @@ watch(successJobs, (list) => { border-radius: 8px; border: 1px solid #e5e7eb; } +.marketing-pack-out { + margin-top: 1rem; + padding: 0.85rem 1rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} +.marketing-pack-h { + margin: 0 0 0.35rem; + font-size: 1rem; + color: #1e293b; +} +.marketing-pack-meta { + margin: 0 0 0.75rem; + font-size: 0.8rem; +} +.marketing-details { + margin-bottom: 0.65rem; +} +.marketing-details summary { + cursor: pointer; + font-weight: 600; + font-size: 0.88rem; + color: #334155; +} +.marketing-pre { + margin: 0.5rem 0 0; + padding: 0.65rem; + font-size: 0.75rem; + line-height: 1.45; + overflow: auto; + max-height: 320px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; +}