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」中的 - llm_* 字段控制。 + 选择已成功的任务,调整下方统计规则后点「保存以上设置」,再点「重新生成报告」。默认会先按系统规则生成统计稿,再用全文智能润色与补充(需本系统已配置可用的智能服务);不会重新爬取数据。各章是否做评价智能解读等,可用「填入推荐示例」带上,或在下方「高级选项」里微调(多数情况不必动)。 阅读与下载请至 报告查看

@@ -232,7 +231,7 @@ watch(
@@ -266,9 +265,7 @@ watch( 当前没有已成功的任务,无法生成报告;请先在任务列表确认流水线成功。

- 按钮因本页记录的「生成中」状态而禁用。若你已重启后端或确定没有在生成,可点「清除误锁(本地)」。 - (亦可在控制台执行: - sessionStorage.removeItem('ma_generation_inflight');sessionStorage.removeItem('ma_generation_inflight_ts');location.reload()) + 按钮因本页记录的「生成中」状态而暂时不可用。若你已重启服务或确定没有在生成,可先点「清除误锁(本地)」再试。

任务 #{{ regenPendingJobId }} 的报告正在重新生成中,请稍候再切换任务或重复提交。 @@ -312,7 +309,7 @@ watch( />

- 高级:用 JSON 编辑(一般不需要) + 高级选项(编辑底层配置,一般不需要)

打开时会根据上面表单生成内容;改完后点「写回表单」再保存。可在此加入 llm_comment_sentimentllm_matrix_group_summaries @@ -320,14 +317,14 @@ watch( generator:"llm";若只要规则稿请勾选「本次仅用规则引擎」。