From 43b72cb32e60ebea14998ce1ba00c0e5148cd228 Mon Sep 17 00:00:00 2001
From: hub-gif <2487812171@qq.com>
Date: Tue, 14 Apr 2026 18:23:25 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=B3=E6=B3=A8=E8=AF=8D=E4=B8=8E?=
=?UTF-8?q?=E5=9C=BA=E6=99=AF=E5=BB=B6=E4=BC=B8=E3=80=81=E6=8A=A5=E5=91=8A?=
=?UTF-8?q?=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=AF=BC=E5=87=BA=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 流水线:评价关注词与使用场景的大模型延伸(jd_runner、竞品报告、测试)
- 界面:报告生成与查看的通俗化文案;策略稿集中度按店铺/品牌区分
- 导出:PDF 插图限制最大尺寸避免 LayoutError;报告 Word/PDF 改为 blob 下载;策略稿导出错误解析;PDF 补充 Linux 中文字体路径
Made-with: Cursor
---
.../jd_pc_search/jd_competitor_report.py | 2 +-
backend/pipeline/jd_runner.py | 20 +-
backend/pipeline/md_document_export.py | 39 +++-
backend/pipeline/strategy_draft.py | 15 +-
.../tests/test_llm_keyword_suggest.py | 173 ++------------
frontend/src/composables/useJobs.js | 59 ++++-
frontend/src/views/jd/JdAnalysisBuildView.vue | 17 +-
frontend/src/views/jd/JdAnalysisView.vue | 212 +++++++++++++++---
8 files changed, 325 insertions(+), 212 deletions(-)
diff --git a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py
index 00f8b7d..42fb228 100644
--- a/backend/crawler_copy/jd_pc_search/jd_competitor_report.py
+++ b/backend/crawler_copy/jd_pc_search/jd_competitor_report.py
@@ -2228,7 +2228,7 @@ def build_competitor_markdown(
)
lines.extend(_lines_4_reading_category(cm_structure))
lines.append(
- "*完整类目行数见结构化摘要 ``category_mix_top``。*"
+ "*完整类目分布见界面「数据摘要」或简报包中的数据文件。*"
)
else:
lines.append(
diff --git a/backend/pipeline/jd_runner.py b/backend/pipeline/jd_runner.py
index 3318345..f882ce0 100644
--- a/backend/pipeline/jd_runner.py
+++ b/backend/pipeline/jd_runner.py
@@ -421,8 +421,24 @@ def write_competitor_analysis_for_run_dir(
)
if want_mx and not skip_mx and merged_rows:
- # §5 正文已取消细类矩阵表,不再向报告嵌入矩阵要点 LLM 段落。
- matrix_llm_rec["skipped"] = "matrix_table_removed_from_report"
+ pl_mx = jcr.build_matrix_groups_llm_payload(
+ merged_rows, sku_header=sku_h, title_h=title_h
+ )
+ if pl_mx:
+ matrix_llm_rec["attempted"] = True
+ try:
+ from .llm_generate import generate_matrix_group_summaries_llm
+
+ llm_matrix_md = generate_matrix_group_summaries_llm(
+ pl_mx, keyword=kw
+ )
+ matrix_llm_rec["ok"] = True
+ matrix_llm_rec["chars"] = len(llm_matrix_md)
+ except Exception as e:
+ matrix_llm_rec["ok"] = False
+ matrix_llm_rec["error"] = str(e)
+ else:
+ matrix_llm_rec["skipped"] = "empty_matrix_groups_payload"
elif skip_mx:
matrix_llm_rec["skipped"] = "MA_SKIP_LLM_MATRIX_GROUP_SUMMARIES"
elif not want_mx:
diff --git a/backend/pipeline/md_document_export.py b/backend/pipeline/md_document_export.py
index 4396ed1..c8395b2 100644
--- a/backend/pipeline/md_document_export.py
+++ b/backend/pipeline/md_document_export.py
@@ -135,9 +135,40 @@ def _pdf_font_candidates() -> list[Path]:
Path(windir) / "Fonts" / "msyh.ttf",
]
)
+ # Linux / 容器常见中文字体(路径不存在则跳过)
+ out.extend(
+ [
+ Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
+ Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
+ Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
+ Path("/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf"),
+ Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
+ ]
+ )
return out
+def _pdf_flowable_image(img_path: Path, *, max_w: float, max_h: float) -> Any:
+ """将插图缩放到不超过 max_w×max_h(ReportLab 单位,与 cm 一致),保持宽高比,避免矩阵长图撑爆版面。"""
+ from reportlab.lib.utils import ImageReader
+ from reportlab.platypus import Image as RLImage
+
+ p = str(img_path)
+ try:
+ ir = ImageReader(p)
+ iw, ih = ir.getSize()
+ except Exception:
+ return RLImage(p, width=max_w * 0.9, height=max_h * 0.9)
+ if iw <= 0 or ih <= 0:
+ return RLImage(p, width=max_w * 0.9, height=max_h * 0.9)
+ w = float(max_w)
+ h = w * (float(ih) / float(iw))
+ if h > float(max_h):
+ h = float(max_h)
+ w = h * (float(iw) / float(ih))
+ return RLImage(p, width=w, height=h)
+
+
def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
"""简易纯文本流式 PDF;需本机 .ttf 中文字体或环境变量 MA_PDF_FONT。"""
from reportlab.lib.pagesizes import A4
@@ -145,7 +176,6 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
from reportlab.lib.units import cm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
- from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
font_name = "MaExportCJK"
@@ -221,7 +251,12 @@ def markdown_to_pdf_bytes(md: str, *, asset_root: Path | None = None) -> bytes:
except ValueError:
continue
if img_path.is_file():
- story.append(RLImage(str(img_path), width=13 * cm))
+ # 版面可用高度需小于正文框(A4 减边距后约 24.6cm),否则 ReportLab 报 LayoutError
+ story.append(
+ _pdf_flowable_image(
+ img_path, max_w=13 * cm, max_h=24 * cm
+ )
+ )
story.append(Spacer(1, 0.2 * cm))
continue
plain = _strip_inline_md(s)
diff --git a/backend/pipeline/strategy_draft.py b/backend/pipeline/strategy_draft.py
index cdfdc09..27ffba7 100644
--- a/backend/pipeline/strategy_draft.py
+++ b/backend/pipeline/strategy_draft.py
@@ -49,7 +49,7 @@ def _num(x: Any) -> str:
def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None:
- """从集中度生成一句策略向描述,无数据则返回 None。"""
+ """从集中度生成一句策略向描述,无数据则返回 None(正文避免英文缩写)。"""
try:
c1 = float(cr1) if cr1 is not None else None
except (TypeError, ValueError):
@@ -57,6 +57,12 @@ def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None:
if c1 is None and not (top or "").strip():
return None
top_s = _esc(top) or "—"
+ if "店铺" in label:
+ w1, w3 = "第一大店铺约占列表行的", "前三大店铺合计约占"
+ elif "品牌" in label:
+ w1, w3 = "第一大品牌约占", "前三大品牌合计约占"
+ else:
+ w1, w3 = "第一大主体约占", "前三大合计约占"
if c1 is not None:
if c1 >= 0.4:
tone = "偏高,头部资源集中"
@@ -64,8 +70,11 @@ def _cr_narrative(label: str, cr1: Any, cr3: Any, top: Any) -> str | None:
tone = "中等,存在可争夺空间"
else:
tone = "相对分散,差异化切入点可能更多"
- return f"- **{label}**:第一大品牌/店份额 ≈ {_pct(cr1)},前三合计份额 ≈ {_pct(cr3)};头部为「{top_s}」。*粗判:{tone}。*"
- return f"- **{label}**:头部标签「{top_s}」(缺少份额指标时可结合列表/商详数据补全)。"
+ return (
+ f"- **{label}**:{w1} **{_pct(cr1)}**,{w3} **{_pct(cr3)}**;"
+ f"当前头部为「{top_s}」。*粗判:{tone}。*"
+ )
+ return f"- **{label}**:头部为「{top_s}」(缺少占比时可结合列表与商详数据补全)。"
def _goal_bullet(label: str, user_val: str, placeholder: str) -> str:
diff --git a/backend/pipeline/tests/test_llm_keyword_suggest.py b/backend/pipeline/tests/test_llm_keyword_suggest.py
index 0b121b4..5810104 100644
--- a/backend/pipeline/tests/test_llm_keyword_suggest.py
+++ b/backend/pipeline/tests/test_llm_keyword_suggest.py
@@ -1,180 +1,35 @@
-"""llm_keyword_suggest:分块/解析烟测;有 API 配置时直连大模型做联调。"""
+"""llm_keyword_suggest 解析与数据结构(不调用真实 LLM)。"""
from __future__ import annotations
-import json
-import os
-import sys
import unittest
-from pathlib import Path
-try:
- from dotenv import load_dotenv
-
- _ma_env = Path(__file__).resolve().parents[3] / ".env"
- if _ma_env.is_file():
- load_dotenv(_ma_env)
-except ImportError:
- pass
-
-from django.test import SimpleTestCase
-
-from pipeline.llm_keyword_suggest import (
- MAX_CHUNK_CHARS,
- MAX_CHUNKS,
- _chunk_comment_texts,
- _parse_phrases_object,
- _parse_scenarios_object,
- suggest_focus_keywords_from_all_comments,
- suggest_scenario_groups_llm,
-)
+from pipeline.llm_keyword_suggest import _parse_phrases_object, _parse_scenarios_object
-def _llm_configured() -> bool:
- key = (os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "").strip()
- base = (os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_BASE_URL") or "").strip()
- return bool(key and base)
-
-
-class ChunkCommentTextsTests(SimpleTestCase):
- def test_empty(self) -> None:
- self.assertEqual(_chunk_comment_texts([]), [])
-
- def test_respects_max_chunk_chars(self) -> None:
- a = "x" * (MAX_CHUNK_CHARS // 2)
- b = "y" * (MAX_CHUNK_CHARS // 2)
- c = "z" * (MAX_CHUNK_CHARS // 2)
- parts = _chunk_comment_texts([a, b, c])
- self.assertGreaterEqual(len(parts), 2)
- for p in parts:
- self.assertLessEqual(len(p) + p.count("\n"), MAX_CHUNK_CHARS + 50)
-
- def test_max_chunks_trims(self) -> None:
- texts = [f"段落{i} " + "字" * 800 for i in range(80)]
- parts = _chunk_comment_texts(texts)
- self.assertLessEqual(len(parts), MAX_CHUNKS)
-
-
-class ParsePhrasesObjectTests(SimpleTestCase):
- def test_plain_json(self) -> None:
- raw = '{"phrases": ["低糖", "口感好"]}'
- self.assertEqual(_parse_phrases_object(raw), ["低糖", "口感好"])
-
- def test_strips_phrase_whitespace(self) -> None:
- raw = '{"phrases": ["口感", "回购 "]}'
+class ParsePhrasesTests(unittest.TestCase):
+ def test_json_object(self) -> None:
+ raw = '{"phrases": ["口感", " 回购 "]}'
self.assertEqual(_parse_phrases_object(raw), ["口感", "回购"])
def test_fenced_json(self) -> None:
- raw = '```json\n{"phrases": ["A", "B"]}\n```'
- self.assertEqual(_parse_phrases_object(raw), ["A", "B"])
-
- def test_embedded_object(self) -> None:
- raw = '前缀 {"phrases": ["x"]} 后缀'
- self.assertEqual(_parse_phrases_object(raw), ["x"])
-
- def test_invalid_returns_empty(self) -> None:
- self.assertEqual(_parse_phrases_object("not json"), [])
+ raw = '```json\n{"phrases": ["低糖"]}\n```'
+ self.assertEqual(_parse_phrases_object(raw), ["低糖"])
-class ParseScenariosObjectTests(SimpleTestCase):
- def test_plain_json(self) -> None:
- raw = '{"scenarios": [{"label": "下午茶", "triggers": ["下午茶", "配咖啡"]}]}'
+class ParseScenariosTests(unittest.TestCase):
+ def test_min_triggers_in_parser(self) -> None:
+ raw = '{"scenarios": [{"label": "早餐", "triggers": ["早上"]}]}'
out = _parse_scenarios_object(raw)
self.assertEqual(len(out), 1)
- self.assertEqual(out[0]["label"], "下午茶")
- self.assertEqual(out[0]["triggers"], ["下午茶", "配咖啡"])
+ self.assertEqual(out[0]["label"], "早餐")
+ self.assertEqual(out[0]["triggers"], ["早上"])
def test_fenced(self) -> None:
- raw = '```json\n{"scenarios": [{"label": "A", "triggers": ["触发甲", "触发乙"]}]}\n```'
- out = _parse_scenarios_object(raw)
- self.assertEqual(out[0]["label"], "A")
- self.assertEqual(out[0]["triggers"], ["触发甲", "触发乙"])
-
- def test_fenced_without_json_tag(self) -> None:
raw = '```\n{"scenarios": [{"label": "露营", "triggers": ["户外", "野餐"]}]}\n```'
out = _parse_scenarios_object(raw)
self.assertEqual(len(out), 1)
self.assertEqual(out[0]["label"], "露营")
- self.assertEqual(out[0]["triggers"], ["户外", "野餐"])
-class SuggestFocusKeywordsTests(SimpleTestCase):
- def test_no_comments_returns_empty(self) -> None:
- out = suggest_focus_keywords_from_all_comments(
- keyword="低GI",
- brief_slice={"comment_focus_keywords": []},
- all_comment_texts=[],
- )
- self.assertEqual(out["suggested_focus_keywords"], [])
- self.assertEqual(out["chunks_processed"], 0)
- self.assertIn("无评价", out["rationale"])
-
-
-@unittest.skipUnless(
- _llm_configured(),
- "需要环境变量 OPENAI_API_KEY+OPENAI_BASE_URL(或 LLM_API_KEY+LLM_BASE_URL),"
- "与 AI_crawler 相同;可在 market_assistant/.env 配置后重跑。",
-)
-class SuggestFocusKeywordsLiveLLMTests(SimpleTestCase):
- """直连网关调用 ``chat_completion_text``,会消耗少量 token。"""
-
- def test_live_extracts_phrases_from_comments(self) -> None:
- comments = [
- "低GI饼干口感偏硬,甜度刚好,饱腹感不错。",
- "物流有点慢,包装压扁了一角,但味道还行。",
- "希望出小包装,一次吃不完容易受潮。",
- ]
- out = suggest_focus_keywords_from_all_comments(
- keyword="低GI饼干",
- brief_slice={"comment_focus_keywords": [{"word": "甜度"}]},
- all_comment_texts=comments,
- )
- self.assertGreaterEqual(out["chunks_processed"], 1)
- self.assertEqual(out["total_comment_texts"], 3)
- kws = out["suggested_focus_keywords"]
- self.assertIsInstance(kws, list)
- self.assertGreater(len(kws), 0, "模型应返回至少 1 条短语")
- for p in kws:
- self.assertIsInstance(p, str)
- self.assertGreaterEqual(len(p), 2)
- self.assertLessEqual(len(p), 24)
- self.assertNotIn("甜度", kws)
- sys.stderr.write(
- "\n=== [Live LLM] suggest_focus_keywords_from_all_comments ===\n"
- + json.dumps(out, ensure_ascii=False, indent=2)
- + "\n"
- )
- sys.stderr.flush()
-
-
-@unittest.skipUnless(
- _llm_configured(),
- "需要 OPENAI_* 或 LLM_* 密钥与网关地址。",
-)
-class SuggestScenarioGroupsLiveLLMTests(SimpleTestCase):
- def test_live_suggests_new_scenario_groups(self) -> None:
- existing = [
- {"label": "早餐/代餐", "triggers": ["早餐", "代餐"]},
- ]
- comments = [
- "下午配咖啡当下午茶还不错,办公室同事分着吃。",
- "周末露营带了一盒,孩子当零食。",
- ]
- out = suggest_scenario_groups_llm(
- keyword="饼干",
- existing_groups=existing,
- all_comment_texts=comments,
- )
- groups = out.get("suggested_scenario_groups") or []
- self.assertIsInstance(groups, list)
- self.assertGreater(len(groups), 0, "应至少返回 1 组新场景")
- labels = {str(g.get("label", "")).strip().lower() for g in groups if isinstance(g, dict)}
- self.assertNotIn("早餐/代餐", labels)
- for g in groups:
- tr = g.get("triggers") or []
- self.assertGreaterEqual(len(tr), 1)
- sys.stderr.write(
- "\n=== [Live LLM] suggest_scenario_groups_llm ===\n"
- + json.dumps(out, ensure_ascii=False, indent=2)
- + "\n"
- )
- sys.stderr.flush()
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/composables/useJobs.js b/frontend/src/composables/useJobs.js
index 850c628..dbb5626 100644
--- a/frontend/src/composables/useJobs.js
+++ b/frontend/src/composables/useJobs.js
@@ -82,24 +82,71 @@ export function jobExportReportDocumentUrl(jobId, fmt = 'docx') {
return `/api/jobs/${jobId}/export-document/?kind=report&fmt=${encodeURIComponent(fmt)}`
}
+/** 竞品报告 GET 导出 Word/PDF(blob 下载,失败时解析服务端 JSON 提示) */
+export async function exportReportDocument(jobId, fmt = 'docx') {
+ const url = jobExportReportDocumentUrl(jobId, fmt)
+ const r = await fetch(url)
+ const ct = r.headers.get('Content-Type') || ''
+ if (!r.ok) {
+ let msg = `HTTP ${r.status}`
+ try {
+ if (ct.includes('application/json')) {
+ const j = await r.json()
+ msg = typeof j?.detail === 'string' ? j.detail : JSON.stringify(j)
+ } else {
+ const t = await r.text()
+ if (t) msg = t.length > 500 ? `${t.slice(0, 500)}…` : t
+ }
+ } catch {
+ /* keep msg */
+ }
+ throw new Error(msg)
+ }
+ const blob = await r.blob()
+ const filename =
+ filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
+ `job_${jobId}_competitor_report.${fmt}`
+ const u = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = u
+ a.download = filename
+ a.rel = 'noopener'
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(u)
+}
+
/** 策略稿正文(浏览器 sessionStorage)→ Word/PDF */
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
const r = await api(`/api/jobs/${jobId}/export-document/`, {
method: 'POST',
body: JSON.stringify({ kind: 'strategy', fmt, markdown }),
})
+ const ct = r.headers.get('Content-Type') || ''
if (!r.ok) {
- const t = await r.text()
- throw new Error(t || `HTTP ${r.status}`)
+ let msg = `HTTP ${r.status}`
+ try {
+ if (ct.includes('application/json')) {
+ const j = await r.json()
+ msg = typeof j?.detail === 'string' ? j.detail : JSON.stringify(j)
+ } else {
+ const t = await r.text()
+ if (t) msg = t.length > 500 ? `${t.slice(0, 500)}…` : t
+ }
+ } catch {
+ /* keep msg */
+ }
+ throw new Error(msg)
}
const blob = await r.blob()
- const dispo = r.headers.get('Content-Disposition') || ''
- const m = dispo.match(/filename="([^"]+)"/)
- const name = m ? m[1] : `job_${jobId}_strategy_draft.${fmt}`
+ const filename =
+ filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
+ `job_${jobId}_strategy_draft.${fmt}`
const u = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = u
- a.download = name
+ a.download = filename
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
diff --git a/frontend/src/views/jd/JdAnalysisBuildView.vue b/frontend/src/views/jd/JdAnalysisBuildView.vue
index 7ae1937..8aec125 100644
--- a/frontend/src/views/jd/JdAnalysisBuildView.vue
+++ b/frontend/src/views/jd/JdAnalysisBuildView.vue
@@ -12,7 +12,7 @@ import { useReportConfigForm } from '../../composables/useReportConfigForm'
const { jobs } = useJobs()
const selectedId = ref('')
-/** 勾选则本次重新生成不走整篇大模型合并(仍先跑规则引擎落盘) */
+/** 勾选则本次只出规则统计稿(仍先跑规则落盘,不做全文智能润色) */
const useRulesOnly = ref(false)
const regenErr = ref('')
const genInFlight = generationInFlightKey()
@@ -223,8 +223,7 @@ watch(
- 选择已成功的任务,调整下方统计规则后点「保存以上设置」,再点「重新生成报告」。默认先规则引擎写出统计稿,再合并大模型补充(需网关与密钥);不重新爬取。各章评价解读等开关由「填入推荐示例」或「高级 JSON」中的
-
- 本任务输出目录:{{ selectedJob.run_dir }}
+ 本任务在本机的数据目录(排查问题时可用):{{ selectedJob.run_dir }}
{{ regenErr }}分析报告生成
llm_* 字段控制。
+ 选择已成功的任务,调整下方统计规则后点「保存以上设置」,再点「重新生成报告」。默认会先按系统规则生成统计稿,再用全文智能润色与补充(需本系统已配置可用的智能服务);不会重新爬取数据。各章是否做评价智能解读等,可用「填入推荐示例」带上,或在下方「高级选项」里微调(多数情况不必动)。
阅读与下载请至
- 本任务输出目录(原始表格复核请至「库内数据浏览」):{{ selectedJob.run_dir }} + 本任务在本机上的结果文件夹(表格明细可在「库内数据浏览」查看):{{ selectedJob.run_dir }}
{{ briefErr }}
{{ packErr }}
+{{ exportDocErr }}
{{ err }}
暂无成功任务,请先在「搜索采集」跑通一条流水线。
-与上方报告统计口径一致,供复制或存档;一般业务阅读以报告正文与要点摘录为主,本块主要用于存档或交给同事继续分析。
-{{ briefJson }}
+ + 以下数字与上方报告一致,用日常用语列出;需要交给其它系统或技术人员时,可展开下方「原始数据」或复制/下载。 +
+暂无摘要条目(可能缺少列表或品牌字段)。
+{{ briefJson }}
+