feat(marketing): 策略稿两步生成商详包 API 与预览页入口

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-22 10:25:08 +08:00
parent 0f2118f9b1
commit 7b9009bd90
8 changed files with 404 additions and 7 deletions

View File

@ -0,0 +1,128 @@
"""策略稿 → 核心信息卡 → 商详文案包(两步 LLMJSON 输出)。"""
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字符串数组46 条商品短标题备选每条约 30 字内
- listing_subtitle一条列表副文案 60 字内
- detail_headline商详首屏下 lead12
- selling_bullets字符串数组58 条卖点每条约 40 字内
- spec_sidebar_lines字符串数组03 条参数区旁短句可空数组
- faq对象数组每项含 questionanswer 字符串35 答句不得超出信息卡承诺
"""
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,
}

View File

@ -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

View File

@ -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")

View File

@ -77,6 +77,11 @@ urlpatterns = [
views.JobStrategyDraftView.as_view(),
name="job-strategy-draft",
),
path(
"jobs/<int:pk>/marketing-detail-pack/",
views.JobMarketingDetailPackView.as_view(),
name="job-marketing-detail-pack",
),
path(
"jobs/<int:pk>/export-document/",
views.JobExportDocumentView.as_view(),

View File

@ -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",

View File

@ -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):
"""

View File

@ -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 } })

View File

@ -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) => {
<section class="ma-card">
<h2>策略稿预览</h2>
<p class="hint-top">
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本浏览器会话内需要改决策请回到
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本浏览器会话内可点击<strong>生成商详包</strong>先抽核心信息卡再派生标题与卖点等两次大模型调用需要改决策请回到
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
重新提交分析数据见
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>
@ -187,6 +247,14 @@ watch(successJobs, (list) => {
<button type="button" class="ma-btn ma-btn-primary" @click="goBuildSameJob">
去策略生成
</button>
<button
type="button"
class="ma-btn ma-btn-secondary"
:disabled="!draftMd || !selectedId || marketingBusy"
@click="generateMarketingDetailPack"
>
{{ marketingBusy ? '商详包生成中…' : '生成商详包' }}
</button>
</div>
<p v-if="draftMeta?.generated_at" class="meta-line ma-muted">
@ -194,6 +262,21 @@ watch(successJobs, (list) => {
<template v-if="draftMeta.keyword"> · 关键词{{ draftMeta.keyword }}</template>
</p>
<p v-if="exportErr" class="ma-err">{{ exportErr }}</p>
<p v-if="marketingErr" class="ma-err">{{ marketingErr }}</p>
<div v-if="marketingResult" class="marketing-pack-out">
<h3 class="marketing-pack-h">商详包</h3>
<p class="ma-muted marketing-pack-meta">
{{ marketingResult.generated_at }} · {{ marketingResult.source }}
</p>
<details open class="marketing-details">
<summary>核心信息卡</summary>
<pre class="marketing-pre">{{ JSON.stringify(marketingResult.core_info_card, null, 2) }}</pre>
</details>
<details open class="marketing-details">
<summary>商详字段</summary>
<pre class="marketing-pre">{{ JSON.stringify(marketingResult.detail_page_pack, null, 2) }}</pre>
</details>
</div>
<p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted">
任务目录<span class="run-dir-path">{{ selectedJob.run_dir }}</span>
</p>
@ -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;
}
</style>