""" 基于聚类结果与词频 CSV 生成 output/voc_report.html(词云 + 词频 + AI 报告 + 各簇表述)。 用法:: python3 main_voc分析.py python3 main_voc分析.py --only-step 7 --industry "..." --product "..." # 仅纳入本 stage 内评论占比 ≥10% 的簇: python3 main_voc分析.py --only-step 7 --filter-small-clusters ... # 调试:保存报告 LLM 完整原文到 output/report_llm_raw.txt python3 main_voc分析.py --only-step 7 --save-llm-raw --industry "..." --product "..." """ from __future__ import annotations import csv import html import json import logging import os import re import sqlite3 import sys from collections import Counter, defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Sequence, Set, Tuple from openai import OpenAI from prompts.loader import ( build_category_analysis_prompts, build_report_analysis_requirements, build_report_correction_message, build_report_json_markers, build_report_output_format, build_report_system, build_word_assign_prompts, sync_voc_report_constants, ) logger = logging.getLogger("voc_report") PROJECT_ROOT = Path(__file__).resolve().parent STRUCTURED_DB = PROJECT_ROOT / "voc_structured.sqlite" STRUCTURED_APPENDIX_SAMPLE_N = 15 DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" MODEL_NAME = os.environ.get("DASHSCOPE_MODEL", "qwen3.6-flash").strip() WORDCLOUD_TOP_N = 180 WORD_FREQ_TABLE_N = 180 # 页面词频表、排名展示上限 WORD_CATEGORY_CLASSIFY_N = 180 # LLM 词频分类词表范围(≥ 词表展示数) WORD_FREQ_PAGE_SIZE = 30 WORD_FREQ_PAGES = 6 # 词云字号映射:0.5 次幂缓解「超高频词过大、长尾过小」 WORDCLOUD_SIZE_POWER = 0.5 WORDCLOUD_SIZE_MIN = 14 WORDCLOUD_SIZE_MAX = 96 MAX_PHRASES_PER_CLUSTER = 15 # 启用簇筛选时的默认阈值(本 stage 内去重评论占比) DEFAULT_CLUSTER_MIN_REVIEW_RATIO = 0.10 OUTLIER_LABEL_ZH = "未归类" _STAGE_3B_TITLES: Dict[str, str] = { "3b_aspect_opinion_positive": "全量正面产品反馈", "3b_aspect_opinion_negative": "全量负面产品反馈", "3b_aspect_opinion_neutral": "全量产品客观描述", } _STAGE_2B_BLOCK_TITLE: Dict[str, str] = { "positive": "正面产品反馈", "negative": "负面产品反馈", "neutral": "产品客观描述", } # 词频分类展示名迁移(兼容旧 LLM 输出) _WORD_CATEGORY_DISPLAY_ALIASES: Dict[str, str] = { "痛点/场景": "需求/场景", } _STAGE_SORT_KEY: Dict[str, int] = { "3a_pain_global": 0, "3b_aspect_opinion_positive": 1, "3b_aspect_opinion_negative": 2, "3b_aspect_opinion_neutral": 3, } # 与 聚类.py 流程一致的 stage 前缀(用于模版与映射说明) STAGE_1_AUDIENCE = "1_audience" STAGE_2A_PAIN_PREFIX = "2a_pain_audience_c" STAGE_2B_AO_PREFIX = "2b_aspect_opinion_" STAGE_3A_PAIN = "3a_pain_global" STAGE_3B_NEGATIVE = "3b_aspect_opinion_negative" STAGE_3B_AO_PREFIX = "3b_aspect_opinion_" # 底部「聚类效果验证」固定展示的 stage(写死,不含 -1 离群簇) PHRASE_APPENDIX_STAGES: Tuple[str, ...] = (STAGE_3A_PAIN, STAGE_3B_NEGATIVE) STEP2_TOP2_META_STAGE = "step2_filter" _AUDIENCE_RANK_ZH: Tuple[str, ...] = ("第一", "第二") WORD_CATEGORIES: Tuple[str, ...] = ( "成分/原料", "剂型", "受众/使用对象", "功效/功能", "需求/场景", "品质/体验", "价格/价值", "物流/包装", ) # 词云按分类配色(与 WORD_CATEGORIES 顺序一一对应) CATEGORY_COLORS: Dict[str, str] = { "成分/原料": "#dc2626", "剂型": "#db2777", "受众/使用对象": "#2563eb", "功效/功能": "#16a34a", "需求/场景": "#ea580c", "品质/体验": "#7c3aed", "价格/价值": "#ca8a04", "物流/包装": "#0891b2", } WORDCLOUD_UNCATEGORIZED_COLOR = "#9ca3af" TRANSLATE_CHARS_PER_REQUEST = 200_000 TRANSLATE_MAX_OUTPUT_TOKENS = 65536 REPORT_MAX_OUTPUT_TOKENS = 65536 TRANSLATE_LLM_TIMEOUT_SEC = 600.0 REPORT_LLM_TIMEOUT_SEC = 600.0 REPORT_PARSE_MAX_RETRIES = 2 # JSON 段放前、HTML 放后:输出被 max_tokens 截断时优先保留可解析的 JSON REPORT_MARKERS: Tuple[str, ...] = ( "===WORD_ZH_JSON===", "===WORD_CATEGORY_JSON===", "===CLUSTER_NAMES_JSON===", "===REPORT_HTML===", ) WORD_CATEGORY_ASSIGN_BATCH_SIZE = 40 WORD_CATEGORY_ASSIGN_MAX_TOKENS = 8192 WORD_CATEGORY_ANALYSIS_MAX_WORDS = 25 # 生成 analysis 时每类最多展示的词条数 WORD_CATEGORY_ANALYSIS_MAX_TOKENS = 4096 @dataclass class ClusterBundle: section: str stage: str stage_title_zh: str cluster_label: int cluster_title_zh: str anchor_id: str review_count: int phrase_count: int ratio: float # 占本 stage 去重评论比(小簇过滤用) ratio_global: float # 短语数 / 全部评论 a(报告洞察用) embed_texts: List[str] = field(default_factory=list) embed_texts_zh: List[str] = field(default_factory=list) def _resolve_api_key() -> str: v = os.environ.get("DASHSCOPE_API_KEY", "").strip() if v: return v fp = os.environ.get("DASHSCOPE_API_KEY_FILE", "").strip() if fp: p = Path(fp).expanduser() if p.is_file(): return p.read_text(encoding="utf-8").strip().strip('"').strip("'") local = PROJECT_ROOT / ".dashscope_key" if local.is_file(): return local.read_text(encoding="utf-8").strip().strip('"').strip("'") return "" def _strip_think(text: str) -> str: if not text: return text text = re.sub( r"[\s\S]*?", "", text, flags=re.IGNORECASE ) text = re.sub(r"", "", text, flags=re.IGNORECASE) return text.strip() def _call_llm_messages( messages: Sequence[Dict[str, str]], api_key: str, *, temperature: float = 0.3, max_tokens: int = 16384, timeout: float = 300.0, ) -> str: client = OpenAI( api_key=api_key, base_url=DASHSCOPE_BASE_URL, timeout=timeout ) extra_body: Dict[str, Any] = {} if MODEL_NAME.lower().startswith(("qwen3.6", "qwen3.5", "qwen3")): extra_body["enable_thinking"] = False resp = client.chat.completions.create( model=MODEL_NAME, messages=list(messages), temperature=temperature, max_tokens=max_tokens, **({"extra_body": extra_body} if extra_body else {}), ) msg = resp.choices[0].message text = msg.content or getattr(msg, "reasoning_content", None) or "" if not text.strip(): raise RuntimeError("LLM 返回为空") return _strip_think(text) def _call_llm( system: str, user: str, api_key: str, *, temperature: float = 0.3, max_tokens: int = 16384, timeout: float = 300.0, ) -> str: return _call_llm_messages( [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], api_key, temperature=temperature, max_tokens=max_tokens, timeout=timeout, ) def _load_review_count(csv_path: Path) -> int: n = 0 with csv_path.open(encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for row in reader: if (row.get("content") or "").strip(): n += 1 return n def _load_review_texts_by_source_row(csv_path: Path) -> Dict[int, str]: """与 结构化_server.load_reviews_from_file 一致:第 1 条数据行 source_row=1。""" out: Dict[int, str] = {} with csv_path.open(encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for i, row in enumerate(reader, start=1): text = (row.get("content") or "").strip() if text: out[i] = text return out def _latest_structured_job_id(conn: sqlite3.Connection) -> int: row = conn.execute( "SELECT id FROM analysis_jobs ORDER BY id DESC LIMIT 1" ).fetchone() if not row: raise RuntimeError("voc_structured.sqlite 中无结构化任务") return int(row[0]) def _load_structured_samples( structured_db: Path, *, job_id: int | None = None, limit: int = STRUCTURED_APPENDIX_SAMPLE_N, ) -> List[Tuple[int, Dict[str, Any]]]: conn = sqlite3.connect(structured_db) try: jid = job_id if job_id is not None else _latest_structured_job_id(conn) cur = conn.execute( """ SELECT source_row, extraction_json FROM comment_extractions WHERE job_id = ? ORDER BY source_row LIMIT ? """, (jid, limit), ) return [(int(sr), json.loads(js)) for sr, js in cur.fetchall()] finally: conn.close() def _extraction_to_display_zh(ext: Dict[str, Any]) -> Dict[str, Any]: """将库内英文字段转为报告展示用中文键(与业务阅读一致)。""" feedback: List[Dict[str, str]] = [] for item in ext.get("product_feedback") or []: if not isinstance(item, dict): continue feedback.append( { "方面": str(item.get("aspect", "")).strip(), "观点": str(item.get("opinion", "")).strip(), "态度": str(item.get("sentiment", "")).strip(), "类别标签": str(item.get("category", "")).strip(), } ) pains = ext.get("pain_points") or [] if not isinstance(pains, list): pains = [] return { "受众": str(ext.get("audience", "")).strip(), "需求/痛点": [str(p).strip() for p in pains if str(p).strip()], "产品反馈": feedback, } def _build_structured_appendix_samples( *, structured_db: Path, cleaned_csv: Path, job_id: int | None = None, limit: int = STRUCTURED_APPENDIX_SAMPLE_N, ) -> List[Tuple[int, str, Dict[str, Any]]]: if not structured_db.is_file(): logger.warning("未找到结构化库 %s,跳过结构化提取效果验证", structured_db) return [] texts = _load_review_texts_by_source_row(cleaned_csv) try: rows = _load_structured_samples(structured_db, job_id=job_id, limit=limit) except (RuntimeError, sqlite3.Error) as e: logger.warning("读取结构化样本失败: %s", e) return [] out: List[Tuple[int, str, Dict[str, Any]]] = [] for sr, ext in rows: content = texts.get(sr, "").strip() or "(原文缺失)" out.append((sr, content, _extraction_to_display_zh(ext))) return out def _load_word_freq(path: Path, top_n: int) -> List[Tuple[str, int]]: rows: List[Tuple[str, int]] = [] with path.open(encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: w = (row.get("word") or "").strip() if not w: continue rows.append((w, int(row.get("count") or 0))) rows.sort(key=lambda x: x[1], reverse=True) return rows[:top_n] def _stage_section(stage: str) -> str: if stage == "1_audience" or stage.startswith("2a_") or stage.startswith("2b_"): return "part1" return "part2" def _audience_cluster_from_stage(stage: str) -> int | None: if stage.startswith("2a_pain_audience_c"): try: return int(stage.split("_c")[-1]) except ValueError: return None m = re.match( r"2b_aspect_opinion_(?:positive|negative|neutral)_audience_c(\d+)$", stage, ) if m: return int(m.group(1)) return None def _sentiment_suffix_from_2b_stage(stage: str) -> str | None: m = re.match( r"2b_aspect_opinion_(positive|negative|neutral)_audience_c\d+$", stage, ) return m.group(1) if m else None def _stage_title_zh(stage: str, audience_names: Dict[int, str]) -> str: if stage == "1_audience": return "受众画像聚类(阶段一)" if stage in _STAGE_3B_TITLES: return _STAGE_3B_TITLES[stage] aud_c = _audience_cluster_from_stage(stage) if aud_c is not None: aud_name = audience_names.get(aud_c, f"受众{aud_c}") if stage.startswith("2a_"): return f"{aud_name} · 用户需求(阶段二)" sent = _sentiment_suffix_from_2b_stage(stage) if sent: label = _STAGE_2B_BLOCK_TITLE.get(sent, sent) return f"{aud_name} · {label}(阶段二)" return f"{aud_name} · 产品反馈(阶段二)" if stage == "3a_pain_global": return "全量用户需求(阶段三)" return stage def _phrase_appendix_stage_title(stage: str) -> str: if stage == STAGE_3A_PAIN: return "全量用户需求(阶段三)" if stage in _STAGE_3B_TITLES: return _STAGE_3B_TITLES[stage] return stage def _bundles_for_phrase_appendix(bundles: List[ClusterBundle]) -> List[ClusterBundle]: return [ b for b in bundles if b.stage in PHRASE_APPENDIX_STAGES and b.cluster_label != -1 ] def _bundle_sort_key(b: ClusterBundle) -> Tuple[int, int, str, int]: sec = 0 if b.section == "part1" else 1 stage_ord = _STAGE_SORT_KEY.get(b.stage, 0) lab = -1 if b.cluster_label == -1 else b.cluster_label return (sec, stage_ord, b.stage, lab) def _cluster_key(stage: str, label: int) -> str: return f"{stage}|{label}" def _anchor_id(stage: str, label: int) -> str: safe = re.sub(r"[^a-zA-Z0-9_-]", "_", stage) return f"cluster-{safe}-{label}" def _latest_cluster_run(conn: sqlite3.Connection) -> Tuple[int, int]: row = conn.execute( "SELECT id, job_id FROM cluster_runs ORDER BY id DESC LIMIT 1" ).fetchone() if not row: raise RuntimeError("voc_clustering.sqlite 中无聚类记录") return int(row[0]), int(row[1]) def _load_assignments( cconn: sqlite3.Connection, run_id: int ) -> List[Tuple[str, int, int, str, int]]: cur = cconn.execute( """ SELECT stage, cluster_label, source_row, embed_text, embedding_item_id FROM cluster_assignments WHERE run_id = ? ORDER BY stage, cluster_label, source_row """, (run_id,), ) return [ (str(s), int(lab), int(sr), str(et), int(eid)) for s, lab, sr, et, eid in cur.fetchall() ] def build_cluster_bundles( *, cluster_db: Path, cleaned_csv: Path, embed_db: Path | None = None, # 保留参数兼容 VOC分析,不再使用 min_cluster_review_ratio: float | None = None, ) -> Tuple[int, List[ClusterBundle]]: del embed_db total_reviews = _load_review_count(cleaned_csv) cconn = sqlite3.connect(cluster_db) try: run_id, _ = _latest_cluster_run(cconn) raw = _load_assignments(cconn, run_id) finally: cconn.close() grouped: Dict[Tuple[str, int], List[Tuple[int, str, int]]] = defaultdict(list) texts_by_key: Dict[Tuple[str, int], List[str]] = defaultdict(list) stage_source_rows: Dict[str, set[int]] = defaultdict(set) for stage, lab, sr, et, _eid in raw: key = (stage, lab) grouped[key].append((sr, et, _eid)) texts_by_key[key].append(et) stage_source_rows[stage].add(sr) bundles: List[ClusterBundle] = [] for (stage, lab), items in sorted(grouped.items(), key=lambda x: (x[0][0], x[0][1])): sec = _stage_section(stage) if sec not in ("part1", "part2"): continue unique_srs = sorted({sr for sr, _, _ in items}) cnt = len(unique_srs) stage_total = len(stage_source_rows.get(stage, set())) phrase_cnt = len(items) stage_ratio = cnt / stage_total if stage_total else 0.0 ratio = stage_ratio ratio_global = phrase_cnt / total_reviews if total_reviews else 0.0 if ( min_cluster_review_ratio is not None and stage_total > 0 and stage_ratio < min_cluster_review_ratio ): logger.info( "报告跳过小簇 %s / %s:%s 条评论 (本步骤 %.1f%% < %.0f%%)", stage, lab, cnt, stage_ratio * 100, min_cluster_review_ratio * 100, ) continue text_ctr = Counter(texts_by_key[(stage, lab)]) reps = [t for t, _ in text_ctr.most_common(MAX_PHRASES_PER_CLUSTER)] bundles.append( ClusterBundle( section=sec, stage=stage, stage_title_zh=stage, cluster_label=lab, cluster_title_zh=str(lab), anchor_id=_anchor_id(stage, lab), review_count=cnt, phrase_count=phrase_cnt, ratio=ratio, ratio_global=ratio_global, embed_texts=reps, ) ) bundles.sort(key=_bundle_sort_key) return total_reviews, bundles def _apply_cluster_names( bundles: List[ClusterBundle], names: Dict[str, str] ) -> None: audience_names: Dict[int, str] = {} for b in bundles: if b.stage != "1_audience": continue if b.cluster_label == -1: audience_names[-1] = OUTLIER_LABEL_ZH b.cluster_title_zh = OUTLIER_LABEL_ZH continue key = _cluster_key(b.stage, b.cluster_label) title = names.get(key, "").strip() or f"受众群体 {b.cluster_label}" b.cluster_title_zh = title audience_names[b.cluster_label] = title for b in bundles: b.stage_title_zh = _stage_title_zh(b.stage, audience_names) if b.stage == "1_audience": continue if b.cluster_label == -1: aud_c = _audience_cluster_from_stage(b.stage) if aud_c is not None: aud = audience_names.get(aud_c, f"受众{aud_c}") b.cluster_title_zh = f"{aud} · {OUTLIER_LABEL_ZH}" else: b.cluster_title_zh = OUTLIER_LABEL_ZH continue key = _cluster_key(b.stage, b.cluster_label) sub = names.get(key, "").strip() aud_c = _audience_cluster_from_stage(b.stage) if aud_c is not None: aud = audience_names.get(aud_c, f"受众{aud_c}") b.cluster_title_zh = f"{aud} · {sub}" if sub else f"{aud} · 子簇 {b.cluster_label}" else: b.cluster_title_zh = sub or f"主题簇 {b.cluster_label}" def _parse_numbered_translations( raw: str, count: int, *, originals: Sequence[str] ) -> List[str]: out: List[str] = [] for i in range(count): m = re.search(rf"\[{i + 1}\]\s*([\s\S]*?)(?=\n\[{i + 2}\]|\Z)", raw) out.append(m.group(1).strip() if m else originals[i]) while len(out) < count: j = len(out) out.append(originals[j] if j < len(originals) else "") return out[:count] def _chunk_texts_for_translation(texts: List[str]) -> List[List[str]]: batches: List[List[str]] = [] current: List[str] = [] size = 0 for text in texts: block_size = len(text) + 32 if current and size + block_size > TRANSLATE_CHARS_PER_REQUEST: batches.append(current) current = [] size = 0 current.append(text) size += block_size if current: batches.append(current) return batches def _translate_unique_phrases( phrases: List[str], api_key: str ) -> Dict[str, str]: unique: List[str] = [] seen: set[str] = set() for p in phrases: t = p.strip() if not t or t in seen: continue seen.add(t) unique.append(t) if not unique: return {} merged: Dict[str, str] = {} batches = _chunk_texts_for_translation(unique) for bi, batch in enumerate(batches, start=1): logger.info( "批量翻译短语(第 %s/%s 批):%s 条", bi, len(batches), len(batch), ) body = "\n\n".join(f"[{i + 1}]\n{t}" for i, t in enumerate(batch)) user = ( f"将以下 {len(batch)} 条英文 VOC 结构化短语逐条译为流畅简体中文。\n" "输出格式:仍用 [1]、[2]… 编号;不要解释、不要 Markdown。\n\n" + body ) raw = _call_llm( "你是专业英中翻译。按编号输出全部译文,不要遗漏。", user, api_key, temperature=0.2, max_tokens=TRANSLATE_MAX_OUTPUT_TOKENS, timeout=TRANSLATE_LLM_TIMEOUT_SEC, ) zh_list = _parse_numbered_translations(raw, len(batch), originals=batch) for en, zh in zip(batch, zh_list): merged[en] = zh return merged def _fill_phrase_translations( bundles: List[ClusterBundle], zh_map: Dict[str, str] ) -> None: for b in bundles: b.embed_texts_zh = [zh_map.get(t, t) for t in b.embed_texts] def _bundles_for_prompt(bundles: List[ClusterBundle], section: str) -> str: """按短语数降序,供 LLM 按规模分析各簇。""" subset = [b for b in bundles if b.section == section] subset.sort(key=lambda b: (-b.phrase_count, b.stage, b.cluster_label)) lines: List[str] = [] for b in subset: pct_a = f"{b.ratio_global * 100:.1f}%" pct_stage = f"{b.ratio * 100:.1f}%" key = _cluster_key(b.stage, b.cluster_label) lines.append( f"- id={key};阶段={b.stage};簇标签={b.cluster_label};" f"结构化短语数={b.phrase_count}(占全部评论 a 的 {pct_a});" f"去重评论数={b.review_count}(占本阶段 {pct_stage});" f"代表短语:{' | '.join(b.embed_texts)}" ) return "\n".join(lines) if lines else "(无)" def _audience_cluster_ids_from_bundles(bundles: Sequence[ClusterBundle]) -> List[int]: ids: set[int] = set() for b in bundles: aud = _audience_cluster_from_stage(b.stage) if aud is not None: ids.add(aud) return sorted(ids) def _load_step2_top2_audience(cluster_db: Path) -> List[int]: """读取聚类阶段二入选的前两个受众簇标签(与 聚类.py _top2_audience_clusters 一致)。""" try: cconn = sqlite3.connect(cluster_db) try: run_id, _ = _latest_cluster_run(cconn) row = cconn.execute( """ SELECT meta_json FROM cluster_stage_meta WHERE run_id = ? AND stage = ? """, (run_id, STEP2_TOP2_META_STAGE), ).fetchone() finally: cconn.close() if not row: return [] meta = json.loads(str(row[0])) raw = meta.get("top2_audience_clusters") if not isinstance(raw, list): return [] return [int(x) for x in raw] except (json.JSONDecodeError, TypeError, ValueError, sqlite3.Error) as e: logger.warning("读取 step2 top2 受众簇失败: %s", e) return [] def _ordered_step2_audiences( bundles: Sequence[ClusterBundle], top2_from_db: Sequence[int] ) -> List[int]: """阶段二模版顺序:优先 DB 中 top2 排名,再补齐 bundles 里出现的其它受众簇。""" in_bundles = set(_audience_cluster_ids_from_bundles(bundles)) ordered: List[int] = [] for aud in top2_from_db: if aud in in_bundles and aud not in ordered: ordered.append(aud) for aud in sorted(in_bundles): if aud not in ordered: ordered.append(aud) return ordered def _audience_rank_title(rank_index: int, audience_cluster: int) -> str: del audience_cluster # 仅用于调用方传入映射注释,勿出现在正文标题 rank_zh = ( _AUDIENCE_RANK_ZH[rank_index] if rank_index < len(_AUDIENCE_RANK_ZH) else f"第{rank_index + 1}" ) return f"{rank_zh}大受众簇" def _stages_in_bundles(bundles: Sequence[ClusterBundle]) -> List[str]: return sorted({b.stage for b in bundles}) def _cluster_stage_mapping_guide( bundles: Sequence[ClusterBundle], *, top2_audience_clusters: Sequence[int], ) -> str: """将本批次实际 stage 映射到 HTML 模版小节(与 聚类.py 五段流程一致)。""" stages = _stages_in_bundles(bundles) if not stages: return "(本批次无聚类簇数据)" aud_ids = _ordered_step2_audiences(bundles, top2_audience_clusters) top2_txt = ( "、".join(str(a) for a in top2_audience_clusters) if top2_audience_clusters else "(见各 2a/2b stage 后缀)" ) lines = [ "阶段二入选规则(聚类.py):1_audience 完成后,按各受众簇「去重评论数」降序," f"仅对规模最大的前 2 个簇(本批次 top2 簇标签={top2_txt})分别做:", " · 2a:簇内 pain_point → 报告中称「用户需求」(每受众 1 个 stage)", " · 2b:簇内 aspect_opinion → 正面/负面产品反馈、产品客观描述(每受众 3 个 stage)", "聚类 stage → HTML 模版小节(仅分析下列已出现的 stage;无数据的小节可省略
  • ):", f"- {STAGE_1_AUDIENCE} → 二·(一)·1.受众画像 ·(1)受众群体特征 &(2)受众的主要需求", ] for i, aud in enumerate(aud_ids): rank = _audience_rank_title(i, aud) lines.append( f"- {STAGE_2A_PAIN_PREFIX}{aud} → 二·(一)·2.分受众分析 · {rank} ·(1)用户需求(2a)" ) for suf, block_title in _STAGE_2B_BLOCK_TITLE.items(): st = f"{STAGE_2B_AO_PREFIX}{suf}_audience_c{aud}" slot = {"positive": "(2)", "negative": "(3)", "neutral": "(4)"}[suf] sent_en = {"positive": "Positive", "negative": "Negative", "neutral": "Neutral"}[ suf ] lines.append( f"- {st} → 二·(一)·2.分受众分析 · {rank} ·{slot}{block_title}(2b,sentiment={sent_en})" ) lines.append(f"- {STAGE_3A_PAIN} → 二·(二)·1.全部受众需求分析(3a)") for slot, suf, title in ( ("2.", "positive", "正面产品反馈"), ("3.", "negative", "负面产品反馈"), ("4.", "neutral", "产品客观描述"), ): st = f"{STAGE_3B_AO_PREFIX}{suf}" marker = "(本批次未出现可省略)" if st not in stages else "" lines.append(f"- {st} → 二·(二)·{slot}{title}(3b){marker}") lines.append( "- 占比口径:括号内 (XX.X%) = 该簇结构化短语数 ÷ a;" "同一