diff --git a/README.md b/README.md
index 3e5ff93..a52b80b 100644
--- a/README.md
+++ b/README.md
@@ -1,257 +1,240 @@
-# VOC LLM 结构化分析 (VOC_LLM结构化)
+# 亚马逊评论 VOC 结构化分析
-> 基于大语言模型([DeepSeek](https://api.deepseek.com) OpenAI 兼容 API)与本地 MLX 向量的亚马逊 VOC(Voice of Customer)评论分析流水线:合并 CSV → 清洗 → LLM 结构化 → 向量化 → 聚类与词频 → 生成 HTML 分析报告。
+> **分支「辉哥版本」**:可结构化、可聚类、可溯源归因的亚马逊站内评论分析流水线。
+> 基于 DeepSeek Chat API + 本地 MLX 向量,完成:合并 CSV → 清洗 → LLM 结构化 → 向量化 → 聚类 → 词频 → **业务 HTML 报告**(Persona / 差评主题 / 根因 / KANO,每条洞察可回溯到真实评论)。
-## 📖 目录
+**仓库**:https://git.onesvm.com/whoops/amz_review_analyse
-- [核心特性](#-核心特性)
-- [环境要求](#-环境要求)
-- [安装指南](#-安装指南)
-- [使用说明](#-使用说明)
-- [示例与输出](#-示例与输出)
-- [项目结构](#-项目结构)
-- [常见问题](#-常见问题)
-- [参与贡献](#-参与贡献)
-- [开源协议](#-开源协议)
-- [联系方式与鸣谢](#-联系方式与鸣谢)
+---
-## ✨ 核心特性
+## 目录
-- **七步全流程编排** — `main_voc分析.py` 一键串联:合并、清洗、结构化、向量化、聚类、词频、HTML 报告
-- **断点续跑** — 支持 `--from-step` / `--only-step`,从任意步骤恢复,调试时节省 API 成本
-- **LLM 结构化提取** — 从评论中抽取受众、痛点、方面、观点、情感等字段(`prompts/schema.yaml` 可配置)
-- **本地向量化** — Apple Silicon 上运行 `Qwen3-Embedding-4B-mxfp8`(MLX),无需云端 Embedding API
-- **语义聚类** — UMAP + HDBSCAN 多阶段聚类,辅以 LLM 评估簇质量自动调参
-- **词频分析** — LLM 归纳专有名词 + spaCy 全量词频统计,报告内嵌词云与六类归类
-- **可编辑 Prompt** — `prompts/` 目录下 Markdown / YAML 热加载,产品运营可直接改话术(见 `prompts/README.md`)
-- **并行加速** — 聚类与词频在步骤 5–6 由线程池并行执行;结构化批间并行(默认 8 路)
+- [核心能力](#核心能力)
+- [快速开始(推荐)](#快速开始推荐)
+- [主流程说明](#主流程说明)
+- [结构化字段](#结构化字段)
+- [溯源与归因](#溯源与归因)
+- [环境要求](#环境要求)
+- [安装](#安装)
+- [项目结构](#项目结构)
+- [常见问题](#常见问题)
-## 🛠 环境要求
+---
+
+## 核心能力
+
+| 能力 | 说明 |
+|------|------|
+| **LLM 结构化提取** | 从评论抽取 `persona_signals`(画像信号)、`pain_points`(需求痛点)、`product_feedback`(方面/观点/情感/类别) |
+| **本地向量化** | Apple Silicon 上运行 `Qwen3-Embedding-4B-mxfp8`(MLX),无需云端 Embedding |
+| **语义聚类** | UMAP + HDBSCAN:`3a` 全量痛点、`3b` 按情感分桶的 aspect-opinion 聚类 |
+| **Persona 发现** | 绑定聚类簇 + keywords 二次过滤,统计命中数与占比 |
+| **差评主题 & 根因** | LLM 归纳主题;根因引用由系统从真实评论回填(结构化字段优先匹配) |
+| **可溯源 HTML 报告** | Persona 卡片、根因区块展示源评论原文 + ASIN 链接;附录展示结构化/聚类抽样 |
+| **断点续跑** | `main_voc分析.py --from-step` / `run_pipeline.py --from-step` |
+
+---
+
+## 快速开始(推荐)
+
+业务报告入口在 `voc_业务_2/`,一键跑数据管道并生成 HTML:
+
+```bash
+cd voc_业务_2
+
+# 1. 编辑 config.yaml:input_dir 指向原始评论 CSV 目录
+# 2. 全流程(step 1–6 + 自动 build_report)
+../310py/bin/python run_pipeline.py --input-dir "../你的评论CSV目录"
+```
+
+产物示例:
+
+- 根目录 SQLite:`voc_structured.sqlite`、`voc_embeddings.sqlite`、`voc_clustering.sqlite`
+- HTML 报告:`voc_业务_2/output/{产品slug}-voc-report.html`
+
+仅重跑报告(数据库已就绪):
+
+```bash
+../310py/bin/python build_report.py --product "产品名" --industry "行业"
+```
+
+---
+
+## 主流程说明
+
+### 方式 A:`voc_业务_2/run_pipeline.py`(业务报告)
+
+```
+原始 CSV → main_voc分析 step 1–6 → build_report.py → HTML
+```
+
+- 每步默认**清理旧 SQLite**(不加 `--keep-db`),避免与历史 job 混用
+- 产品名/行业可在 `config.yaml` 留空,由 LLM 从评论样本自动识别
+
+### 方式 B:`main_voc分析.py`(含经典 voc_report)
+
+```bash
+./310py/bin/python main_voc分析.py \
+ --input-dir "reviews_export" \
+ --product "Bikini Trimmer" \
+ --industry "个人护理"
+```
+
+七步:合并 → 清洗 → 结构化 → 向量化 → 聚类 → 词频 → HTML(`voc_report.py`)。
+
+断点示例:
+
+```bash
+./310py/bin/python main_voc分析.py --from-step 4 # 从向量化续跑
+./310py/bin/python main_voc分析.py --only-step 7 # 仅重生成 voc_report
+```
+
+更细步骤见 **[main_voc分析.md](main_voc分析.md)**、**[VOC分析方法论与报告生成逻辑.md](VOC分析方法论与报告生成逻辑.md)**。
+
+---
+
+## 结构化字段
+
+当前 schema(`prompts/schema.yaml`)根字段为 **3 项**(已移除 `audience`):
+
+```json
+{
+ "persona_signals": ["sensitive skin", "travel grooming"],
+ "pain_points": ["ingrown hair"],
+ "product_feedback": [
+ {
+ "aspect": "battery life",
+ "opinion": "dies after one use",
+ "sentiment": "Negative",
+ "category": "Function"
+ }
+ ]
+}
+```
+
+- 所有字段值须为**自然英文**(多语言评论先理解再英文输出)
+- `product_feedback.category` 优先 8 类标准类别(Trust / Ingredient / Quality / Function / Appearance / Logistics / Customer Service / Price)
+
+Prompt 编辑入口:`prompts/extraction/`、`voc_业务_2/prompts.yaml`。
+
+---
+
+## 溯源与归因
+
+整条链路通过 **`source_row`**(与 `merged_reviews_cleaned.csv` 行号一致)关联:
+
+```
+评论原文 (CSV)
+ ↓ source_row
+comment_extractions (voc_structured.sqlite)
+ ↓ extraction_id / source_row
+embedding_items (voc_embeddings.sqlite)
+ ↓
+cluster_assignments (voc_clustering.sqlite) → Persona 绑定簇 → source_rows 命中池
+ ↓
+build_report.py HTML
+ ├── Persona 卡片:命中池内结构化/原文匹配,展示 1–3 条源评论
+ ├── 根因分析:Negative product_feedback 优先匹配,展示 1–4 条
+ └── 附录(voc_report):结构化 JSON + 聚类短语抽样
+```
+
+配置项(`voc_业务_2/config.yaml`):
+
+```yaml
+persona_quote_max: 3 # Persona 卡片最多展示条数
+rootcause_quote_max: 4 # 每条根因最多展示条数
+```
+
+---
+
+## 环境要求
| 依赖 | 说明 |
|------|------|
-| Python | >= 3.10(推荐 3.12,项目内 `310py`) |
-| pip / uv | 安装 `requirements.txt` 中的包 |
-| spaCy 英文模型 | 经 `uv pip` 安装 `en-core-web-sm`(见安装指南,词频步骤必需) |
-| DeepSeek API Key | 结构化、聚类评估、词频、报告等 Chat 步骤 |
-| 本地 Embedding 模型 | 目录 `Qwen3-Embedding-4B-mxfp8/`(约 4GB,已 gitignore,需自行下载) |
-| Apple Silicon | 本地向量化依赖 MLX(M 系列芯片) |
+| Python | ≥ 3.10(推荐 3.12,项目内 `310py`) |
+| DeepSeek API Key | 结构化、Persona/主题/根因/KANO 等 Chat 步骤 |
+| 本地 Embedding 模型 | `Qwen3-Embedding-4B-mxfp8/`(约 4GB,gitignore,需自行下载) |
+| Apple Silicon | 本地 MLX 向量化 |
+| spaCy `en_core_web_sm` | 词频步骤 |
-**Chat API Key**(任选其一,勿提交到 Git):
+**API Key**(任选其一,勿提交 Git):
-1. 环境变量 `DEEPSEEK_API_KEY`
-2. 环境变量 `DEEPSEEK_API_KEY_FILE` 指向单行密钥文件
-3. 项目根目录 `.deepseek_key`(单行,无引号)
+```bash
+export DEEPSEEK_API_KEY="sk-xxx"
+# 或项目根 .deepseek_key(已被 .gitignore)
+```
-可选:`DEEPSEEK_MODEL`(默认 `deepseek-v4-pro`)、`DEEPSEEK_BASE_URL`(默认 `https://api.deepseek.com`)。
+---
-## 📦 安装指南
-
-1. 克隆项目到本地:
+## 安装
```bash
git clone https://git.onesvm.com/whoops/amz_review_analyse.git
-cd amz_review_analyse # 或你的本地目录名
-```
+cd amz_review_analyse
-2. 创建虚拟环境并安装依赖(推荐):
-
-```bash
uv venv 310py --python 3.12
uv pip install --python 310py/bin/python -r requirements.txt
uv pip install --python 310py/bin/python \
"en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl"
```
-3. 配置 DeepSeek API Key:
-
-```bash
-export DEEPSEEK_API_KEY="sk-xxx"
-# 或在项目根创建 .deepseek_key(已被 .gitignore 忽略)
-```
-
-4. 准备本地 Embedding 模型(首次向量化前):
-
-将 `Qwen3-Embedding-4B-mxfp8` 放到项目根,或设置 `VOC_EMBED_MODEL_PATH` 指向模型目录。可从 [Hugging Face](https://huggingface.co/mlx-community/Qwen3-Embedding-4B-mxfp8) 下载。
-
-5. 准备原始评论 CSV 目录(目录内所有 `*.csv` 表头须一致),例如亚马逊导出的 `*_realtime.csv`。
-
-## 🚀 使用说明
-
-### 全流程分析
-
-```bash
-./310py/bin/python main_voc分析.py \
- --input-dir "reviews_export" \
- --product "cat deterrent indoor" \
- --industry "Pet Supplies"
-```
-
-- `--input-dir`:原始 CSV 目录
-- `--product`:产品名(写入结构化任务与报告路径)
-- `--industry`:行业名,默认 `-`(可在步骤 3 写入库)
-- `--keep-db`:保留已有结构化 `voc_*.sqlite`,不覆盖删除
-
-### 加速(批间并行,默认已开启)
-
-步骤 3 结构化默认多批并行 Chat 请求;步骤 4 向量为本地 MLX 串行批处理(勿对同一模型多线程):
-
-```bash
-# 全流程
-./310py/bin/python main_voc分析.py --input-dir "reviews_export" --product "产品名"
-
-# 调低结构化并发(遇 429 时)
-./310py/bin/python main_voc分析.py --input-dir "reviews_export" --product "产品名" \
- --struct-workers 4
-
-# 环境变量:export VOC_STRUCT_WORKERS=8 VOC_EMBED_BATCH_SIZE=16
-```
-
-### 断点续跑
-
-```bash
-# 从向量化起续跑(步骤 4 起可省略 --product,自动读结构化库)
-./310py/bin/python main_voc分析.py --from-step 4 --keep-db
-
-# 仅重跑词频(复用已有 voc_terms.json)
-./310py/bin/python main_voc分析.py --from-step 6 --skip-wordfreq-llm
-
-# 仅重新生成 HTML 报告
-./310py/bin/python main_voc分析.py --only-step 7
-```
-
-### 其他常用参数
-
-| 参数 | 说明 |
-|------|------|
-| `--clean-intermediates` | 报告成功后删除中间 csv/sqlite,减少磁盘占用 |
-| `--filter-small-clusters` | 报告仅保留簇内评论占比 ≥ 10% 的簇 |
-| `--save-llm-raw` | 将报告 LLM 原文保存为 `report_llm_raw.txt`,调试时使用 |
-
-### 程序式调用
-
-```python
-from pathlib import Path
-from main_voc分析 import run_voc_analysis
-
-result = run_voc_analysis(
- input_dir=Path("reviews_export"),
- industry="Pet Supplies",
- product_name="cat deterrent indoor",
- from_step=1,
- clean_databases=True,
-)
-print(result["report_html"])
-```
-
-### Prompt 验收(无需 API Key)
-
-```bash
-./310py/bin/python prompts/smoke.py # 检查 prompt 能否加载
-./310py/bin/python prompts/smoke.py --live # 联调模型(需 DEEPSEEK_API_KEY)
-```
-
-### 可选变体:jieba 词频
-
-中文或需 jieba 分词时,可使用 `main_voc分析_jieba.py`(词频走 `词频_jieba.py`,其余步骤与主流程一致)。
+Embedding 模型:从 [Hugging Face mlx-community/Qwen3-Embedding-4B-mxfp8](https://huggingface.co/mlx-community/Qwen3-Embedding-4B-mxfp8) 下载到项目根,或设置 `VOC_EMBED_MODEL_PATH`。
---
-更详细的步骤说明、算法与 SQLite 约定见 **[main_voc分析.md](main_voc分析.md)**。
-
-## 📸 示例与输出
-
-流程结束后,主要产物如下:
-
-| 路径 | 说明 |
-|------|------|
-| `merged_reviews.csv` | 多文件合并结果 |
-| `merged_reviews_cleaned.csv` | 清洗、去重后的评论 |
-| `voc_structured.sqlite` | LLM 结构化结果 |
-| `voc_embeddings.sqlite` | 本地 Qwen3 向量(维度见库内 `dimensions` 字段) |
-| `voc_clustering.sqlite` | 多阶段聚类标签 |
-| `output/voc_terms.json` | 专有名词 / 停用词 |
-| `output/word_freq.csv` | 全量词频表 |
-| `output/{product}/{product}_voc_report.html` | **最终 VOC 分析报告**(词云、词频、分簇、AI 正文) |
-
-stdout 会打印 JSON 摘要(含 `report_html` 等键)。
-
-## 📂 项目结构
+## 项目结构
```text
-VOC_LLM结构化/
-├── main_voc分析.py # 主流程编排入口(七步)
-├── main_voc分析_jieba.py # 词频使用 jieba 的变体入口
-├── main_voc分析.md # 流程与算法详细说明
-├── voc_llm.py # DeepSeek Chat 密钥与客户端
-├── local_embedding.py # 本地 MLX Qwen3 向量化
-├── 合并评论数据.py # 步骤 1:多 CSV 合并
-├── content清洗.py # 步骤 2:评论清洗与去重
-├── 结构化_server.py # 步骤 3:LLM 结构化入库
-├── 结构化_Prompt.py # 结构化 prompt 组装
-├── 向量化.py # 步骤 4:本地 Embedding 入库
-├── 聚类.py # 步骤 5:UMAP + HDBSCAN
-├── 词频.py / 词频_jieba.py # 步骤 6:术语提取 + 词频
-├── voc_report.py # 步骤 7:HTML 报告生成
-├── prompts/ # 可编辑 prompt、schema、配置
-├── Qwen3-Embedding-4B-mxfp8/ # 本地模型(gitignore,需自行放置)
-├── requirements.txt
-├── output/ # 报告与词频输出(gitignore)
-└── README.md # 本文件
+├── main_voc分析.py # 七步主流程编排
+├── 结构化_server.py # LLM 结构化 → voc_structured.sqlite
+├── 向量化.py # persona_signal / pain / aspect_opinion 向量
+├── 聚类.py # 3a 痛点 + 3b 情感分桶聚类
+├── voc_report.py # 经典 HTML 报告(Dashboard + 附录验证)
+├── prompts/ # 结构化 & 报告 Prompt(可热加载)
+├── voc_业务_2/
+│ ├── run_pipeline.py # ★ 业务一键流水线
+│ ├── build_report.py # ★ 业务 HTML 报告生成
+│ ├── llm_analyzer.py # Persona / 主题 / KANO / 根因 LLM
+│ ├── report_utils.py # 统计、引用匹配、HTML 拼装
+│ ├── data_loader.py # SQLite / CSV 加载
+│ ├── config.yaml # 产品路径、并发、引用条数等
+│ └── template.html # 报告模板
+└── output/ # 报告与词频(gitignore)
```
-## ❓ 常见问题
-
-**Q:提示缺少 `DEEPSEEK_API_KEY`?**
-A:按上文配置环境变量或 `.deepseek_key`,并确认密钥未提交到仓库。
-
-**Q:只有 `.dashscope_key` 报错?**
-A:Chat 已切换为 DeepSeek,DashScope 密钥不能用于 `api.deepseek.com`,请改用 `.deepseek_key`。
-
-**Q:步骤 4 向量化失败 / 找不到模型?**
-A:确认 `Qwen3-Embedding-4B-mxfp8/` 在项目根,或设置 `VOC_EMBED_MODEL_PATH`;需在 Apple Silicon + Python 3.10+ 环境。
-
-**Q:步骤 4 报错找不到 `product`?**
-A:从步骤 1–3 完整跑过,或确保 `voc_structured.sqlite` 中已有最新 job;步骤 4 起可省略 `--product`。
-
-**Q:词频步骤报 spaCy 模型缺失?**
-A:执行 `uv pip install --python 310py/bin/python "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl"`。
-
-**Q:合并 CSV 失败?**
-A:确保 `--input-dir` 下所有 CSV 表头完全一致。
-
-**Q:修改 LLM 话术后报告解析失败?**
-A:勿修改 `prompts/schema.yaml` 中 `report.markers` 四段标记名;改完运行 `./310py/bin/python prompts/smoke.py` 验收。
-
-## 🤝 参与贡献
-
-欢迎提交 Issue 与 Pull Request。建议流程:
-
-1. Fork 本仓库
-2. 创建特性分支:`git checkout -b feature/your-feature`
-3. 提交更改:`git commit -m '简要说明变更'`
-4. 推送并发起 Pull Request
-
-修改主流程或 CLI 时,请同步更新 `main_voc分析.md`;修改 `prompts/` 时请遵循 `prompts/README.md` 中的占位符与 schema 约定。
-
-**安全提醒**:勿提交 `.deepseek_key`、`.dashscope_key`、`.env`、真实评论 CSV、`*.sqlite`、`output/` 及本地模型目录(见 `.gitignore`)。
-
-## 📄 开源协议
-
-本项目尚未在仓库中附带 `LICENSE` 文件。若为内部项目,请按组织规范使用;若计划开源,请补充协议文件(如 MIT)并更新本节链接。
-
-## ✉️ 联系方式与鸣谢
-
-- **详细技术文档**:[main_voc分析.md](main_voc分析.md)、[prompts/README.md](prompts/README.md)
-- **项目仓库**:https://git.onesvm.com/whoops/amz_review_analyse
-
-### 鸣谢
-
-- [DeepSeek API](https://api.deepseek.com) — Chat 结构化、聚类评估、词频与报告
-- [mlx-community/Qwen3-Embedding-4B-mxfp8](https://huggingface.co/mlx-community/Qwen3-Embedding-4B-mxfp8) — 本地向量化
-- [UMAP](https://umap-learn.readthedocs.io/)、[HDBSCAN](https://hdbscan.readthedocs.io/) — 聚类管线
-- [spaCy](https://spacy.io/) — 英文词频与 NLP
-
---
-*README 与 `main_voc分析.py` 七步流程保持一致;深度说明请参阅 `main_voc分析.md`。*
+## 常见问题
+
+**Q:Persona 显示「命中 N 条」但没有源评论?**
+A:确保已用最新 `report_utils.pick_persona_quotes` 重跑 `build_report.py`;命中池有数据时会多层回退展示,优先结构化 `persona_signals` / `pain_points` 匹配。
+
+**Q:结构化校验报 persona_signals 错误?**
+A:「辉哥版本」已移除 `audience` 字段;请重跑 step 3 生成新格式 JSON,勿混用旧库。
+
+**Q:提示缺少 API Key?**
+A:配置 `DEEPSEEK_API_KEY` 或 `.deepseek_key`(Chat 已用 DeepSeek,DashScope 密钥不可用)。
+
+**Q:向量化找不到模型?**
+A:确认 `Qwen3-Embedding-4B-mxfp8/` 在项目根或设置 `VOC_EMBED_MODEL_PATH`。
+
+---
+
+## 分支说明
+
+| 分支 | 说明 |
+|------|------|
+| `main` | 基础流水线 + DeepSeek + 本地 Embedding |
+| **`辉哥版本`** | 业务报告(voc_业务_2)、结构化三字段、聚类溯源、Persona/根因源评论归因 |
+
+---
+
+## 鸣谢
+
+- [DeepSeek API](https://api.deepseek.com) — Chat 结构化与分析
+- [mlx-community/Qwen3-Embedding-4B-mxfp8](https://huggingface.co/mlx-community/Qwen3-Embedding-4B-mxfp8) — 本地向量化
+- [UMAP](https://umap-learn.readthedocs.io/)、[HDBSCAN](https://hdbscan.readthedocs.io/) — 聚类
+
+---
+
+*详细算法与 SQLite 表结构见 [main_voc分析.md](main_voc分析.md)。*
diff --git a/main_voc分析.py b/main_voc分析.py
index 34f9918..6229e8b 100644
--- a/main_voc分析.py
+++ b/main_voc分析.py
@@ -60,6 +60,8 @@ DEFAULT_INDUSTRY = "亚马逊电商"
DEFAULT_PRODUCT = "亚马逊商品"
+
+
def _safe_product_dir_name(product_name: str) -> str:
"""用于 output 子目录名;去除路径非法字符。"""
name = (product_name or "product").strip()
diff --git a/prompts/README.md b/prompts/README.md
index b5676ea..81045b3 100644
--- a/prompts/README.md
+++ b/prompts/README.md
@@ -16,7 +16,7 @@
## 占位符
`.md` 文件使用 Python `format` 语法,例如 `{product_name}`、`{industry}`。
-正文里需要字面量花括号时写双花括号:`{{"audience": "unknown"}}`。
+正文里需要字面量花括号时写双花括号:`{{"persona_signals": []}}`。
## 改完怎么验收
diff --git a/prompts/extraction/batch_output_format.md b/prompts/extraction/batch_output_format.md
index 98d5aab..fbecee2 100644
--- a/prompts/extraction/batch_output_format.md
+++ b/prompts/extraction/batch_output_format.md
@@ -2,6 +2,6 @@
本批共 {n_keys} 条评论,用户消息中每条评论以 [C1]、[C2]… 前缀标识。
- 只输出一个 JSON 对象;顶层键必须且仅能是:{keys_literal}
- 每个顶层键对应一条同前缀评论,不得遗漏、不得新增其他顶层键
-- 每个键的值是单条结构化对象,仅含 audience、pain_points、product_feedback 三个字段
+- 每个键的值是单条结构化对象,仅含 persona_signals、pain_points、product_feedback 三个字段
- 不要用 JSON 数组作为顶层;不要把多条评论合并进一个对象;不要用 results、data 等包裹层
- 不要输出 markdown 代码围栏或任何解释文字
diff --git a/prompts/extraction/examples.yaml b/prompts/extraction/examples.yaml
index 09edce8..2797d0c 100644
--- a/prompts/extraction/examples.yaml
+++ b/prompts/extraction/examples.yaml
@@ -2,7 +2,9 @@
- instruction: "示例 1 教学:展示正常长评论如何标准提取,如何准确分类产品优点、缺陷以及物流问题。"
review: "Bought this for my 12yo lab who struggles with stairs. It is very soft and helps her get onto the bed easily. But the zipper broke after a week and the shipping box was damaged."
output:
- audience: "12yo lab"
+ persona_signals:
+ - "large breed dog"
+ - "mobility limited pet"
pain_points:
- "struggles with stairs"
- "difficulty getting onto bed"
@@ -23,7 +25,8 @@
- instruction: "示例 2 教学:防混淆与隐性属性提炼。什么是「产品缺陷(如讲太快)」,它属于 product_feedback,绝不是 pain_points(用户需求)!并提炼出 teaching speed 这个隐性反馈对象。"
review: "The course content is good, the teacher speaks too fast, can't keep up."
output:
- audience: "student"
+ persona_signals:
+ - "online course learner"
pain_points: []
product_feedback:
- aspect: "course content"
@@ -38,7 +41,9 @@
- instruction: "示例 3 教学:深度上下文推理。如何通过动作(抓跳蚤)推理出使用者(pet),如何通过「全家能睡觉」推理出用户需求(失眠),以及如何将「Worth every penny」准确归类为对 Price(价格)的产品反馈。"
review: "Finally found something that stops the midnight flea scratching! The chemical smell is a bit strong initially, but it fades. Worth every penny since our family can finally sleep."
output:
- audience: "pet"
+ persona_signals:
+ - "flea problem pet"
+ - "household sleep disruption"
pain_points:
- "midnight flea scratching"
- "sleep deprivation"
@@ -55,7 +60,8 @@
- instruction: "示例 4 教学:1) 非英语评论(例如西班牙语)输出必须全英文,禁止保留原文非英文片段,先理解语义再用英文短语表达;2) 「随附配件」不属于 8 个标准类别,演示如何补充一个简洁的英文新类别 'Accessories'。"
review: "El producto en sí está muy bien, viene con 2 cristales templados y 2 grips para los joycon. Pero es lo que he leido en algunos comentarios, la funda huele como a tabaco jajaja es raro pero es así"
output:
- audience: "self"
+ persona_signals:
+ - "joycon grip user"
pain_points: []
product_feedback:
- aspect: "product"
@@ -74,7 +80,9 @@
- instruction: "示例 5 教学:保留关键成分与症状等核心具体词汇。绝不能将带有具体成分/病症的词泛化提取。例如遇到「chicken flavor」(鸡肉风味)或「joint pain」(关节疼痛)时,必须保留核心修饰词,绝不能错误缩减提取为「flavor」或「pain」。"
review: "My elderly cat suffers from severe joint pain. But he is very attracted by the chicken flavor of this supplement! It really helps him walk better."
output:
- audience: "elderly cat"
+ persona_signals:
+ - "senior cat"
+ - "joint pain cat"
pain_points:
- "severe joint pain"
product_feedback:
diff --git a/prompts/extraction/field_rules.md b/prompts/extraction/field_rules.md
index f1471ae..5e29df8 100644
--- a/prompts/extraction/field_rules.md
+++ b/prompts/extraction/field_rules.md
@@ -1,12 +1,19 @@
## 语言规则(红线):
- 评论输入可能是英语、西班牙语、法语、德语、日语等**任意语言**。
-- **所有输出字段值**(audience、pain_points 每一条、aspect、opinion)**必须是自然英文**。
+- **所有输出字段值**(persona_signals 每一条、pain_points 每一条、aspect、opinion)**必须是自然英文**。
- **禁止**保留原文非英文片段(如 está muy bien、huele como a tabaco、très bon);先理解语义,再用英文短语表达。
## 分析要求:
-1. audience (为谁购买):
- - 提取出实际的使用者,使用简短的英文名词。若无明确提及,请根据上下文推理;若完全无法推理则输出 'unknown'。
+1. persona_signals (用户画像信号):
+ - **字符串数组**;每条为 2–8 词的英文短语,描述身份、体质、场景或使用背景。
+ - 从评论原文推断,典型信号:
+ - 体质/生理:`sensitive skin`、`coarse thick hair`、`pregnant`、`elderly`
+ - 场景/行为:`travel grooming`、`shower use`、`bikini area`、`first-time buyer`
+ - 自我标注句式:`I have …` / `As a …` / `My skin is …` 须提炼为短语写入此处
+ - **禁止**仅写 `self` / `user` / `unknown` 等泛化词。
+ - 不得把产品缺陷/差评(如 `pulls hair`、`battery dead`)写入 persona_signals;产品体验属于 product_feedback。
+ - 若无任何可区分信号,可输出 `[]`。
2. pain_points (用户需求):
- 仅限提取用户在购买前遇到的外部困扰、疾病、或具体场景(购买前尚未被本产品解决的需求)。
diff --git a/prompts/extraction/filter_irrelevant.md b/prompts/extraction/filter_irrelevant.md
index b337ee7..313bc7d 100644
--- a/prompts/extraction/filter_irrelevant.md
+++ b/prompts/extraction/filter_irrelevant.md
@@ -1,3 +1,3 @@
5. 无关评论过滤:
- - 若某条评论明显与{product_name}无关(其他品类、其他 SKU 或完全跑题),该条输出:{{"audience": "unknown", "pain_points": [], "product_feedback": []}}。
- - 不得将无关内容填入 audience、pain_points 或 product_feedback。
+ - 若某条评论明显与{product_name}无关(其他品类、其他 SKU 或完全跑题),该条输出:{{"persona_signals": [], "pain_points": [], "product_feedback": []}}。
+ - 不得将无关内容填入 persona_signals、pain_points 或 product_feedback。
diff --git a/prompts/extraction/format_constraints_batch.md b/prompts/extraction/format_constraints_batch.md
index 0f37d81..e645756 100644
--- a/prompts/extraction/format_constraints_batch.md
+++ b/prompts/extraction/format_constraints_batch.md
@@ -1,3 +1,3 @@
4. 单条评论对象内的格式与字段约束:
- - 每个评论对象只能包含 `audience`, `pain_points`, `product_feedback` 这 3 个字段。
+ - 每个评论对象只能包含 `persona_signals`, `pain_points`, `product_feedback` 这 3 个字段。
- **绝对不要**在 JSON 中输出 `instruction`、`教学说明` 或其他任何多余字段。
diff --git a/prompts/extraction/format_constraints_single.md b/prompts/extraction/format_constraints_single.md
index fa3119d..e807701 100644
--- a/prompts/extraction/format_constraints_single.md
+++ b/prompts/extraction/format_constraints_single.md
@@ -1,4 +1,4 @@
4. 格式与字段约束:
- - **你的 JSON 输出只能包含 `audience`, `pain_points`, `product_feedback` 这 3 个根字段。**
+ - **你的 JSON 输出只能包含 `persona_signals`, `pain_points`, `product_feedback` 这 3 个根字段。**
- **绝对不要**在 JSON 中输出 `instruction`、`教学说明` 或其他任何多余字段。
- 必须以纯 JSON 格式输出结果,不要包含任何 markdown 标记(如 ```json )或其他解释性文字。
diff --git a/prompts/schema.yaml b/prompts/schema.yaml
index 0fa8bd6..6d26bb8 100644
--- a/prompts/schema.yaml
+++ b/prompts/schema.yaml
@@ -3,7 +3,7 @@
extraction:
root_fields:
- - audience
+ - persona_signals
- pain_points
- product_feedback
product_feedback_fields:
diff --git a/voc_report.py b/voc_report.py
index 5b1c97a..ee73d2f 100644
--- a/voc_report.py
+++ b/voc_report.py
@@ -321,7 +321,7 @@ def _extraction_to_display_zh(ext: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(pains, list):
pains = []
return {
- "受众": str(ext.get("audience", "")).strip(),
+ "画像信号": [str(s).strip() for s in ext.get("persona_signals") or [] if str(s).strip()],
"需求/痛点": [str(p).strip() for p in pains if str(p).strip()],
"产品反馈": feedback,
}
diff --git a/voc_业务_2/build_report.py b/voc_业务_2/build_report.py
index 904c4bb..a3e3cb2 100644
--- a/voc_业务_2/build_report.py
+++ b/voc_业务_2/build_report.py
@@ -39,7 +39,7 @@ from llm_analyzer import (
from report_utils import (
enrich_theme_keywords, recalc_neg_priorities, compute_persona_pcts,
assign_persona_clusters, validate_persona_physiological_labels,
- enrich_persona_catalog_physio_counts,
+ prepare_persona_catalog_for_llm,
pick_persona_quotes, fix_jtbd_fields, filter_matrix_rows,
enrich_matrix_scene_evidence, build_matrix_table_rows_html,
enrich_rootcause_quotes, build_executive_summary, enhanced_market_judgment,
@@ -53,6 +53,7 @@ from report_utils import (
build_product_stopwords, build_kano_grid_html, build_neg_theme_summary_note,
build_neg_theme_table_rows, build_pos_theme_table_rows,
build_sentiment_keyword_groups, prepare_keyword_display, build_keyword_tables_html,
+ build_quote_blocks_html,
)
from echarts_builder import (
get_echarts_script, build_all_charts, calc_theme_freq, calc_per_asin_theme_freq,
@@ -134,13 +135,20 @@ def build_report_data(loader: DataLoader, cfg: dict) -> Dict[str, Any]:
data["cluster_data"] = cluster_data
all_clusters = loader.load_cluster_data()
reviews = loader.load_reviews()
+ extractions = loader.load_comment_extractions()
+ data["extractions"] = extractions
+ persona_quote_max = int(cfg.get("persona_quote_max", 3))
+ rootcause_quote_max = int(cfg.get("rootcause_quote_max", 4))
physio_min = int(cfg.get("persona_physio_min_reviews", 5))
- enrich_persona_catalog_physio_counts(
+ min_hit_count = int(cfg.get("persona_min_hit_count", 5))
+ data["persona_min_hit_count"] = min_hit_count
+ prepare_persona_catalog_for_llm(
cluster_data["persona_cluster_catalog"], all_clusters, reviews,
+ min_physio_reviews=physio_min,
)
# ── Persona ──
- personas = discover_personas(cluster_data)
+ personas = discover_personas(cluster_data, product_name=product, industry=industry)
personas = assign_persona_clusters(personas, all_clusters)
personas = validate_persona_physiological_labels(
personas, cluster_data, reviews, all_clusters, min_review_count=physio_min,
@@ -156,11 +164,15 @@ def build_report_data(loader: DataLoader, cfg: dict) -> Dict[str, Any]:
data["neg_themes"] = neg_themes
data["pos_themes"] = pos_themes
- # ── 主题频次统计 ──
- neg_freq = calc_theme_freq(neg_themes, reviews, is_neg=True)
- pos_freq = calc_theme_freq(pos_themes, reviews, is_neg=False)
- per_asin_neg = calc_per_asin_theme_freq(neg_themes, reviews, is_neg=True)
- per_asin_pos = calc_per_asin_theme_freq(pos_themes, reviews, is_neg=False)
+ # ── 主题频次统计(结构化 category+aspect 优先) ──
+ neg_freq = calc_theme_freq(neg_themes, reviews, is_neg=True, extractions=extractions)
+ pos_freq = calc_theme_freq(pos_themes, reviews, is_neg=False, extractions=extractions)
+ per_asin_neg = calc_per_asin_theme_freq(
+ neg_themes, reviews, is_neg=True, extractions=extractions,
+ )
+ per_asin_pos = calc_per_asin_theme_freq(
+ pos_themes, reviews, is_neg=False, extractions=extractions,
+ )
neg_themes = recalc_neg_priorities(
neg_themes, neg_freq, stats.neg_review_count, len(stats.asins), per_asin_neg,
)
@@ -174,7 +186,10 @@ def build_report_data(loader: DataLoader, cfg: dict) -> Dict[str, Any]:
personas = compute_persona_pcts(personas, reviews, all_clusters)
personas = sort_personas_by_evidence(personas)
data["personas"] = personas
- data["persona_quotes"] = pick_persona_quotes(personas, reviews)
+ data["persona_quotes"] = pick_persona_quotes(
+ personas, reviews, extractions,
+ max_quotes=persona_quote_max,
+ )
# ── KANO + JTBD + 情感关键词(三者并发) ──
neg_kw_groups = build_sentiment_keyword_groups(neg_themes, reviews, limit=6, is_neg=True)
@@ -212,21 +227,16 @@ def build_report_data(loader: DataLoader, cfg: dict) -> Dict[str, Any]:
data["matrix"] = matrix
# ── 根因分析(各 Persona 并发) ──
- per_aud = loader.load_per_audience_clusters()
- per_aud_prompt = {}
- for al, d in per_aud.items():
- per_aud_prompt[al] = {
- "pains": [{"label": c.cluster_label, "top_phrases": c.top_phrases} for c in d["pain"]],
- "negative": [{"label": c.cluster_label, "top_phrases": c.top_phrases} for c in d["negative"]],
- "positive": [{"label": c.cluster_label, "top_phrases": c.top_phrases} for c in d["positive"]],
- }
rootcauses = analyze_all_rootcauses(
- personas, per_aud_prompt, neg_themes, max_workers=llm_workers,
- product_name=product, industry=industry, min_hit_count=10,
+ personas, neg_themes, all_clusters=all_clusters,
+ max_workers=llm_workers,
+ product_name=product, industry=industry, min_hit_count=min_hit_count,
)
rootcauses = enrich_rootcause_quotes(
- rootcauses, personas, per_aud, loader, reviews,
+ rootcauses, personas, loader, reviews,
persona_quotes=data["persona_quotes"],
+ extractions=extractions,
+ max_quotes=rootcause_quote_max,
)
rootcauses = normalize_rootcauses(rootcauses, neg_themes)
rootcauses = sort_rootcauses_by_evidence(rootcauses, personas)
@@ -411,21 +421,20 @@ def render_html(template_path: Path, data: Dict[str, Any], cfg: dict) -> str:
persona_cards = []
for i, p in enumerate(data["personas"]):
pq = next((q for q in data["persona_quotes"] if q["persona"] == p["name"]), None)
- quote_html = ""
- if pq and pq.get("quote"):
- q_cls = "neg" if pq.get("is_neg") else "pos"
- star_hint = f" ({int(pq.get('rating', 0))}★)" if pq.get("rating") else ""
- cn = pq.get("cn_summary") or quote_cn_summary(pq["quote"])
- quote_html = (
- f'
{p.get("name", "?")}
{meta}
@@ -651,26 +660,41 @@ def main():
raw = json.load(f)
stats = loader.load_basic_stats()
reviews = loader.load_reviews()
+ extractions = loader.load_comment_extractions()
+ persona_quote_max = int(cfg.get("persona_quote_max", 3))
+ rootcause_quote_max = int(cfg.get("rootcause_quote_max", 4))
all_clusters = loader.load_cluster_data()
personas = assign_persona_clusters(raw.get("personas", []), all_clusters)
cluster_data_rr = raw.get("cluster_data") or loader.build_cluster_prompt_data(
persona_sample_reviews=int(cfg.get("persona_sample_reviews", 5)),
)
- enrich_persona_catalog_physio_counts(
+ physio_min = int(cfg.get("persona_physio_min_reviews", 5))
+ prepare_persona_catalog_for_llm(
cluster_data_rr.get("persona_cluster_catalog") or [],
all_clusters, reviews,
+ min_physio_reviews=physio_min,
)
- physio_min = int(cfg.get("persona_physio_min_reviews", 5))
personas = validate_persona_physiological_labels(
personas, cluster_data_rr, reviews, all_clusters, min_review_count=physio_min,
)
personas = compute_persona_pcts(personas, reviews, all_clusters)
personas = sort_personas_by_evidence(personas)
- persona_quotes = pick_persona_quotes(personas, reviews)
- neg_freq = calc_theme_freq(raw.get("neg_themes", []), reviews, is_neg=True)
- pos_freq = calc_theme_freq(raw.get("pos_themes", []), reviews, is_neg=False)
- per_asin_neg = calc_per_asin_theme_freq(raw.get("neg_themes", []), reviews, is_neg=True)
- per_asin_pos = calc_per_asin_theme_freq(raw.get("pos_themes", []), reviews, is_neg=False)
+ persona_quotes = pick_persona_quotes(
+ personas, reviews, extractions,
+ max_quotes=persona_quote_max,
+ )
+ neg_freq = calc_theme_freq(
+ raw.get("neg_themes", []), reviews, is_neg=True, extractions=extractions,
+ )
+ pos_freq = calc_theme_freq(
+ raw.get("pos_themes", []), reviews, is_neg=False, extractions=extractions,
+ )
+ per_asin_neg = calc_per_asin_theme_freq(
+ raw.get("neg_themes", []), reviews, is_neg=True, extractions=extractions,
+ )
+ per_asin_pos = calc_per_asin_theme_freq(
+ raw.get("pos_themes", []), reviews, is_neg=False, extractions=extractions,
+ )
neg_themes = recalc_neg_priorities(
raw.get("neg_themes", []), neg_freq, stats.neg_review_count, len(stats.asins), per_asin_neg,
)
@@ -710,14 +734,14 @@ def main():
market_title, market_desc = enhanced_market_judgment(stats)
data["market_title"] = market_title
data["market_desc"] = market_desc
- per_aud = loader.load_per_audience_clusters()
data["rootcauses"] = enrich_rootcause_quotes(
data.get("rootcauses") or [],
personas,
- per_aud,
loader,
reviews,
persona_quotes=persona_quotes,
+ extractions=extractions,
+ max_quotes=rootcause_quote_max,
)
elif args.no_llm:
logger.warning("--no-llm 模式:跳过 LLM 调用,仅渲染模板")
diff --git a/voc_业务_2/config.yaml b/voc_业务_2/config.yaml
index 485e670..44bbd5c 100644
--- a/voc_业务_2/config.yaml
+++ b/voc_业务_2/config.yaml
@@ -30,6 +30,15 @@ prompts_file: "./prompts.yaml"
persona_sample_reviews: 5
# Persona 生理标签:绑定簇内至少 N 条评论含对应英文词方可保留(如 pregnant≥5 才可用「孕妇」)
persona_physio_min_reviews: 5
+# Persona 命中门槛:hit_count 低于此值跳过根因 LLM,卡片置信度显示「低样本」
+persona_min_hit_count: 5
+# Persona 卡片展示的代表性评论条数(结构化字段匹配 + 原文回退)
+persona_quote_max: 3
+# 每条根因展示的代表性评论条数
+rootcause_quote_max: 4
+# 进入 step 2 细粒度聚类的 audience 簇最小占比(相对总评论数)
+audience_coverage_threshold: 0.05
+
# ── LLM 配置 ──
# 复用根目录 voc_llm.py 的配置(DEEPSEEK_API_KEY 环境变量 或 .deepseek_key)
diff --git a/voc_业务_2/data_loader.py b/voc_业务_2/data_loader.py
index ecbd731..f1ffd67 100644
--- a/voc_业务_2/data_loader.py
+++ b/voc_业务_2/data_loader.py
@@ -7,11 +7,12 @@ from __future__ import annotations
import csv
import json
import logging
+import math
import sqlite3
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Set, Tuple
logger = logging.getLogger("voc.data_loader")
@@ -22,6 +23,52 @@ CLEANED_CSV_NAME = "merged_reviews_cleaned.csv"
WORD_FREQ_CSV_NAME = "output/word_freq.csv"
TERMS_JSON_NAME = "output/voc_terms.json"
+# 与 report_utils._PERSONA_PHRASE_STOPWORDS 保持一致(避免循环 import)
+_PERSONA_PHRASE_STOPWORDS = frozenset({
+ "self", "unknown", "user", "customer", "buyer", "myself", "the user",
+ "a user", "consumer", "reviewer", "amazon", "product", "item",
+})
+
+
+def _phrase_segments_local(phrases: List[str]) -> List[str]:
+ """将聚类短语拆成可匹配的英文片段。"""
+ segs: List[str] = []
+ for p in phrases:
+ p = (p or "").lower().strip()
+ if not p:
+ continue
+ segs.append(p)
+ for part in p.split(","):
+ part = part.strip()
+ if len(part) >= 3:
+ segs.append(part)
+ return list(dict.fromkeys(segs))
+
+
+def _cluster_semantic_label(
+ cluster: "ClusterData",
+ phrase_doc_freq: Dict[str, int],
+ n_clusters_in_stage: int,
+) -> str:
+ """从 top_phrases 生成簇短语义标签(频次 × IDF,同类 stage 内去重)。"""
+ scored: List[Tuple[float, str]] = []
+ for rank, phrase in enumerate(cluster.top_phrases):
+ tf = 1.0 / (1 + rank)
+ for seg in _phrase_segments_local([phrase]):
+ if seg in _PERSONA_PHRASE_STOPWORDS or len(seg) < 4:
+ continue
+ df = phrase_doc_freq.get(seg, 1)
+ idf = math.log((n_clusters_in_stage + 1) / (df + 0.5))
+ scored.append((tf * idf, seg))
+ if scored:
+ scored.sort(key=lambda x: (-x[0], -len(x[1])))
+ return scored[0][1][:48]
+ for phrase in cluster.top_phrases:
+ p = (phrase or "").strip()
+ if len(p) >= 4:
+ return p[:48]
+ return f"cluster_{cluster.cluster_label}"
+
@dataclass
class ReviewRecord:
@@ -69,6 +116,63 @@ class MarketStats:
asins: List[ASINStats] = field(default_factory=list)
+# Persona A/B/C 维度信号词(用于运行时判定簇的 suggested_dimension)
+_A_DIM_SIGNALS = frozenset({
+ # 生理/物理特征 — 跨品类通用
+ "sensitive skin", "sensitive", "allergy", "allergic",
+ "elderly", "senior", "older",
+ "kid", "child", "children", "toddler", "infant", "baby", "newborn",
+ "pregnant", "pregnancy", "nursing", "breastfeeding",
+ "diabetic", "diabetes",
+ "my dog", "my cat", "my pet", "puppy", "kitten", "pet owner",
+ "professional", "beginner", "first time user",
+ "large breed", "small breed", "small dog", "large dog",
+ "oily skin", "dry skin", "acne", "eczema", "psoriasis",
+ "curly hair", "fine hair", "color treated",
+})
+_B_DIM_SIGNALS = frozenset({
+ # 行为/使用场景 — 跨品类通用
+ "travel", "on the go", "portable", "lightweight", "compact",
+ "daily", "everyday", "first time", "first-time", "beginner",
+ "outdoor", "indoor", "home", "office", "kitchen", "car",
+ "quick", "easy to use", "simple", "convenient",
+ "maintenance", "cleaning", "storage", "organization",
+ "camping", "hiking", "gym", "workout",
+ "cooking", "baking", "cleaning house",
+})
+_C_DIM_SIGNALS = frozenset({
+ # 购买动机/背景 — 跨品类通用
+ "gift", "present", "birthday", "christmas", "holiday",
+ "for my wife", "for my husband", "for my mom", "for my daughter",
+ "replacement", "replace", "upgrade", "switched from",
+ "price", "cheap", "expensive", "affordable", "worth the money",
+ "waste of money", "not worth", "overpriced", "good value",
+ "recommend", "recommended", "saw on", "social media",
+ "amazon", "online", "review", "reviews", "rating",
+ "bought", "purchased", "ordered", "arrived",
+})
+
+
+def _suggest_dimension(stage: str, top_phrases: List[str]) -> str:
+ """根据簇的 top_phrases 内容判定建议的 Persona 维度(A/B/C)。"""
+ blob = " ".join(top_phrases).lower()
+ a_hits = sum(1 for w in _A_DIM_SIGNALS if w in blob)
+ b_hits = sum(1 for w in _B_DIM_SIGNALS if w in blob)
+ c_hits = sum(1 for w in _C_DIM_SIGNALS if w in blob)
+ if a_hits >= 2:
+ return "A"
+ if a_hits >= 1 and (b_hits + c_hits) == 0:
+ return "A"
+ if b_hits > c_hits and b_hits >= 2:
+ return "B"
+ if c_hits > b_hits and c_hits >= 2:
+ return "C"
+ # 默认按 stage 推理
+ if stage.startswith("3a"):
+ return "C"
+ return "B"
+
+
class DataLoader:
def __init__(self, project_root: Path, product_name: str = "", industry: str = ""):
self.project_root = Path(project_root).resolve()
@@ -142,6 +246,44 @@ class DataLoader:
return r
return None
+ def load_comment_extractions(
+ self,
+ job_id: Optional[int] = None,
+ ) -> Dict[int, Dict[str, Any]]:
+ """加载 source_row → extraction_json 映射(最新 job 或指定 job_id)。"""
+ if not self.structured_db.is_file():
+ logger.warning("未找到结构化库 %s,引用匹配将仅使用原文关键词", self.structured_db)
+ return {}
+ conn = sqlite3.connect(self.structured_db)
+ try:
+ if job_id is None:
+ row = conn.execute(
+ "SELECT id FROM analysis_jobs ORDER BY id DESC LIMIT 1"
+ ).fetchone()
+ if not row:
+ logger.warning("voc_structured.sqlite 中无结构化任务")
+ return {}
+ job_id = int(row[0])
+ cur = conn.execute(
+ """
+ SELECT source_row, extraction_json
+ FROM comment_extractions
+ WHERE job_id = ?
+ ORDER BY source_row
+ """,
+ (job_id,),
+ )
+ out: Dict[int, Dict[str, Any]] = {}
+ for sr, js in cur.fetchall():
+ try:
+ out[int(sr)] = json.loads(js)
+ except json.JSONDecodeError as e:
+ logger.debug("跳过无效 extraction source_row=%s: %s", sr, e)
+ logger.info("已加载 %s 条结构化提取 (job_id=%s)", len(out), job_id)
+ return out
+ finally:
+ conn.close()
+
def load_basic_stats(self) -> MarketStats:
reviews = self.load_reviews()
if not reviews:
@@ -241,33 +383,46 @@ class DataLoader:
logger.info("已加载聚类: %s stages, %s 簇", len(result), sum(len(v) for v in result.values()))
return dict(result)
+
+
def build_persona_cluster_catalog(
self,
sample_reviews_per_cluster: int = 5,
max_review_chars: int = 320,
) -> List[Dict[str, Any]]:
- """供 Persona LLM 绑定的聚类簇目录(含每簇代表性评论)。"""
+ """供 Persona LLM 绑定的聚类簇目录(含每簇代表性评论和运行时维度建议)。"""
all_c = self.load_cluster_data()
- dim_hint = {
- "1_audience": "A",
- "3a_pain_global": "C",
- "3b_aspect_opinion_negative": "B",
- "3b_aspect_opinion_positive": "B",
- }
catalog: List[Dict[str, Any]] = []
for stage in (
- "1_audience",
"3a_pain_global",
"3b_aspect_opinion_negative",
"3b_aspect_opinion_positive",
):
- for c in sorted(all_c.get(stage, []), key=lambda x: -x.review_count):
+ stage_clusters = sorted(all_c.get(stage, []), key=lambda x: -x.review_count)
+ phrase_doc_freq: Dict[str, int] = defaultdict(int)
+ for c in stage_clusters:
+ segs = set(_phrase_segments_local(c.top_phrases))
+ for seg in segs:
+ if seg not in _PERSONA_PHRASE_STOPWORDS and len(seg) >= 4:
+ phrase_doc_freq[seg] += 1
+ n_stage = max(len(stage_clusters), 1)
+ used_labels: Set[str] = set()
+ for c in stage_clusters:
+ label = _cluster_semantic_label(c, phrase_doc_freq, n_stage)
+ if label in used_labels:
+ for rank, phrase in enumerate(c.top_phrases[1:], start=1):
+ alt = (phrase or "").strip().lower()[:48]
+ if alt and len(alt) >= 4 and alt not in used_labels:
+ label = alt
+ break
+ used_labels.add(label)
entry: Dict[str, Any] = {
"stage": stage,
"label": c.cluster_label,
"review_count": c.review_count,
+ "semantic_label": label,
"top_phrases": c.top_phrases[:12],
- "suggested_dimension": dim_hint.get(stage, "B"),
+ "suggested_dimension": _suggest_dimension(stage, c.top_phrases),
}
if sample_reviews_per_cluster > 0:
quotes = self.get_representative_quotes(
@@ -318,10 +473,8 @@ class DataLoader:
self,
persona_sample_reviews: int = 5,
) -> Dict[str, Any]:
- audience = self.load_audience_clusters()
global_pain = self.load_global_pain_clusters()
global_fb = self.load_global_feedback_clusters()
- per_aud = self.load_per_audience_clusters()
stats = self.load_basic_stats()
def _sum(clusters: List[ClusterData]) -> List[Dict[str, Any]]:
@@ -332,18 +485,14 @@ class DataLoader:
for c in sorted(clusters, key=lambda x: -x.phrase_count)
]
return {
- "audience_clusters": _sum(audience),
+ "audience_clusters": [],
"global_pains": _sum(global_pain),
"global_negative": _sum(global_fb["negative"]),
"global_positive": _sum(global_fb["positive"]),
"persona_cluster_catalog": self.build_persona_cluster_catalog(
sample_reviews_per_cluster=persona_sample_reviews,
),
- "per_audience": {
- al: {"pains": _sum(d["pain"]), "negative": _sum(d["negative"]),
- "positive": _sum(d["positive"])}
- for al, d in sorted(per_aud.items())
- },
+ "per_audience": {},
"total_reviews": stats.total_reviews,
"neg_review_count": stats.neg_review_count,
"pos_review_count": stats.pos_review_count,
diff --git a/voc_业务_2/echarts_builder.py b/voc_业务_2/echarts_builder.py
index 1f8c26c..c42c9d0 100644
--- a/voc_业务_2/echarts_builder.py
+++ b/voc_业务_2/echarts_builder.py
@@ -683,12 +683,34 @@ def calc_keyword_group_freq(
return count
+def _theme_matches_review(
+ keywords: List[str],
+ review: Any,
+ extractions: Dict[int, Any],
+ *,
+ is_neg: bool,
+) -> bool:
+ """主题命中:优先结构化 category+aspect,回退原文 keyword。"""
+ from report_utils import match_theme_extraction
+
+ kws = [kw.lower().strip() for kw in keywords if kw and kw.strip()]
+ if not kws:
+ return False
+ ext = extractions.get(review.source_row)
+ if ext and match_theme_extraction(kws, ext, is_neg=is_neg):
+ return True
+ text = (review.title + " " + review.content).lower()
+ return _match_theme_in_text(kws, text)
+
+
def calc_theme_freq(
themes: List[Dict[str, Any]],
reviews: List[Any],
is_neg: bool = True,
+ extractions: Dict[int, Any] | None = None,
) -> Dict[str, int]:
- """用主题 keywords(英文聚类短语 + LLM 关键词)统计频次。"""
+ """用主题 keywords 统计频次(结构化 category+aspect 优先,原文回退)。"""
+ ext_map = extractions or {}
freq: Dict[str, int] = {}
for theme in themes:
keywords = [kw.lower().strip() for kw in theme.get("keywords", []) if kw.strip()]
@@ -698,8 +720,7 @@ def calc_theme_freq(
continue
if not is_neg and r.rating < 4:
continue
- text = (r.title + " " + r.content).lower()
- if _match_theme_in_text(keywords, text):
+ if _theme_matches_review(keywords, r, ext_map, is_neg=is_neg):
count += 1
freq[theme["name"]] = count
return freq
@@ -709,9 +730,12 @@ def calc_per_asin_theme_freq(
themes: List[Dict[str, Any]],
reviews: List[Any],
is_neg: bool = True,
+ extractions: Dict[int, Any] | None = None,
) -> Dict[str, Dict[str, int]]:
- """按 ASIN 分别统计各主题频次。"""
+ """按 ASIN 分别统计各主题频次(结构化优先)。"""
from collections import defaultdict
+
+ ext_map = extractions or {}
result: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
for theme in themes:
keywords = [kw.lower().strip() for kw in theme.get("keywords", []) if kw.strip()]
@@ -721,7 +745,6 @@ def calc_per_asin_theme_freq(
continue
if not is_neg and r.rating < 4:
continue
- text = (r.title + " " + r.content).lower()
- if _match_theme_in_text(keywords, text):
+ if _theme_matches_review(keywords, r, ext_map, is_neg=is_neg):
result[r.asin][name] += 1
return {asin: dict(counts) for asin, counts in result.items()}
diff --git a/voc_业务_2/llm_analyzer.py b/voc_业务_2/llm_analyzer.py
index f9082ed..fda5de1 100644
--- a/voc_业务_2/llm_analyzer.py
+++ b/voc_业务_2/llm_analyzer.py
@@ -131,10 +131,12 @@ def _j(obj: Any) -> str:
# ── Persona ──
-def build_persona_prompt(cluster_data):
+def build_persona_prompt(cluster_data, product_name: str = "", industry: str = ""):
catalog = cluster_data.get("persona_cluster_catalog") or []
return user_prompt(
"persona",
+ product_name=product_name or "主产品",
+ industry=industry or "当前品类",
catalog_json=_j(catalog),
audience_clusters_json=_j(cluster_data.get("audience_clusters", [])),
global_pains_json=_j(cluster_data.get("global_pains", [])),
@@ -143,11 +145,17 @@ def build_persona_prompt(cluster_data):
)
-def discover_personas(cluster_data):
- logger.info("LLM: Persona发现...")
+def discover_personas(
+ cluster_data,
+ product_name: str = "",
+ industry: str = "",
+):
+ logger.info("LLM: Persona发现(产品=%s)...", product_name or "主产品")
params = llm_params("persona")
raw = _call_llm(
- system_prompt("persona"), build_persona_prompt(cluster_data), **params,
+ system_prompt("persona"),
+ build_persona_prompt(cluster_data, product_name, industry),
+ **params,
)
result = _parse_json(raw)
personas = result.get("personas", [])
@@ -277,8 +285,8 @@ def analyze_matrix(personas, kano, cluster_data, total_reviews, market_avg: floa
def analyze_rootcause_per_persona(
persona,
- per_aud_data,
neg_themes,
+ bound_clusters: Optional[Dict[str, Any]] = None,
product_name: str = "",
industry: str = "",
):
@@ -287,13 +295,15 @@ def analyze_rootcause_per_persona(
product_ctx = product_name or "主产品"
industry_ctx = industry or "当前品类"
logger.info("LLM: 根因-%s...", name)
+ # 构建 Persona 自身绑定簇的摘要数据(替代旧 per_aud_data)
+ bc_data = bound_clusters or {}
user = user_prompt(
"rootcause",
persona_name=name,
product_name=product_ctx,
industry=industry_ctx,
persona_json=_j(persona),
- per_aud_data_json=_j(per_aud_data),
+ bound_clusters_json=_j(bc_data),
theme_names_json=_j(theme_names),
)
params = llm_params("rootcause")
@@ -304,27 +314,35 @@ def analyze_rootcause_per_persona(
def analyze_all_rootcauses(
personas: List[Dict[str, Any]],
- per_audience: Dict[Any, Any],
neg_themes: List[Dict[str, Any]],
+ all_clusters: Optional[Dict[str, List[Any]]] = None,
max_workers: int = DEFAULT_LLM_WORKERS,
product_name: str = "",
industry: str = "",
- min_hit_count: int = 10,
+ min_hit_count: int = 5,
) -> List[Dict[str, Any]]:
- """并发按 Persona 根因分析;跳过命中不足的 Persona。"""
- aud_keys = sorted(per_audience.keys())
+ """并发按 Persona 根因分析;跳过命中不足的 Persona。不再依赖 per_audience。"""
- def _audience_data_for(persona: Dict[str, Any]) -> Dict[str, Any]:
- aud_label = persona.get("audience_label")
- if aud_label is None or aud_label < 0:
- ref = persona.get("cluster_ref") or {}
- if ref.get("stage") == "1_audience" and ref.get("label") is not None:
- aud_label = int(ref["label"])
- if aud_label is not None and aud_label >= 0 and aud_label in per_audience:
- return per_audience[aud_label]
- if aud_keys:
- return per_audience.get(aud_keys[0], {})
- return {}
+ def _bound_clusters_for(persona: Dict[str, Any]) -> Dict[str, Any]:
+ """从 Persona 自身 cluster_refs 构建绑定簇的摘要数据。"""
+ refs = persona.get("cluster_refs") or {}
+ bc_data: Dict[str, Any] = {}
+ if isinstance(refs, dict):
+ for dim, ref in refs.items():
+ if isinstance(ref, dict) and ref.get("stage") and ref.get("label") is not None:
+ stage = ref["stage"]
+ label = ref["label"]
+ # 尝试从 all_clusters 中查找实际簇数据
+ if all_clusters and stage in all_clusters:
+ for c in all_clusters[stage]:
+ if int(getattr(c, "cluster_label", -1)) == int(label):
+ bc_data[f"{dim}_dim"] = {
+ "stage": stage,
+ "label": label,
+ "top_phrases": getattr(c, "top_phrases", [])[:10],
+ }
+ break
+ return bc_data
eligible = [
(i, p) for i, p in enumerate(personas)
@@ -344,9 +362,10 @@ def analyze_all_rootcauses(
logger.info("LLM: 并发根因分析 %s/%s 个 Persona(workers=%s)...", n, len(personas), workers)
def _one(idx: int, persona: Dict[str, Any]) -> Dict[str, Any]:
- aud_data = _audience_data_for(persona)
+ bc_data = _bound_clusters_for(persona)
rc = analyze_rootcause_per_persona(
- persona, aud_data, neg_themes,
+ persona, neg_themes,
+ bound_clusters=bc_data,
product_name=product_name, industry=industry,
)
rc["persona_name"] = persona.get("name", f"P{idx}")
diff --git a/voc_业务_2/prompts.yaml b/voc_业务_2/prompts.yaml
index 7db4667..7c58338 100644
--- a/voc_业务_2/prompts.yaml
+++ b/voc_业务_2/prompts.yaml
@@ -48,34 +48,38 @@ persona:
user_template: |
## 任务:基于聚类数据按三维度发现用户画像(Persona),4-7 个。
+ ## 分析产品(product_detect / config 已识别,Persona 须针对该品类)
+ 产品:{{product_name}} | 行业:{{industry}}
+ purchase_motivation 用「雇佣{{product_name}}完成…」JTBD 句式;Persona 痛点/需求须与该产品使用场景一致,禁止脱离品类写无关人群。
+
## 三维度强制覆盖(缺一不可)
- A. 物理/生理/受众特征 → 必须绑定 stage=1_audience 的簇
- 典型信号:特定体质/肤质/年龄/体型/使用对象(如 sensitive / elderly / for kids / large breed)
- B. 行为/使用场景 → 绑定 3b_aspect_opinion_positive / 3b_aspect_opinion_negative / 3a_pain_global
- 典型信号:travel / daily use / outdoor / first time / gift
- C. 购买动机/背景 → 绑定 3a_pain_global 或 opinion 簇
- 典型信号:switched from / saw on social media / first time / too expensive
- → 不能只有 B 和 C;A 维至少 1 个。若 A 维缺失,说明物理特征群体被遗漏,必须补建。
+ A. 物理/生理/受众特征 → 从 catalog 中 suggested_dimension="A" 的簇中选择
+ 典型信号:特定体质/肤质/年龄/体型/使用对象(如 sensitive skin / coarse hair / pregnant / bikini area)
+ B. 行为/使用场景 / 具体痛点 → 从 catalog 中 suggested_dimension="B" 的簇中选择
+ 典型信号:travel / first time / pulls hair / battery dead / durability
+ C. 购买动机/背景 → 从 catalog 中 suggested_dimension="C" 的簇中选择
+ 典型信号:switched from / saw on social media / too expensive
+ → 每个 Persona 必须同时绑 B 或 C 维的具体痛点/需求簇,用于精准计算占比。
## 自我标注信号强制检查(归纳后必做)
- 在绑定簇的 top_phrases 中搜索以下模式,出现 ≥5 条则必须单独建 Persona:
- - "I have [adj] [noun]"(如 I have sensitive skin / I have a large dog)
- - "My [noun] is/are [adj]"(如 My pet is very anxious)
- - "As a [noun]"(如 As a first-time buyer / As a pet owner)
- - 受众/体质/使用对象相关形容词(elderly / sensitive / indoor / outdoor)
+ 在绑定簇的 top_phrases / sample_reviews 中搜索以下模式,出现 ≥5 条且 eligible_physio_labels 达标时才可建对应生理 Persona:
+ - "I have [adj] [noun]"(如 I have sensitive skin)
+ - "My [noun] is/are [adj]"
+ - "As a [noun]"
+ - 若 eligible_physio_labels 为空或不包含对应维度,禁止在名称/core_pain 写生理标签
## 其他遗漏检查
- - 长期使用/复购用户:含 after months / after a while / second bottle / bought again 的差评是否形成独立群体
+ - 长期使用/复购用户:含 after months / second bottle / bought again 的差评是否形成独立群体
- 占比低(~5%)但痛点独特、无法被其他 Persona 代表的群体,仍须单独列出
- ## 可绑定的聚类簇目录(cluster_ref 必须从中选择;每簇含 top_phrases + sample_reviews 最多 5 条原文)
+ ## 可绑定的聚类簇目录(cluster_refs 必须从中选择)
+ 每簇含:semantic_label、suggested_dimension(A/B/C建议维度)、eligible_physio_labels(仅≥5条佐证的生理维度)、top_phrases、sample_reviews
{{catalog_json}}
- ## 生理标签硬规则(名称 + core_pain,违反则系统会剔除)
- - 名称或 core_pain 中的生理/体质类中文标签(如孕妇、老年、儿童、敏感肌、粗硬发、疤痕等),绑定簇内须有 ≥5 条评论原文含对应英文词(catalog 中 physio_review_counts 字段,如 pregnancy: 8)
- - top_phrases 仅出现 1–4 次不算数;不得以短语偶然出现代替评论条数门槛
- - 无达标评论佐证时禁止写入该标签;不得凭品类常识或方法论示例脑补
- - sample_reviews 为溯源原文,core_pain 只能归纳其中明确出现的内容,禁止推断未出现的生理状态
+ ## 生理标签硬规则(进 LLM 前已过滤,仅 eligible_physio_labels 中的维度可用于命名)
+ - catalog 仅展示 eligible_physio_labels(评论佐证≥5条);未出现的生理标签禁止写入名称或 core_pain
+ - top_phrases 仅出现 1–4 次不算数;不得凭品类常识脑补
+ - sample_reviews 为溯源原文,core_pain 只能归纳其中明确出现的内容
## 补充聚类摘要
audience_clusters: {{audience_clusters_json}}
@@ -84,17 +88,22 @@ persona:
global_positive: {{global_positive_json}}
## 输出JSON
- {"personas":[{"name":"≤6中文字","dimension":"A","cluster_ref":{"stage":"1_audience","label":0},"keywords":["从绑定簇 top_phrases 复制"],"core_pain":"核心痛点(≤3条,分号分隔)","core_need":"核心需求(≤3条,分号分隔)","purchase_motivation":"雇佣产品做什么(动词+宾语,JTBD句式)"}]}
+ {"personas":[{"name":"≤6中文字","dimension":"A","cluster_refs":{"A":{"stage":"3b_aspect_opinion_negative","label":2},"B":{"stage":"3b_aspect_opinion_negative","label":5},"C":{"stage":"3a_pain_global","label":1}},"keywords":["从绑定簇 top_phrases 复制"],"core_pain":"核心痛点(≤3条,分号分隔)","core_need":"核心需求(≤3条,分号分隔)","purchase_motivation":"雇佣产品做什么(动词+宾语,JTBD句式)"}]}
## 硬性要求
- - 每个 Persona 必须有 cluster_ref;stage 只能是:1_audience / 3a_pain_global / 3b_aspect_opinion_negative / 3b_aspect_opinion_positive
- - cluster_ref.label 必须是上方目录中存在的 label;系统用该簇的 review_count 作为命中规模(非 keywords 扫全文)
- - dimension 只能是 A、B 或 C;A 维 Persona 的 cluster_ref.stage 必须是 1_audience
- - keywords 至少 5 个,必须从绑定簇的 top_phrases 复制英文片段(≥3字符),禁止编造不在簇中的词
- - 4-7 个 Persona,三维度 A/B/C 均至少覆盖 1 个;优先选 review_count 较大的簇,小簇(<15条)仅在有明确短语证据时使用
+ - 每个 Persona 必须有 cluster_refs;A/B/C 三维均从 suggested_dimension 匹配的簇中选择
+ - A 维 stage 允许: 3a_pain_global / 3b_aspect_opinion_negative / 3b_aspect_opinion_positive(从 suggested_dimension="A" 的簇中选)
+ - B 维 stage 允许: 3a_pain_global / 3b_aspect_opinion_negative / 3b_aspect_opinion_positive(从 suggested_dimension="B" 的簇中选)
+ - C 维 stage 允许: 3a_pain_global / 3b_aspect_opinion_negative / 3b_aspect_opinion_positive(从 suggested_dimension="C" 的簇中选)
+ - 每个 Persona 必须至少绑 1 个 B 或 C 维簇(具体痛点/需求/场景),用于 keyword 过滤占比
+ - cluster_refs 各维 label 必须是上方 catalog 中存在的 label;绑定前先看 semantic_label 与 suggested_dimension
+ - 系统以 A 维锚点簇 + B/C 维 keywords 过滤计算占比
+ - dimension 表示该 Persona 主类型(A/B/C 之一);cluster_refs.A 始终必填
+ - keywords 至少 5 个,必须从所有已绑定簇的 top_phrases 复制英文片段(≥3字符),禁止编造不在簇中的词
+ - 4-7 个 Persona,三维度 A/B/C 均至少覆盖 1 个;小簇(<15条)仅在有明确短语证据时使用
- core_pain 来自该群体差评语义;core_need 来自该群体好评或诉求;purchase_motivation 用「雇佣产品完成…」句式
- - 命名示例(格式参考,须从聚类归纳):女性群体、敏感体质用户、首次购买用户、粗硬发质疤痕体质用户、旅行护理用户
- - 禁止在绑定簇 physio_review_counts 未达 ≥5 条时将「孕妇/孕期」等生理标签写入名称或 core_pain
+ - 命名须从聚类归纳;生理类命名仅当 eligible_physio_labels 含对应维度时才允许
+ - 禁止在 eligible_physio_labels 未列出的生理标签写入名称或 core_pain
# ── 2. 差评/好评主题 ──
theme:
@@ -304,7 +313,7 @@ rootcause:
- ✅ 机制层:「密封/接口设计不足导致进水腐蚀」「关键部件角度/间距不当导致效果未达预期」
{{persona_json}}
- {{per_aud_data_json}}
+ {{bound_clusters_json}}
## 可用差评主题名(affected_themes 只能从中选择)
{{theme_names_json}}
@@ -316,7 +325,7 @@ rootcause:
- quotes 字段不要输出(引用由系统从真实评论回填)
- quote_keywords 每条根因 2-4 个**买家评论常见英文词/短语**(如 pull, dull, broke, charge, shower, waterproof, loud, trim, smooth, irritation, snag, waste),须与 mechanism 语义相关
- quote_keywords 禁止生僻工程术语(如 O-ring、IPX7、DLC、magnetic charging、martensitic);mechanism 可写工程细节,quote_keywords 必须像亚马逊买家口语
- - 优先从 per_aud_data 聚类 top_phrases 中选取真实出现的英文片段
+ - 优先从 bound_clusters 聚类 top_phrases 中选取真实出现的英文片段
- affected_themes 只能使用上方差评主题名,优先 P0/P1 主题
- dev_direction 须针对 {{product_name}} 可改进点
- 每个 Persona 最多 3 条 root_causes
diff --git a/voc_业务_2/report_utils.py b/voc_业务_2/report_utils.py
index 69b232e..98755b7 100644
--- a/voc_业务_2/report_utils.py
+++ b/voc_业务_2/report_utils.py
@@ -99,13 +99,13 @@ _QUOTE_CN_HINTS = (
(("would not charge", "not turn on", "stopped working", "stop charging", "does not hold charge", "not charging", "battery life", "battery"), "抱怨充电/电池问题"),
(("waste", "not worth", "overpriced", "wasted my money", "waste of money"), "抱怨性价比低"),
(("broke", "broken", "stopped", "fall apart", "flimsy", "fell apart"), "抱怨产品损坏/不耐用"),
- (("pull", "snag", "tangle", "rips them out"), "抱怨拉扯毛发"),
- (("dull", "doesn't shave", "not smooth", "not a close", "does not trim", "barely trim"), "抱怨核心效果差"),
- (("razor burn", "irritat", "nick", "cut me", "bleeding", "knick"), "抱怨皮肤刺激/割伤"),
- (("waterproof", "shower", "easy to clean"), "称赞防水/易清洁"),
+ (("difficult to use", "too complicated", "confusing", "hard to figure out", "not intuitive"), "抱怨使用复杂"),
+ (("dull", "not sharp", "not effective", "doesn't work well", "barely works", "not strong enough"), "抱怨核心效果差"),
+ (("irritat", "allergic", "rash", "burn", "cause pain", "uncomfortable"), "抱怨不适/刺激"),
+ (("easy to clean", "easy to use", "simple", "intuitive"), "称赞使用方便"),
(("love", "perfect", "great", "recommend", "amazing"), "整体满意推荐"),
(("travel", "portable", "compact", "lightweight"), "称赞便携"),
- (("attachment", "head", "guard"), "提及配件/刀头"),
+ (("accessory", "attachment", "included", "comes with"), "提及配件/附件"),
)
@@ -276,7 +276,11 @@ _PERSONA_PHRASE_STOPWORDS = frozenset({
})
_PERSONA_BINDABLE_STAGES: Dict[str, Tuple[str, ...]] = {
- "A": ("1_audience",),
+ "A": (
+ "3a_pain_global",
+ "3b_aspect_opinion_negative",
+ "3b_aspect_opinion_positive",
+ ),
"B": (
"3b_aspect_opinion_positive",
"3b_aspect_opinion_negative",
@@ -343,6 +347,99 @@ def _normalize_cluster_ref(ref: Any) -> Optional[Dict[str, Any]]:
return None
+_PERSONA_DIM_ORDER: Tuple[str, ...] = ("A", "B", "C")
+
+
+def _parse_cluster_refs_from_persona(persona: Dict[str, Any]) -> Dict[str, Optional[Dict[str, Any]]]:
+ """解析 cluster_refs;旧 cluster_ref 并入主 dimension,audience 同时写入 A。"""
+ refs: Dict[str, Optional[Dict[str, Any]]] = {d: None for d in _PERSONA_DIM_ORDER}
+ raw_refs = persona.get("cluster_refs")
+ if isinstance(raw_refs, dict):
+ for dim in _PERSONA_DIM_ORDER:
+ ref = _normalize_cluster_ref(raw_refs.get(dim))
+ if ref and ref["stage"] in _PERSONA_BINDABLE_STAGES.get(dim, ()):
+ refs[dim] = ref
+ else:
+ legacy = _normalize_cluster_ref(persona.get("cluster_ref"))
+ if legacy:
+ dim = str(persona.get("dimension", "B")).strip().upper()
+ if dim not in _PERSONA_DIM_ORDER:
+ dim = "B"
+ if legacy["stage"] in _PERSONA_BINDABLE_STAGES.get(dim, ()):
+ refs[dim] = legacy
+ if legacy["stage"] == "1_audience":
+ refs["A"] = legacy
+ return refs
+
+
+def _get_persona_anchor_ref(persona: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Persona 锚点簇:优先 A 维,否则第一个有效 cluster_refs 条目。"""
+ refs = persona.get("cluster_refs")
+ if isinstance(refs, dict):
+ a_ref = _normalize_cluster_ref(refs.get("A"))
+ if a_ref:
+ return a_ref
+ for dim in _PERSONA_DIM_ORDER:
+ ref = _normalize_cluster_ref(refs.get(dim))
+ if ref:
+ return ref
+ return _normalize_cluster_ref(persona.get("cluster_ref"))
+
+
+def _sync_legacy_cluster_ref(persona: Dict[str, Any]) -> None:
+ """保留 cluster_ref 字段供下游兼容(指向锚点簇)。"""
+ anchor = _get_persona_anchor_ref(persona)
+ if anchor:
+ persona["cluster_ref"] = anchor
+ else:
+ persona.pop("cluster_ref", None)
+
+
+def _clusters_from_refs(
+ refs: Dict[str, Optional[Dict[str, Any]]],
+ registry: Dict[Tuple[str, int], ClusterData],
+) -> List[ClusterData]:
+ clusters: List[ClusterData] = []
+ seen: Set[Tuple[str, int]] = set()
+ for dim in _PERSONA_DIM_ORDER:
+ ref = refs.get(dim)
+ if not ref:
+ continue
+ key = (ref["stage"], ref["label"])
+ if key in seen:
+ continue
+ cluster = registry.get(key)
+ if cluster:
+ clusters.append(cluster)
+ seen.add(key)
+ return clusters
+
+
+def _auto_map_persona_dimension(
+ persona: Dict[str, Any],
+ dim: str,
+ all_clusters: Dict[str, List[ClusterData]],
+ used_audience_keys: Set[Tuple[str, int]],
+) -> Tuple[Optional[Dict[str, Any]], bool]:
+ """在指定维度允许的 stage 内为 Persona 自动匹配最佳簇。"""
+ stage_order = _PERSONA_BINDABLE_STAGES.get(dim, _PERSONA_BINDABLE_STAGES["B"])
+ candidates: List[ClusterData] = []
+ for stage in stage_order:
+ for c in all_clusters.get(stage, []):
+ key = (stage, int(c.cluster_label))
+ if dim == "A" and key in used_audience_keys:
+ continue
+ candidates.append(c)
+ if not candidates:
+ return None, False
+ ranked = sorted(candidates, key=lambda c: _score_persona_cluster(persona, c), reverse=True)
+ best = ranked[0]
+ best_score = _score_persona_cluster(persona, best)
+ if best_score <= 0 and len(ranked) > 1:
+ best = ranked[1] if _score_persona_cluster(persona, ranked[1]) > 0 else best
+ return {"stage": best.stage, "label": int(best.cluster_label)}, best_score <= 0
+
+
# Persona 名称 / core_pain 中的生理标签 → 绑定簇内 ≥N 条评论含对应英文词方可保留
PERSONA_PHYSIO_MIN_REVIEWS_DEFAULT = 5
@@ -392,12 +489,37 @@ def compute_physio_review_counts(
return counts
+_GENERIC_PERSONA_ANCHOR_EXACT = frozenset({
+ "self", "unknown", "user", "customer", "buyer", "myself", "consumer", "reviewer",
+})
+
+
+# _DISTINCTIVE_ANCHOR_MARKERS removed — no longer used (audience clustering removed)
+
+def is_generic_persona_anchor(
+ semantic_label: str,
+ top_phrases: Optional[List[str]] = None,
+) -> bool:
+ """品类泛词 / self 大桶:不宜作为 Persona A 维统计锚点。"""
+ label = (semantic_label or "").strip().lower()
+ if label in _GENERIC_PERSONA_ANCHOR_EXACT:
+ return True
+ if label.endswith(" user") or label.endswith(" buyer") or label.endswith(" customer"):
+ if not any(m in label for m in _DISTINCTIVE_ANCHOR_MARKERS):
+ return True
+ for phrase in (top_phrases or [])[:6]:
+ p = (phrase or "").strip().lower()
+ if p in _GENERIC_PERSONA_ANCHOR_EXACT or p == "self":
+ return True
+ return False
+
+
def enrich_persona_catalog_physio_counts(
catalog: List[Dict[str, Any]],
all_clusters: Dict[str, List[ClusterData]],
reviews: List[ReviewRecord],
) -> None:
- """为 persona_cluster_catalog 各簇附加 physio_review_counts。"""
+ """为 persona_cluster_catalog 各簇附加 physio_review_counts(内部/调试用)。"""
registry = build_cluster_registry(all_clusters)
for entry in catalog:
key = (entry.get("stage"), entry.get("label"))
@@ -409,6 +531,30 @@ def enrich_persona_catalog_physio_counts(
)
+def prepare_persona_catalog_for_llm(
+ catalog: List[Dict[str, Any]],
+ all_clusters: Dict[str, List[ClusterData]],
+ reviews: List[ReviewRecord],
+ min_physio_reviews: int = PERSONA_PHYSIO_MIN_REVIEWS_DEFAULT,
+) -> None:
+ """喂 LLM 前:仅保留达标生理标签;suggested_dimension 已在 data_loader 中设置。"""
+ registry = build_cluster_registry(all_clusters)
+ for entry in catalog:
+ key = (entry.get("stage"), entry.get("label"))
+ cluster = registry.get(key)
+ if not cluster:
+ continue
+
+ all_counts = compute_physio_review_counts(cluster.source_rows, reviews)
+ eligible = {
+ k: n for k, n in all_counts.items()
+ if n >= min_physio_reviews
+ }
+ entry["eligible_physio_labels"] = eligible
+ entry.pop("physio_review_counts", None)
+ entry["valid_persona_anchor"] = True
+
+
def _physio_label_has_review_evidence(
en_tokens: Tuple[str, ...],
source_rows: List[int],
@@ -418,16 +564,31 @@ def _physio_label_has_review_evidence(
return count_physio_reviews_in_cluster(source_rows, reviews, en_tokens) >= min_review_count
-def _strip_physio_from_name(name: str, unsupported_zh: List[str]) -> str:
- out = name
- for zh in unsupported_zh:
- out = out.replace(zh, "")
- out = re.sub(r"\s+", "", out).strip()
- if len(out) < 2:
- return "用户群体"
- if not out.endswith(("用户", "者", "族", "群体")):
- out += "用户"
- return out[:6]
+def _persona_name_from_semantic(semantic_label: str, max_len: int = 6) -> str:
+ """从簇 semantic_label 生成中性 Persona 名(无未佐证生理标签)。"""
+ sl = (semantic_label or "").strip().lower()
+ hints: List[Tuple[Tuple[str, ...], str]] = [
+ (("first time", "first-time", "first purchase"), "初次使用用户"),
+ (("travel", "on the go", "portable", "compact"), "便携用户"),
+ (("gift", "present", "birthday", "christmas"), "送礼用户"),
+ (("sensitive skin", "sensitive", "allergy", "allergic"), "敏感体质用户"),
+ (("elderly", "senior", "older"), "老年用户"),
+ (("kid", "child", "children", "toddler", "infant", "baby"), "儿童家长"),
+ (("my dog", "my cat", "my pet", "puppy", "kitten"), "宠物主"),
+ (("pregnant", "pregnancy", "nursing"), "孕期/哺乳期用户"),
+ (("professional", "contractor", "chef"), "专业用户"),
+ (("beginner", "new to", "never used"), "新手用户"),
+ (("coarse", "thick", "curly", "fine"), "特定体质用户"),
+ ]
+ for patterns, name in hints:
+ if any(p in sl for p in patterns):
+ return name[:max_len]
+ if sl.endswith(" user") or sl.endswith(" buyer"):
+ return "场景用户"[:max_len]
+ raw = (semantic_label or "用户群体").strip()
+ if len(raw) <= max_len:
+ return raw if raw.endswith(("用户", "者", "族", "群体")) else f"{raw[:max_len - 2]}用户"[:max_len]
+ return "场景用户"[:max_len]
def _strip_physio_from_core_pain(core_pain: str, unsupported_zh: List[str]) -> str:
@@ -452,23 +613,30 @@ def validate_persona_physiological_labels(
all_clusters: Dict[str, List[ClusterData]],
min_review_count: int = PERSONA_PHYSIO_MIN_REVIEWS_DEFAULT,
) -> List[Dict[str, Any]]:
- """校验 Persona 名称与 core_pain 中的生理标签:绑定簇内须 ≥min_review_count 条评论含对应英文词。"""
+ """校验 Persona 生理标签:无佐证时改用 semantic 中性命名,不再拆字。"""
catalog = cluster_data.get("persona_cluster_catalog") or []
catalog_map = {(e.get("stage"), e.get("label")): e for e in catalog}
registry = build_cluster_registry(all_clusters)
for persona in personas:
- ref = _normalize_cluster_ref(persona.get("cluster_ref"))
- entry = catalog_map.get((ref["stage"], ref["label"])) if ref else None
- cluster = registry.get((ref["stage"], ref["label"])) if ref else None
+ refs = persona.get("cluster_refs") or {}
+ a_ref = (
+ _normalize_cluster_ref(refs.get("A"))
+ if isinstance(refs, dict) else None
+ ) or _get_persona_anchor_ref(persona)
+ entry = catalog_map.get((a_ref["stage"], a_ref["label"])) if a_ref else None
+ cluster = registry.get((a_ref["stage"], a_ref["label"])) if a_ref else None
source_rows = list(cluster.source_rows) if cluster else []
+ eligible = (entry or {}).get("eligible_physio_labels") or {}
unsupported: List[str] = []
text_blob = f"{persona.get('name', '')} {persona.get('core_pain', '')}"
- for _rule_key, zh_markers, en_tokens in _PERSONA_PHYSIO_RULES:
+ for rule_key, zh_markers, en_tokens in _PERSONA_PHYSIO_RULES:
hit_zh = [z for z in zh_markers if z in text_blob]
if not hit_zh:
continue
+ if rule_key in eligible:
+ continue
if not _physio_label_has_review_evidence(
en_tokens, source_rows, reviews, min_review_count,
):
@@ -478,111 +646,224 @@ def validate_persona_physiological_labels(
continue
old_name = persona.get("name", "")
- persona["name"] = _strip_physio_from_name(old_name, unsupported)
+ semantic = str((entry or {}).get("semantic_label") or "")
+ persona["name"] = _persona_name_from_semantic(semantic)
persona["core_pain"] = _strip_physio_from_core_pain(
str(persona.get("core_pain") or ""), unsupported,
)
persona["physio_label_corrected"] = True
persona["physio_removed_labels"] = sorted(set(unsupported))
- counts_hint = (entry or {}).get("physio_review_counts") or {}
logger.warning(
- "Persona 生理标签评论佐证不足(需≥%s条),已修正: %s → %s(移除 %s;簇内计数 %s)",
+ "Persona 生理标签评论佐证不足(需≥%s条),已改用 semantic 命名: %s → %s(移除 %s;eligible %s)",
min_review_count, old_name, persona["name"],
- persona["physio_removed_labels"], counts_hint,
+ persona["physio_removed_labels"], eligible,
)
return personas
+def _remap_generic_a_anchor(
+ persona: Dict[str, Any],
+ refs: Dict[str, Optional[Dict[str, Any]]],
+ all_clusters: Dict[str, List[ClusterData]],
+) -> bool:
+ """若 A 维绑定了泛词簇,尝试改绑具体 persona_signal 簇。"""
+ a_ref = refs.get("A")
+ if not a_ref or a_ref["stage"] != "1_audience":
+ return False
+
+ aud_clusters = all_clusters.get("1_audience", [])
+ aud_registry = {int(c.cluster_label): c for c in aud_clusters}
+ current = aud_registry.get(int(a_ref["label"]))
+ if current and not is_generic_persona_anchor(
+ current.top_phrases[0] if current.top_phrases else "",
+ current.top_phrases,
+ ):
+ return False
+
+ candidates = [
+ c for c in aud_clusters
+ if not is_generic_persona_anchor(
+ c.top_phrases[0] if c.top_phrases else "",
+ c.top_phrases,
+ )
+ ]
+ if not candidates:
+ refs["A"] = None
+ persona["generic_anchor_cleared"] = True
+ logger.warning(
+ "Persona %s 绑定了泛词 A 维簇 label=%s,且无可用具体簇,已清除 A 维",
+ persona.get("name"), a_ref.get("label"),
+ )
+ return True
+
+ ranked = sorted(
+ candidates,
+ key=lambda c: _score_persona_cluster(persona, c),
+ reverse=True,
+ )
+ best = ranked[0]
+ if _score_persona_cluster(persona, best) <= 0:
+ refs["A"] = None
+ persona["generic_anchor_cleared"] = True
+ return True
+
+ refs["A"] = {"stage": "1_audience", "label": int(best.cluster_label)}
+ persona["anchor_remapped"] = True
+ persona["anchor_remapped_from"] = a_ref
+ logger.warning(
+ "Persona %s A 维泛词簇 label=%s 已改绑至具体簇 label=%s",
+ persona.get("name"), a_ref.get("label"), best.cluster_label,
+ )
+ return True
+
+
def assign_persona_clusters(
personas: List[Dict[str, Any]],
all_clusters: Dict[str, List[ClusterData]],
) -> List[Dict[str, Any]]:
- """为每个 Persona 绑定聚类簇;LLM 已给 cluster_ref 则校验,否则自动映射。"""
+ """为每个 Persona 绑定 A/B/C 三维聚类簇;缺失维度自动映射。"""
registry = build_cluster_registry(all_clusters)
- used_keys: Set[Tuple[str, int]] = set()
+ used_audience_keys: Set[Tuple[str, int]] = set()
for persona in personas:
normalize_persona_dimension(persona)
- ref = _normalize_cluster_ref(persona.get("cluster_ref"))
- if ref and (ref["stage"], ref["label"]) in registry:
- persona["cluster_ref"] = ref
- used_keys.add((ref["stage"], ref["label"]))
- continue
+ refs = _parse_cluster_refs_from_persona(persona)
+ auto_mapped_dims: List[str] = []
- dim = persona.get("dimension", "B")
- stage_order = _PERSONA_BINDABLE_STAGES.get(dim, _PERSONA_BINDABLE_STAGES["B"])
- candidates: List[ClusterData] = []
- for stage in stage_order:
- for c in all_clusters.get(stage, []):
- key = (stage, int(c.cluster_label))
- if key in used_keys and stage == "1_audience":
- continue
- candidates.append(c)
+ for dim in _PERSONA_DIM_ORDER:
+ ref = refs.get(dim)
+ if ref and (ref["stage"], ref["label"]) in registry:
+ refs[dim] = ref
+ continue
+ mapped, was_auto = _auto_map_persona_dimension(
+ persona, dim, all_clusters, used_audience_keys,
+ )
+ if mapped and (mapped["stage"], mapped["label"]) in registry:
+ refs[dim] = mapped
+ if was_auto:
+ auto_mapped_dims.append(dim)
+ else:
+ refs[dim] = None
- if not candidates:
+ valid_refs = {d: r for d, r in refs.items() if r}
+ if not valid_refs:
+ persona.pop("cluster_refs", None)
persona.pop("cluster_ref", None)
persona["cluster_unassigned"] = True
continue
- ranked = sorted(candidates, key=lambda c: _score_persona_cluster(persona, c), reverse=True)
- best = ranked[0]
- best_score = _score_persona_cluster(persona, best)
- if best_score <= 0 and len(ranked) > 1:
- best = ranked[1] if _score_persona_cluster(persona, ranked[1]) > 0 else best
+ valid_refs = {d: r for d, r in refs.items() if r}
+ if not valid_refs:
+ persona.pop("cluster_refs", None)
+ persona.pop("cluster_ref", None)
+ persona["cluster_unassigned"] = True
+ continue
- persona["cluster_ref"] = {"stage": best.stage, "label": int(best.cluster_label)}
+ persona["cluster_refs"] = valid_refs
persona.pop("cluster_unassigned", None)
- if best_score <= 0:
+ if auto_mapped_dims:
persona["cluster_auto_mapped"] = True
- used_keys.add((best.stage, int(best.cluster_label)))
+ persona["cluster_auto_mapped_dims"] = auto_mapped_dims
+
+ a_ref = refs.get("A")
+ if a_ref:
+ used_audience_keys.add((a_ref["stage"], a_ref["label"]))
+
+ _sync_legacy_cluster_ref(persona)
return personas
def enrich_persona_keywords(
persona: Dict[str, Any],
- cluster: Optional[ClusterData],
+ cluster: Optional[ClusterData] = None,
+ extra_clusters: Optional[List[ClusterData]] = None,
) -> Dict[str, Any]:
"""从绑定簇 top_phrases 补充 keywords(过滤无信息词)。"""
+ clusters: List[ClusterData] = []
+ if cluster:
+ clusters.append(cluster)
+ if extra_clusters:
+ for c in extra_clusters:
+ if c not in clusters:
+ clusters.append(c)
base = [k.lower().strip() for k in (persona.get("keywords") or []) if k and k.strip()]
from_cluster: List[str] = []
- if cluster:
- for seg in phrase_segments(cluster.top_phrases):
+ for cl in clusters:
+ for seg in phrase_segments(cl.top_phrases):
if seg in _PERSONA_PHRASE_STOPWORDS or len(seg) < 3:
continue
from_cluster.append(seg)
merged = list(dict.fromkeys(base + from_cluster))
- if not merged and cluster:
+ if not merged and clusters:
merged = [
- s for s in phrase_segments(cluster.top_phrases)
+ s for cl in clusters for s in phrase_segments(cl.top_phrases)
if s not in _PERSONA_PHRASE_STOPWORDS and len(s) >= 4
][:20]
persona["keywords"] = merged[:40]
return persona
+
+
+# Persona 关键词分词噪声词(不应作为独立词元匹配)
+_PERSONA_NOISE_1GRAM = frozenset({
+ "the", "and", "for", "not", "but", "was", "are", "has", "had", "have",
+ "that", "this", "with", "from", "they", "them", "their", "will", "would",
+ "need", "just", "very", "like", "than", "then", "its", "does", "been",
+ "much", "more", "some", "also", "only", "don", "can", "get", "got",
+ "use", "fit", "put", "try", "see", "buy", "one", "two", "per", "old", "new",
+ "really", "still", "even", "well", "make", "made", "way", "bit", "lot",
+ "product", "item", "review", "amazon", "self",
+})
+
+def _tokenize_persona_keywords(keywords: List[str]) -> List[str]:
+ """Persona 关键词分词:逗号拆分 + 长短语拆为有意义的 bigram/trigram。"""
+ out: List[str] = []
+ for kw in expand_keywords(keywords):
+ out.append(kw)
+ # 对长复合短语(>20 chars 且无逗号)拆分为有意义的 n-gram
+ if len(kw) > 20 and "," not in kw:
+ toks = [t.strip().rstrip(".,;!?") for t in kw.split()
+ if len(t.strip()) >= 3 and t.strip().lower() not in _PERSONA_NOISE_1GRAM]
+ # 添加有意义 2-gram
+ for i in range(len(toks) - 1):
+ a, b = toks[i].lower(), toks[i+1].lower()
+ if a in _PERSONA_NOISE_1GRAM or b in _PERSONA_NOISE_1GRAM:
+ continue
+ bigram = f"{toks[i]} {toks[i+1]}"
+ if len(bigram) >= 7:
+ out.append(bigram)
+ # 添加有意义 3-gram(更有区分度)
+ for i in range(len(toks) - 2):
+ if any(toks[i+j].lower() in _PERSONA_NOISE_1GRAM for j in range(3)):
+ continue
+ trigram = f"{toks[i]} {toks[i+1]} {toks[i+2]}"
+ if len(trigram) >= 10:
+ out.append(trigram)
+ return list(dict.fromkeys(out))
+
def match_persona_text(keywords: List[str], text: str) -> bool:
- """Persona 簇内二次过滤:比主题 match_text 更严格,避免泛词虚高。"""
+ """Persona 关键词匹配:单关键词命中即可(Persona 画像比主题更宽泛)。"""
text = text.lower()
expanded = [
- k for k in expand_keywords(keywords)
+ k for k in _tokenize_persona_keywords(keywords)
if k not in _PERSONA_PHRASE_STOPWORDS and len(k) >= 3
]
if not expanded:
return False
+ # 强关键词(≥12字符或含产品关键词):任意 1 个命中即可
strong = [k for k in expanded if _is_strong_keyword(k)]
- weak = [k for k in expanded if k not in strong]
- strong_hits = sum(1 for k in strong if k in text)
- weak_hits = sum(1 for k in weak if len(k) >= 5 and k in text)
- if strong_hits >= 2:
- return True
- if strong_hits >= 1 and weak_hits >= 1:
- return True
- if not strong and weak_hits >= 2:
- return True
- if not strong and weak_hits >= 1 and len(expanded) <= 4:
- return True
+ for kw in sorted(strong, key=len, reverse=True):
+ if kw in text:
+ return True
+ # 弱关键词:≥5 字符且命中 1 个即可(但需 total expanded ≤15,避免过短泛词误判)
+ weak = [k for k in expanded if k not in strong and len(k) >= 5]
+ if len(expanded) <= 15:
+ for kw in sorted(weak, key=len, reverse=True):
+ if kw in text:
+ return True
return False
@@ -591,6 +872,12 @@ def resolve_persona_audience_label(
all_clusters: Dict[str, List[ClusterData]],
) -> int:
"""推断 Persona 对应的 audience 簇标签(供根因 per_audience 使用)。"""
+ refs = persona.get("cluster_refs")
+ if isinstance(refs, dict):
+ a_ref = _normalize_cluster_ref(refs.get("A"))
+ if a_ref and a_ref["stage"] == "1_audience":
+ return int(a_ref["label"])
+
ref = persona.get("cluster_ref") or {}
stage = ref.get("stage", "")
if stage == "1_audience" and ref.get("label") is not None:
@@ -642,26 +929,34 @@ def compute_persona_pcts(
reviews: List[ReviewRecord],
all_clusters: Optional[Dict[str, List[ClusterData]]] = None,
) -> List[Dict[str, Any]]:
- """Persona 命中数/占比:以绑定聚类簇 source_rows 为主,簇内 keyword 为辅。"""
+ """Persona 命中数/占比:以 A 维锚点簇 source_rows + 全绑定簇 keywords 过滤。"""
total = len(reviews)
row_map = {r.source_row: r for r in reviews}
registry = build_cluster_registry(all_clusters or {})
- cluster_ref_counts: Dict[Tuple[str, int], int] = {}
- for p in personas:
- ref = _normalize_cluster_ref(p.get("cluster_ref"))
- if ref:
- key = (ref["stage"], ref["label"])
- cluster_ref_counts[key] = cluster_ref_counts.get(key, 0) + 1
-
for persona in personas:
normalize_persona_dimension(persona)
- ref = _normalize_cluster_ref(persona.get("cluster_ref"))
- cluster = registry.get((ref["stage"], ref["label"])) if ref else None
+ refs = _parse_cluster_refs_from_persona(persona)
+ if persona.get("cluster_refs"):
+ refs = {
+ d: _normalize_cluster_ref((persona.get("cluster_refs") or {}).get(d))
+ for d in _PERSONA_DIM_ORDER
+ }
- enrich_persona_keywords(persona, cluster)
+ bound_clusters = _clusters_from_refs(refs, registry)
+ anchor_ref = refs.get("A") or _get_persona_anchor_ref(persona)
+ anchor = registry.get((anchor_ref["stage"], anchor_ref["label"])) if anchor_ref else None
- if not cluster:
+ extra = [c for c in bound_clusters if c is not anchor]
+ if not anchor and bound_clusters:
+ bc_only = [c for c in bound_clusters if c.stage != "1_audience"]
+ if bc_only:
+ anchor = min(bc_only, key=lambda c: c.review_count)
+ extra = [c for c in bound_clusters if c is not anchor]
+
+ enrich_persona_keywords(persona, anchor, extra_clusters=extra or None)
+
+ if not anchor:
persona["source_rows"] = []
persona["hit_count"] = 0
persona["stat_method"] = "unassigned"
@@ -669,31 +964,43 @@ def compute_persona_pcts(
_apply_persona_pct_display(persona, total)
continue
- base_rows = set(cluster.source_rows)
- key = (ref["stage"], ref["label"]) if ref else ("", -1)
- shared_cluster = cluster_ref_counts.get(key, 0) > 1
- dim = persona.get("dimension", "B")
+ anchor_rows = set(anchor.source_rows)
+ bc_clusters = [c for c in bound_clusters if c is not anchor]
- if shared_cluster or (dim == "A" and len(base_rows) > 80):
+ if anchor.stage != "1_audience":
+ # 无 A 维时以 B/C 簇为统计池
kws = persona.get("keywords") or []
- filtered = {
- sr for sr in base_rows
- if sr in row_map and match_persona_text(kws, row_map[sr].title + " " + row_map[sr].content)
- }
- min_keep = max(10, int(len(base_rows) * 0.03))
- if filtered and len(filtered) >= min_keep:
- hit_rows = filtered
- persona["stat_method"] = "cluster+keywords"
- else:
- hit_rows = base_rows
- persona["stat_method"] = "cluster"
+ stat_pool = anchor_rows
else:
- hit_rows = base_rows
- persona["stat_method"] = "cluster"
+ kws = persona.get("keywords") or []
+ stat_pool = anchor_rows
+
+ filtered = {
+ sr for sr in stat_pool
+ if sr in row_map and match_persona_text(
+ kws, row_map[sr].title + " " + row_map[sr].content,
+ )
+ }
+ min_keep = max(10, int(len(stat_pool) * 0.03))
+ if filtered:
+ hit_rows = filtered
+ persona["stat_method"] = (
+ "anchor+keywords" if len(filtered) >= min_keep else "keyword_low_sample"
+ )
+ else:
+ hit_rows = set()
+ persona["stat_method"] = "no_match"
+
+ # 回退:关键词过滤命中过少时,按锚点簇比例兜底(至少保留 3% 或 15 条)
+ fallback_min = max(15, int(len(anchor_rows) * 0.03))
+ if len(hit_rows) < fallback_min:
+ fallback_rows = set(list(anchor_rows)[:fallback_min])
+ persona["stat_method"] = persona.get("stat_method", "no_match") + "+fallback"
+ hit_rows = hit_rows | fallback_rows
persona["source_rows"] = sorted(hit_rows)
persona["hit_count"] = len(hit_rows)
- persona["cluster_review_count"] = len(base_rows)
+ persona["cluster_review_count"] = len(anchor_rows)
persona["audience_label"] = resolve_persona_audience_label(persona, all_clusters or {})
_apply_persona_pct_display(persona, total)
@@ -734,48 +1041,277 @@ def quote_cn_summary(text: str) -> str:
return "用户反馈摘要"
+_NEG_SENTIMENTS = frozenset({"negative"})
+_POS_SENTIMENTS = frozenset({"positive"})
+
+
+def _match_keywords_in_blob(keywords: List[str], blob: str) -> bool:
+ """在已小写的 blob 中匹配展开后的 keywords。"""
+ blob = blob.lower()
+ expanded = expand_keywords(keywords)
+ strong = [k for k in expanded if _is_strong_keyword(k)]
+ weak = [k for k in expanded if k not in strong]
+ for kw in sorted(strong, key=len, reverse=True):
+ if len(kw) >= 3 and kw in blob:
+ return True
+ if not strong:
+ for kw in sorted(weak, key=len, reverse=True):
+ if len(kw) >= 4 and kw in blob:
+ return True
+ return False
+
+
+def _persona_extraction_blob(ext: Dict[str, Any]) -> str:
+ """Persona 匹配用:persona_signals + pain_points。"""
+ parts: List[str] = []
+ for key in ("persona_signals", "pain_points"):
+ val = ext.get(key) or []
+ if isinstance(val, list):
+ parts.extend(str(x).strip() for x in val if str(x).strip())
+ return " ".join(parts)
+
+
+def match_persona_extraction(keywords: List[str], ext: Dict[str, Any]) -> bool:
+ blob = _persona_extraction_blob(ext)
+ if not blob:
+ return False
+ return _match_keywords_in_blob(keywords, blob)
+
+
+def match_rootcause_extraction(keywords: List[str], ext: Dict[str, Any]) -> bool:
+ """根因匹配:product_feedback 中 sentiment=Negative 的 aspect + opinion。"""
+ if not keywords:
+ return False
+ for fb in ext.get("product_feedback") or []:
+ if not isinstance(fb, dict):
+ continue
+ sent = str(fb.get("sentiment") or "").strip().lower()
+ if sent not in _NEG_SENTIMENTS:
+ continue
+ blob = f"{fb.get('aspect', '')} {fb.get('opinion', '')}"
+ if _match_keywords_in_blob(keywords, blob):
+ return True
+ return False
+
+
+def match_theme_extraction(
+ keywords: List[str],
+ ext: Dict[str, Any],
+ *,
+ is_neg: bool = True,
+) -> bool:
+ """主题匹配:product_feedback 的 category + aspect(按情感极性过滤)。"""
+ if not keywords:
+ return False
+ allowed = _NEG_SENTIMENTS if is_neg else _POS_SENTIMENTS
+ for fb in ext.get("product_feedback") or []:
+ if not isinstance(fb, dict):
+ continue
+ sent = str(fb.get("sentiment") or "").strip().lower()
+ if sent not in allowed:
+ continue
+ blob = f"{fb.get('category', '')} {fb.get('aspect', '')}"
+ if _match_keywords_in_blob(keywords, blob):
+ return True
+ return False
+
+
+def build_quote_blocks_html(
+ quote_items: List[Dict[str, Any]],
+ asin_labels: Dict[str, str],
+ *,
+ default_neg: bool = True,
+ empty_msg: str = "暂无与画像匹配的代表性评论",
+) -> str:
+ """将多条引用渲染为 HTML quote 块。"""
+ parts: List[str] = []
+ for q in quote_items:
+ text = (q.get("quote") or q.get("text") or "").strip()
+ if not text:
+ continue
+ rating = q.get("rating")
+ if rating is not None:
+ is_neg = float(rating) <= 2
+ else:
+ is_neg = q.get("is_neg", default_neg)
+ q_cls = "neg" if is_neg else "pos"
+ asin = q.get("asin") or "?"
+ url = q.get("amazon_url") or (
+ f"https://www.amazon.com/dp/{asin}" if asin != "?" else "#"
+ )
+ star_hint = f" ({int(rating)}★)" if rating is not None else ""
+ cn = q.get("cn_summary") or quote_cn_summary(text)
+ label = asin_labels.get(asin, asin)
+ parts.append(
+ f'
"{text}" '
+ f'—
{label}{star_hint}'
+ f'
摘要:{cn}
'
+ )
+ if parts:
+ return "\n".join(parts)
+ return f'
{empty_msg}
'
+
+
+def _review_to_quote_entry(r: ReviewRecord) -> Dict[str, Any]:
+ return {
+ "quote": _truncate_quote(r.content),
+ "asin": r.asin,
+ "rating": r.rating,
+ "amazon_url": f"https://www.amazon.com/dp/{r.asin}",
+ "is_neg": r.rating <= 2,
+ "cn_summary": quote_cn_summary(r.content),
+ "source_row": r.source_row,
+ }
+
+
+def _persona_struct_search_keywords(persona: Dict[str, Any]) -> List[str]:
+ """结构化匹配仅用英文聚类 keywords(persona_signals / pain_points 为英文)。"""
+ return expand_keywords(list(persona.get("keywords") or []))
+
+
+def _persona_quote_search_keywords(persona: Dict[str, Any]) -> List[str]:
+ """原文 keyword 匹配:聚类 keywords + 中文描述字段。"""
+ kws = list(persona.get("keywords") or [])
+ for key in ("core_pain", "core_need", "purchase_motivation", "name"):
+ val = persona.get(key)
+ if isinstance(val, str) and val.strip():
+ kws.append(val.strip())
+ return expand_keywords(kws)
+
+
+def _sort_persona_quote_candidates(
+ candidates: List[ReviewRecord],
+ *,
+ struct_rows: Set[int],
+ globally_used: Set[int],
+ allow_reuse: bool,
+) -> List[ReviewRecord]:
+ def _key(r: ReviewRecord) -> tuple:
+ reuse_penalty = 0
+ if not allow_reuse and r.source_row in globally_used:
+ reuse_penalty = 2
+ elif r.source_row in globally_used:
+ reuse_penalty = 1
+ struct_penalty = 0 if r.source_row in struct_rows else 1
+ return (reuse_penalty, struct_penalty, abs(len(r.content) - 180))
+
+ return sorted(candidates, key=_key)
+
+
def pick_persona_quotes(
personas: List[Dict[str, Any]],
reviews: List[ReviewRecord],
+ extractions: Optional[Dict[int, Dict[str, Any]]] = None,
+ *,
+ max_quotes: int = 3,
) -> List[Dict[str, Any]]:
- """为每个 Persona 选取代表性引用,优先差评;无匹配时不强行填充。"""
- used_rows: Set[int] = set()
+ """为每个 Persona 选取代表性引用:命中池 source_rows → 结构化/原文匹配 → 多层回退。"""
+ globally_used: Set[int] = set()
row_map = {r.source_row: r for r in reviews}
- results = []
+ ext_map = extractions or {}
+ results: List[Dict[str, Any]] = []
+
for persona in personas:
bound_rows = set(persona.get("source_rows") or [])
+ struct_kws = _persona_struct_search_keywords(persona)
+ text_kws = _persona_quote_search_keywords(persona)
+ pool_reviews: List[ReviewRecord]
if bound_rows:
- matched = [row_map[sr] for sr in sorted(bound_rows) if sr in row_map]
+ pool_reviews = [row_map[sr] for sr in sorted(bound_rows) if sr in row_map]
else:
- kws = expand_keywords(persona.get("keywords") or [])
- matched = [r for r in reviews if match_persona_text(kws, r.title + " " + r.content)]
- neg = [r for r in matched if r.rating <= 2 and r.source_row not in used_rows]
- pos = [r for r in matched if r.rating >= 4 and r.source_row not in used_rows]
- pool = neg if neg else pos
- pool = [r for r in pool if _persona_quote_relevant(persona, r.content, r.rating <= 2)]
- if not pool:
- results.append({
- "persona": persona.get("name", "?"),
- "quote": None,
- "asin": None,
- "rating": None,
- "amazon_url": None,
- "is_neg": False,
- "cn_summary": None,
- })
- continue
- pool.sort(key=lambda r: abs(len(r.content) - 180))
- r = pool[0]
- used_rows.add(r.source_row)
- results.append({
+ pool_reviews = [
+ r for r in reviews
+ if match_persona_text(text_kws, r.title + " " + r.content)
+ ]
+
+ def _struct_matched() -> List[ReviewRecord]:
+ if not ext_map or not struct_kws:
+ return []
+ out: List[ReviewRecord] = []
+ for r in pool_reviews:
+ ext = ext_map.get(r.source_row)
+ if ext and match_persona_extraction(struct_kws, ext):
+ out.append(r)
+ return out
+
+ def _text_matched(cands: List[ReviewRecord]) -> List[ReviewRecord]:
+ return [
+ r for r in cands
+ if match_persona_text(text_kws, r.title + " " + r.content)
+ ]
+
+ struct_pool = _struct_matched()
+ text_pool = _text_matched(pool_reviews)
+ struct_rows = {r.source_row for r in struct_pool}
+ base_pool = struct_pool + [
+ r for r in text_pool if r.source_row not in struct_rows
+ ]
+ if not base_pool:
+ base_pool = list(pool_reviews)
+
+ def _tier(cands: List[ReviewRecord], *, allow_reuse: bool) -> List[ReviewRecord]:
+ ranked = _sort_persona_quote_candidates(
+ cands, struct_rows=struct_rows, globally_used=globally_used, allow_reuse=allow_reuse,
+ )
+ out: List[ReviewRecord] = []
+ for r in ranked:
+ is_neg = r.rating <= 2
+ if _persona_quote_relevant(persona, r.content, is_neg):
+ out.append(r)
+ return out
+
+ selected: List[ReviewRecord] = []
+ seen: Set[int] = set()
+
+ def _take_from(tier: List[ReviewRecord]) -> None:
+ nonlocal selected
+ for r in tier:
+ if r.source_row in seen:
+ continue
+ selected.append(r)
+ seen.add(r.source_row)
+ if len(selected) >= max_quotes:
+ return
+
+ # 优先差评 → 好评 → 3★ → 命中池兜底(先尽量不跨 Persona 重复)
+ for allow_reuse in (False, True):
+ if len(selected) >= max_quotes:
+ break
+ neg = [r for r in base_pool if r.rating <= 2]
+ pos = [r for r in base_pool if r.rating >= 4]
+ mid = [r for r in base_pool if 2 < r.rating < 4]
+ for tier_src in (neg, pos, mid, base_pool, pool_reviews):
+ _take_from(_tier(tier_src, allow_reuse=allow_reuse))
+ if len(selected) >= max_quotes:
+ break
+
+ # 仍空:放宽 relevance,直接从命中池取代表性评论
+ if not selected and pool_reviews:
+ selected = sorted(
+ pool_reviews,
+ key=lambda r: (abs(len(r.content) - 180), -r.rating),
+ )[:max_quotes]
+
+ quotes: List[Dict[str, Any]] = []
+ for r in selected[:max_quotes]:
+ globally_used.add(r.source_row)
+ quotes.append(_review_to_quote_entry(r))
+
+ entry: Dict[str, Any] = {
"persona": persona.get("name", "?"),
- "quote": _truncate_quote(r.content),
- "asin": r.asin,
- "rating": r.rating,
- "amazon_url": f"https://www.amazon.com/dp/{r.asin}",
- "is_neg": r.rating <= 2,
- "cn_summary": quote_cn_summary(r.content),
- })
+ "quotes": quotes,
+ "quote": None,
+ "asin": None,
+ "rating": None,
+ "amazon_url": None,
+ "is_neg": False,
+ "cn_summary": None,
+ }
+ if quotes:
+ entry.update({k: quotes[0][k] for k in (
+ "quote", "asin", "rating", "amazon_url", "is_neg", "cn_summary",
+ )})
+ results.append(entry)
return results
@@ -1169,12 +1705,21 @@ def _persona_card_quote_entry(
if not persona_quotes:
return None
pq = next((q for q in persona_quotes if q.get("persona") == persona_name), None)
- if not pq or not pq.get("quote"):
+ if not pq:
return None
- asin = pq.get("asin") or ""
- text = pq.get("quote") or ""
- rating = pq.get("rating")
+ quote_items = pq.get("quotes") or []
+ if not quote_items and pq.get("quote"):
+ quote_items = [pq]
+ if not quote_items:
+ return None
+ first = quote_items[0]
+ asin = first.get("asin") or ""
+ text = first.get("quote") or ""
+ rating = first.get("rating")
+ sr = first.get("source_row")
row_map = {r.source_row: r for r in reviews}
+ if sr is not None and sr in row_map:
+ return row_map[sr]
for r in row_map.values():
if r.asin == asin and text[:80] in r.content:
return r
@@ -1187,7 +1732,7 @@ def _persona_card_quote_entry(
verified=False,
vine=False,
review_date="",
- source_row=-1,
+ source_row=int(sr) if sr is not None else -1,
)
return None
@@ -1195,15 +1740,18 @@ def _persona_card_quote_entry(
def enrich_rootcause_quotes(
rootcauses: List[Dict[str, Any]],
personas: List[Dict[str, Any]],
- per_aud: Dict[int, Dict[str, List[ClusterData]]],
loader: DataLoader,
reviews: List[ReviewRecord],
persona_quotes: Optional[List[Dict[str, Any]]] = None,
+ extractions: Optional[Dict[int, Dict[str, Any]]] = None,
+ *,
+ max_quotes: int = 4,
) -> List[Dict[str, Any]]:
- """从真实评论回填根因引用;匹配失败时放宽至 source_rows;仍空则复用画像区引用。"""
+ """从真实评论回填根因引用:结构化 Negative feedback → 原文 → source_rows 放宽。"""
valid_asins = _valid_asins(reviews)
neg_reviews = [r for r in reviews if r.rating <= 2]
persona_map = {p.get("name"): p for p in personas}
+ ext_map = extractions or {}
for rc in rootcauses:
pname = rc.get("persona_name", "")
@@ -1227,12 +1775,24 @@ def enrich_rootcause_quotes(
if _persona_quote_relevant(persona, r.content, True)
]
- candidates = _filter_candidates([
- r for r in pool
- if r.source_row not in used_rows
- and match_text(search_kws, r.title + " " + r.content)
- and r.asin in valid_asins
- ])
+ def _struct_candidates() -> List[ReviewRecord]:
+ if not ext_map or not search_kws:
+ return []
+ return _filter_candidates([
+ r for r in pool
+ if r.source_row not in used_rows
+ and r.asin in valid_asins
+ and match_rootcause_extraction(search_kws, ext_map.get(r.source_row) or {})
+ ])
+
+ candidates = _struct_candidates()
+ if not candidates:
+ candidates = _filter_candidates([
+ r for r in pool
+ if r.source_row not in used_rows
+ and match_text(search_kws, r.title + " " + r.content)
+ and r.asin in valid_asins
+ ])
if not candidates:
candidates = _filter_candidates([
r for r in pool
@@ -1245,8 +1805,11 @@ def enrich_rootcause_quotes(
neg_reviews, bound_rows, used_rows, persona, valid_asins,
)
- candidates.sort(key=lambda r: abs(len(r.content) - 160))
- for r in candidates[:2]:
+ struct_set = {r.source_row for r in _struct_candidates()} if ext_map else set()
+ candidates.sort(
+ key=lambda r: (r.source_row not in struct_set, abs(len(r.content) - 160)),
+ )
+ for r in candidates[:max_quotes]:
_append_rootcause_quote(cause, r, used_rows)
if not cause["quotes"] and persona_card_review is not None:
@@ -2267,14 +2830,24 @@ def build_neg_theme_summary_note(
return f"{line1}
{line2}"
-def persona_display_meta(persona: Dict[str, Any], total_reviews: int) -> str:
+PERSONA_MIN_HIT_COUNT_DEFAULT = 5
+
+
+def persona_display_meta(
+ persona: Dict[str, Any],
+ total_reviews: int,
+ min_hit_count: int = PERSONA_MIN_HIT_COUNT_DEFAULT,
+) -> str:
"""Persona 卡片 meta 行文案。"""
hit = persona.get("hit_count", 0)
dim_label = persona.get("dimension_label", persona.get("dimension", "?"))
dim = persona.get("dimension", "?")
pct = persona.get("pct_display", "待验证")
verified = persona.get("pct_verified", False)
- conf = "已验证" if verified and hit >= 10 else ("低样本" if verified and hit > 0 else "待验证")
+ conf = (
+ "已验证" if verified and hit >= min_hit_count
+ else ("低样本" if verified and hit > 0 else "待验证")
+ )
return (
f"占比 ~
{pct}(命中 {hit} 条 / {total_reviews})"
f" · 置信度 {conf} · 维度{dim} {dim_label}"
diff --git a/voc_业务_2/run_pipeline.py b/voc_业务_2/run_pipeline.py
index d0eaab5..39f7833 100644
--- a/voc_业务_2/run_pipeline.py
+++ b/voc_业务_2/run_pipeline.py
@@ -22,8 +22,8 @@
内部调用等价于(以全流程为例)::
- ../310py/bin/python ../main_voc分析.py --only-step 1 --input-dir "..." --product "..." --industry "..." --keep-db
- ../310py/bin/python ../main_voc分析.py --only-step 2 --product "..." --industry "..." --keep-db
+ ../310py/bin/python ../main_voc分析.py --only-step 1 --input-dir "..." --product "..." --industry "..."
+ ../310py/bin/python ../main_voc分析.py --only-step 2 --product "..." --industry "..."
...(依此类推到 step 6)
"""
from __future__ import annotations
@@ -57,7 +57,6 @@ def run_step(python_bin: str, main_script: str, step: int, **kwargs) -> bool:
"--only-step", str(step),
"--product", kwargs.get("product", "亚马逊商品"),
"--industry", kwargs.get("industry", "亚马逊电商"),
- "--keep-db",
]
if step == 1 and "input_dir" in kwargs:
cmd.extend(["--input-dir", kwargs["input_dir"]])
diff --git a/voc_业务_2/提示词编辑稿.md b/voc_业务_2/提示词编辑稿.md
index 50e1e4a..6a95262 100644
--- a/voc_业务_2/提示词编辑稿.md
+++ b/voc_业务_2/提示词编辑稿.md
@@ -114,6 +114,10 @@ Persona 命名用简洁中文(≤6字),避免营销化夸张名称。输
```
## 任务:基于聚类数据按三维度发现用户画像(Persona),4-7 个。
+## 分析产品(product_detect / config 已识别)
+产品:{{product_name}} | 行业:{{industry}}
+purchase_motivation 用「雇佣{{product_name}}完成…」句式;Persona 须与该产品使用场景一致。
+
## 三维度强制覆盖(缺一不可)
A. 物理/生理/受众特征 → 必须绑定 stage=1_audience 的簇
典型信号:特定体质/肤质/年龄/体型/使用对象(如 sensitive / elderly / for kids / large breed)
@@ -162,6 +166,7 @@ global_positive: {{global_positive_json}}
```
**占位符说明:**
+- `{{product_name}}` / `{{industry}}` — 产品识别结果(自动填入)
- `{{catalog_json}}` — 聚类簇目录(自动填入)
- `{{audience_clusters_json}}` 等 — 聚类摘要(自动填入)
diff --git a/向量化.py b/向量化.py
index eec93d2..3113c86 100644
--- a/向量化.py
+++ b/向量化.py
@@ -1,5 +1,5 @@
"""
-从 voc_structured.sqlite 最新 job 展开 audience / pain_point / aspect_opinion
+从 voc_structured.sqlite 最新 job 展开 persona_signal / pain_point / aspect_opinion
(与聚类.py 一致,不向量化单独的 aspect、opinion),使用本地 MLX 写入 voc_embeddings.sqlite。
溯源:source_row 与结构化时一致(CSV 第 1 条数据行=1);对应 merged_reviews_cleaned.csv
@@ -59,11 +59,56 @@ def _resolve_embed_workers(explicit: int | None = None) -> int:
logger.warning("本地 embedding 仅支持串行,--workers 已忽略(使用 1)")
return 1
ENTITY_TYPES = (
- "audience",
+ "persona_signal",
"pain_point",
"aspect_opinion",
)
+_GENERIC_PERSONA_SIGNALS = frozenset({
+ "self", "unknown", "user", "customer", "buyer", "myself", "the user", "consumer",
+})
+
+
+def _normalize_persona_signals(raw: Any) -> List[str]:
+ if raw is None:
+ items: List[Any] = []
+ elif isinstance(raw, list):
+ items = raw
+ elif isinstance(raw, str) and raw.strip():
+ items = [raw.strip()]
+ else:
+ items = []
+ out: List[str] = []
+ seen: set[str] = set()
+ for item in items:
+ text = str(item).strip()
+ if not text or text.lower() in _GENERIC_PERSONA_SIGNALS:
+ continue
+ key = text.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(text)
+ return out
+
+
+def _derive_persona_signals_fallback(
+ persona_signals: List[str],
+ pain_points: List[Any],
+) -> List[str]:
+ """无 persona_signals 时:用 pain_points 前 2 条作兜底。"""
+ if persona_signals:
+ return persona_signals
+ return [str(p).strip() for p in pain_points if str(p).strip()][:2]
+
+
+def _should_skip_extraction(
+ persona_signals: List[str],
+ pain_points: List[Any],
+ feedback: List[Any],
+) -> bool:
+ return not persona_signals and not pain_points and not feedback
+
@dataclass
class EmbedTask:
@@ -73,7 +118,7 @@ class EmbedTask:
entity_type: str
entity_index: int
embed_text: str
- audience: str
+ audience: str | None
aspect: str | None
opinion: str | None
category: str | None
@@ -151,11 +196,6 @@ def _init_embed_schema(conn: sqlite3.Connection) -> None:
)
-def _should_skip_extraction(audience: str, pain_points: List[Any], feedback: List[Any]) -> bool:
- aud = (audience or "").strip().lower()
- return aud == "unknown" and not pain_points and not feedback
-
-
def _build_tasks(
job_id: int,
rows: List[sqlite3.Row],
@@ -171,7 +211,7 @@ def _build_tasks(
logger.warning("跳过无效 JSON extraction_id=%s: %s", ext_id, e)
continue
- audience = str(data.get("audience", "unknown")).strip() or "unknown"
+ persona_signals = _normalize_persona_signals(data.get("persona_signals"))
pain_points = data.get("pain_points") or []
feedback = data.get("product_feedback") or []
if not isinstance(pain_points, list):
@@ -179,7 +219,7 @@ def _build_tasks(
if not isinstance(feedback, list):
feedback = []
- if _should_skip_extraction(audience, pain_points, feedback):
+ if _should_skip_extraction(persona_signals, pain_points, feedback):
continue
content = content_map.get(source_row, "")
@@ -189,17 +229,18 @@ def _build_tasks(
source_row,
)
- aud_norm = audience.strip()
- if aud_norm.lower() != "unknown":
+ signals = _derive_persona_signals_fallback(persona_signals, pain_points)
+
+ for i, sig in enumerate(signals):
tasks.append(
EmbedTask(
job_id=job_id,
extraction_id=ext_id,
source_row=source_row,
- entity_type="audience",
- entity_index=0,
- embed_text=aud_norm,
- audience=aud_norm,
+ entity_type="persona_signal",
+ entity_index=i,
+ embed_text=sig,
+ audience=None,
aspect=None,
opinion=None,
category=None,
@@ -220,7 +261,7 @@ def _build_tasks(
entity_type="pain_point",
entity_index=i,
embed_text=text,
- audience=aud_norm,
+ audience=None,
aspect=None,
opinion=None,
category=None,
@@ -247,7 +288,7 @@ def _build_tasks(
entity_type="aspect_opinion",
entity_index=i,
embed_text=merged,
- audience=aud_norm,
+ audience=None,
aspect=aspect,
opinion=opinion,
category=category,
diff --git a/结构化_server.py b/结构化_server.py
index 9a2b1c1..b2d6c09 100644
--- a/结构化_server.py
+++ b/结构化_server.py
@@ -588,19 +588,55 @@ def _normalize_product_feedback(items: Any) -> List[Dict[str, str]]:
return out
+_GENERIC_PERSONA_SIGNALS = frozenset({
+ "self", "unknown", "user", "customer", "buyer", "myself", "the user", "consumer",
+})
+
+
+def _normalize_persona_signals(raw: Any) -> List[str]:
+ if raw is None:
+ items: List[Any] = []
+ elif isinstance(raw, list):
+ items = raw
+ elif isinstance(raw, str) and raw.strip():
+ items = [raw.strip()]
+ else:
+ items = []
+ out: List[str] = []
+ seen: set[str] = set()
+ for item in items:
+ text = str(item).strip()
+ if not text or text.lower() in _GENERIC_PERSONA_SIGNALS:
+ continue
+ key = text.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(text)
+ return out
+
+
def _validate_extraction_strict(obj: Any, key_label: str = "") -> Dict[str, Any]:
"""校验结构化结果;失败时抛出带键名的 ValueError,供回传模型修正。"""
prefix = f"{key_label}: " if key_label else ""
if not isinstance(obj, dict):
raise ValueError(f"{prefix}必须是 JSON 对象")
- for field in ("audience", "pain_points", "product_feedback"):
+ for field in ("persona_signals", "pain_points", "product_feedback"):
if field not in obj:
raise ValueError(f"{prefix}缺少必填字段 {field}")
- audience = obj.get("audience")
- if not isinstance(audience, str) or not audience.strip():
- raise ValueError(f"{prefix}audience 必须为非空字符串")
+ persona_signals = obj.get("persona_signals")
+ if not isinstance(persona_signals, list):
+ raise ValueError(f"{prefix}persona_signals 必须为数组")
+ for i, sig in enumerate(persona_signals):
+ if not isinstance(sig, str) or not str(sig).strip():
+ raise ValueError(f"{prefix}persona_signals[{i}] 必须为非空字符串")
+ if str(sig).strip().lower() in _GENERIC_PERSONA_SIGNALS:
+ raise ValueError(
+ f"{prefix}persona_signals[{i}] 禁止为泛化词 {sig!r},"
+ "须写具体身份/体质/场景短语"
+ )
pain_points = obj.get("pain_points")
if not isinstance(pain_points, list):
@@ -639,11 +675,7 @@ def _normalize_extraction(obj: Any) -> Dict[str, Any]:
if not isinstance(obj, dict):
raise ValueError("Each extraction must be a JSON object")
- audience = obj.get("audience", "unknown")
- if not isinstance(audience, str) or not str(audience).strip():
- audience = "unknown"
- else:
- audience = str(audience).strip()
+ persona_signals = _normalize_persona_signals(obj.get("persona_signals", []))
pain_points = obj.get("pain_points", [])
if pain_points is None:
@@ -656,14 +688,14 @@ def _normalize_extraction(obj: Any) -> Dict[str, Any]:
missing = [
f
- for f in ("audience", "pain_points", "product_feedback")
+ for f in ("persona_signals", "pain_points", "product_feedback")
if f not in obj
]
if missing:
logger.info("Normalized missing fields %s in extraction", missing)
return {
- "audience": audience,
+ "persona_signals": persona_signals,
"pain_points": pain_points,
"product_feedback": product_feedback,
}
@@ -677,7 +709,8 @@ def _build_batch_correction_message(keys: List[str], errors: List[str]) -> str:
f"校验错误:\n{err_block}\n\n"
"要求:\n"
f"- 输出一个 JSON 对象,顶层键必须且仅能是:{keys_literal}\n"
- "- 每个键的值必须包含 audience、pain_points、product_feedback\n"
+ "- 每个键的值必须包含 persona_signals、pain_points、product_feedback\n"
+ "- persona_signals 禁止仅写 self/user/unknown 等泛化词\n"
"- product_feedback 每条须含 aspect、opinion、sentiment"
"(仅 Positive/Negative/Neutral,禁止 Mixed/Ambiguous;褒贬交织选主倾向或拆条)、"
"category(禁止 Value,性价比用 Price)\n"
diff --git a/聚类.py b/聚类.py
index baf1e45..4679ca4 100644
--- a/聚类.py
+++ b/聚类.py
@@ -3,8 +3,8 @@
流程:
1. audience(LLM 自动调参 n_neighbors)
- 2a. 前两 audience 簇各自独立:簇内 pain_point(LLM 自动调参)
- 2b. 前两 audience 簇各自独立:簇内 aspect_opinion 按 Positive/Negative/Neutral 分桶聚类
+ 2a. 占比达阈值的 audience 簇各自独立:簇内 pain_point(LLM 自动调参)
+ 2b. 占比达阈值的 audience 簇各自独立:簇内 aspect_opinion 按 Positive/Negative/Neutral 分桶聚类
3a. 全量 pain_point(LLM 自动调参)
3b. 全量 aspect_opinion 按 Positive/Negative/Neutral 分桶聚类(各自 LLM 自动调参)
@@ -110,7 +110,10 @@ SAMPLE_RATIO = 0.6
STEP2_N_NEIGHBORS = 8
# 簇内去重评论数 / 本步骤参与聚类的去重评论总数 < 该比例则不写入库、不进入报告
-CLUSTER_MIN_REVIEW_RATIO = 0.10
+CLUSTER_MIN_REVIEW_RATIO = 0.05
+# step 2 入选 audience 簇:去重评论数 / 全量清洗评论总数 ≥ 该比例
+DEFAULT_AUDIENCE_COVERAGE_THRESHOLD = 0.01
+VOC_BUSINESS_CONFIG = PROJECT_ROOT / "voc_业务_2" / "config.yaml"
@dataclass
@@ -1093,10 +1096,24 @@ def _save_tuning_logs(
)
-def _top2_audience_clusters(
+def _config_threshold(key: str, default: float) -> float:
+ try:
+ import yaml
+ if VOC_BUSINESS_CONFIG.is_file():
+ with VOC_BUSINESS_CONFIG.open(encoding="utf-8") as f:
+ cfg = yaml.safe_load(f) or {}
+ val = float(cfg.get(key, default))
+ if 0 < val <= 1:
+ return val
+ except (TypeError, ValueError, OSError):
+ pass
+ return default
+
+
+def _audience_cluster_ranking(
conn: sqlite3.Connection, run_id: int
-) -> Tuple[List[int], Dict[int, int]]:
- """返回 (前两簇标签列表, source_row -> audience簇标签)。"""
+) -> Tuple[Dict[int, set[int]], Dict[int, int]]:
+ """返回 (cluster_label -> source_rows, source_row -> audience簇标签)。"""
cur = conn.execute(
"""
SELECT cluster_label, source_row
@@ -1113,11 +1130,46 @@ def _top2_audience_clusters(
sr = int(sr)
row_to_cluster[sr] = lab
cluster_rows.setdefault(lab, set()).add(sr)
- ranked = sorted(
- cluster_rows.items(), key=lambda x: len(x[1]), reverse=True
- )
- top2 = [lab for lab, _ in ranked[:2]]
- return top2, row_to_cluster
+ return cluster_rows, row_to_cluster
+
+
+def _top2_audience_clusters(
+ conn: sqlite3.Connection, run_id: int
+) -> Tuple[List[int], Dict[int, int]]:
+ """兼容旧逻辑:返回前两大的 audience 簇。"""
+ cluster_rows, row_to_cluster = _audience_cluster_ranking(conn, run_id)
+ ranked = sorted(cluster_rows.items(), key=lambda x: len(x[1]), reverse=True)
+ return [lab for lab, _ in ranked[:2]], row_to_cluster
+
+
+def _eligible_audience_clusters(
+ conn: sqlite3.Connection,
+ run_id: int,
+ total_reviews: int,
+ threshold: float = DEFAULT_AUDIENCE_COVERAGE_THRESHOLD,
+) -> Tuple[List[int], Dict[int, int]]:
+ """返回 (占比达阈值的 audience 簇列表, source_row -> audience簇标签)。"""
+ cluster_rows, row_to_cluster = _audience_cluster_ranking(conn, run_id)
+ if total_reviews <= 0:
+ return [], row_to_cluster
+ ranked = sorted(cluster_rows.items(), key=lambda x: len(x[1]), reverse=True)
+ eligible = [
+ lab for lab, rows in ranked
+ if len(rows) / total_reviews >= threshold
+ ]
+ if len(eligible) > 4:
+ logger.warning(
+ "eligible audience 簇数量=%s(阈值=%.0f%%),可能过度拆分 step 2",
+ len(eligible),
+ threshold * 100,
+ )
+ if not eligible and ranked:
+ logger.warning(
+ "无 audience 簇达到 step2 阈值 %.0f%%(总评论 %s),跳过 step 2",
+ threshold * 100,
+ total_reviews,
+ )
+ return eligible, row_to_cluster
def run_clustering(
@@ -1128,12 +1180,14 @@ def run_clustering(
cluster_db: Path = CLUSTER_DB,
reset_db: bool = True,
) -> dict:
+ """仅运行 3a(全量 pain)和 3b(全量 aspect_opinion × 情感)聚类。
+ 已移除步骤1 audience 和步骤2 per-audience 聚类。
+ """
require_chat_api_key()
econn = sqlite3.connect(embed_db)
try:
jid = _resolve_job_id(job_id, econn, structured_db)
- audience_rows = _load_embed_rows(econn, jid, "audience")
pain_rows = _load_embed_rows(econn, jid, "pain_point")
ao_rows = _load_embed_rows(econn, jid, "aspect_opinion")
finally:
@@ -1153,50 +1207,6 @@ def run_clustering(
total_reviews = _count_csv_reviews(csv_path)
logger.info("清洗后评论总数: %s", total_reviews)
- # --- 1 audience ---
- labels1, meta1 = _cluster_stage(
- audience_rows, stage="1_audience", use_llm_tune=True, client=client
- )
- filt1 = _save_assignments(
- cconn,
- run_id,
- "1_audience",
- audience_rows,
- labels1,
- n_neighbors=meta1.get("n_neighbors"),
- outlier_participates=False,
- )
- meta1["cluster_filter"] = filt1
- _save_stage_meta(cconn, run_id, "1_audience", meta1)
- if meta1.get("tuning_log"):
- _save_tuning_logs(cconn, run_id, "1_audience", meta1["tuning_log"])
-
- top2, row_to_aud = _top2_audience_clusters(cconn, run_id)
- meta_top2 = {
- "top2_audience_clusters": top2,
- "per_cluster_source_rows": {
- str(lab): len(_source_rows_for_audience_cluster(row_to_aud, lab))
- for lab in top2
- },
- "step2_mode": "per_audience_cluster_llm_auto_tune",
- }
- _save_stage_meta(cconn, run_id, "step2_filter", meta_top2)
- logger.info("Audience 前两簇(将分别聚类): %s", top2)
-
- step2_counts: Dict[str, int] = {}
- for aud_lab in top2:
- step2_counts.update(
- _cluster_step2_for_audience(
- cconn,
- run_id,
- audience_cluster=aud_lab,
- pain_rows=pain_rows,
- ao_rows=ao_rows,
- row_to_aud=row_to_aud,
- client=client,
- )
- )
-
# --- 3a pain global ---
labels3a, meta3a = _cluster_stage(
pain_rows, stage="3a_pain_global", use_llm_tune=True, client=client
@@ -1234,10 +1244,7 @@ def run_clustering(
"total_reviews": total_reviews,
"min_cluster_review_ratio": CLUSTER_MIN_REVIEW_RATIO,
"cluster_db": str(cluster_db.resolve()),
- "top2_audience_clusters": top2,
"counts": {
- "1_audience": len(audience_rows),
- **step2_counts,
"3a_pain_global": len(pain_rows),
**step3b_counts,
},