mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(marketing): 营销内容命名、文生图/视频提示词与结果规范化
两步 LLM 增加可复制文生图/文生视频提示词并强化卖点可视化;normalize_detail_page_pack 补全键;产品用语由商详包改为营销内容;前端导出 Markdown 与策略预览区展示一致;营销包落盘模块入库。 Made-with: Cursor
This commit is contained in:
parent
5df82f7d80
commit
6470d0d56c
@ -1,4 +1,4 @@
|
||||
"""策略稿 → 核心信息卡 → 商详文案包(两步 LLM,JSON 输出)。"""
|
||||
"""策略稿 → 核心信息卡 → 营销内容多触点文案(两步 LLM,JSON 输出)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@ -8,14 +8,16 @@ from .llm_client import call_llm
|
||||
|
||||
_MAX_STRATEGY_CHARS = 28_000
|
||||
|
||||
CORE_CARD_SYSTEM = """你是电商商详与营销文案顾问。根据用户提供的「策略稿全文」与结构化决策、业务备注,输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏,不要前后说明文字)。
|
||||
CORE_CARD_SYSTEM = """你是电商营销内容顾问。根据用户提供的「策略稿全文」与结构化决策、业务备注,输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏,不要前后说明文字)。
|
||||
|
||||
**硬性**:
|
||||
- 事实、数字、功效、检测结论、销量、评价原文:**仅可**来自输入;**禁止**编造未出现的品牌名、数据、「用户说」引语。
|
||||
- 食品/健康相关:**禁止**治疗承诺与夸大疗效;无依据写「输入未体现」或「待法务确认」。
|
||||
- 句子短、可落地;兼顾**购买者决策**与商详写手可用性。
|
||||
- 句子短、可落地;兼顾**购买者决策**与**列表/商详/主图等多触点**上架可用性。
|
||||
- **读者第一眼须知道在卖什么**:禁止通篇只有「价值感」「信任」「体验」而**不出现可识别的品类/形态**(如饼干、燕麦、奶粉、饮料等)。若输入未给出具体 SKU 名,仍须写清**类目 + 形态/规格层级**(如「低 GI 方向早餐饼干(待业务定款)」),不得用「优质好物」「健康之选」等**无品类**的句子糊弄本条。
|
||||
|
||||
**JSON 键(须全部出现,值为字符串;无内容用空串)**:
|
||||
- what_we_sell:**卖的是什么**(必填,建议 25~80 字)。写清**品类 + 主推形态/规格或适用场景**,让读者**不读策略稿**也能回答「你们在卖哪种货」。**仅可**综合策略稿、`strategy_decisions`(尤其 **pillar_product**、battlefield_one_line、audience_segment、marketing_strategy)、`business_notes` 与 `keyword` 监测语境中已出现的信息;若 `pillar_product` 非空须与之**不矛盾**。无具体商品名时须明确写「待业务补充主推 SKU/品名」,并保留类目词(可与关键词监测范围对读)。
|
||||
- one_liner_value:一句话价值主张(买家能得到什么)
|
||||
- buyer_job_to_be_done:购买者的任务或情境(一句)
|
||||
- key_pain_or_desire:核心痛点或欲望(与策略一致)
|
||||
@ -27,22 +29,80 @@ CORE_CARD_SYSTEM = """你是电商商详与营销文案顾问。根据用户提
|
||||
- open_points_for_business:待业务补充(无则空串)
|
||||
"""
|
||||
|
||||
DETAIL_PACK_SYSTEM = """你是京东商详向文案手。输入为已定稿的「核心信息卡」JSON 与关键词。请输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏)。
|
||||
DETAIL_PACK_SYSTEM = """你是京东场景营销内容写手。输入为已定稿的「核心信息卡」JSON 与关键词。请输出**仅一段 UTF-8 JSON 对象**(不要 Markdown 代码围栏)。
|
||||
|
||||
**硬性**:
|
||||
- **仅可**依据核心信息卡展开;**禁止**新增数字、功效、认证、评价引语、竞品具体名(除非信息卡里已有)。
|
||||
- 购买者视角,短句;禁止输出 JSON 键名英文给最终读者(值全部为中文商详可用文案)。
|
||||
- 购买者视角,短句;禁止输出 JSON 键名英文给最终读者(值全部为中文**多触点上架**可用文案)。
|
||||
- 不要泄露「核心信息卡」「策略稿」等内部词。
|
||||
- **每条 listing_titles、listing_subtitle、detail_headline、selling_bullets 的前两条**均须让读者能识别**在卖什么品类/什么货**(须与信息卡 **what_we_sell** 一致,可缩写但**禁止**偷换品类或只剩空洞形容词)。若信息卡 `what_we_sell` 已写品类,文案中**至少一处**直接出现该类目词或同义可识别表述。
|
||||
- **文生图/文生视频提示词**:须为**可直接复制**到常见文生图、文生视频模型的**中文**描述;**仅可**依据信息卡已有事实与品类,**禁止**在提示词里写「策略稿」「信息卡」「JSON」等元话语;**禁止**要求生成未授权的具体品牌 Logo、真实包装上的可辨认商标、带疗效承诺的贴片字。
|
||||
- **文生图须「有货、有卖点画面」**(硬性):
|
||||
- 从信息卡 **what_we_sell**、**one_liner_value**、**key_pain_or_desire**、**why_this_product**、**differentiation_vs_alternatives**、**price_value_framing** 中提炼 **1~3 条可画出来的卖点**,写入 ``text_to_image_prompt_main``;**场景图** ``text_to_image_prompt_scene`` 非空时须保留**至少 1 处**同款质地或品类辨识(非空场景图时)。
|
||||
- **禁止**整段只有「白底」「居中」「电商主图」「健康食品」等空壳,而**不出现具体货态**(形态、切片、包装类型、手持/摆放方式至少择一)。
|
||||
- **口感/质地类**(如松软、酥脆、绵密、有嚼劲):**必须**写成**可见结构**,不能只写一次形容词了事。例:**松软**→「吐司切片横截面气孔细腻、边缘微翘显蓬松」「轻按后缓慢回弹」「手撕开可见柔软内里」;**酥脆**→「饼干断面层次清晰、碎屑自然」。信息卡未提质地则**不写**,勿编造。
|
||||
- **配料/品类视觉**(如全麦):可写「麸皮颗粒隐约可见」「浅褐全麦外皮」等,**禁止**疗效字幕、血糖仪、前后对比治病画面。
|
||||
|
||||
**JSON 键(须全部出现)**:
|
||||
- listing_titles:字符串数组,4~6 条商品短标题备选(每条约 30 字内)
|
||||
- listing_titles:字符串数组,4~6 条商品短标题备选(每条约 30 字内;**每条须含可识别品类或品名线索**,禁止 6 条全是「安心之选」类)
|
||||
- listing_subtitle:一条列表副文案(约 60 字内)
|
||||
- detail_headline:商详首屏下 lead,1~2 句
|
||||
- detail_headline:商品详情页首屏下 lead,1~2 句(**首句须点明卖的是什么货**,再写价值)
|
||||
- selling_bullets:字符串数组,5~8 条卖点(每条约 40 字内)
|
||||
- spec_sidebar_lines:字符串数组,0~3 条参数区旁短句(可空数组)
|
||||
- faq:对象数组,每项含 question、answer 字符串,3~5 组;答句不得超出信息卡承诺
|
||||
- traceability_note:**依据与边界**(必填,2~4 句)。用业务可读中文说明:本包与信息卡中**哪些承诺方向一致**、**哪些表述须业务或法务核对**、**输入未体现的不得对外宣称**;**禁止**新数字、新功效、新认证。
|
||||
- main_image_three_points:字符串数组,**恰好 3 条**,主图/首图用超短句(每条建议 6~14 字);须与 **what_we_sell** 品类一致,可来自卖点压缩,禁止空泛口号
|
||||
- live_or_short_hook:一条直播或短视频开场钩句(≤40 字);同一事实约束
|
||||
- customer_service_opening:一条客服首句/欢迎语建议(≤50 字);同一事实约束
|
||||
- text_to_image_prompt_main:字符串,**主图/首图**文生图提示词(建议 **100~260** 字)。**必须**依次包含:① **具体货态**(与 **what_we_sell** 一致的品类+形态,如全麦吐司切片摞放、独立小包饼干);② **至少一条质地/卖点的视觉化描写**(与信息卡一致,参见上文「松软→截面/按压/手撕」等);③ **构图与背景**(如白底居中、轻微投影);④ **光影**(柔和棚拍、写实);⑤ **规避**(无 Logo、无疗效字、无竞品名)。**英文模型**可关键风格词括注英文。
|
||||
- text_to_image_prompt_scene:字符串,**场景/生活方式**备选图(建议 **80~200** 字):早餐桌、手持、厨房台面等;**须含**与主图**同一品类**的清晰货态,并**至少一处**质地或食用情境(如蒸汽、刀切截面、蘸牛奶)。与主图完全重复则宁可缩短但保留情境差分。无合适场景时 ``""``。
|
||||
- text_to_video_prompt:字符串,文生视频提示词(建议 **100~260** 字),竖屏 9:16、**5~15 秒**。**须**含 **1 个能体现质地或卖点的镜头**(如慢镜撕开吐司见柔软内里、刀切截面特写、轻捏回弹),与信息卡卖点一致;另写开场与转场(推近/平移)。**禁止**疗效字幕、未授权标识;可「无对白」或「一句中性口播」。
|
||||
"""
|
||||
|
||||
# 第二步 JSON 完整键表;模型漏键或旧落盘缺字段时由 ``normalize_detail_page_pack`` 补齐。
|
||||
_DETAIL_PAGE_PACK_DEFAULTS: dict[str, Any] = {
|
||||
"listing_titles": [],
|
||||
"listing_subtitle": "",
|
||||
"detail_headline": "",
|
||||
"selling_bullets": [],
|
||||
"spec_sidebar_lines": [],
|
||||
"faq": [],
|
||||
"traceability_note": "",
|
||||
"main_image_three_points": [],
|
||||
"live_or_short_hook": "",
|
||||
"customer_service_opening": "",
|
||||
"text_to_image_prompt_main": "",
|
||||
"text_to_image_prompt_scene": "",
|
||||
"text_to_video_prompt": "",
|
||||
}
|
||||
|
||||
_DETAIL_PAGE_PACK_LIST_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"listing_titles",
|
||||
"selling_bullets",
|
||||
"spec_sidebar_lines",
|
||||
"main_image_three_points",
|
||||
"faq",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_detail_page_pack(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""保证 ``detail_page_pack`` 含全部约定键,避免模型漏输出或旧 JSON 缺字段。"""
|
||||
out: dict[str, Any] = dict(data)
|
||||
for key in _DETAIL_PAGE_PACK_DEFAULTS:
|
||||
v = out.get(key)
|
||||
if key in _DETAIL_PAGE_PACK_LIST_KEYS:
|
||||
if isinstance(v, list):
|
||||
continue
|
||||
out[key] = []
|
||||
continue
|
||||
if v is None:
|
||||
out[key] = ""
|
||||
elif not isinstance(v, str):
|
||||
out[key] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
def _truncate_strategy(md: str) -> tuple[str, bool]:
|
||||
t = (md or "").strip()
|
||||
@ -101,11 +161,18 @@ def generate_detail_page_pack(
|
||||
"keyword": keyword,
|
||||
"core_info_card": core_info_card,
|
||||
}
|
||||
user = "请根据以下 JSON 输出商详包(仅 JSON 对象):\n" + json.dumps(
|
||||
payload, ensure_ascii=False
|
||||
user = (
|
||||
"请根据以下 JSON 输出营销内容多触点文案(**仅**一段 JSON 对象)。\n"
|
||||
"**必填键名(缺一不可,勿省略)**:listing_titles, listing_subtitle, detail_headline, "
|
||||
"selling_bullets, spec_sidebar_lines, faq, traceability_note, main_image_three_points, "
|
||||
"live_or_short_hook, customer_service_opening, text_to_image_prompt_main, "
|
||||
"text_to_image_prompt_scene, text_to_video_prompt。\n"
|
||||
"**文生图/视频**:须让「货」和卖点**看得见**(如松软→截面气孔、手撕/按压回弹);禁止整段只有白底健康食品而无具体形态与质地描写。\n"
|
||||
"输入数据:\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)
|
||||
)
|
||||
raw = call_llm(DETAIL_PACK_SYSTEM, user)
|
||||
return _parse_llm_json(raw)
|
||||
return normalize_detail_page_pack(_parse_llm_json(raw))
|
||||
|
||||
|
||||
def generate_marketing_detail_pack(
|
||||
|
||||
23
backend/pipeline/reporting/marketing_pack_persist.py
Normal file
23
backend/pipeline/reporting/marketing_pack_persist.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""营销内容包等产物落盘(任务 run_dir,便于归档与审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def persist_marketing_detail_pack_v1(run_dir: str | None, payload: dict[str, Any]) -> Path | None:
|
||||
"""
|
||||
写入 ``{run_dir}/marketing/marketing_detail_pack_v1.json``。
|
||||
``payload`` 建议与 API 响应体一致(含 schema_version、job_id、core_info_card 等)。
|
||||
"""
|
||||
if not run_dir or not str(run_dir).strip():
|
||||
return None
|
||||
root = Path(run_dir)
|
||||
if not root.is_dir():
|
||||
return None
|
||||
out_dir = root / "marketing"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / "marketing_detail_pack_v1.json"
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
@ -476,7 +476,7 @@ class StrategyDraftRequestSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class MarketingDetailPackRequestSerializer(serializers.Serializer):
|
||||
"""策略稿正文 → 核心信息卡 → 商详包(两步 LLM)。"""
|
||||
"""策略稿正文 → 核心信息卡 → 营销内容(多触点文案,两步 LLM)。"""
|
||||
|
||||
strategy_markdown = serializers.CharField(
|
||||
required=True,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""商详包:两步 JSON LLM 与解析。"""
|
||||
"""营销内容包:两步 JSON LLM 与解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
@ -9,10 +10,27 @@ from django.test import SimpleTestCase
|
||||
from pipeline.llm.generate_marketing_detail import (
|
||||
_parse_llm_json,
|
||||
generate_marketing_detail_pack,
|
||||
normalize_detail_page_pack,
|
||||
)
|
||||
from pipeline.reporting.marketing_pack_persist import persist_marketing_detail_pack_v1
|
||||
|
||||
|
||||
class MarketingDetailPackTests(SimpleTestCase):
|
||||
def test_normalize_fills_missing_keys_including_aigc(self) -> None:
|
||||
"""旧结果或模型漏键时补齐文生图/文生视频等字段。"""
|
||||
slim = {
|
||||
"traceability_note": "x",
|
||||
"main_image_three_points": ["a", "b", "c"],
|
||||
"live_or_short_hook": "h",
|
||||
"customer_service_opening": "c",
|
||||
}
|
||||
n = normalize_detail_page_pack(slim)
|
||||
self.assertEqual(n["text_to_image_prompt_main"], "")
|
||||
self.assertEqual(n["text_to_image_prompt_scene"], "")
|
||||
self.assertEqual(n["text_to_video_prompt"], "")
|
||||
self.assertEqual(n["listing_titles"], [])
|
||||
self.assertEqual(n["traceability_note"], "x")
|
||||
|
||||
def test_parse_llm_json_strips_wrapped(self) -> None:
|
||||
raw = '前缀 {"a": 1, "b": "x"} 后缀'
|
||||
d = _parse_llm_json(raw)
|
||||
@ -24,6 +42,7 @@ class MarketingDetailPackTests(SimpleTestCase):
|
||||
|
||||
def test_generate_marketing_detail_pack_two_calls(self) -> None:
|
||||
core = {
|
||||
"what_we_sell": "低GI方向早餐饼干(监测关键词语境,待业务定主推款)",
|
||||
"one_liner_value": "低负担早餐选择",
|
||||
"buyer_job_to_be_done": "控糖加餐",
|
||||
"key_pain_or_desire": "怕升糖",
|
||||
@ -41,6 +60,13 @@ class MarketingDetailPackTests(SimpleTestCase):
|
||||
"selling_bullets": ["卖点1"],
|
||||
"spec_sidebar_lines": [],
|
||||
"faq": [{"question": "是否低GI?", "answer": "以包装与检测为准。"}],
|
||||
"traceability_note": "与策略方向一致;具体宣称以包装为准。",
|
||||
"main_image_three_points": ["低GI早餐", "独立小包", "配料可核对"],
|
||||
"live_or_short_hook": "控糖早餐怎么选?先看配料表。",
|
||||
"customer_service_opening": "您好,这款饼干适合关注血糖管理的早餐场景,需要我帮您对比规格吗?",
|
||||
"text_to_image_prompt_main": "电商主图,白底,居中摆放一盘低GI早餐饼干,柔和棚拍光,写实,无品牌logo,健康清爽风格",
|
||||
"text_to_image_prompt_scene": "早餐餐桌场景,牛奶与饼干搭配,自然窗光,生活感,无文字贴片",
|
||||
"text_to_video_prompt": "竖屏9:16,5秒:从俯拍早餐桌缓慢推近至饼干包装,无对白,干净色调,无疗效字幕",
|
||||
}
|
||||
with patch(
|
||||
"pipeline.llm.generate_marketing_detail.call_llm",
|
||||
@ -54,3 +80,16 @@ class MarketingDetailPackTests(SimpleTestCase):
|
||||
)
|
||||
self.assertEqual(out["core_info_card"]["one_liner_value"], "低负担早餐选择")
|
||||
self.assertEqual(out["detail_page_pack"]["listing_titles"][0], "标题A")
|
||||
self.assertIn("竖屏", out["detail_page_pack"]["text_to_video_prompt"])
|
||||
|
||||
def test_persist_marketing_detail_pack_v1_writes_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
payload = {"schema_version": 1, "job_id": 1, "x": "y"}
|
||||
path = persist_marketing_detail_pack_v1(td, payload)
|
||||
self.assertIsNotNone(path)
|
||||
assert path is not None
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertEqual(path.parent.name, "marketing")
|
||||
self.assertIn("marketing_detail_pack_v1.json", path.name)
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(loaded["job_id"], 1)
|
||||
|
||||
@ -28,6 +28,7 @@ from ..reporting.brief_strategy_scope import (
|
||||
list_matrix_groups_for_api,
|
||||
resolve_strategy_matrix_group_index,
|
||||
)
|
||||
from ..reporting.marketing_pack_persist import persist_marketing_detail_pack_v1
|
||||
from ..reporting.md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes
|
||||
from ..reporting.report_matrix_group_evidence import (
|
||||
load_report_matrix_group_evidence_markdown,
|
||||
@ -266,7 +267,7 @@ class JobStrategyDraftView(APIView):
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class JobMarketingDetailPackView(APIView):
|
||||
"""
|
||||
根据浏览器会话中的策略稿 Markdown,经「核心信息卡」再派生**商详包**(JSON)。
|
||||
根据浏览器会话中的策略稿 Markdown,经「核心信息卡」再派生**营销内容**(多触点文案 JSON)。
|
||||
两步均走 ``call_llm``;事实约束见提示词。
|
||||
"""
|
||||
|
||||
@ -281,7 +282,7 @@ class JobMarketingDetailPackView(APIView):
|
||||
raise Http404()
|
||||
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
|
||||
return Response(
|
||||
{"detail": "仅可对已成功且含 run_dir 的任务生成商详包"},
|
||||
{"detail": "仅可对已成功且含 run_dir 的任务生成营销内容"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
ser = MarketingDetailPackRequestSerializer(data=request.data or {})
|
||||
@ -314,6 +315,10 @@ class JobMarketingDetailPackView(APIView):
|
||||
"source": "llm_marketing_detail_pack_v1",
|
||||
**inner,
|
||||
}
|
||||
try:
|
||||
persist_marketing_detail_pack_v1(job.run_dir, body)
|
||||
except OSError:
|
||||
pass
|
||||
return Response(body)
|
||||
|
||||
|
||||
@ -323,7 +328,7 @@ class JobExportDocumentView(APIView):
|
||||
将 Markdown 导出为 Word(.docx)或简易 PDF。
|
||||
- GET:``kind=report``,读取 ``run_dir/competitor_analysis.md``;若文件缺失但已有合并表,
|
||||
则先按任务配置调用 ``regenerate_competitor_report`` 再导出(与「报告生成」规则版一致)。
|
||||
- POST:``kind=strategy``,请求体 JSON 字段 ``markdown`` 为策略稿正文(与前端 sessionStorage 一致)。
|
||||
- POST:``kind=strategy``,请求体 ``markdown`` 为策略稿正文;``kind=marketing_detail`` 为营销内容等派生稿(同一套转版逻辑,下载文件名不同)。
|
||||
PDF 依赖本机中文字体或环境变量 ``MA_PDF_FONT`` 指向 .ttf。
|
||||
"""
|
||||
|
||||
@ -408,9 +413,9 @@ class JobExportDocumentView(APIView):
|
||||
kind = (body.get("kind") or "strategy").strip().lower()
|
||||
fmt = (body.get("fmt") or "docx").strip().lower()
|
||||
md = (body.get("markdown") or "").strip()
|
||||
if kind != "strategy":
|
||||
if kind not in ("strategy", "marketing_detail"):
|
||||
return Response(
|
||||
{"detail": "POST 仅支持 kind=strategy"},
|
||||
{"detail": "POST 的 kind 须为 strategy 或 marketing_detail"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not md:
|
||||
@ -424,14 +429,18 @@ class JobExportDocumentView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
if kind == "marketing_detail":
|
||||
base = f"job_{pk}_marketing_detail_pack"
|
||||
else:
|
||||
base = f"job_{pk}_strategy_draft"
|
||||
if fmt == "docx":
|
||||
data = markdown_to_docx_bytes(md)
|
||||
ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
fn = f"job_{pk}_strategy_draft.docx"
|
||||
fn = f"{base}.docx"
|
||||
else:
|
||||
data = markdown_to_pdf_bytes(md)
|
||||
ct = "application/pdf"
|
||||
fn = f"job_{pk}_strategy_draft.pdf"
|
||||
fn = f"{base}.pdf"
|
||||
except ValueError as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
resp = HttpResponse(data, content_type=ct)
|
||||
|
||||
@ -1,44 +1,5 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const jobs = ref([])
|
||||
|
||||
/** 终态 */
|
||||
const TERMINAL_JOB_STATUSES = new Set(['success', 'failed', 'cancelled'])
|
||||
|
||||
function isActiveJobStatus(status) {
|
||||
return status === 'pending' || status === 'running'
|
||||
}
|
||||
|
||||
/** 单一定时器轮询列表(避免 N 个任务 → N 路 GET /api/jobs/:id/) */
|
||||
let jobsListPollTimer = null
|
||||
|
||||
function stopJobsListPoll() {
|
||||
if (jobsListPollTimer != null) {
|
||||
clearInterval(jobsListPollTimer)
|
||||
jobsListPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobsListQuietly() {
|
||||
try {
|
||||
const r = await api('/api/jobs/')
|
||||
if (r.ok) {
|
||||
jobs.value = await r.json()
|
||||
}
|
||||
} catch {
|
||||
/* 忽略网络错误,下一轮再试 */
|
||||
}
|
||||
}
|
||||
|
||||
function syncJobsListPoll() {
|
||||
const hasActive = jobs.value.some((j) => isActiveJobStatus(j.status))
|
||||
if (!hasActive) {
|
||||
stopJobsListPoll()
|
||||
return
|
||||
}
|
||||
if (jobsListPollTimer != null) return
|
||||
jobsListPollTimer = setInterval(fetchJobsListQuietly, 3000)
|
||||
}
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useJobStore } from '../stores/jobs'
|
||||
|
||||
export function api(path, opts = {}) {
|
||||
return fetch(path, {
|
||||
@ -48,9 +9,7 @@ export function api(path, opts = {}) {
|
||||
}
|
||||
|
||||
export async function refreshJobs() {
|
||||
const r = await api('/api/jobs/')
|
||||
if (!r.ok) throw new Error(await r.text())
|
||||
jobs.value = await r.json()
|
||||
return useJobStore().refreshJobs()
|
||||
}
|
||||
|
||||
export function jobCancelUrl(jobId) {
|
||||
@ -117,11 +76,14 @@ export async function exportReportDocument(jobId, fmt = 'docx') {
|
||||
URL.revokeObjectURL(u)
|
||||
}
|
||||
|
||||
/** 策略稿正文(浏览器 sessionStorage)→ Word/PDF */
|
||||
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
|
||||
/**
|
||||
* 策略稿或营销内容 Markdown → Word/PDF
|
||||
* @param {'strategy' | 'marketing_detail'} [kind]
|
||||
*/
|
||||
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx', kind = 'strategy') {
|
||||
const r = await api(`/api/jobs/${jobId}/export-document/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind: 'strategy', fmt, markdown }),
|
||||
body: JSON.stringify({ kind, fmt, markdown }),
|
||||
})
|
||||
const ct = r.headers.get('Content-Type') || ''
|
||||
if (!r.ok) {
|
||||
@ -140,9 +102,12 @@ export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
|
||||
throw new Error(msg)
|
||||
}
|
||||
const blob = await r.blob()
|
||||
const fallback =
|
||||
kind === 'marketing_detail'
|
||||
? `job_${jobId}_marketing_detail_pack.${fmt}`
|
||||
: `job_${jobId}_strategy_draft.${fmt}`
|
||||
const filename =
|
||||
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
|
||||
`job_${jobId}_strategy_draft.${fmt}`
|
||||
filenameFromContentDisposition(r.headers.get('Content-Disposition')) || fallback
|
||||
const u = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = u
|
||||
@ -282,14 +247,6 @@ export async function downloadJobDatasetExport(jobId, kind, exportFmt) {
|
||||
URL.revokeObjectURL(u)
|
||||
}
|
||||
|
||||
watch(
|
||||
jobs,
|
||||
() => {
|
||||
syncJobsListPoll()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
export function jobConfigHint(j) {
|
||||
const parts = []
|
||||
if (j.page_start != null || j.page_to != null) {
|
||||
@ -311,6 +268,8 @@ export function jobConfigHint(j) {
|
||||
}
|
||||
|
||||
export function useJobs() {
|
||||
const store = useJobStore()
|
||||
const { jobs } = storeToRefs(store)
|
||||
return {
|
||||
jobs,
|
||||
refreshJobs,
|
||||
|
||||
150
frontend/src/lib/marketingPackMarkdown.js
Normal file
150
frontend/src/lib/marketingPackMarkdown.js
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 将营销内容 API 返回的 JSON 转为可导出 Word/PDF 的 Markdown(中文小标题)。
|
||||
*/
|
||||
|
||||
const CORE_LABELS = {
|
||||
what_we_sell: '卖的是什么(本品)',
|
||||
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: '待业务补充',
|
||||
}
|
||||
|
||||
function escLine(s) {
|
||||
if (s == null || s === '') return '—'
|
||||
return String(s).replace(/\r\n/g, '\n').trim() || '—'
|
||||
}
|
||||
|
||||
function pushCoreCard(lines, card) {
|
||||
if (!card || typeof card !== 'object') {
|
||||
lines.push('(无核心信息卡数据)')
|
||||
lines.push('')
|
||||
return
|
||||
}
|
||||
for (const [en, zh] of Object.entries(CORE_LABELS)) {
|
||||
lines.push(`### ${zh}`)
|
||||
lines.push('')
|
||||
lines.push(escLine(card[en]))
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
function pushBullets(lines, title, arr) {
|
||||
lines.push(`### ${title}`)
|
||||
lines.push('')
|
||||
if (!Array.isArray(arr) || !arr.length) {
|
||||
lines.push('—')
|
||||
lines.push('')
|
||||
return
|
||||
}
|
||||
let any = false
|
||||
for (const item of arr) {
|
||||
const t = escLine(item)
|
||||
if (t !== '—') {
|
||||
lines.push(`- ${t}`)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
if (!any) lines.push('—')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
function pushFaq(lines, faq) {
|
||||
lines.push('### 买家问答')
|
||||
lines.push('')
|
||||
if (!Array.isArray(faq) || !faq.length) {
|
||||
lines.push('—')
|
||||
lines.push('')
|
||||
return
|
||||
}
|
||||
let n = 0
|
||||
for (const item of faq) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const q = escLine(item.question)
|
||||
const a = escLine(item.answer)
|
||||
if (q === '—' && a === '—') continue
|
||||
n += 1
|
||||
lines.push(`#### 问 ${n}:${q === '—' ? '(未提供)' : q}`)
|
||||
lines.push('')
|
||||
lines.push(a)
|
||||
lines.push('')
|
||||
}
|
||||
if (n === 0) {
|
||||
lines.push('—')
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
function pushDetailPack(lines, pack) {
|
||||
if (!pack || typeof pack !== 'object') {
|
||||
lines.push('(无多触点营销文案数据)')
|
||||
lines.push('')
|
||||
return
|
||||
}
|
||||
lines.push('### 依据与边界')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.traceability_note))
|
||||
lines.push('')
|
||||
pushBullets(lines, '商品短标题备选', pack.listing_titles)
|
||||
lines.push('### 列表副文案')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.listing_subtitle))
|
||||
lines.push('')
|
||||
lines.push('### 商品详情页首屏引导')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.detail_headline))
|
||||
lines.push('')
|
||||
pushBullets(lines, '卖点列表', pack.selling_bullets)
|
||||
pushBullets(lines, '参数区旁短句', pack.spec_sidebar_lines)
|
||||
pushFaq(lines, pack.faq)
|
||||
pushBullets(lines, '主图三要点', pack.main_image_three_points)
|
||||
lines.push('### 文生图提示词(主图)')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.text_to_image_prompt_main))
|
||||
lines.push('')
|
||||
lines.push('### 文生图提示词(场景/备选)')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.text_to_image_prompt_scene))
|
||||
lines.push('')
|
||||
lines.push('### 文生视频提示词(短视频)')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.text_to_video_prompt))
|
||||
lines.push('')
|
||||
lines.push('### 直播/短视频钩句')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.live_or_short_hook))
|
||||
lines.push('')
|
||||
lines.push('### 客服首句建议')
|
||||
lines.push('')
|
||||
lines.push(escLine(pack.customer_service_opening))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} result marketing-detail-pack API 的 JSON 体
|
||||
* @returns {string}
|
||||
*/
|
||||
export function marketingPackResultToMarkdown(result) {
|
||||
if (!result || typeof result !== 'object') return ''
|
||||
const lines = []
|
||||
const jobId = result.job_id ?? ''
|
||||
const kw = result.keyword ?? ''
|
||||
const genAt = result.generated_at ?? ''
|
||||
const src = result.source ?? ''
|
||||
lines.push('# 营销内容')
|
||||
lines.push('')
|
||||
lines.push(`> 任务 #${jobId} · 关键词:${kw} · ${genAt}${src ? ` · ${src}` : ''}`)
|
||||
lines.push('')
|
||||
lines.push('## 核心信息卡')
|
||||
lines.push('')
|
||||
pushCoreCard(lines, result.core_info_card)
|
||||
lines.push('## 多触点文案(列表/详情页/主图等)')
|
||||
lines.push('')
|
||||
pushDetailPack(lines, result.detail_page_pack)
|
||||
return lines.join('\n').trim() + '\n'
|
||||
}
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '../../composables/useJobs'
|
||||
import { generationInFlightKey, withGenerationInFlight } from '../../composables/useGenerationInFlight'
|
||||
import { loadStrategyDraftRecord } from '../../lib/strategyDraftStorage'
|
||||
import { marketingPackResultToMarkdown } from '../../lib/marketingPackMarkdown'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -23,6 +24,7 @@ const draftMeta = ref(null)
|
||||
const viewMode = ref('render')
|
||||
const exportErr = ref('')
|
||||
const marketingErr = ref('')
|
||||
const marketingExportErr = ref('')
|
||||
const marketingResult = ref(null)
|
||||
|
||||
const exportBusy = computed(() => {
|
||||
@ -37,6 +39,18 @@ const marketingBusy = computed(() => {
|
||||
return genInFlight.value.includes(`marketing-detail-pack:${id}`)
|
||||
})
|
||||
|
||||
const marketingExportBusy = computed(() => {
|
||||
const id = selectedId.value
|
||||
if (!id) return false
|
||||
return genInFlight.value.some((k) => String(k).startsWith(`export-marketing-pack:${id}:`))
|
||||
})
|
||||
|
||||
function isMarketingExporting(fmt) {
|
||||
const id = selectedId.value
|
||||
if (!id) return false
|
||||
return genInFlight.value.includes(`export-marketing-pack:${id}:${fmt}`)
|
||||
}
|
||||
|
||||
function payloadForMarketing(lastRequest) {
|
||||
if (!lastRequest || typeof lastRequest !== 'object') {
|
||||
return { business_notes: '', strategy_decisions: {} }
|
||||
@ -61,6 +75,40 @@ const selectedJob = computed(() =>
|
||||
successJobs.value.find((j) => String(j.id) === selectedId.value),
|
||||
)
|
||||
|
||||
function pickDetailPackSubset(pack, keys) {
|
||||
if (!pack || typeof pack !== 'object') return null
|
||||
const o = {}
|
||||
for (const k of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(pack, k)) o[k] = pack[k]
|
||||
}
|
||||
return Object.keys(o).length ? o : null
|
||||
}
|
||||
|
||||
/** 列表/详情页主文案(与「触点」分开展示) */
|
||||
const marketingPackDetailList = computed(() =>
|
||||
pickDetailPackSubset(marketingResult.value?.detail_page_pack, [
|
||||
'listing_titles',
|
||||
'listing_subtitle',
|
||||
'detail_headline',
|
||||
'selling_bullets',
|
||||
'spec_sidebar_lines',
|
||||
'faq',
|
||||
]),
|
||||
)
|
||||
|
||||
/** 依据、主图要点、文生图/文生视频提示词、短视频钩句、客服 */
|
||||
const marketingPackTouchBlock = computed(() =>
|
||||
pickDetailPackSubset(marketingResult.value?.detail_page_pack, [
|
||||
'traceability_note',
|
||||
'main_image_three_points',
|
||||
'text_to_image_prompt_main',
|
||||
'text_to_image_prompt_scene',
|
||||
'text_to_video_prompt',
|
||||
'live_or_short_hook',
|
||||
'customer_service_opening',
|
||||
]),
|
||||
)
|
||||
|
||||
function loadDraft() {
|
||||
const id = selectedId.value
|
||||
if (!id) {
|
||||
@ -109,9 +157,44 @@ function downloadDraftMd() {
|
||||
URL.revokeObjectURL(u)
|
||||
}
|
||||
|
||||
function downloadMarketingPackJson() {
|
||||
if (!marketingResult.value || !selectedId.value) return
|
||||
const blob = new Blob([JSON.stringify(marketingResult.value, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
})
|
||||
const u = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = u
|
||||
a.download = `job_${selectedId.value}_marketing_detail_pack.json`
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(u)
|
||||
}
|
||||
|
||||
async function exportMarketingPackFmt(fmt) {
|
||||
if (!marketingResult.value || !selectedId.value) return
|
||||
marketingExportErr.value = ''
|
||||
const id = selectedId.value
|
||||
const md = marketingPackResultToMarkdown(marketingResult.value)
|
||||
if (!md.trim()) {
|
||||
marketingExportErr.value = '无可导出的营销内容'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await withGenerationInFlight(`export-marketing-pack:${id}:${fmt}`, async () => {
|
||||
await exportStrategyDocument(id, md, fmt, 'marketing_detail')
|
||||
})
|
||||
} catch (e) {
|
||||
marketingExportErr.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMarketingDetailPack() {
|
||||
if (!draftMd.value || !selectedId.value) return
|
||||
marketingErr.value = ''
|
||||
marketingExportErr.value = ''
|
||||
marketingResult.value = null
|
||||
const id = selectedId.value
|
||||
const { business_notes, strategy_decisions } = payloadForMarketing(
|
||||
@ -211,6 +294,8 @@ watch(
|
||||
)
|
||||
|
||||
watch(selectedId, (id) => {
|
||||
marketingResult.value = null
|
||||
marketingExportErr.value = ''
|
||||
loadDraft()
|
||||
const want = id ? String(id) : ''
|
||||
if (String(route.query.job || '') !== want) {
|
||||
@ -233,7 +318,7 @@ watch(successJobs, (list) => {
|
||||
<section class="ma-card">
|
||||
<h2>策略稿预览</h2>
|
||||
<p class="hint-top">
|
||||
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本机浏览器 <strong>localStorage</strong>,同域名下可跨标签查看)。生成/导出/商详包等耗时操作状态在全局任务锁中同步,跨标签页可看到进行中。需要改决策请回到
|
||||
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本机浏览器 <strong>localStorage</strong>,同域名下可跨标签查看)。生成/导出/营销内容等耗时操作状态在全局任务锁中同步,跨标签页可看到进行中。需要改决策请回到
|
||||
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
|
||||
重新提交。分析数据见
|
||||
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>。
|
||||
@ -280,7 +365,7 @@ watch(successJobs, (list) => {
|
||||
:disabled="!draftMd || !selectedId || marketingBusy"
|
||||
@click="generateMarketingDetailPack"
|
||||
>
|
||||
{{ marketingBusy ? '商详包生成中…' : '生成商详包' }}
|
||||
{{ marketingBusy ? '营销内容生成中…' : '生成营销内容' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -290,19 +375,58 @@ watch(successJobs, (list) => {
|
||||
</p>
|
||||
<p v-if="exportErr" class="ma-err">{{ exportErr }}</p>
|
||||
<p v-if="marketingErr" class="ma-err">{{ marketingErr }}</p>
|
||||
<p v-if="marketingExportErr" class="ma-err">{{ marketingExportErr }}</p>
|
||||
<div v-if="marketingResult" class="marketing-pack-out">
|
||||
<h3 class="marketing-pack-h">商详包</h3>
|
||||
<h3 class="marketing-pack-h">营销内容</h3>
|
||||
<p class="ma-muted marketing-pack-meta">
|
||||
{{ marketingResult.generated_at }} · {{ marketingResult.source }}
|
||||
</p>
|
||||
<p class="ma-muted marketing-pack-disk">
|
||||
服务端会将本包写入任务目录
|
||||
<code>marketing/marketing_detail_pack_v1.json</code>(与批次一并归档;目录不可写时仅内存结果)。
|
||||
</p>
|
||||
<div class="toolbar marketing-pack-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || marketingExportBusy || marketingBusy"
|
||||
@click="downloadMarketingPackJson"
|
||||
>
|
||||
下载 JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || marketingExportBusy || marketingBusy"
|
||||
@click="exportMarketingPackFmt('docx')"
|
||||
>
|
||||
{{ isMarketingExporting('docx') ? '导出中…' : '营销内容导出 Word' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || marketingExportBusy || marketingBusy"
|
||||
@click="exportMarketingPackFmt('pdf')"
|
||||
>
|
||||
{{ isMarketingExporting('pdf') ? '导出中…' : '营销内容导出 PDF' }}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<details v-if="marketingPackDetailList" open class="marketing-details">
|
||||
<summary>列表与详情页主文案</summary>
|
||||
<pre class="marketing-pre">{{ JSON.stringify(marketingPackDetailList, null, 2) }}</pre>
|
||||
</details>
|
||||
<details v-else-if="marketingResult.detail_page_pack" open class="marketing-details">
|
||||
<summary>详情页包字段</summary>
|
||||
<pre class="marketing-pre">{{ JSON.stringify(marketingResult.detail_page_pack, null, 2) }}</pre>
|
||||
</details>
|
||||
<details v-if="marketingPackTouchBlock" open class="marketing-details">
|
||||
<summary>依据、主图、文生图/文生视频提示词、钩句与客服</summary>
|
||||
<pre class="marketing-pre">{{ JSON.stringify(marketingPackTouchBlock, 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>
|
||||
@ -461,6 +585,29 @@ watch(successJobs, (list) => {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.marketing-pack-disk {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.marketing-pack-disk code {
|
||||
font-size: 0.85em;
|
||||
background: #e2e8f0;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.marketing-product-hint {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.marketing-pack-actions {
|
||||
margin: 0 0 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.marketing-details {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user