mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(marketing): 策略稿两步生成商详包 API 与预览页入口
Made-with: Cursor
This commit is contained in:
parent
0f2118f9b1
commit
7b9009bd90
128
backend/pipeline/llm/generate_marketing_detail.py
Normal file
128
backend/pipeline/llm/generate_marketing_detail.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
@ -22,7 +22,6 @@ _REPORT_CONFIG_ALLOWED_KEYS = frozenset(
|
|||||||
"llm_promo_group_summaries",
|
"llm_promo_group_summaries",
|
||||||
"llm_strategy_opportunities",
|
"llm_strategy_opportunities",
|
||||||
"llm_comment_group_summaries",
|
"llm_comment_group_summaries",
|
||||||
"llm_scenario_group_summaries",
|
|
||||||
"llm_group_summaries_chunk_by_matrix",
|
"llm_group_summaries_chunk_by_matrix",
|
||||||
"chapter8_text_mining_probe",
|
"chapter8_text_mining_probe",
|
||||||
"chapter8_text_mining_probe_live_llm",
|
"chapter8_text_mining_probe_live_llm",
|
||||||
@ -34,8 +33,6 @@ _REPORT_CONFIG_ALLOWED_KEYS = frozenset(
|
|||||||
"chapter8_probe_cooc_vocab",
|
"chapter8_probe_cooc_vocab",
|
||||||
"chapter8_probe_cooc_pairs",
|
"chapter8_probe_cooc_pairs",
|
||||||
"chapter8_probe_wordcloud_max",
|
"chapter8_probe_wordcloud_max",
|
||||||
"comment_focus_words",
|
|
||||||
"comment_scenario_groups",
|
|
||||||
"external_market_table_rows",
|
"external_market_table_rows",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -46,6 +43,10 @@ def validate_report_config_body(value: dict) -> dict:
|
|||||||
raise serializers.ValidationError("须为 JSON 对象")
|
raise serializers.ValidationError("须为 JSON 对象")
|
||||||
value = dict(value)
|
value = dict(value)
|
||||||
value.pop("llm_section_bridges", None)
|
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
|
extra = set(value.keys()) - _REPORT_CONFIG_ALLOWED_KEYS
|
||||||
if extra:
|
if extra:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
@ -60,7 +61,6 @@ def validate_report_config_body(value: dict) -> dict:
|
|||||||
"llm_promo_group_summaries",
|
"llm_promo_group_summaries",
|
||||||
"llm_strategy_opportunities",
|
"llm_strategy_opportunities",
|
||||||
"llm_comment_group_summaries",
|
"llm_comment_group_summaries",
|
||||||
"llm_scenario_group_summaries",
|
|
||||||
"llm_group_summaries_chunk_by_matrix",
|
"llm_group_summaries_chunk_by_matrix",
|
||||||
"chapter8_text_mining_probe",
|
"chapter8_text_mining_probe",
|
||||||
"chapter8_text_mining_probe_live_llm",
|
"chapter8_text_mining_probe_live_llm",
|
||||||
@ -473,3 +473,30 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
trim_whitespace=True,
|
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
|
||||||
|
|||||||
56
backend/pipeline/tests/test_marketing_detail_pack.py
Normal file
56
backend/pipeline/tests/test_marketing_detail_pack.py
Normal 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")
|
||||||
@ -77,6 +77,11 @@ urlpatterns = [
|
|||||||
views.JobStrategyDraftView.as_view(),
|
views.JobStrategyDraftView.as_view(),
|
||||||
name="job-strategy-draft",
|
name="job-strategy-draft",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"jobs/<int:pk>/marketing-detail-pack/",
|
||||||
|
views.JobMarketingDetailPackView.as_view(),
|
||||||
|
name="job-marketing-detail-pack",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"jobs/<int:pk>/export-document/",
|
"jobs/<int:pk>/export-document/",
|
||||||
views.JobExportDocumentView.as_view(),
|
views.JobExportDocumentView.as_view(),
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from .job_report_views import (
|
|||||||
JobCompetitorBriefPackView,
|
JobCompetitorBriefPackView,
|
||||||
JobCompetitorBriefView,
|
JobCompetitorBriefView,
|
||||||
JobExportDocumentView,
|
JobExportDocumentView,
|
||||||
|
JobMarketingDetailPackView,
|
||||||
JobReportAssetView,
|
JobReportAssetView,
|
||||||
JobStrategyDraftView,
|
JobStrategyDraftView,
|
||||||
)
|
)
|
||||||
@ -46,6 +47,7 @@ __all__ = [
|
|||||||
"JobCompetitorBriefPackView",
|
"JobCompetitorBriefPackView",
|
||||||
"JobCompetitorBriefView",
|
"JobCompetitorBriefView",
|
||||||
"JobExportDocumentView",
|
"JobExportDocumentView",
|
||||||
|
"JobMarketingDetailPackView",
|
||||||
"JobReportAssetView",
|
"JobReportAssetView",
|
||||||
"JobStrategyDraftView",
|
"JobStrategyDraftView",
|
||||||
"JobCancelView",
|
"JobCancelView",
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from ..jd.runner import (
|
|||||||
regenerate_competitor_report,
|
regenerate_competitor_report,
|
||||||
)
|
)
|
||||||
from ..llm.generate import generate_strategy_draft_markdown_llm
|
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 ..models import JobStatus, PipelineJob
|
||||||
from ..reporting.brief_pack import build_brief_pack_zip_bytes
|
from ..reporting.brief_pack import build_brief_pack_zip_bytes
|
||||||
from ..reporting.brief_strategy_scope import (
|
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.report_strategy_excerpt import load_report_strategy_excerpt
|
||||||
from ..reporting.strategy_draft import build_strategy_draft_markdown
|
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
|
from .common import job_run_dir_usable
|
||||||
|
|
||||||
|
|
||||||
@ -258,6 +263,60 @@ class JobStrategyDraftView(APIView):
|
|||||||
return Response(body)
|
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")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
class JobExportDocumentView(APIView):
|
class JobExportDocumentView(APIView):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -190,6 +190,7 @@ async function generateAndGoPreview() {
|
|||||||
markdown: j.markdown || '',
|
markdown: j.markdown || '',
|
||||||
keyword: j.keyword || '',
|
keyword: j.keyword || '',
|
||||||
generated_at: j.generated_at || '',
|
generated_at: j.generated_at || '',
|
||||||
|
last_request: buildPayload(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
router.push({ path: '/jd/strategy-view', query: { job: id } })
|
router.push({ path: '/jd/strategy-view', query: { job: id } })
|
||||||
|
|||||||
@ -2,7 +2,12 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import MarkdownPreview from '../../components/MarkdownPreview.vue'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -14,6 +19,25 @@ const draftMeta = ref(null)
|
|||||||
const viewMode = ref('render')
|
const viewMode = ref('render')
|
||||||
const exportErr = ref('')
|
const exportErr = ref('')
|
||||||
const exportBusy = ref(false)
|
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}`
|
const STORAGE_KEY = (id) => `ma_strategy_draft_${id}`
|
||||||
|
|
||||||
@ -44,6 +68,7 @@ function loadDraft() {
|
|||||||
draftMeta.value = {
|
draftMeta.value = {
|
||||||
keyword: o.keyword || '',
|
keyword: o.keyword || '',
|
||||||
generated_at: o.generated_at || '',
|
generated_at: o.generated_at || '',
|
||||||
|
last_request: o.last_request || null,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
draftMd.value = ''
|
draftMd.value = ''
|
||||||
@ -73,6 +98,41 @@ function downloadDraftMd() {
|
|||||||
URL.revokeObjectURL(u)
|
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) {
|
async function exportStrategyFmt(fmt) {
|
||||||
if (!draftMd.value || !selectedId.value) return
|
if (!draftMd.value || !selectedId.value) return
|
||||||
exportErr.value = ''
|
exportErr.value = ''
|
||||||
@ -146,7 +206,7 @@ watch(successJobs, (list) => {
|
|||||||
<section class="ma-card">
|
<section class="ma-card">
|
||||||
<h2>策略稿预览</h2>
|
<h2>策略稿预览</h2>
|
||||||
<p class="hint-top">
|
<p class="hint-top">
|
||||||
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本浏览器会话内)。需要改决策请回到
|
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本浏览器会话内)。可点击<strong>生成商详包</strong>:先抽核心信息卡,再派生标题与卖点等(两次大模型调用)。需要改决策请回到
|
||||||
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
|
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
|
||||||
重新提交。分析数据见
|
重新提交。分析数据见
|
||||||
<RouterLink to="/jd/analysis-view">报告查看</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 type="button" class="ma-btn ma-btn-primary" @click="goBuildSameJob">
|
||||||
去策略生成
|
去策略生成
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ma-btn ma-btn-secondary"
|
||||||
|
:disabled="!draftMd || !selectedId || marketingBusy"
|
||||||
|
@click="generateMarketingDetailPack"
|
||||||
|
>
|
||||||
|
{{ marketingBusy ? '商详包生成中…' : '生成商详包' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="draftMeta?.generated_at" class="meta-line ma-muted">
|
<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>
|
<template v-if="draftMeta.keyword"> · 关键词:{{ draftMeta.keyword }}</template>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="exportErr" class="ma-err">{{ exportErr }}</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">
|
<p v-if="selectedJob?.run_dir" class="run-dir-note ma-muted">
|
||||||
任务目录:<span class="run-dir-path">{{ selectedJob.run_dir }}</span>
|
任务目录:<span class="run-dir-path">{{ selectedJob.run_dir }}</span>
|
||||||
</p>
|
</p>
|
||||||
@ -335,4 +418,40 @@ watch(successJobs, (list) => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e5e7eb;
|
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>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user