mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 08:01:34 +08:00
feat: 关注词与场景延伸、报告体验与导出修复
- 流水线:评价关注词与使用场景的大模型延伸(jd_runner、竞品报告、测试) - 界面:报告生成与查看的通俗化文案;策略稿集中度按店铺/品牌区分 - 导出:PDF 插图限制最大尺寸避免 LayoutError;报告 Word/PDF 改为 blob 下载;策略稿导出错误解析;PDF 补充 Linux 中文字体路径 Made-with: Cursor
This commit is contained in:
parent
3d6193af06
commit
43b72cb32e
@ -2228,7 +2228,7 @@ def build_competitor_markdown(
|
||||
)
|
||||
lines.extend(_lines_4_reading_category(cm_structure))
|
||||
lines.append(
|
||||
"*完整类目行数见结构化摘要 ``category_mix_top``。*"
|
||||
"*完整类目分布见界面「数据摘要」或简报包中的数据文件。*"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
<section class="ma-card">
|
||||
<h2>分析报告生成</h2>
|
||||
<p class="hint-top">
|
||||
选择<strong>已成功</strong>的任务,调整下方统计规则后点「保存以上设置」,再点「重新生成报告」。默认<strong>先规则引擎</strong>写出统计稿,再<strong>合并大模型补充</strong>(需网关与密钥);不重新爬取。各章评价解读等开关由「填入推荐示例」或「高级 JSON」中的
|
||||
<code>llm_*</code> 字段控制。
|
||||
选择<strong>已成功</strong>的任务,调整下方统计规则后点「保存以上设置」,再点「重新生成报告」。默认会<strong>先按系统规则生成统计稿</strong>,再<strong>用全文智能润色与补充</strong>(需本系统已配置可用的智能服务);不会重新爬取数据。各章是否做评价智能解读等,可用「填入推荐示例」带上,或在下方「高级选项」里微调(多数情况不必动)。
|
||||
阅读与下载请至
|
||||
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>。
|
||||
</p>
|
||||
@ -232,7 +231,7 @@ watch(
|
||||
<div class="toolbar">
|
||||
<label class="chk-inline chk-rules-only">
|
||||
<input v-model="useRulesOnly" type="checkbox" />
|
||||
本次仅用规则引擎(跳过整篇大模型合并,更快、不调 LLM 全文接口)
|
||||
本次只生成规则统计稿(不做全文智能润色,更快、不调用智能服务)
|
||||
</label>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
@ -266,9 +265,7 @@ watch(
|
||||
当前没有<strong>已成功</strong>的任务,无法生成报告;请先在任务列表确认流水线成功。
|
||||
</p>
|
||||
<p v-else-if="regenBusyAny" class="hint-top">
|
||||
按钮因本页记录的「生成中」状态而禁用。若你已重启后端或确定没有在生成,可点「清除误锁(本地)」。
|
||||
(亦可在控制台执行:
|
||||
<code>sessionStorage.removeItem('ma_generation_inflight');sessionStorage.removeItem('ma_generation_inflight_ts');location.reload()</code>)
|
||||
按钮因本页记录的「生成中」状态而暂时不可用。若你已重启服务或确定没有在生成,可先点「清除误锁(本地)」再试。
|
||||
</p>
|
||||
<p v-if="regenBusyOtherTask" class="ma-warn-banner">
|
||||
任务 #{{ regenPendingJobId }} 的报告正在重新生成中,请稍候再切换任务或重复提交。
|
||||
@ -312,7 +309,7 @@ watch(
|
||||
/>
|
||||
|
||||
<details class="rc-advanced" @toggle="onAdvancedJsonToggle">
|
||||
<summary>高级:用 JSON 编辑(一般不需要)</summary>
|
||||
<summary>高级选项(编辑底层配置,一般不需要)</summary>
|
||||
<p class="rc-help">
|
||||
打开时会根据上面表单生成内容;改完后点「写回表单」再保存。可在此加入
|
||||
<code>llm_comment_sentiment</code>、<code>llm_matrix_group_summaries</code>
|
||||
@ -320,14 +317,14 @@ watch(
|
||||
<code>generator:"llm"</code>;若只要规则稿请勾选「本次仅用规则引擎」。
|
||||
</p>
|
||||
<textarea v-model="advancedJsonText" class="report-config-editor" rows="10" spellcheck="false" />
|
||||
<button type="button" class="ma-btn ma-btn-secondary rc-add" @click="applyAdvancedJsonToForm">将 JSON 写回表单</button>
|
||||
<button type="button" class="ma-btn ma-btn-secondary rc-add" @click="applyAdvancedJsonToForm">将配置写回表单</button>
|
||||
</details>
|
||||
|
||||
<p v-if="reportConfigErr" class="ma-err">{{ reportConfigErr }}</p>
|
||||
</div>
|
||||
|
||||
<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 v-if="regenErr" class="ma-err">{{ regenErr }}</p>
|
||||
|
||||
@ -2,6 +2,94 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import MarkdownPreview from '../../components/MarkdownPreview.vue'
|
||||
|
||||
/** 将结构化摘要转为非技术用户可读的条目(不出现 cr1 等字段名)。 */
|
||||
function pctShare(x) {
|
||||
if (x == null || x === '') return '—'
|
||||
const n = Number(x)
|
||||
if (Number.isNaN(n)) return '—'
|
||||
return `${(n * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function briefHumanSummary(j) {
|
||||
const rows = []
|
||||
if (!j || typeof j !== 'object') return rows
|
||||
if (j.keyword) rows.push({ label: '搜索关键词', value: String(j.keyword) })
|
||||
if (j.batch_label) rows.push({ label: '批次', value: String(j.batch_label) })
|
||||
const sc = j.scope
|
||||
if (sc && typeof sc === 'object') {
|
||||
if (sc.merged_sku_count != null)
|
||||
rows.push({ label: '深入采集的商品款数(SKU)', value: String(sc.merged_sku_count) })
|
||||
if (sc.comment_flat_rows != null)
|
||||
rows.push({ label: '评价条数', value: String(sc.comment_flat_rows) })
|
||||
if (sc.structure_source_rows != null)
|
||||
rows.push({ label: '列表/结构统计所用行数', value: String(sc.structure_source_rows) })
|
||||
if (sc.uses_pc_search_list_export === true)
|
||||
rows.push({ label: '是否含搜索列表全量', value: '是' })
|
||||
}
|
||||
const conc = j.concentration
|
||||
if (conc && typeof conc === 'object') {
|
||||
const shops = conc.shops_from_list
|
||||
if (shops && typeof shops === 'object') {
|
||||
if (shops.top_label && shops.cr1 != null) {
|
||||
rows.push({
|
||||
label: '第一大店铺(占列表行比例)',
|
||||
value: `${pctShare(shops.cr1)} · ${shops.top_label}`,
|
||||
})
|
||||
}
|
||||
if (shops.cr3 != null) {
|
||||
rows.push({
|
||||
label: '前三大店铺合计(占列表行比例)',
|
||||
value: pctShare(shops.cr3),
|
||||
})
|
||||
}
|
||||
}
|
||||
const lb = conc.list_brand_field
|
||||
if (lb && typeof lb === 'object') {
|
||||
if (lb.top_label && lb.cr1 != null) {
|
||||
rows.push({
|
||||
label: '第一大品牌(列表侧,按行)',
|
||||
value: `${pctShare(lb.cr1)} · ${lb.top_label}`,
|
||||
})
|
||||
}
|
||||
if (lb.cr3 != null) {
|
||||
rows.push({
|
||||
label: '前三大品牌合计(列表侧)',
|
||||
value: pctShare(lb.cr3),
|
||||
})
|
||||
}
|
||||
}
|
||||
const db = conc.detail_brand_among_merged
|
||||
if (db && typeof db === 'object') {
|
||||
if (db.top_label && db.cr1 != null) {
|
||||
rows.push({
|
||||
label: '第一大品牌(深入样本)',
|
||||
value: `${pctShare(db.cr1)} · ${db.top_label}`,
|
||||
})
|
||||
}
|
||||
if (db.cr3 != null) {
|
||||
rows.push({
|
||||
label: '前三大品牌合计(深入样本)',
|
||||
value: pctShare(db.cr3),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const p = j.price_stats
|
||||
if (p && typeof p === 'object' && p.n > 0) {
|
||||
rows.push({ label: '价格统计·样本量', value: String(p.n) })
|
||||
if (p.median != null)
|
||||
rows.push({ label: '价格统计·中位数(元)', value: Number(p.median).toFixed(2) })
|
||||
if (p.mean != null)
|
||||
rows.push({ label: '价格统计·平均(元)', value: Number(p.mean).toFixed(2) })
|
||||
}
|
||||
const src = j.price_stats_source
|
||||
if (src === 'pc_search_export_all_rows')
|
||||
rows.push({ label: '价格统计·数据来源', value: '搜索列表全量' })
|
||||
else if (src === 'keyword_pipeline_merged')
|
||||
rows.push({ label: '价格统计·数据来源', value: '深入采集合并表' })
|
||||
return rows
|
||||
}
|
||||
import {
|
||||
refreshJobs,
|
||||
useJobs,
|
||||
@ -10,7 +98,7 @@ import {
|
||||
previewUrl,
|
||||
jobCompetitorBriefUrl,
|
||||
downloadCompetitorBriefPack,
|
||||
jobExportReportDocumentUrl,
|
||||
exportReportDocument,
|
||||
} from '../../composables/useJobs'
|
||||
import {
|
||||
generationInFlightKey,
|
||||
@ -23,9 +111,27 @@ const reportMd = ref('')
|
||||
const err = ref('')
|
||||
const viewMode = ref('render')
|
||||
const briefJson = ref('')
|
||||
const briefData = ref(null)
|
||||
const briefErr = ref('')
|
||||
const briefCopyOk = ref(false)
|
||||
const packErr = ref('')
|
||||
const exportDocErr = ref('')
|
||||
/** 正在导出的格式:docx | pdf | null */
|
||||
const exportDocFmt = ref(null)
|
||||
|
||||
async function exportReportFmt(fmt) {
|
||||
const id = selectedId.value
|
||||
if (!id) return
|
||||
exportDocErr.value = ''
|
||||
exportDocFmt.value = fmt
|
||||
try {
|
||||
await exportReportDocument(id, fmt)
|
||||
} catch (e) {
|
||||
exportDocErr.value = String(e?.message || e)
|
||||
} finally {
|
||||
exportDocFmt.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 Markdown 中的 report_assets 相对路径转为可访问的 API URL(在线预览插图) */
|
||||
function reportMdWithAssetUrls(md, jobId) {
|
||||
@ -72,6 +178,8 @@ const selectedJob = computed(() =>
|
||||
successJobs.value.find((j) => String(j.id) === selectedId.value),
|
||||
)
|
||||
|
||||
const briefHumanRows = computed(() => briefHumanSummary(briefData.value))
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
await refreshJobs()
|
||||
@ -107,6 +215,7 @@ async function loadReport() {
|
||||
|
||||
async function loadCompetitorBrief() {
|
||||
briefJson.value = ''
|
||||
briefData.value = null
|
||||
briefErr.value = ''
|
||||
briefCopyOk.value = false
|
||||
const id = selectedId.value
|
||||
@ -120,11 +229,12 @@ async function loadCompetitorBrief() {
|
||||
const j = JSON.parse(text)
|
||||
briefErr.value = j.detail || text
|
||||
} catch {
|
||||
briefErr.value = text || `HTTP ${r.status}`
|
||||
briefErr.value = text || `请求失败(${r.status})`
|
||||
}
|
||||
return
|
||||
}
|
||||
const j = JSON.parse(text)
|
||||
briefData.value = j
|
||||
briefJson.value = JSON.stringify(j, null, 2)
|
||||
} catch (e) {
|
||||
briefErr.value = String(e)
|
||||
@ -176,6 +286,7 @@ onMounted(loadList)
|
||||
|
||||
watch(selectedId, async () => {
|
||||
briefJson.value = ''
|
||||
briefData.value = null
|
||||
briefErr.value = ''
|
||||
packErr.value = ''
|
||||
const id = selectedId.value
|
||||
@ -235,40 +346,36 @@ watch(
|
||||
>
|
||||
下载报告
|
||||
</a>
|
||||
<a
|
||||
class="ma-btn ma-btn-secondary dl-link"
|
||||
:class="{ disabled: !selectedId }"
|
||||
:href="selectedId ? jobExportReportDocumentUrl(selectedId, 'docx') : '#'"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click="(e) => { if (!selectedId) e.preventDefault() }"
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || exportDocFmt || loading"
|
||||
@click="exportReportFmt('docx')"
|
||||
>
|
||||
导出 Word
|
||||
</a>
|
||||
<a
|
||||
class="ma-btn ma-btn-secondary dl-link"
|
||||
:class="{ disabled: !selectedId }"
|
||||
:href="selectedId ? jobExportReportDocumentUrl(selectedId, 'pdf') : '#'"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@click="(e) => { if (!selectedId) e.preventDefault() }"
|
||||
{{ exportDocFmt === 'docx' ? '导出中…' : '导出 Word' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || exportDocFmt || loading"
|
||||
@click="exportReportFmt('pdf')"
|
||||
>
|
||||
导出 PDF
|
||||
</a>
|
||||
{{ exportDocFmt === 'pdf' ? '导出中…' : '导出 PDF' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-secondary"
|
||||
:disabled="!selectedId || briefLoading || loading"
|
||||
title="生成与报告相同统计口径的结构化数据"
|
||||
title="加载与报告数字一致的数据摘要(可先读易读版,再展开原始格式)"
|
||||
@click="loadCompetitorBrief"
|
||||
>
|
||||
{{ briefLoading ? '摘要加载中…' : '加载结构化摘要' }}
|
||||
{{ briefLoading ? '摘要加载中…' : '加载数据摘要' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ma-btn ma-btn-primary"
|
||||
:disabled="!selectedId || packLoading || loading || briefLoading"
|
||||
title="ZIP:报告稿、结构化数据、要点摘录、说明"
|
||||
title="下载压缩包:报告、配图、数据与说明"
|
||||
@click="downloadBriefPack"
|
||||
>
|
||||
{{ packLoading ? '打包中…' : '一键下载简报包' }}
|
||||
@ -279,27 +386,40 @@ watch(
|
||||
</p>
|
||||
|
||||
<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 v-if="briefErr" class="ma-err">{{ briefErr }}</p>
|
||||
<p v-if="packErr" class="ma-err">{{ packErr }}</p>
|
||||
<p v-if="exportDocErr" class="ma-err">{{ exportDocErr }}</p>
|
||||
<p v-if="err" class="ma-err">{{ err }}</p>
|
||||
<p v-if="!successJobs.length" class="ma-muted">暂无成功任务,请先在「搜索采集」跑通一条流水线。</p>
|
||||
</section>
|
||||
|
||||
<section v-if="briefJson" class="ma-card preview-card">
|
||||
<section v-if="briefData" class="ma-card preview-card">
|
||||
<div class="preview-head">
|
||||
<h2>竞品数据摘要(机器整理)</h2>
|
||||
<div class="tabs">
|
||||
<button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="copyBriefJson">
|
||||
{{ briefCopyOk ? '已复制' : '复制' }}
|
||||
{{ briefCopyOk ? '已复制' : '复制原始数据' }}
|
||||
</button>
|
||||
<button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="downloadBriefJson">下载文件</button>
|
||||
<button type="button" class="ma-btn ma-btn-secondary brief-tool" @click="downloadBriefJson">下载数据文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-top brief-hint">与上方报告统计口径一致,供复制或存档;一般业务阅读以报告正文与要点摘录为主,本块主要用于存档或交给同事继续分析。</p>
|
||||
<pre class="raw-md brief-json">{{ briefJson }}</pre>
|
||||
<p class="hint-top brief-hint">
|
||||
以下数字与上方报告一致,用日常用语列出;需要交给其它系统或技术人员时,可展开下方「原始数据」或复制/下载。
|
||||
</p>
|
||||
<dl v-if="briefHumanRows.length" class="brief-dl">
|
||||
<template v-for="(row, idx) in briefHumanRows" :key="idx">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<p v-else class="ma-muted brief-hint">暂无摘要条目(可能缺少列表或品牌字段)。</p>
|
||||
<details class="brief-raw-wrap">
|
||||
<summary>展开原始数据(机器可读格式)</summary>
|
||||
<pre class="raw-md brief-json">{{ briefJson }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section v-if="reportMd" class="ma-card preview-card">
|
||||
@ -447,4 +567,38 @@ watch(
|
||||
.brief-json {
|
||||
max-height: min(50vh, 560px);
|
||||
}
|
||||
.brief-dl {
|
||||
margin: 0.5rem 0 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 38%) 1fr;
|
||||
gap: 0.35rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.brief-dl dt {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.brief-dl dd {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
word-break: break-word;
|
||||
}
|
||||
.brief-raw-wrap {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
.brief-raw-wrap summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
color: #4b5563;
|
||||
user-select: none;
|
||||
}
|
||||
.brief-raw-wrap .brief-json {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user