diff --git a/.gitignore b/.gitignore index 8dbeaa9..19c468c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # macOS .DS_Store +# Cursor / IDE 本地历史 +.history/ + # Python 310py/ .venv/ @@ -28,6 +31,11 @@ voc_clustering.sqlite # 流水线输出(含 word_freq.csv、报告 HTML 等) output/ +# 样例报告 HTML(可本地重生成) +bikini-trimmer-voc-v*.html +voc_业务_2/bikini-trimmer-voc-report.html +voc_业务_2/.echarts_cache.js + # 原始 / 中间 CSV(不纳入版本库) *.csv diff --git a/业务说明.html b/AMZ评论VOC流程解释.html similarity index 100% rename from 业务说明.html rename to AMZ评论VOC流程解释.html diff --git a/VOC分析方法论与报告生成逻辑.md b/VOC分析方法论与报告生成逻辑.md new file mode 100644 index 0000000..fb72d07 --- /dev/null +++ b/VOC分析方法论与报告生成逻辑.md @@ -0,0 +1,815 @@ +# VOC 数据清洗、分析与报告生成通用方法论 + +> 文档版本:v1.0 · 2026-06-11 +> 适用范围:亚马逊任意品类竞品 VOC 分析 +> 数据来源:卖家精灵 / Shulex 导出的实时评论 CSV +> 报告输出:双层结构 HTML 报告(描述层 What + 分析层 Why) + +--- + +## 目录 + +1. [原始数据结构](#1-原始数据结构) +2. [数据清洗规则](#2-数据清洗规则) +3. [描述层 What — 分析逻辑](#3-描述层-what--分析逻辑) + - 3.1 有效评论统计 + - 3.2 用户画像(Persona)识别 + - 3.3 正负反馈主题提取 + - 3.4 情感关键词分析 +4. [分析层 Why — 分析逻辑](#4-分析层-why--分析逻辑) + - 4.1 KANO 模型需求分类 + - 4.2 JTBD 动机框架 + - 4.3 人群 × 场景 × 需求矩阵 + - 4.4 痛点根因分析 +5. [报告生成逻辑](#5-报告生成逻辑) + - 5.1 HTML 整体结构 + - 5.2 可视化组件 + - 5.3 使用场景字段写入规则(核心规则) +6. [执行 SOP(逐步操作流程)](#6-执行-sop逐步操作流程) +7. [关键阈值与判断规则速查表](#7-关键阈值与判断规则速查表) +8. [新品类接入清单](#8-新品类接入清单) + +--- + +## 1. 原始数据结构 + +### 文件命名规则 + +``` +{ASIN}_realtime.csv +``` + +每个竞品 ASIN 对应一个独立文件,分析时批量读取同一目录下的全部文件。 + +### CSV 字段说明 + +| 字段名 | 类型 | 说明 | +|---|---|---| +| `asin` | string | 亚马逊标准识别号,文件可能带 BOM 头(`\ufeffasin`),读取时须用 `utf-8-sig` 编码 | +| `rating` | float(字符串形式) | 评分,取值 `1.0` / `2.0` / `3.0` / `4.0` / `5.0`,须用 `float()` 转换,**不能用 `int()`** | +| `title` | string | 评论标题 | +| `content` | string | 评论正文(主要分析字段) | +| `verified` | string | `"True"` / `"False"`,是否已验证购买 | +| `vine` | string | `"True"` / `"False"`,是否为 Vine 评测 | +| `review_date` | string | ISO 8601 格式,如 `2026-05-28T00:00:00+00:00` | + +--- + +## 2. 数据清洗规则 + +### 2.1 有效评论筛选(不可更改的核心规则) + +**只保留以下两类评论,其余全部排除:** + +```python +def is_valid(row): + return ( + row.get('verified', '').strip().lower() == 'true' + or + row.get('vine', '').strip().lower() == 'true' + ) +``` + +**排除理由**:未验证且非 Vine 的评论可能包含刷评、竞品恶意差评或未实际购买的猜测,会干扰真实用户体验数据。 + +### 2.2 差评 / 好评 / 中性评论定义 + +| 分类 | 星级 | 用途 | +|---|---|---| +| 好评(Positive) | ★★★★★ / ★★★★ | 提取正向主题、魅力型需求、用户满意点 | +| 中性(Neutral) | ★★★ | 单独记录,不进入主题频次统计 | +| 差评(Negative) | ★★ / ★ | 主要分析对象,提取痛点主题和根因 | + +### 2.3 新买家 vs 复购买家区分规则 + +| 类型 | content 字段识别关键词 | +|---|---| +| **新买家** | `first time` / `just got` / `just bought` / `new to` | +| **复购买家** | `reorder` / `bought again` / `second time` / `repurchase` / `keep buying` | + +> 两类买家的差评重点通常不同: +> - 新买家 → 效果不符预期、开箱即坏、使用门槛高 +> - 复购买家 → 某个功能在长期使用后失效、品质下降 + +--- + +## 3. 描述层 What — 分析逻辑 + +### 3.1 有效评论统计 + +对每个 ASIN 分别计算,再汇总全市场数据: + +| 指标 | 计算公式 | +|---|---| +| 有效评论数 | 通过 `is_valid()` 筛选后的总行数 | +| 加权平均评分 | `Σ(各ASIN均分 × 各ASIN有效评论数) / 全市场有效评论总数` | +| 差评率 | `≤2星评论数 / 有效总数` | +| 正评率 | `≥4星评论数 / 有效总数` | +| 各星级分布 | 1–5 星各自数量及占比 | + +**市场竞争状态判断**: + +| 加权均分 | 判断 | +|---|---| +| < 3.5 | 市场存在严重系统性缺陷,是新品进入的明确窗口期 | +| 3.5 – 4.0 | 市场有改进空间,部分功能存在普遍短板 | +| > 4.0 | 市场整体较成熟,需通过差异化或细分切入 | + +### 3.2 用户画像(Persona)识别 + +#### 识别方法 + +在评论 `title + content` 字段中搜索特征词,将评论人归入对应 Persona。一条评论可同时归入多个 Persona。 + +#### Persona 识别词的建立原则 + +1. **阅读全部差评(≤2星)**,找出用户描述自身处境的词汇("I have... / I am... / As a...") +2. **阅读全部好评(≥4星)**,找出用户描述自身需求背景的词汇 +3. 从中归纳出 4–6 个差异化的用户群体 +4. 每个群体设定 5–10 个识别关键词 + +#### ⚠️ Persona 必须覆盖的三个分类维度(缺一不可) + +在最终确认 Persona 列表前,必须检查是否已从以下三个维度进行了覆盖,**不能只按其中一个维度拆分就停止**: + +| 维度 | 说明 | 典型信号词 | +|---|---|---| +| **A. 物理/生理特征** | 用户身体特征决定了产品对他们的效果上限(最易被遗漏) | `thick/dark/coarse hair` / `sensitive skin` / `pregnant` / `curly` / `Latina` / `Type 4 hair` | +| **B. 行为/场景** | 用户在什么情境下使用产品 | `travel` / `in the shower` / `gift` / `daily` | +| **C. 购买动机/背景** | 用户为何从其他方案切换过来 | `switched from razor` / `too expensive` / `saw on TikTok` / `first time` | + +> **关键原则**:如果你的 Persona 列表里只有场景类和动机类群体,而没有任何一个群体是按身体特征定义的,说明维度 A 被遗漏了,必须重新检查差评中的自我标注词汇。 + +#### 自我标注信号强制检查步骤 + +在完成初步 Persona 归纳后,**必须**额外执行以下搜索,确认是否有被遗漏的物理特征用户群: + +``` +搜索差评中所有含以下模式的句子: + "I have [adj] [noun]"(如 I have thick hair / I have sensitive skin) + "My [noun] is/are [adj]"(如 My skin is super sensitive) + "As a [noun/adj person]"(如 As a Latina / As a curly-haired person) + "[族裔/肤色/发质形容词]"(如 Latina / dark hair / coarse / Type 4) + +若上述词汇出现 ≥ 5 条,则该物理特征代表一个独立 Persona,必须单独列出。 +``` + +#### Persona 识别词模板格式 + +```python +PERSONA_KEYWORDS = { + '{群体名称A}': ['{关键词1}', '{关键词2}', ...], + '{群体名称B}': ['{关键词1}', '{关键词2}', ...], + # 根据实际品类补充 +} +``` + +#### Persona 占比估算规则 + +``` +占比 = 命中该Persona识别词的评论数 / 有效评论总数 +四舍五入至整5% +``` + +> 因一条评论可被多个 Persona 命中,各 Persona 占比之和可超过 100%。 + +#### Persona 卡片内容规格(每个群体输出以下信息) + +| 字段 | 来源 | 说明 | +|---|---|---| +| 群体名称 | 自定义 | 简洁描述身份特征,≤6 字 | +| 占比 | 统计计算 | 见上方公式 | +| 核心痛点 | 该群体差评 | ≤3 条,原文语义概括 | +| 核心需求 | 该群体好评+诉求 | ≤3 条 | +| 购买动机 | JTBD 分析 | 用"雇佣产品做什么"句式 | +| 代表性引用 | 真实评论原文 | 必须来自实际评论,注明 ASIN | + +### 3.3 正负反馈主题提取 + +#### 主题识别关键词组的建立方法 + +1. 阅读**全部差评(≤2星)**,记录用户描述问题时的高频词 +2. 将语义相近的词归为同一主题,形成关键词组 +3. 每个主题设定 5–10 个关键词 +4. 覆盖 80%+ 的差评内容(长尾主题可合并为"其他") + +#### ⚠️ 主题拆分规则:相近但机制不同的问题必须独立成主题 + +语义相近不等于根因相同。以下情况**必须拆分为独立主题,不得合并**: + +| 合并后失真的典型例子 | 应该如何拆分 | 原因 | +|---|---|---| +| "剃效差"(笼统) | ① 留茬/剃不干净 ② 拉扯/扯毛而非切断 | 机制不同:留茬=刀头贴肤不足;拉扯=刀片咬不断粗硬毛,影响人群完全不同 | +| "皮肤问题"(笼统) | ① 割伤/出血 ② 摩擦热/灼烧感 ③ 剃须疹/内生毛 | 根因不同,对应不同的工程解决方案 | +| "产品损坏"(笼统) | ① 充电失效 ② 配件断裂/脱落 | 分属电气系统和结构系统,受影响时间节点不同(充电=使用初期;断裂=一段时间后) | + +> **判断是否需要拆分的问题**: +> "同一主题下的差评,是否描述的是同一个物理/工程原因?" +> 如果不是,必须拆开。 + +**通用差评主题模板格式**: + +```python +NEGATIVE_THEMES = { + '{主题名称}': ['{关键词1}', '{关键词2}', ...], + # 品类相关主题 +} +``` + +**通用好评主题模板格式**: + +```python +POSITIVE_THEMES = { + '{主题名称}': ['{关键词1}', '{关键词2}', ...], +} +``` + +#### 频次统计规则 + +1. 在 `title + content` 中搜索关键词 +2. 同一评论中同一关键词出现多次,仍计为 1 次(避免重复计数) +3. 差评主题只统计 ≤2 星评论;好评主题只统计 ≥4 星评论 +4. **频次 = 命中该主题的评论条数**(非词语出现总次数) + +#### 主题优先级判定规则 + +| 优先级 | 差评频次门槛 | 涉及竞品范围 | +|---|---|---| +| **P0(立即处理)** | ≥ 总有效差评数的 20% | 80%+ 竞品均出现 | +| **P1(短期处理)** | 总有效差评数的 10–20% | 60%+ 竞品出现 | +| **P2(中期关注)** | 总有效差评数的 3–10% | 40%+ 竞品出现 | + +> **频次门槛的动态计算**: +> `P0 绝对门槛 = 全市场有效差评数 × 20%` +> 例:1123 条有效评论,差评率 35% ≈ 393 条差评,P0 门槛 ≈ 79 条 + +### 3.4 情感关键词分析 + +对所有有效评论进行词频统计,提取高频情感词。 + +**输出字段规格**: + +| 字段 | 说明 | 规则 | +|---|---|---| +| 词汇 | 英文原词或词组 | 保留原文,不翻译 | +| 出现频次 | 在有效评论中出现的条数 | 同一评论多次出现计1次 | +| 情感极性 | 正面 / 负面 / 中性 | 根据语境判断,同一词在不同语境可有不同极性 | +| 含义/使用场景 | 该词汇在评论中的具体语境 | **只写评论中明确出现的内容,不推断** | +| 主要关联人群 | 对应的 Persona 名称 | 可多个 | + +--- + +## 4. 分析层 Why — 分析逻辑 + +### 4.0 Persona 完整性验证(进入分析层前的强制关卡) + +**在开始 KANO / JTBD 分析之前,必须完成以下交叉验证,发现遗漏立即返回 3.2 节补充。** + +#### 验证方法:每个 P0/P1 主题 → 强制归因到 Persona + +为每一个 P0/P1 差评主题填写下表: + +| 差评主题 | 频次 | 该主题的典型描述 | 主要影响哪类用户? | 对应已有 Persona? | +|---|---|---|---|---| +| {主题1} | {N条} | {原文特征} | {用户特征描述} | {Persona名 / ❌未覆盖} | +| {主题2} | ... | ... | ... | ... | + +**如果某个 P0/P1 主题在"对应已有 Persona"列填写了 ❌,说明存在遗漏的用户群体,必须新增 Persona。** + +#### 常见漏洞场景 + +| 被遗漏的情况 | 漏洞原因 | 补救方式 | +|---|---|---| +| 身体特征群体(如粗硬发质用户) | 只按场景/动机分群,未检查维度 A(物理特征) | 返回 3.2 节执行自我标注信号强制检查 | +| 长期使用复购用户 | 只看差评内容,未注意时间轴("after months of use") | 检查含 `months` / `after a while` / `second bottle` 的差评是否形成独立群体 | +| 特定人群的特殊需求 | 该群体占比较小但痛点极具体 | 即使占比低(~5%),若痛点独特且无法被其他 Persona 代表,必须单独列出 | + +### 4.1 KANO 模型需求分类 + +#### 四种类型定义与判断标准 + +| 类型 | 定义 | 判断标准 | 常见错误 | +|---|---|---|---| +| **基本型(Must-be)** | 不满足→强烈差评;满足→用户不会特别提及或表扬 | ① 差评频次达 P0 级别 ② 80%+ 竞品均出现该缺陷 ③ 好评中几乎不出现"因为做到了 X 所以好评" | 把"剃净度"归为基本型——剃净度好坏都会被用户提及,属期望型 | +| **期望型(Performance)** | 做得越好评分越高,做得越差评分越低,线性关系 | ① 好评中被作为"这款优于竞品"的主要理由 ② 差评中作为"原本期待但未达到"的失望点 ③ 用户用程度词描述(`better/worse/not as good as`) | 把"电池续航"归为基本型——续航差才差评,续航超长会被用户特别称赞 | +| **魅力型(Attractive)** | 满足→产生超预期惊喜和好评;不满足→用户不会差评 | ① 好评中出现强情感词 `love` / `obsessed` / `amazing` / `didn't expect` / `bonus` ② 该功能在差评中几乎不出现 ③ 竞品普遍缺失,属市场空白 | 把"附赠收纳袋"归为期望型——用户从未因为"没有收纳袋"而差评,属意外惊喜 | +| **反向型(Reverse)** | 某些用户认为该功能是负担,反而差评 | ① 差评中出现对某个"功能"的明确抱怨 ② 该内容在好评中也受另一部分人喜爱(说明用户分歧) | 把"产品损坏"归为反向型——没有用户"希望产品能损坏" | + +#### 各类型的输出格式要求 + +每个 KANO 条目必须包含以下 5 个字段,缺一不可: + +``` +需求项:[具体需求描述,动词+名词形式] +评论频次证据:[支撑该分类的评论条数及代表性原文片段] +主要影响 Persona:[哪类用户群对该需求最敏感] +分类原因:[用一句话解释为什么是这个 KANO 类型,而不是其他类型] +竞品现状:[现有竞品是否满足,满足程度如何] +``` + +**示例(基本型)**: +``` +需求项:充电后可正常启动 +评论频次证据:118条差评(P0级别),"stopped working after a few uses" / "won't charge at all" +主要影响 Persona:所有群体,尤其是复购用户(第二台也坏后彻底失去信任) +分类原因:充电失效是"有就正常、坏了就1星"的底线需求,好评中没有人因"能充电"而特别表扬 +竞品现状:全部5款竞品均有此问题,说明是行业普遍工程缺陷 +``` + +**示例(魅力型)**: +``` +需求项:LCD 电量显示 +评论频次证据:好评中 28条提及,"love that I can see the battery level" / "so convenient",差评中0条因缺少LCD而差评 +主要影响 Persona:旅行护理族(出行前确认电量)/ 所有群体 +分类原因:用户不会因为"没有电量显示"而差评,但有了之后会主动提及并作为推荐理由 +竞品现状:仅1款(FANKRUAI)有此功能,属差异化空白 +``` + +#### KANO 归类操作步骤 + +**步骤一:基本型识别** +- 列出所有 P0/P1 差评主题 +- 检查每个主题对应的好评:如果好评中几乎没有人因"做到了这点"而表扬,确认为基本型 +- 每个基本型需求必须注明:频次(条数)+ 出现该问题的竞品数量 + +**步骤二:期望型 vs 魅力型区分** + +在好评中对每个高频好评主题做以下判断: + +| 判断问题 | 若"是"→ | 若"否"→ | +|---|---|---| +| 差评中有人因该功能**不够好**而差评? | 期望型 | 魅力型候选 | +| 好评用程度词描述(`better/works great/very`)? | 期望型 | 魅力型候选 | +| 好评中出现 `love/obsessed/amazing/bonus/didn't expect`? | 魅力型 | 继续判断 | +| 竞品普遍缺失,属市场新鲜感? | 魅力型 | 继续判断 | + +**步骤三:反向型搜索(不得以"未发现"一笔带过)** + +必须主动在差评中搜索以下关键词,并记录每个词的出现频次: + +``` +搜索词组(在全部有效评论 title+content 中搜索): + 过于复杂:too many parts / too complicated / confusing / hard to use + 过于嘈杂:too loud / so loud / noise / noisy + 功能多余:don't need / unnecessary / didn't ask for / useless feature + 操作繁琐:takes too long / too many steps / annoying to clean +``` + +**结果处理规则**: +- 若任意词组频次 ≥ 5 条 → 该功能为反向型,单独列出并附引用 +- 若所有词组总频次 < 5 条 → 填写:"反向型:经主动搜索 [列出搜索词],出现频次共 [N] 条,低于阈值,本品类暂无明确反向需求"(**禁止直接写"无"或"未发现"**) + +### 4.2 JTBD 动机框架 + +> JTBD(Jobs To Be Done):用户"雇佣"产品来完成什么任务。分析维度:功能性动机、情感性动机、社会性动机。 + +**输出格式(每个 Persona 一行)**: + +| 字段 | 说明 | 填写规则 | +|---|---|---| +| 用户群 | Persona 名称 | — | +| 核心 Job | 用户想完成的任务 | 动词+宾语形式,如"用电动工具替代传统方式" | +| 功能性动机 | 实用层面的驱动因素 | 必须能从评论中找到佐证句子 | +| 情感性动机 | 情绪/心理层面的驱动因素 | 必须能从评论中找到佐证句子 | +| 社会性动机 | 他人视角/社交驱动(如无评论佐证则留空) | 可选 | +| 购买触发时机 | 什么具体事件让用户决定购买 | 来自评论中的具体描述 | + +### 4.3 人群 × 场景 × 需求矩阵 + +矩阵将 Persona、使用场景、KANO 需求分层和当前满意度整合为一张全景视图。 + +**列结构**: + +| 列 | 填写来源 | +|---|---| +| 用户群 | Persona 名称 + 占比 | +| 使用场景(When/Where) | **严格遵守场景字段规则(见 5.3 节)** | +| 基本型需求 | KANO 基本型 + 该群体 P0 差评 | +| 期望型需求 | KANO 期望型 + 该群体 P1 差评 | +| 魅力型需求 | KANO 魅力型 + 该群体好评加分点 | +| 当前满意度 | 该群体对应评论的均分和好评率综合判断 | + +**满意度评级标准**: + +| 当前满意度 | 对应均分参考 | 显示样式 | +|---|---|---| +| 高 | ≥ 4.0 | 绿色 | +| 中等 | 3.3 – 3.9 | 黄色 | +| 低 ⚠ | < 3.3 | 红色 | + +### 4.4 痛点根因分析 + +**适用条件**:差评主题达到 P0 或 P1 级别时,必须进行根因分析。 + +**分析框架**: + +``` +根因 N:[工程/设计/材料/体验设计问题名称] +→ 导致后果:[差评主题名称] × [频次] +→ 失效机制:[从产品结构或工作原理层面解释为什么会出现这个问题] +→ 关键引用:[2-3条真实评论原文(英文)— 所属ASIN品牌] +``` + +**根因分析的层次要求**: + +| 层次 | 示例(错误 → 正确) | +|---|---| +| 停留在现象层(❌) | "产品质量差" | +| 到达机制层(✅) | "充电口防水胶圈未达到IP67标准,浴室蒸汽渗入导致腐蚀" | + +--- + +## 5. 报告生成逻辑 + +### 5.1 HTML 整体结构 + +报告采用**纯 HTML 内嵌 CSS + JS**,无外部文件依赖,单文件可直接分享。 + +``` +{产品关键词}-voc-v{版本号}.html +├── +│ ├── Chart.js CDN(可视化依赖) +│ │ └── https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js +│ └── + + + + + +
+ +

{{PRODUCT_NAME}} — VOC 深度分析报告

+

{{ASIN_COUNT}} 个竞品 ASIN · 数据来源:{{DATA_SOURCE}} · 分析日期:{{ANALYSIS_DATE}}

+ +
+
报告阅读指引
+

描述层 What:先看「决策摘要」→ 数据总览 → 各 ASIN 主题对比 → 受众画像 → 正负反馈主题 → 情感词频
+ 分析层 Why:解释动机根因(KANO 需求分层 → JTBD 动机框架 → 人群×场景×需求矩阵 → 痛点根因分析)

+
+ +
+

决策摘要

+ {{EXEC_SUMMARY_HTML}} +
+ + {{INSIGHTS_CALLOUT}} + + +
+ 描述层 What +

数据总览

+
+ +
+
市场竞争状态:{{MARKET_TITLE}}
+

{{MARKET_DESC}}

+
+ +
+
{{TOTAL_REVIEWS}}
有效评论总数(Verified+Vine)
+
{{WEIGHTED_AVG}}
{{ASIN_COUNT}}款加权平均评分
+
{{POS_RATE}}
正评率(≥4★)
+
{{NEUTRAL_RATE}}
中评率(3★,{{NEUTRAL_COUNT}}条)
+
{{NEG_RATE}}
差评率(≤2★)
+
+ +

各 ASIN 有效评论统计

+ {{ASIN_TABLE_NOTE}} +
{{ASIN_TABLE_SUMMARY}}
+ {{ASIN_TABLE_APPENDIX}} + +
+ {{STAR_SUMMARY_HTML}} +

各 ASIN 评分分布(堆叠)

+

{{STAR_CHART_NOTE}}

+
+
+
+

数据来源:{{DATA_SOURCE}} · 筛选 verified=True 或 vine=True · 轴标签 A/B/… 悬停图表查看完整竞品名

+
+ +
+ + +
+ 描述层 What +

各 ASIN 主题分布

+
+

横向对比各竞品在 Top 差评/好评主题上的评论命中数,识别弱点集中 ASIN 与卖点组合差异。

+ {{ASIN_THEME_INSIGHTS_HTML}} + +
+

各 ASIN 差评主题对比(Top 6 主题 × {{ASIN_COUNT}} 竞品)

+

{{NEG_THEME_CHART_NOTE}}

+
+ {{NEG_THEME_MINI_HTML}} +
+
+

各 ASIN 好评主题对比(Top 6 主题 × {{ASIN_COUNT}} 竞品)

+

{{POS_THEME_CHART_NOTE}}

+
+ {{POS_THEME_MINI_HTML}} +
+ +
+ + +
+ 描述层 What +

用户画像(Persona)

+
+ +
{{PERSONA_CARDS}}
+ +
+ + +
+ 描述层 What +

正负反馈主题统计

+
+ +

{{NEG_THEME_SUMMARY_NOTE}}

+ +
+
+

差评主题频次

+
+
+
+

好评主题频次(≥4 星,共 {{POS_REVIEW_COUNT}} 条)

+
+
+
+ +

差评主题明细

+
+ + + {{NEG_THEME_TABLE_ROWS}} +
差评主题(按根因拆分,不合并)频次占差评比优先级涉及范围&最痛 ASIN
+
+ +

好评主题明细

+

占好评比基于单主题命中计数;同一评论可命中多个主题,各行占比之和可能超过 100%(当前合计约 {{POS_PCT_SUM}}%)。KANO 预判为 What 层摘要,详细分类见下方 KANO 模型章节。

+
+ + + {{POS_THEME_TABLE_ROWS}} +
好评主题频次占好评比KANO 预判
+
+ +
+ + +
+ 描述层 What +

情感关键词分析

+
+

规则:同一评论中同一词组多次出现计 1 次;极性根据评论语境判断;含义只写评论中明确出现的内容,不推断。

+ +
+
+

高频负面情感词(差评,≤2★)

+
+ + + {{KEYWORD_NEG_TABLE_ROWS}} +
词汇频次情感极性评论中使用语境主要关联 Persona
+
+
+
+

高频正面情感词(好评,≥4★)

+
+ + + {{KEYWORD_POS_TABLE_ROWS}} +
词汇频次情感极性评论中使用语境主要关联 Persona
+
+
+
+ +
+ + +
+ 分析层 Why +

KANO 模型需求分类

+
+ +

每个需求条目包含:需求项 · 频次证据 · 主要影响 Persona · 分类原因 · 竞品现状(5 字段,按 SOP 要求)

+ + {{KANO_GRID_HTML}} + +
+ + +
+ 分析层 Why +

JTBD 动机框架

+
+ +

各动机字段须来自 Persona 聚类数据(keywords / core_pain / core_need 等)佐证;无法找到佐证的字段填「-」。

+ +
+ + + {{JTBD_TABLE_ROWS}} +
用户群核心 Job功能性动机情感性动机社会性动机购买触发时机
+
+ +
+ + +
+ 分析层 Why +

人群 × 场景 × 需求矩阵

+
+

场景字段来源:评论中出现次数 ≥5 的场景词方可填写;每 Persona 最多 2 个场景、全报告最多 4 个 Persona。全市场均分偏低时,「高满意度」会自动校准为中等并注明原因。

+ +
+ + + + + + + + + + + + {{MATRIX_TABLE_ROWS}} +
用户群使用场景(When/Where)
仅评论中佐证≥5条的场景
基本型需求期望型需求魅力型需求当前满意度
+
+ +
+ + +
+ 分析层 Why +

痛点根因分析(按 Persona 展开)

+
+ + {{ROOTCAUSE_CARDS}} + + + +
+ + + + diff --git a/voc_业务_2/报告LLM提示词.md b/voc_业务_2/报告LLM提示词.md new file mode 100644 index 0000000..02fb4b1 --- /dev/null +++ b/voc_业务_2/报告LLM提示词.md @@ -0,0 +1,28 @@ +# VOC 报告 LLM 提示词 · 说明索引 + +## 给谁用哪个文件? + +| 角色 | 文件 | 做什么 | +|------|------|--------| +| **业务员** | **[提示词编辑稿.md](./提示词编辑稿.md)** | 改中文任务说明、硬性要求、示例(**只改这个**) | +| **技术同事** | [prompts.yaml](./prompts.yaml) | 把编辑稿内容同步进来,程序实际读取此文件 | +| **开发** | [prompt_loader.py](./prompt_loader.py) · [llm_analyzer.py](./llm_analyzer.py) | 一般不用动 | + +## 工作流程 + +``` +业务员修改 提示词编辑稿.md + ↓ +技术同事复制到 prompts.yaml 对应段落(见编辑稿末尾「同步清单」) + ↓ +运行 run_pipeline.py 或 build_report.py 生成报告 +``` + +## 模型参数(config.yaml,非提示词正文) + +- `report_model` / `report_reasoning_effort` / `report_max_tokens` — 报告 LLM 模型与思考深度 +- `llm_max_workers` — 根因等并发数 + +## 不走 LLM 的报告内容 + +决策摘要、部分市场竞争文案由 `report_utils.py` 规则生成,不在提示词文件内。 diff --git a/voc_业务_2/提示词编辑稿.md b/voc_业务_2/提示词编辑稿.md new file mode 100644 index 0000000..50e1e4a --- /dev/null +++ b/voc_业务_2/提示词编辑稿.md @@ -0,0 +1,611 @@ +# VOC 报告 · 提示词编辑稿(业务员用) + +> **请你只改本文件。** 改完后交给技术同事,他会把内容同步到 `prompts.yaml` 并重新生成报告。 +> **你不需要打开** `prompts.yaml`(那是程序用的配置文件)。 +> **方法论依据:** `VOC分析方法论与报告生成逻辑.md` v1.0 + +--- + +## 使用说明(3 步) + +1. 在下方找到要改的**报告章节**(如「用户画像」「差评主题」) +2. 直接修改对应框里的**中文文字**(任务说明、硬性要求、示例等) +3. **不要删除** 带 `{{ }}` 的行——那是系统自动填入数据的占位符,例如: + - `{{personas_json}}` = 自动填入用户画像列表 + - `{{catalog_json}}` = 自动填入聚类数据 + +**请勿修改:** +- JSON 格式示例里的英文字段名(如 `"personas"`、`"themes"`、`"name"`) +- 所有 `{{xxx}}` 占位符整行 + +**可以修改:** +- 「你是…专家」这类角色描述 +- 「## 任务」「## 硬性要求」下的中文规则和示例 +- 数量要求(如 4–7 个 Persona 改成 5–8 个) + +**示例边界(v1.2):** +- 文中所有中文/英文示例**仅说明格式与分析逻辑** +- 实际 Persona、主题名、keywords、场景、KANO、根因**须来自当前批次聚类数据**,禁止照搬示例文字 + +--- + +## 章节与报告对照表 + +| 本文件章节 | 报告里看到的位置 | 同步到 prompts.yaml 的键名 | +|-----------|-----------------|---------------------------| +| 0. 产品识别 | (无单独章节,用于自动识别产品名) | `product_detect` | +| 1. 用户画像 | 用户画像(Persona) | `persona` | +| 2. 正负主题 | 正负反馈主题统计 | `theme` | +| 3. KANO | KANO 模型需求分类 | `kano` | +| 4. JTBD | JTBD 动机框架 | `jtbd` | +| 5. 矩阵 | 人群 × 场景 × 需求矩阵 | `matrix` | +| 6. 根因 | 痛点根因分析 | `rootcause` | +| 7. 情感词 | 情感关键词分析 | `keyword` | + +--- + +# 0. 产品 / 行业自动识别 + +**同步位置:** `prompts.yaml` → `product_detect` + +| 参数 | 当前值 | 说明 | +|------|--------|------| +| temperature | 0.2 | 数值越小输出越稳定,一般不用改 | +| max_tokens | 1000 | 最大输出长度,一般不用改 | + +--- + +## 【角色设定】→ 粘贴到 `product_detect.system` + +``` +你是亚马逊商品识别专家。根据用户评论内容推断产品名称和所属行业。输出严格JSON,不输出多余文字。 +``` + +--- + +## 【任务说明】→ 粘贴到 `product_detect.user_template` + +``` +根据以下亚马逊评论内容和目录名,推断产品名称和所属行业。 + +## 评论样本 +{{sample_text}} +{{dir_info}} +## 输出JSON +{"product_name": "推断的产品名称(中英文均可,简洁描述,如 Kitchen Blender / Pet Repellent Spray)", "industry": "所属行业(中文,如 个人护理/宠物用品/厨房家电/健康补充剂 等)"} + +要求: +- product_name 用评论中高频提及的核心产品词命名,不用品牌名或 ASIN;不要包含行业大类词 +- industry 用一个中文行业大类词 +- 目录名中的关键词可作为重要参考线索 +- JSON 示例仅说明格式,product_name 须从上方评论样本归纳 +``` + +**占位符说明:** +- `{{sample_text}}` — 系统自动插入评论样本,勿删 +- `{{dir_info}}` — 系统自动插入文件夹名称,勿删 + +--- + +# 1. 用户画像 Persona + +**同步位置:** `prompts.yaml` → `persona` +**报告章节:** 用户画像(Persona) + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.4 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `persona.system` + +``` +你是资深的消费者洞察专家,严格遵循 VOC 分析方法论 v1.0 第 3.2 节与 4.0 节。 +Persona 命名用简洁中文(≤6字),避免营销化夸张名称。输出严格JSON。 +示例仅说明格式;Persona 名与 keywords 须来自当前聚类 top_phrases,禁止照搬示例。 +``` + +--- + +## 【任务说明】→ 粘贴到 `persona.user_template` + +``` +## 任务:基于聚类数据按三维度发现用户画像(Persona),4-7 个。 + +## 三维度强制覆盖(缺一不可) +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 维缺失,说明物理特征群体被遗漏,必须补建。 + +## 自我标注信号强制检查(归纳后必做) +在绑定簇的 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) + +## 其他遗漏检查 +- 长期使用/复购用户:含 after months / after a while / second bottle / bought again 的差评是否形成独立群体 +- 占比低(~5%)但痛点独特、无法被其他 Persona 代表的群体,仍须单独列出 + +## 可绑定的聚类簇目录(cluster_ref 必须从中选择;每簇含 top_phrases + sample_reviews 最多 5 条原文) +{{catalog_json}} + +## 生理标签硬规则(名称 + core_pain,违反则系统会剔除) +- 生理/体质类中文标签须在绑定簇内 ≥5 条评论原文含对应英文词(catalog 字段 physio_review_counts,如 pregnancy: 8) +- top_phrases 偶然出现 1–4 次不算;无达标评论禁止写入 +- core_pain 只能归纳 sample_reviews 中明确出现的内容 + +## 补充聚类摘要 +audience_clusters: {{audience_clusters_json}} +global_pains: {{global_pains_json}} +global_negative: {{global_negative_json}} +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句式)"}]} +## 硬性要求 +- 每个 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条)仅在有明确短语证据时使用 +- core_pain 来自该群体差评语义;core_need 来自该群体好评或诉求;purchase_motivation 用「雇佣产品完成…」句式 +- 命名示例(格式参考,须从聚类归纳):敏感体质用户、首次购买用户、粗毛疤痕体质用户、旅行护理用户、蜡脱替代用户(禁止:受害者联盟、体验官、刮刀逃离者等夸张抽象名) +- 禁止在 physio_review_counts 未达 ≥5 条时将「孕妇/孕期」写入名称或 core_pain +``` + +**占位符说明:** +- `{{catalog_json}}` — 聚类簇目录(自动填入) +- `{{audience_clusters_json}}` 等 — 聚类摘要(自动填入) + +--- + +# 2. 差评 / 好评主题 + +**同步位置:** `prompts.yaml` → `theme` +**报告章节:** 正负反馈主题统计 + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.3 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `theme.system` + +``` +你是亚马逊 VOC 分析专家,遵循方法论 3.3 节。 +从聚类短语归纳主题;根因/失效机制不同的问题必须独立成主题,禁止笼统合并。 +主题名用简洁中文(≤8字),适用于任意品类。输出严格JSON。 +示例仅说明拆分逻辑;主题名与 keywords 须来自当前聚类数据,禁止照搬示例主题名。 +``` + +--- + +## 【差评专用补充】→ 粘贴到 `theme.negative_extra` + +(仅分析差评主题时使用,好评不走这段) + +``` +## 优先级(共 {{neg_review_count}} 条差评,系统会按实际频次与竞品覆盖率重算) +- P0:频次 ≥ {{p0_threshold}} 条 且 80%+ 竞品均出现 +- P1:频次为差评总数 10–20% 且 60%+ 竞品出现 +- P2:频次为差评总数 3–10% 且 40%+ 竞品出现 + +## 拆分红线(方法论 3.3) +判断问题:「同一主题下的差评,是否描述同一个物理/工程原因?」若不是,必须拆开。 +典型拆分(机制/根因不同须拆开,勿照搬下列中文名): +- 核心效果未达预期 → ①效果弱/不明显 ②使用方式与预期不符(机制不同) +- 使用过程不适 → ①物理伤害/刺激 ②过热/异味/过敏(根因不同) +- 产品失效/损坏 → ①供电/充电问题 ②结构件断裂/脱落(失效环节不同) +``` + +**占位符说明:** +- `{{neg_review_count}}` — 差评总数(自动填入) +- `{{p0_threshold}}` — P0 优先级门槛(自动填入) + +--- + +## 【任务说明】→ 粘贴到 `theme.user_template` + +``` +## 任务:归纳 {{theme_type}} 主题(适用于当前品类,勿预设具体产品类型) +{{extra_block}} + +## 归纳原则 +- 从 top_phrases 中归纳 4–8 个主题,差评主题应覆盖 80%+ 差评内容(长尾可合并为「其他」) +- keywords 语义相近但失效机制不同 → 必须拆成独立主题 +- 好评主题可与差评维度对应但用正向表述(如 核心使用体验 ↔ 核心效果未达预期) +- 示例主题名仅作格式与拆分参考;实际 name/keywords 必须来自上方 cluster 数据的 top_phrases + +## 数据 +{{cluster_data_json}} +## 输出JSON +{"themes":[{"name":"≤8中文字","keywords":["english phrase from top_phrases"],"priority":"P0/P1/P2(仅差评)","description":"一句话说明该主题的用户抱怨/满意点"}]} +## 硬性要求 +- keywords 必须是英文,从 top_phrases 中复制完整片段或逗号后的子句(≥3字符) +- 每个主题至少 5 个 keywords +- 差评 priority 按上方门槛初步标注(系统会重算) +- 差评示例(格式参考,须从 top_phrases 归纳):效果未达预期、使用不适、供电失效、结构损坏、性价比低 +- 好评示例(格式参考):核心使用体验、产品质量耐用、便携与设计、易用性、超预期惊喜 +``` + +**占位符说明:** +- `{{theme_type}}` — 自动填 `negative` 或 `positive` +- `{{extra_block}}` — 差评时自动插入上方「差评专用补充」 +- `{{cluster_data_json}}` — 聚类短语数据(自动填入) + +--- + +# 3. KANO 需求分类 + +**同步位置:** `prompts.yaml` → `kano` +**报告章节:** KANO 模型需求分类 + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.3 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `kano.system` + +``` +你是产品需求分析专家,专精 KANO 模型,遵循方法论 4.1 节。 +四象限分类;每个条目 5 字段缺一不可。输出严格JSON。 +KANO 示例仅说明四象限判断;item/evidence 须来自当前差评/好评主题 keywords,禁止照搬示例。 +``` + +--- + +## 【反向型主动搜索】→ 粘贴到 `kano.reverse_search_block` + +(KANO 分析时自动插入) + +``` +## 反向型主动搜索(步骤三,不得以「未发现」一笔带过) +在差评/好评主题 keywords 中检索以下词组及同义表达,记录出现频次: +- 过于复杂:too many parts / too complicated / confusing / hard to use +- 过于嘈杂:too loud / so loud / noise / noisy +- 功能多余:don't need / unnecessary / didn't ask for / useless feature +- 操作繁琐:takes too long / too many steps / annoying to clean +处理规则: +- 任一词组频次 ≥5 → 输出 reverse 类型条目并附 evidence +- 全部词组总频次 <5 → 必须输出 1 条 type=reverse 的占位条目,item 写「本品类暂无明确反向需求」,evidence 写「经主动搜索 [列出搜索词],共 N 条,低于阈值 5」 +``` + +--- + +## 【任务说明】→ 粘贴到 `kano.user_template` + +``` +## 任务:KANO 四象限分类 + +## 分类标准(含常见误判) +- 基本型 Must-be:P0 级差评 + 好评中几乎无人因「做到了 X」而表扬 + ❌ 误判:核心性能指标(好坏都会被提及)→ 期望型 + ✅ 正确:开箱即能用、供电正常、关键部件不脱落 +- 期望型 Performance:好评差评均出现,做得越好评分越高 + ❌ 误判:续航/容量 → 基本型(超长续航会被特别称赞) + ✅ 正确:核心效果、续航/容量、易清洁程度 +- 魅力型 Attractive:好评中出现 love/obsessed/amazing/didn't expect/bonus;差评中几乎不出现 + ❌ 误判:附赠配件 → 期望型(无人因缺该配件差评) + ✅ 正确:电量/状态显示、超预期配件、意外惊喜功能 +- 反向型 Reverse:用户主动抱怨某「功能」是负担(功能过载/太吵/太复杂) + ❌ 误判:产品损坏/充电故障 → 基本型(无人「希望产品损坏」) + +{{reverse_search_block}} + +## 操作步骤 +1. 基本型:从 P0/P1 差评主题出发,检查好评是否几乎无人表扬该点 +2. 期望型 vs 魅力型:差评有人因「不够好」→ 期望型;好评有 love/amazing 且竞品普遍缺失 → 魅力型 +3. 反向型:执行上方主动搜索,按规则输出 + +## 差评主题 +{{neg_themes_json}} +## 好评主题 +{{pos_themes_json}} +## Persona +{{personas_json}} +## 输出JSON +{"kano":[{"type":"must-be/performance/attractive/reverse","item":"单条需求(动词+名词,禁止用逗号/顿号合并多条)","evidence":"评论频次证据+代表性英文片段(≤80字)","affected_persona":"主要 Persona","reason":"≤60字,解释为何是该类型而非其他类型","competitor_status":"竞品是否满足及满足程度"}]} +## 硬性要求 +- 每条 item 只写一条需求,禁止在 item 中用逗号合并 +- 至少 8 条记录;must-be / performance / attractive 均需覆盖;reverse 按搜索规则输出(含占位条目) +- evidence 须含频次级别(如 P0/P1)或条数估计 + 英文原文片段 +- 质量缺陷、充电故障、配件脱落 → must-be,不是 reverse +- reverse 仅限用户主动排斥功能过载(too many parts / too complicated / too loud 等) +``` + +**占位符说明:** +- `{{reverse_search_block}}` — 自动插入上方「反向型主动搜索」 +- `{{neg_themes_json}}` / `{{pos_themes_json}}` / `{{personas_json}}` — 前几步分析结果(自动填入) + +--- + +# 4. JTBD 动机框架 + +**同步位置:** `prompts.yaml` → `jtbd` +**报告章节:** JTBD 动机框架 + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.3 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `jtbd.system` + +``` +你是 JTBD 分析专家,遵循方法论 4.2 节。 +为每个 Persona 构建 Jobs To Be Done 框架;所有动机字段须能从 Persona 的 keywords/core_pain/core_need/purchase_motivation 中找到语义佐证。 +无佐证时该字段填 "-"。必须覆盖全部 Persona。输出严格JSON。 +``` + +--- + +## 【任务说明】→ 粘贴到 `jtbd.user_template` + +``` +## 任务:为 {{persona_count}} 个 Persona 构建 JTBD +{{personas_json}} + +## 填写规则 +| 字段 | 规则 | +| core_job | 动词+宾语,用户想完成的任务;须与 Persona 数据语义一致 | +| functional_motivation | 实用层面驱动(效率/效果/成本/便携),禁止写情感词 | +| emotional_motivation | 情绪/心理驱动(自信/焦虑/掌控感/安心),禁止写功能词 | +| social_motivation | 他人视角/社交驱动(如送礼、伴侣评价、公开场合);无评论佐证填 "-" | +| trigger | 购买触发时机,须来自 Persona 数据中的具体事件描述;无佐证填 "-" | + +## 输出JSON +{"jtbd":[{"persona":"名称","core_job":"核心Job","functional_motivation":"功能性动机","emotional_motivation":"情感性动机","social_motivation":"社会性动机或-","trigger":"触发时机或-"}]} +## 硬性要求 +- 必须覆盖全部 {{persona_count}} 个 Persona,一人一行 +- functional 与 emotional 字段内容不可互换 +- 所有字段(含 core_job / trigger)须与对应 Persona 的 keywords / core_pain / core_need / purchase_motivation 语义一致 +- 无法从 Persona 数据找到佐证的字段一律填 "-",禁止编造评论中无依据的内容 +``` + +**占位符说明:** +- `{{persona_count}}` — Persona 个数(自动填入) +- `{{personas_json}}` — Persona 列表(自动填入) + +--- + +# 5. 人群 × 场景 × 需求矩阵 + +**同步位置:** `prompts.yaml` → `matrix` +**报告章节:** 人群 × 场景 × 需求矩阵 + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.3 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `matrix.system` + +``` +你是消费者洞察专家,构建人群×场景×需求矩阵,严格遵循方法论 4.3 节与 5.3 节场景规则。 +场景只允许填写评论中有原词佐证的描述;找不到佐证则不输出该行。 +输出严格JSON。全市场均分低时,细分场景「高满意度」须谨慎标注。 +场景示例仅说明格式;scene 须来自 Persona keywords 中的英文原词佐证,禁止照搬示例。 +``` + +--- + +## 【低分市场补充】→ 粘贴到 `matrix.market_low_hint` + +(仅当全市场加权均分 < 3.5 时自动插入) + +``` +## 重要:全市场加权均分 {{market_avg}}(<3.5),多数场景 satisfaction 应为「中等」或「低」,慎用「高」。 +``` + +--- + +## 【任务说明】→ 粘贴到 `matrix.user_template` + +``` +## 任务:为以下 Persona 构建矩阵(仅输出这些 Persona,最多 4 个) + +## 场景规则(方法论 5.3,核心规则) +【强制】scene 只允许填写 Persona 的 keywords / 聚类短语中能找到英文原词佐证的场景。 +禁止基于产品功能、品类常识或逻辑推断填写场景。 +- ≥5 条评论出现该场景词 → 可填写(如 travel / outdoor / kitchen / office — 以 top_phrases 为准) +- 2–4 条 → 不输出该行 +- <2 条 → 不输出该行(禁止输出 scene 为 — 的行) + +常见错误(禁止): +- ❌ 日常使用(daily 是使用频率非场景) +- ❌ 节日前突击(评论无对应原词) +- ❌ 任何场所(泛化代替留空) +正确示例:✅ 具体地点/情境(须在 keywords 中找到英文原词且 ≥5 条) + +## 需求列映射 +- must_be_needs:KANO 基本型 + 该群体 P0 差评主题名 +- performance_needs:KANO 期望型 + 该群体 P1 差评主题名 +- attractive_needs:KANO 魅力型 + 该群体好评加分点 + +## 满意度评级 +- 高:该群体均分参考 ≥4.0 +- 中等:3.3–3.9 +- 低:<3.3 + +## 每个 Persona 最多 2 个有效场景行 +{{market_hint}} +{{top_personas_json}} +{{kano_json}} +## 总评论数: {{total_reviews}} · 全市场均分: {{market_avg}} +## 输出JSON +{"matrix":[{"persona":"名称","pct":35,"scene":"具体场景(须有评论原词佐证)","must_be_needs":"基本型关键词","performance_needs":"期望型关键词","attractive_needs":"魅力型关键词","satisfaction":"高/中等/低","satisfaction_note":"≤25字"}]} +## 硬性要求 +- 禁止输出 scene 为 —、-、N/A 或空的行 +- 每个 Persona 最多 2 行;全报告最多 4 个 Persona +- must_be_needs / performance_needs / attractive_needs 各 ≤12 字,用顿号分隔关键词,禁止完整句子 +- must_be_needs 须使用差评主题中文名(来自当前批次主题,非示例) +- satisfaction_note ≤25 字 +``` + +**占位符说明:** +- `{{market_hint}}` — 低分市场时插入上方「低分市场补充」 +- `{{top_personas_json}}` / `{{kano_json}}` — Persona 与 KANO 数据(自动填入) +- `{{total_reviews}}` / `{{market_avg}}` — 评论总数与市场均分(自动填入) + +--- + +# 6. 痛点根因分析 + +**同步位置:** `prompts.yaml` → `rootcause` +**报告章节:** 痛点根因分析(按 Persona 展开) + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.4 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `rootcause.system` + +``` +你是产品工程与消费者洞察专家,遵循方法论 4.4 节。 +痛点根因分析须从现象到达机制层(非「质量差」类空话);开发方向须可落地。 +分析对象是当前品类主产品(见用户消息),禁止把其他品类工具问题当作本产品根因。 +输出严格JSON,使用中文。 +根因示例仅说明分析深度;title/mechanism/dev_direction 须针对当前产品与注入主题,禁止照搬示例。 +``` + +--- + +## 【任务说明】→ 粘贴到 `rootcause.user_template` + +``` +## 任务:{{persona_name}} 的痛点根因分析(2-3 条,针对 P0/P1 级痛点) + +## 产品范围:{{product_name}}(行业:{{industry}}) +## 分析边界:只分析该品类产品本身的结构/功能/体验缺陷 + +## 分析框架(每条根因) +根因标题 → 导致后果(关联差评主题×频次)→ 失效机制(从结构/材料/工作原理解释)→ 可落地改进 + +## 层次要求 +- ❌ 现象层:「产品质量差」「用户体验不好」 +- ✅ 机制层:「密封/接口设计不足导致进水腐蚀」「关键部件角度/间距不当导致效果未达预期」 + +{{persona_json}} +{{per_aud_data_json}} +## 可用差评主题名(affected_themes 只能从中选择) +{{theme_names_json}} +## 输出JSON +{"root_causes":[{"title":"根因标题(≤20字)","mechanism":"失效机制(≤120字,白话,禁止医学/化学术语堆砌)","quote_keywords":["用于匹配评论的英文词"],"dev_direction":"可落地改进(≤80字:结构/材料/工艺/说明/品控等)"}],"affected_themes":["主题名"]} +## 硬性要求 +- quotes 字段不要输出(引用由系统从真实评论回填) +- quote_keywords 每条根因 2-4 个英文词,须与 mechanism 直接相关 +- affected_themes 只能使用上方差评主题名,优先 P0/P1 主题 +- dev_direction 须针对 {{product_name}} 可改进点,禁止:传感器、纳米、AI、蓝牙等专业/科幻表述 +- 每个 Persona 最多 3 条 root_causes +``` + +**占位符说明:** +- `{{persona_name}}` / `{{product_name}}` / `{{industry}}` — 当前分析对象(自动填入) +- `{{persona_json}}` / `{{per_aud_data_json}}` / `{{theme_names_json}}` — 画像与主题数据(自动填入) + +--- + +# 7. 情感关键词 + +**同步位置:** `prompts.yaml` → `keyword` +**报告章节:** 情感关键词分析 + +| 参数 | 当前值 | +|------|--------| +| temperature | 0.3 | +| max_tokens | 8000 | + +--- + +## 【角色设定】→ 粘贴到 `keyword.system` + +``` +你是 VOC 文本分析专家,遵循方法论 3.4 节。 +对差评/好评词组做细粒度极性标注;meaning 只写评论中明确出现的语境,禁止推断。 +优先标注有决策价值的情感词组。输出严格JSON。 +示例仅说明标注方式;words/meaning 须来自输入词组与评论语境,禁止照搬示例。 +``` + +--- + +## 【任务说明】→ 粘贴到 `keyword.user_template` + +``` +## 任务:情感关键词分析(差评≤2★ / 好评≥4★ 双表,词组合并,count 按评论去重) +{{neg_groups_json}} +{{pos_groups_json}} +{{personas_json}} + +## 输入说明 +- 每条含 id、words(同义/近义词组)、count(至少命中组内一词的评论条数,已去重) +- 分别处理 negative_groups 与 positive_groups,输出 id 与输入一一对应 + +## 输出字段规则 +- id:与输入 id 一致 +- words:沿用输入词组 +- count:沿用输入 count,禁止改写 +- polarity:细粒度极性 + - 差评侧:强负面 / 负面 / 待观察 + - 好评侧:强正面 / 正面 / 魅力型信号 +- meaning:该词组在评论中的具体语境,只写评论明确出现的内容 +- related_personas:关联 Persona 名称,可多个 + +## 输出JSON +{"negative":[{"id":"neg_1","words":["cut","nick"],"count":42,"polarity":"强负面","meaning":"…","related_personas":[]}],"positive":[{"id":"pos_1","words":["smooth"],"count":30,"polarity":"强正面","meaning":"…","related_personas":[]}]} + +## 硬性要求 +- 各侧最多输出 15 条;跳过纯功能中性词组 +- meaning ≤40 字;禁止编造评论中未出现的场景或原因 +- 必须跳过 {{skip_hint}} +``` + +**占位符说明:** +- `{{neg_groups_json}}` — 差评词组(≤2★,自动填入) +- `{{pos_groups_json}}` — 好评词组(≥4★,自动填入) +- `{{personas_json}}` — Persona 列表(自动填入) +- `{{skip_hint}}` — 需跳过的停用词说明(自动填入,含产品名) + +--- + +# 附录:技术同事同步清单 + +改完本文件后,请技术同事按章节将内容复制到 `prompts.yaml`: + +| 本文件区块 | prompts.yaml 路径 | +|-----------|-------------------| +| 【角色设定】 | `xxx.system` | +| 【任务说明】 | `xxx.user_template` | +| 【差评专用补充】 | `theme.negative_extra` | +| 【反向型主动搜索】 | `kano.reverse_search_block` | +| 【低分市场补充】 | `matrix.market_low_hint` | + +同步完成后运行: + +```bash +cd voc_业务_2 +../310py/bin/python build_report.py --product "产品名" +``` + +--- + +*文档版本与 prompts.yaml meta.version 对齐:1.2 · 2026-06-12* diff --git a/匹配规则参考.py b/匹配规则参考.py new file mode 100644 index 0000000..cf9ebfc --- /dev/null +++ b/匹配规则参考.py @@ -0,0 +1,1647 @@ +#!/usr/bin/env python3 +""" +对「推理预测_明细_业务.csv」按词库指标列做分位数筛选。 + +示例: +# 查看 22 个可筛列的序号对照表 +python3 特征工程_建模/filter_infer_business.py --list-cols + +# 分位数:前 30%(默认 -p 30 --cols 1,3,9 --html-report) + -p 30 --cols 1,3,9 + +# 仅指定输入即可(使用上述默认参数) + -i .../asin_推理预测_明细_业务.csv + +# 临界值:与 --cols 一一对应(max 列 >= 临界值,min 列 <= 临界值,含边界) + --cols 1,3,9 --thresholds 500,0.05,1.2 + +# cols 分隔符:英文逗号、中文逗号、空格均可混用 +--cols "1,8 9" +--cols "1:min,9:max" # 覆盖默认方向 + +# 逐列串联(更严) +--logic seq + +# 强制保留核心词(与分位数结果取并集) +python3 特征工程_建模/filter_infer_business.py -i ...csv -p 20 --cols 1,8,9 \\ + --keep-keywords "turkey tail" + +python3 特征工程_建模/filter_infer_business.py \ + -i "/Users/onesvmwhoops/Cursor_Project/选品:爬数据/特征工程_建模/输出结果/B0G528HRJM/模型结果/B0G528HRJM_推理预测_明细_业务.xlsx"\ + -p 30 --cols 1,3,9 \ + --html-report + +# 筛选后生成词频 HTML(词频基于输入明细_业务表;默认 stem/lemma 并族,加 --freq-synonyms 启用 WordNet 同义词) +python3 特征工程_建模/filter_infer_business.py -i '.../B0G528HRJM_推理预测_明细_业务.xlsx' -p 30 --cols 1,3,9 --html-report +""" +from __future__ import annotations + +import argparse +import base64 +import html +import io +import json +import math +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Literal + +import pandas as pd + +Direction = Literal["max", "min"] +Logic = Literal["and", "seq"] + +# (列名, 默认方向):越大越好 max,越小越好 min +FILTERABLE_COLUMNS: list[tuple[str, Direction]] = [ + ("周搜索量", "max"), + ("周点击量", "max"), + ("周点击率", "max"), + ("曝光点击率", "max"), + ("周销售量", "max"), + ("搜索转化率", "max"), + ("点击转化率", "max"), + ("CPC竞价-最低", "min"), + ("CPC竞价-平均", "min"), + ("CPC竞价-最高", "min"), + ("目标排位建议-90%", "min"), + ("目标排位建议-50%", "min"), + ("CPR", "min"), + ("ABA排名", "min"), + ("ABA排名涨跌幅", "max"), + ("关键词产品数", "min"), + ("推广CPA", "min"), + ("推广成本", "min"), + ("广告点击转化率", "max"), + ("TOP产品的均价", "min"), + ("ABA TOP 3 ASIN点击占比", "min"), + ("ABA TOP 3 ASIN转化占比", "min"), +] + +_COLS_SPLIT_RE = re.compile(r"[,,\s]+") +_KEYWORD_COL_CANDIDATES = ("keyword", "关键词") + + +def _pip_install(package: str) -> None: + print(f"[依赖] 正在安装 {package} …", flush=True) + subprocess.check_call( + [sys.executable, "-m", "pip", "install", package, "-q"], + stdout=subprocess.DEVNULL, + ) + + +def _ensure_nltk(): + try: + import nltk + except ImportError: + _pip_install("nltk") + import nltk # noqa: F401 + import nltk + from nltk.corpus import wordnet as wn + from nltk.stem import PorterStemmer, WordNetLemmatizer + from nltk.tag import pos_tag + from nltk.tokenize import word_tokenize + + for resource, pkg in ( + ("tokenizers/punkt", "punkt"), + ("tokenizers/punkt_tab", "punkt_tab"), + ("corpora/wordnet", "wordnet"), + ("corpora/omw-1.4", "omw-1.4"), + ("taggers/averaged_perceptron_tagger", "averaged_perceptron_tagger"), + ("taggers/averaged_perceptron_tagger_eng", "averaged_perceptron_tagger_eng"), + ("corpora/stopwords", "stopwords"), + ): + try: + nltk.data.find(resource) + except LookupError: + print(f"[依赖] 正在下载 NLTK 数据 {pkg} …", flush=True) + nltk.download(pkg, quiet=True) + + return word_tokenize, pos_tag, WordNetLemmatizer(), PorterStemmer(), wn + + +def _penn_to_wn_pos(tag: str, wn) -> str: + if tag.startswith("J"): + return wn.ADJ + if tag.startswith("V"): + return wn.VERB + if tag.startswith("N"): + return wn.NOUN + if tag.startswith("R"): + return wn.ADV + return wn.NOUN + + +def parse_keep_keywords(spec: str) -> list[str]: + text = (spec or "").strip() + if not text: + return [] + seen: set[str] = set() + out: list[str] = [] + for part in _COLS_SPLIT_RE.split(text): + w = part.strip().lower() + if w and w not in seen: + seen.add(w) + out.append(w) + return out + + +def resolve_keyword_column(df: pd.DataFrame) -> str: + for col in _KEYWORD_COL_CANDIDATES: + if col in df.columns: + return col + raise ValueError( + f"输入表缺少关键词列(需要其一:{_KEYWORD_COL_CANDIDATES})" + ) + + +_CLICKS_COL = "周点击量" +DEFAULT_FILTER_PCT = 30.0 +DEFAULT_FILTER_COLS = "1,3,9" +_HTML_TOP_DEFAULT = 100 +# 输入明细_业务表:全量词库相对排序前 20%(与 train_eval 业务明细一致) +_HTML_LIBRARY_RANK_PCT = 20.0 +# 相对排序表展示:在前 10% 池中再取 rank_pred 前 10% +_HTML_RANK_PRED_PCT = 10.0 + +# 英文停用词:NLTK english + 常见无分析价值 token +_EXTRA_STOP_WORDS = frozenset( + { + "www", + "http", + "https", + "com", + "amazon", + "asin", + "sku", + "oz", + "lb", + "lbs", + "inch", + "inches", + "ft", + "mm", + "cm", + "ml", + "kg", + "pcs", + "pc", + } +) + + +def load_stop_words() -> set[str]: + _ensure_nltk() + import nltk + from nltk.corpus import stopwords + + try: + words = set(stopwords.words("english")) + except LookupError: + nltk.download("stopwords", quiet=True) + words = set(stopwords.words("english")) + words |= _EXTRA_STOP_WORDS + return words + + +class WordVariantEngine: + """NLTK 分词 + 词形变体(stem / POS+lemma / 可选 WordNet 同义词)。""" + + def __init__(self) -> None: + word_tokenize, pos_tag, lemmatizer, stemmer, wn = _ensure_nltk() + self._word_tokenize = word_tokenize + self._pos_tag = pos_tag + self._lemmatizer = lemmatizer + self._stemmer = stemmer + self._wn = wn + self._synonym_pool_cache: dict[str, set[str]] = {} + + def variants_for_word(self, word: str, pos: str | None = None) -> set[str]: + w = (word or "").lower().strip() + if not w: + return set() + out: set[str] = {w, self._stemmer.stem(w)} + if pos is not None: + wn_pos = _penn_to_wn_pos(pos, self._wn) + out.add(self._lemmatizer.lemmatize(w, pos=wn_pos)) + for p in (self._wn.NOUN, self._wn.VERB, self._wn.ADJ, self._wn.ADV): + out.add(self._lemmatizer.lemmatize(w, pos=p)) + return {x for x in out if x} + + def _synonym_variant_pool(self, word: str) -> set[str]: + w = (word or "").lower().strip() + if not w: + return set() + if w in self._synonym_pool_cache: + return self._synonym_pool_cache[w] + extra: set[str] = set() + for syn in self._wn.synsets(w): + for lemma in syn.lemmas(): + name = lemma.name().replace("_", " ").lower() + if name: + extra |= self.variants_for_word(name, pos=None) + self._synonym_pool_cache[w] = extra + return extra + + def variant_pool( + self, word: str, pos: str | None = None, *, include_synonyms: bool = False + ) -> set[str]: + """stem + lemma;include_synonyms 时再并入 WordNet 同义词 lemma/stem。""" + pool = self.variants_for_word(word, pos=pos) + if include_synonyms: + pool |= self._synonym_variant_pool(word) + return pool + + def tokenize_tagged(self, text: str) -> list[tuple[str, str]]: + try: + tokens = self._word_tokenize(str(text).lower()) + return self._pos_tag(tokens) + except Exception: + tokens = re.findall(r"[a-z0-9']+", str(text).lower()) + return [(t, "NN") for t in tokens] + + def text_variant_set(self, text: str) -> set[str]: + variants: set[str] = set() + for tok, tag in self.tokenize_tagged(text): + if tok: + variants |= self.variants_for_word(tok, pos=tag) + return variants + + +class _UnionFind: + def __init__(self) -> None: + self._parent: dict[str, str] = {} + + def add(self, x: str) -> None: + if x not in self._parent: + self._parent[x] = x + + def find(self, x: str) -> str: + self.add(x) + while self._parent[x] != x: + self._parent[x] = self._parent[self._parent[x]] + x = self._parent[x] + return x + + def union(self, a: str, b: str) -> None: + ra, rb = self.find(a), self.find(b) + if ra != rb: + self._parent[rb] = ra + + def groups(self) -> dict[str, list[str]]: + out: dict[str, list[str]] = defaultdict(list) + for x in self._parent: + out[self.find(x)].append(x) + return dict(out) + + +def build_word_freq_groups( + df: pd.DataFrame, + *, + top_n: int = _HTML_TOP_DEFAULT, + stop_words: set[str] | None = None, + include_synonyms: bool = False, +) -> list[dict]: + """拆词 → 停用词过滤 → Union-Find 合并词族(stem/lemma/可选同义词)→ 按周点击量 Top N。""" + if df.empty: + return [] + + kw_col = resolve_keyword_column(df) + if _CLICKS_COL not in df.columns: + raise ValueError(f"输入表缺少列 {_CLICKS_COL!r},无法生成词频报告") + + stops = stop_words if stop_words is not None else load_stop_words() + engine = WordVariantEngine() + uf = _UnionFind() + surface_clicks: dict[str, float] = defaultdict(float) + variant_index: dict[str, set[str]] = defaultdict(set) + + for _, row in df.iterrows(): + kw = row[kw_col] + if pd.isna(kw) or not str(kw).strip(): + continue + clicks = pd.to_numeric(row[_CLICKS_COL], errors="coerce") + if pd.isna(clicks): + clicks = 0.0 + clicks_f = float(clicks) + + seen_in_row: set[str] = set() + for tok, tag in engine.tokenize_tagged(str(kw)): + if not tok or len(tok) < 1 or tok in stops or not re.search(r"[a-z]", tok): + continue + if tok in seen_in_row: + continue + seen_in_row.add(tok) + uf.add(tok) + surface_clicks[tok] += clicks_f + variants = engine.variant_pool(tok, pos=tag, include_synonyms=include_synonyms) + related: set[str] = set() + for v in variants: + related |= variant_index[v] + for other in related: + uf.union(tok, other) + for v in variants: + variant_index[v].add(tok) + + raw_groups = uf.groups() + families: list[dict] = [] + for members in raw_groups.values(): + members_sorted = sorted(members) + total = sum(surface_clicks[m] for m in members_sorted) + member_details = [ + {"surface": m, "clicks": round(surface_clicks[m], 2)} + for m in sorted(members_sorted, key=lambda x: (-surface_clicks[x], x)) + ] + label = member_details[0]["surface"] if member_details else members_sorted[0] + families.append( + { + "label": label, + "total": round(total, 2), + "members": member_details, + } + ) + + families.sort(key=lambda x: (-x["total"], x["label"])) + return families[: max(1, int(top_n))] + + +def default_html_report_path(output_table_path: Path, *paths: Path | None) -> Path: + title = infer_html_report_title(*paths, output_table_path) + return output_table_path.with_name(f"{title}.html") + + +_ASIN_STEM_RE = re.compile(r"^(B0[A-Z0-9]{8,})", re.I) + + +def infer_html_report_title(*paths: Path | None) -> str: + """从输出/输入路径提取 ASIN,生成如 B0G528HRJM_广告词推荐。""" + for path in paths: + if path is None: + continue + stem = path.stem.replace("_筛选", "").replace("_词频", "").replace("_广告词推荐", "") + m = _ASIN_STEM_RE.match(stem) + if m: + return f"{m.group(1).upper()}_广告词推荐" + return "asin_广告词推荐" + + +def filter_top_rank_pred( + df: pd.DataFrame, + top_pct: float, + *, + by_model: bool = True, +) -> pd.DataFrame: + """按 model(若有)保留 rank_pred 最小(预测最优)的前 top_pct% 行。""" + if df.empty or "rank_pred" not in df.columns: + return df.iloc[0:0].copy() + pct = max(0.0, min(100.0, float(top_pct))) + if by_model and "model" in df.columns: + parts: list[pd.DataFrame] = [] + for _, group in df.groupby("model", sort=False): + k = max(1, int(math.ceil(len(group) * pct / 100.0))) + parts.append(group.nsmallest(k, "rank_pred")) + return pd.concat(parts, ignore_index=True) + k = max(1, int(math.ceil(len(df) * pct / 100.0))) + return df.nsmallest(k, "rank_pred").copy() + + +def build_filter_tooltip( + col_specs: list[tuple[str, Direction]], + *, + pct: float | None = None, + thresholds: list[float] | None = None, + logic: str = "and", +) -> str: + pool = f"全量词库相对排序前 {_HTML_LIBRARY_RANK_PCT:g}% 的关键词" + if thresholds is not None: + spec = _format_col_threshold_specs(col_specs, thresholds) + return f"在{pool}中,按临界值筛选:{spec}(logic={logic})" + parts: list[str] = [] + p = pct if pct is not None else 20.0 + for name, direction in col_specs: + hint = "越大越好" if direction == "max" else "越小越好" + parts.append(f"{name}({hint},前 {p:g}%)") + joiner = " 且 " if logic == "and" else " → " + return f"1️⃣在{pool}中,按指标筛选:{joiner.join(parts)}" + + +def _dataframe_to_download_bytes(df: pd.DataFrame, suffix: str) -> bytes: + ext = suffix.lower() + if ext in (".xlsx", ".xls"): + buf = io.BytesIO() + df.to_excel(buf, index=False, engine="openpyxl") + return buf.getvalue() + return df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig") + + +def _download_mime(suffix: str) -> str: + ext = suffix.lower() + if ext == ".xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + if ext == ".xls": + return "application/vnd.ms-excel" + return "text/csv" + + +def _serialize_table_for_html(df: pd.DataFrame) -> str: + """将 DataFrame 序列化为 HTML 内嵌 JSON(供可排序表格渲染)。""" + out = df.copy() + for col in out.columns: + if pd.api.types.is_datetime64_any_dtype(out[col]): + out[col] = out[col].astype(str) + out = out.where(pd.notna(out), None) + payload = { + "columns": [str(c) for c in out.columns], + "rows": out.values.tolist(), + } + return json.dumps(payload, ensure_ascii=False, default=str) + + +def render_word_freq_html( + groups: list[dict], + *, + embed_path: Path, + title: str, + row_count: int, + filtered_df: pd.DataFrame | None = None, + original_rank_df: pd.DataFrame | None = None, + input_download_df: pd.DataFrame | None = None, + filter_tooltip: str = "", +) -> str: + """单文件 HTML:词频图 + 词频/原始/筛选表格 + 底部双下载。""" + suffix = embed_path.suffix.lower() + out_df = filtered_df if filtered_df is not None else pd.DataFrame() + out_bytes = _dataframe_to_download_bytes(out_df, suffix) + out_mime = _download_mime(suffix) + out_b64 = base64.b64encode(out_bytes).decode("ascii") + out_download_name = html.escape(embed_path.name) + + in_df = input_download_df if input_download_df is not None else pd.DataFrame() + in_bytes = _dataframe_to_download_bytes(in_df, suffix) + in_b64 = base64.b64encode(in_bytes).decode("ascii") + in_stem = embed_path.stem.replace("_筛选", "") + in_download_name = html.escape( + f"{in_stem}_原始相对排序前20%{embed_path.suffix}" + ) + + input_tooltip = html.escape( + f"全量词库相对排序前 {_HTML_RANK_PRED_PCT:g}%(," + f"取 相对排序 最优前 {_HTML_RANK_PRED_PCT:g}%)" + ) + filter_tooltip_esc = html.escape(filter_tooltip or "按指定指标筛选") + + chart_data = json.dumps(groups, ensure_ascii=False) + labels = [g["label"] for g in groups] + totals = [g["total"] for g in groups] + displayed = len(groups) + title_esc = html.escape(title) + filtered_json = ( + _serialize_table_for_html(filtered_df) + if filtered_df is not None + else '{"columns":[],"rows":[]}' + ) + original_json = ( + _serialize_table_for_html(original_rank_df) + if original_rank_df is not None + else '{"columns":[],"rows":[]}' + ) + return f""" + + + + +{title_esc} + + + + +
+ +
+

按指定指标筛选结果 {row_count} 行

+

{filter_tooltip_esc}

+

2️⃣「搜索量大、点击率高、平均 CPC 低、相对排序靠前」,分别说明「曝光量高、点击量高、市场竞争小、转化率高」的可能性较大。

+

点击表头可排序;表格区域可上下滚动浏览全部行。

+
+ + + +
+
+
+
+

词频统计

+

统计对象:全量词库相对排序前 {_HTML_LIBRARY_RANK_PCT:g}% 的关键词(按周点击量汇总词频,可查看词族内各原词形及对应周点击量,高点击的词族更值得关注)

+
+
+
+

柱图可左右拖动/滚轮缩放;悬停柱体可查看词族内各原词形及对应周点击量。

+

词频明细 {displayed} 个词族

+

按周点击量合计降序;表格区域可上下滚动浏览全部词族。

+
+ + + + + + + + + + + +
#词族周点击量合计词形数词形明细
+
+
+
+

全量词库相对排序前 {_HTML_RANK_PRED_PCT:g}% 关键词展示

+

在全量词库相对排序里前 {_HTML_RANK_PRED_PCT:g}% 的关键词,这些关键词的点击转化率较高的可能性较大。⚠️防止遗漏不满足筛选条件的高转化小词

+

点击表头可排序,可上下滚动。

+
+ + + +
+
+
+
+

数据下载

+ + +
+
+ + +""" + + +def write_word_freq_html_report( + filtered_df: pd.DataFrame, + input_df: pd.DataFrame, + embed_path: Path, + html_path: Path, + *, + input_path: Path | None = None, + top_n: int = _HTML_TOP_DEFAULT, + include_synonyms: bool = False, + filter_tooltip: str = "", +) -> tuple[Path, list[str]]: + groups = build_word_freq_groups( + input_df, top_n=top_n, include_synonyms=include_synonyms + ) + displayed = len(groups) + original_rank_df = filter_top_rank_pred( + input_df, _HTML_RANK_PRED_PCT, by_model=True + ) + input_download_df = input_df.copy() + title = infer_html_report_title(input_path, embed_path) + content = render_word_freq_html( + groups, + embed_path=embed_path, + title=title, + row_count=len(filtered_df), + filtered_df=filtered_df, + original_rank_df=original_rank_df, + input_download_df=input_download_df, + filter_tooltip=filter_tooltip, + ) + html_path.parent.mkdir(parents=True, exist_ok=True) + html_path.write_text(content, encoding="utf-8") + merge_desc = "stem/lemma/WordNet 同义词" if include_synonyms else "stem/lemma" + cap_note = f"Top {top_n}" if displayed >= top_n else f"共 {displayed} 个(不足 {top_n},已全部展示)" + logs = [ + f"[词频] 基于输入明细_业务表 {len(input_df)} 行 · 词族 {displayed} 个({cap_note})· {merge_desc} 并族", + f" 相对排序前 {_HTML_RANK_PRED_PCT:g}% 关键词表 {len(original_rank_df)} 行 · 已内嵌下载", + f" HTML 含词频/原始/筛选三表 + 底部双下载 · 已内嵌 {embed_path.name}", + ] + return html_path, logs + + +class KeepKeywordMatcher: + """核心词强制保留:子串 | POS+lemma | stem | WordNet 同义词(OR,无 embedding)。""" + + def __init__(self, terms: list[str]) -> None: + if not terms: + raise ValueError("核心词列表为空") + self.terms = terms + self._engine = WordVariantEngine() + + self._core_pools: list[set[str]] = [] + for term in terms: + self._core_pools.append( + self._engine.variant_pool(term, pos=None, include_synonyms=True) + ) + + def matches(self, text: str) -> bool: + if not text or not str(text).strip(): + return False + lowered = str(text).lower() + for term in self.terms: + if term in lowered: + return True + text_vars = self._engine.text_variant_set(lowered) + return any(text_vars & pool for pool in self._core_pools) + + def mask(self, series: pd.Series) -> pd.Series: + return series.map(self.matches) + + +def apply_keep_keywords( + df: pd.DataFrame, + filtered: pd.DataFrame, + terms: list[str], +) -> tuple[pd.DataFrame, list[str]]: + kw_col = resolve_keyword_column(df) + matcher = KeepKeywordMatcher(terms) + pin_mask = matcher.mask(df[kw_col]) + pinned = df[pin_mask] + filtered_idx = set(filtered.index) + rescued = int(sum(1 for idx in pinned.index if idx not in filtered_idx)) + merged = pd.concat([filtered, pinned]).drop_duplicates() + logs = [ + "[保留] 核心词 " + f"{terms!r}(子串 / POS+lemma / stem / WordNet 同义词)", + f" 命中 {len(pinned)} 行,救回 {rescued} 行", + f" 筛选结果 {len(filtered)} 行 → 合并后 {len(merged)} 行", + ] + return merged, logs + + +def print_column_catalog() -> None: + print("可筛选列序号对照表(--cols 填序号;可选 序号:max 或 序号:min 覆盖默认方向):\n") + print(f"{'序号':>4} {'方向':<4} 列名") + print("-" * 48) + for i, (name, direction) in enumerate(FILTERABLE_COLUMNS, start=1): + hint = "越大越好" if direction == "max" else "越小越好" + print(f"{i:>4} {direction:<4} {name} ({hint})") + print("\n--cols 示例:1,8,9 | 1 8 9 | 1,8,9 | 1:min,9:max") + print("--thresholds 示例(与 --cols 个数、顺序一致):500,0.05,1.2 | 500 0.05 1.2") + + +def resolve_column(index: int, direction_override: Direction | None) -> tuple[str, Direction]: + if index < 1 or index > len(FILTERABLE_COLUMNS): + raise ValueError( + f"无效序号 {index},允许 1–{len(FILTERABLE_COLUMNS)}(用 --list-cols 查看)" + ) + name, default_dir = FILTERABLE_COLUMNS[index - 1] + direction = direction_override or default_dir + if direction not in ("max", "min"): + raise ValueError(f"无效方向 {direction!r},仅支持 max / min") + return name, direction + + +def parse_cols_spec(spec: str) -> list[tuple[str, Direction]]: + """解析 --cols:支持逗号、中文逗号、空格分隔;项可为 序号 或 序号:max/min。""" + text = (spec or "").strip() + if not text: + raise ValueError("--cols 不能为空") + parts = [p for p in _COLS_SPLIT_RE.split(text) if p.strip()] + if not parts: + raise ValueError("--cols 解析后为空") + + out: list[tuple[str, Direction]] = [] + seen: set[str] = set() + for part in parts: + direction_override: Direction | None = None + if ":" in part: + idx_str, dir_str = part.split(":", 1) + direction_override = dir_str.strip().lower() # type: ignore[assignment] + if direction_override not in ("max", "min"): + raise ValueError(f"无效方向 {dir_str!r}(在 {part!r} 中)") + else: + idx_str = part + try: + index = int(idx_str.strip()) + except ValueError as e: + raise ValueError(f"无法解析列序号:{part!r}") from e + name, direction = resolve_column(index, direction_override) + if name in seen: + continue + seen.add(name) + out.append((name, direction)) + return out + + +def mask_top_pct(series: pd.Series, direction: Direction, pct: float) -> pd.Series: + """保留该列「最好的前 pct%」行(max:大值;min:小值)。""" + s = pd.to_numeric(series, errors="coerce") + valid = s.notna() + if not valid.any(): + return pd.Series(False, index=series.index) + + q = max(0.0, min(100.0, float(pct))) / 100.0 + if direction == "max": + thr = s[valid].quantile(1.0 - q) + return valid & (s >= thr) + thr = s[valid].quantile(q) + return valid & (s <= thr) + + +def threshold_for_column(series: pd.Series, direction: Direction, pct: float) -> float | None: + s = pd.to_numeric(series, errors="coerce").dropna() + if s.empty: + return None + q = max(0.0, min(100.0, float(pct))) / 100.0 + if direction == "max": + return float(s.quantile(1.0 - q)) + return float(s.quantile(q)) + + +def filter_group( + df: pd.DataFrame, + cols: list[tuple[str, Direction]], + pct: float, + logic: Logic, +) -> tuple[pd.DataFrame, list[str]]: + """对单个 DataFrame 筛选,返回 (结果, 日志行)。""" + if df.empty: + return df, [] + + logs: list[str] = [] + if logic == "and": + mask = pd.Series(True, index=df.index) + for col, direction in cols: + if col not in df.columns: + raise ValueError(f"输入 CSV 缺少列 {col!r},请确认词库已 join 或换 --cols") + col_mask = mask_top_pct(df[col], direction, pct) + thr = threshold_for_column(df[col], direction, pct) + op = ">=" if direction == "max" else "<=" + thr_s = f"{thr:.6g}" if thr is not None else "N/A" + logs.append( + f" - {col} ({direction} top {pct:g}%): {op} {thr_s},保留 {int(col_mask.sum())} 行" + ) + mask &= col_mask + return df[mask].copy(), logs + + # logic == "seq" + current = df + for col, direction in cols: + if col not in current.columns: + raise ValueError(f"输入 CSV 缺少列 {col!r},请确认词库已 join 或换 --cols") + col_mask = mask_top_pct(current[col], direction, pct) + thr = threshold_for_column(current[col], direction, pct) + op = ">=" if direction == "max" else "<=" + thr_s = f"{thr:.6g}" if thr is not None else "N/A" + kept = int(col_mask.sum()) + logs.append( + f" - {col} ({direction} top {pct:g}%): {op} {thr_s}," + f"本步 {len(current)} → {kept} 行" + ) + current = current[col_mask].copy() + return current, logs + + +def filter_infer_business( + df: pd.DataFrame, + cols: list[tuple[str, Direction]], + pct: float, + *, + logic: Logic = "and", + by_model: bool = False, +) -> tuple[pd.DataFrame, list[str]]: + logs: list[str] = [] + if by_model and "model" in df.columns: + parts: list[pd.DataFrame] = [] + for model_name, group in df.groupby("model", sort=False): + sub, sub_logs = filter_group(group, cols, pct, logic) + logs.append(f"[model={model_name}] {len(group)} → {len(sub)} 行") + logs.extend(sub_logs) + parts.append(sub) + out = pd.concat(parts, ignore_index=True) if parts else df.iloc[0:0].copy() + return out, logs + + out, sub_logs = filter_group(df, cols, pct, logic) + logs.extend(sub_logs) + return out, logs + + +def parse_thresholds(spec: str) -> list[float]: + """解析 --thresholds,分隔符与 --cols 相同。""" + text = (spec or "").strip() + if not text: + return [] + parts = [p for p in _COLS_SPLIT_RE.split(text) if p.strip()] + if not parts: + raise ValueError("--thresholds 解析后为空") + out: list[float] = [] + for part in parts: + try: + out.append(float(part.strip())) + except ValueError as e: + raise ValueError(f"无法解析临界值:{part!r}") from e + return out + + +def mask_by_threshold( + series: pd.Series, direction: Direction, threshold: float +) -> pd.Series: + """max:>= 临界值;min:<= 临界值(含边界);NaN 不保留。""" + s = pd.to_numeric(series, errors="coerce") + valid = s.notna() + if direction == "max": + return valid & (s >= threshold) + return valid & (s <= threshold) + + +def filter_group_threshold( + df: pd.DataFrame, + col_specs: list[tuple[str, Direction]], + thresholds: list[float], + logic: Logic, +) -> tuple[pd.DataFrame, list[str]]: + if df.empty: + return df, [] + if len(col_specs) != len(thresholds): + raise ValueError( + f"--cols 与 --thresholds 数量不一致:{len(col_specs)} 列 vs {len(thresholds)} 个临界值" + ) + + logs: list[str] = [] + if logic == "and": + mask = pd.Series(True, index=df.index) + for (col, direction), thr in zip(col_specs, thresholds): + if col not in df.columns: + raise ValueError(f"输入表缺少列 {col!r},请确认词库已 join 或换 --cols") + col_mask = mask_by_threshold(df[col], direction, thr) + op = ">=" if direction == "max" else "<=" + logs.append( + f" - {col} ({direction} {op} {thr:g}):满足 {int(col_mask.sum())} 行" + ) + mask &= col_mask + kept = int(mask.sum()) + logs.append(f" - 同时满足:{kept} 行") + return df[mask].copy(), logs + + current = df + for (col, direction), thr in zip(col_specs, thresholds): + if col not in current.columns: + raise ValueError(f"输入表缺少列 {col!r},请确认词库已 join 或换 --cols") + col_mask = mask_by_threshold(current[col], direction, thr) + op = ">=" if direction == "max" else "<=" + kept = int(col_mask.sum()) + logs.append( + f" - {col} ({direction} {op} {thr:g}):" + f"本步 {len(current)} → {kept} 行" + ) + current = current[col_mask].copy() + return current, logs + + +def filter_infer_business_threshold( + df: pd.DataFrame, + col_specs: list[tuple[str, Direction]], + thresholds: list[float], + *, + logic: Logic = "and", + by_model: bool = False, +) -> tuple[pd.DataFrame, list[str]]: + logs: list[str] = [] + if by_model and "model" in df.columns: + parts: list[pd.DataFrame] = [] + for model_name, group in df.groupby("model", sort=False): + sub, sub_logs = filter_group_threshold( + group, col_specs, thresholds, logic + ) + logs.append(f"[model={model_name}] {len(group)} → {len(sub)} 行") + logs.extend(sub_logs) + parts.append(sub) + out = pd.concat(parts, ignore_index=True) if parts else df.iloc[0:0].copy() + return out, logs + + out, sub_logs = filter_group_threshold(df, col_specs, thresholds, logic) + logs.extend(sub_logs) + return out, logs + + +def default_output_path(input_path: Path) -> Path: + return input_path.with_name(f"{input_path.stem}_筛选{input_path.suffix}") + + +def read_business_table(path: Path) -> pd.DataFrame: + """读取业务明细表(.csv / .xlsx / .xls)。""" + suffix = path.suffix.lower() + if suffix in (".csv", ".txt", ".tsv"): + return pd.read_csv(path, encoding="utf-8-sig") + if suffix in (".xlsx", ".xls"): + return pd.read_excel(path, engine="openpyxl") + raise ValueError(f"不支持的输入格式:{suffix}(请用 .csv / .xlsx / .xls)") + + +def write_business_table(df: pd.DataFrame, path: Path) -> None: + suffix = path.suffix.lower() + if suffix in (".csv", ".txt", ".tsv"): + df.to_csv(path, index=False, encoding="utf-8-sig") + return + if suffix in (".xlsx", ".xls"): + df.to_excel(path, index=False, engine="openpyxl") + return + raise ValueError(f"不支持的输出格式:{suffix}(请用 .csv / .xlsx / .xls)") + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="对推理预测_明细_业务表筛选:分位数(-p)或固定临界值(--thresholds)" + ) + p.add_argument( + "-i", + "--input", + type=Path, + default=None, + help="业务 CSV/xlsx 路径", + ) + p.add_argument( + "-o", + "--output", + type=Path, + default=None, + help="输出路径;默认在同目录生成 *_筛选(扩展名与输入一致)", + ) + p.add_argument( + "-p", + "--pct", + type=float, + default=DEFAULT_FILTER_PCT, + help=f"分位数模式:保留每列最好的前 a%%;与 --thresholds 二选一,默认 {DEFAULT_FILTER_PCT:g}", + ) + p.add_argument( + "-c", + "--cols", + default=DEFAULT_FILTER_COLS, + help=f"列序号,逗号/中文逗号/空格分隔;可写 1:max,9:min 覆盖方向(默认 {DEFAULT_FILTER_COLS})", + ) + p.add_argument( + "-t", + "--thresholds", + default="", + help="临界值模式:与 --cols 个数、顺序一致;max 列>=临界值,min 列<=临界值", + ) + p.add_argument( + "--logic", + choices=("and", "seq"), + default="and", + help="and:各列阈值同时满足;seq:逐列串联筛选(默认 and)", + ) + p.add_argument( + "--by-model", + action="store_true", + help="若存在 model 列,则每个 model 内分别筛选再合并", + ) + p.add_argument( + "--list-cols", + action="store_true", + help="打印可筛选列序号对照表后退出", + ) + p.add_argument( + "-k", + "--keep-keywords", + default="", + help="核心词强制保留(逗号/中文逗号/空格分隔),与筛选结果取并集", + ) + p.add_argument( + "--html-report", + action=argparse.BooleanOptionalAction, + default=True, + help="筛选完成后生成词频 HTML(默认开启;--no-html-report 关闭)", + ) + p.add_argument( + "--html-top", + type=int, + default=_HTML_TOP_DEFAULT, + help=f"词频 HTML 展示词族数量(默认 {_HTML_TOP_DEFAULT})", + ) + p.add_argument( + "--html-output", + type=Path, + default=None, + help="词频 HTML 输出路径;默认 {ASIN}_广告词推荐.html", + ) + p.add_argument( + "--freq-synonyms", + action="store_true", + help="词频并族时启用 WordNet 同义词(默认仅 stem/lemma 并族)", + ) + return p.parse_args() + + +def _format_col_threshold_specs( + col_specs: list[tuple[str, Direction]], + thresholds: list[float] | None = None, +) -> str: + parts: list[str] = [] + for i, (name, direction) in enumerate(col_specs): + if thresholds is not None: + thr = thresholds[i] + op = ">=" if direction == "max" else "<=" + parts.append(f"{name}({direction} {op} {thr:g})") + else: + parts.append(f"{name}({direction})") + return ", ".join(parts) + + +def run_filter_infer_business( + input_path: Path | str, + *, + pct: float | None = DEFAULT_FILTER_PCT, + cols: str = DEFAULT_FILTER_COLS, + thresholds: str = "", + logic: Logic = "and", + by_model: bool = False, + keep_keywords: str = "", + html_report: bool = True, + html_top: int = _HTML_TOP_DEFAULT, + output: Path | None = None, + html_output: Path | None = None, + freq_synonyms: bool = False, + skip_if_missing_vocab_cols: bool = True, +) -> tuple[int, Path | None, Path | None]: + """ + 对「推理预测_明细_业务」筛选并可选生成词频 HTML。 + + 默认等价于:-p 30 --cols 1,3,9 --html-report + 返回 (exit_code, 筛选表路径, HTML路径);跳过时后两者为 None。 + """ + input_path = Path(input_path).expanduser().resolve() + if not input_path.is_file(): + print(f"[失败] 找不到文件:{input_path}", file=sys.stderr) + return 1, None, None + + use_thresholds = bool((thresholds or "").strip()) + if use_thresholds and pct is not None: + print("[提示] 已指定 --thresholds,忽略 --pct,使用临界值模式。", file=sys.stderr) + + try: + col_specs = parse_cols_spec(cols) + except ValueError as e: + print(f"[失败] {e}", file=sys.stderr) + print("提示:运行 --list-cols 查看序号。", file=sys.stderr) + return 1, None, None + + threshold_values: list[float] = [] + if use_thresholds: + try: + threshold_values = parse_thresholds(thresholds) + except ValueError as e: + print(f"[失败] {e}", file=sys.stderr) + return 1, None, None + if len(threshold_values) != len(col_specs): + print( + f"[失败] --cols 有 {len(col_specs)} 列,--thresholds 有 {len(threshold_values)} 个值," + "须一一对应。", + file=sys.stderr, + ) + return 1, None, None + else: + pct_val = DEFAULT_FILTER_PCT if pct is None else float(pct) + if not (0 < pct_val <= 100): + print("[失败] --pct 须在 (0, 100] 内,或使用 --thresholds。", file=sys.stderr) + return 1, None, None + pct = pct_val + + try: + df = read_business_table(input_path) + except ValueError as e: + print(f"[失败] {e}", file=sys.stderr) + return 1, None, None + except ImportError: + print( + "[失败] 读取 xlsx 需要 openpyxl:pip install openpyxl", + file=sys.stderr, + ) + return 1, None, None + + if skip_if_missing_vocab_cols: + missing = [name for name, _ in col_specs if name not in df.columns] + if missing: + print( + f"[业务筛选] 跳过:输入表缺少词库列 {missing}," + "请确认词库已 join 后再筛选。", + file=sys.stderr, + ) + return 0, None, None + + before = len(df) + + try: + if use_thresholds: + filtered, logs = filter_infer_business_threshold( + df, + col_specs, + threshold_values, + logic=logic, + by_model=by_model, + ) + else: + filtered, logs = filter_infer_business( + df, + col_specs, + float(pct), # type: ignore[arg-type] + logic=logic, + by_model=by_model, + ) + except ValueError as e: + print(f"[失败] {e}", file=sys.stderr) + return 1, None, None + + keep_terms = parse_keep_keywords(keep_keywords) + keep_logs: list[str] = [] + if keep_terms: + try: + filtered, keep_logs = apply_keep_keywords(df, filtered, keep_terms) + except ValueError as e: + print(f"[失败] {e}", file=sys.stderr) + return 1, None, None + except Exception as e: + print(f"[失败] 核心词匹配:{e}", file=sys.stderr) + return 1, None, None + + output_path = ( + output.expanduser().resolve() + if output is not None + else default_output_path(input_path) + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + try: + write_business_table(filtered, output_path) + except ValueError as e: + print(f"[失败] {e}", file=sys.stderr) + return 1, None, None + except ImportError: + print( + "[失败] 写入 xlsx 需要 openpyxl:pip install openpyxl", + file=sys.stderr, + ) + return 1, None, None + + selected = _format_col_threshold_specs( + col_specs, threshold_values if use_thresholds else None + ) + if use_thresholds: + mode_desc = f"临界值, logic={logic}, by_model={by_model}" + else: + mode_desc = f"pct={float(pct):g}%, logic={logic}, by_model={by_model}" + print(f"[筛选] 输入 {before} 行 → 输出 {len(filtered)} 行 ({mode_desc})") + print(f"[筛选] 列:{selected}") + for line in logs: + print(line) + for line in keep_logs: + print(line) + print(f"已写入:{output_path}") + + html_path: Path | None = None + if html_report: + html_path = ( + html_output.expanduser().resolve() + if html_output is not None + else default_html_report_path(output_path, input_path) + ) + html_top_n = max(1, int(html_top)) + if use_thresholds: + filter_tooltip = build_filter_tooltip( + col_specs, + thresholds=threshold_values, + logic=logic, + ) + else: + filter_tooltip = build_filter_tooltip( + col_specs, + pct=float(pct), # type: ignore[arg-type] + logic=logic, + ) + try: + html_path, html_logs = write_word_freq_html_report( + filtered, + df, + embed_path=output_path, + html_path=html_path, + input_path=input_path, + top_n=html_top_n, + include_synonyms=freq_synonyms, + filter_tooltip=filter_tooltip, + ) + except ValueError as e: + print(f"[失败] 词频 HTML:{e}", file=sys.stderr) + return 1, output_path, None + except Exception as e: + print(f"[失败] 词频 HTML:{e}", file=sys.stderr) + return 1, output_path, None + for line in html_logs: + print(line) + print(f"已写入:{html_path}") + + return 0, output_path, html_path + + +def main() -> int: + args = parse_args() + if args.list_cols: + print_column_catalog() + return 0 + + if args.input is None: + print("[失败] 请指定 --input,或使用 --list-cols 查看列序号。", file=sys.stderr) + return 1 + + code, _, _ = run_filter_infer_business( + args.input, + pct=args.pct, + cols=args.cols, + thresholds=args.thresholds, + logic=args.logic, # type: ignore[arg-type] + by_model=bool(args.by_model), + keep_keywords=args.keep_keywords, + html_report=bool(args.html_report), + html_top=int(args.html_top), + output=args.output, + html_output=args.html_output, + freq_synonyms=bool(args.freq_synonyms), + skip_if_missing_vocab_cols=True, + ) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/向量化.py b/向量化.py index bc0474c..eec93d2 100644 --- a/向量化.py +++ b/向量化.py @@ -11,7 +11,7 @@ ./310py/bin/python 向量化.py --batch-size 16 ./310py/bin/python 向量化.py --job-id 4 --csv merged_reviews_cleaned.csv -环境变量:VOC_EMBED_MODEL_PATH、VOC_EMBED_BATCH_SIZE(默认 16)、VOC_EMBED_MAX_TEXT_CHARS(默认 512)。 +环境变量:VOC_EMBED_MODEL_PATH、VOC_EMBED_BATCH_SIZE(默认 16)、VOC_EMBED_MAX_TEXT_CHARS(默认 10000)。 本地 MLX 推理串行执行,--workers 仅保留兼容、固定为 1。 """ from __future__ import annotations diff --git a/结构化_server.py b/结构化_server.py index b7c30dd..9a2b1c1 100644 --- a/结构化_server.py +++ b/结构化_server.py @@ -69,16 +69,16 @@ def _get_valid_categories() -> frozenset[str]: # 模型上下文上限;动态分批受 DEFAULT_MAX_BATCH_INPUT_TOKENS 与 DEFAULT_MAX_BATCH_REVIEWS 约束 MODEL_MAX_INPUT_TOKENS = 1000_800 -MODEL_MAX_OUTPUT_TOKENS = 200_530 +MODEL_MAX_OUTPUT_TOKENS = 200_000 CHARS_PER_TOKEN_EST = 3.2 DEFAULT_MAX_BATCH_INPUT_TOKENS = 200_000 # 仅用于 batch_plan 日志中的输出 token 粗估,不参与分批与 API max_tokens DEFAULT_OUTPUT_TOKENS_PER_REVIEW = 450 BATCH_COUNT_MIN = 1 # 动态分批时单批评论条数上限(避免单请求过大导致输出截断) -DEFAULT_MAX_BATCH_REVIEWS = 100 +DEFAULT_MAX_BATCH_REVIEWS = 50 # Chat 批间并行;与 embedding 共用账号时不宜过高,避免连带 429 -STRUCT_DEFAULT_WORKERS = 20 +STRUCT_DEFAULT_WORKERS = 400 def _resolve_max_batch_reviews(explicit: int | None = None) -> int: diff --git a/聚类.py b/聚类.py index d3eb18e..baf1e45 100644 --- a/聚类.py +++ b/聚类.py @@ -100,7 +100,9 @@ INITIAL_N_NEIGHBORS = 10 MAX_N_NEIGHBORS = 45 CROSS_SIMILAR_RATIO_THRESHOLD = 0.10 SILHOUETTE_STOP_THRESHOLD = 0.6 -SILHOUETTE_DECLINE_WINDOW = 8 # 连续 8 个轮廓值:后 7 个均小于第 1 个则停止 +SILHOUETTE_DECLINE_WINDOW = 10 # 连续 N 个轮廓值:后 N-1 个均小于第 1 个则停止 +SILHOUETTE_NEIGHBOR_MARGIN = 0.03 # 邻轮轮廓 ≥ 峰值−此值视为「接近」,参与离群数决胜 +SILHOUETTE_NEIGHBOR_RADIUS = 3 # 峰值轮次前后各 3 轮 SAMPLE_CAP = 30 SAMPLE_RATIO = 0.6 @@ -546,11 +548,29 @@ def _ai_evaluate_cluster_samples( lines.append(f" {i}. {sent}") total += 1 sample_text = "\n".join(lines) - prompt = f"""你是 VOC 评论短语聚类质量评估助手。以下是多个聚类类别的抽样短语。 + prompt = f"""你是 VOC 评论短语聚类质量评估助手。以下是多个聚类类别的抽样短语(英文为主)。 {sample_text} -请统计 cross_similar_count:不同聚类类别之间、语义相似的短语条数(每句最多计 1)。 +任务:统计 cross_similar_count——**不同聚类类别之间**、语义相近的短语条数。 + +## 判断标准(从宽,不要漏判) +将两条短语判为「跨类相似」,只要它们表达的是**同一类用户意图/问题/反馈**,不要求措辞一致。以下情况**都应计入**: +- 同义改写:如 "doesn't work" 与 "not effective" +- 同一痛点不同说法:如 "cat pees on bed" 与 "urinates on sofa" +- 同一产品缺陷的不同表述:如 "strong smell" 与 "odor too strong" +- 核心对象相同、评价方向相同:如 "sprayer broken" 与 "nozzle stopped working" +- 一方是另一方的子集或概括:如 "joint pain" 与 "severe joint pain in elderly dog" + +以下情况**不计入**: +- 仅在同一聚类类别内部相似(不算跨类) +- 明显不同方面:如 "fast shipping" 与 "bad smell" +- 褒贬相反:如 "works great" 与 "doesn't work at all" + +## 计数规则 +1. 逐条短语与其他聚类中的短语比对;只要与**任一**其他类的**任一**短语相近,该条计 1。 +2. 每条短语最多计 1 次。 +3. 宁可多计疑似相近,也不要漏掉明显同义/同主题的跨类重复。 只输出 JSON: {{ @@ -562,10 +582,10 @@ def _ai_evaluate_cluster_samples( resp = client.chat.completions.create( model=LLM_MODEL, messages=[ - {"role": "system", "content": "只输出合法 JSON。"}, + {"role": "system", "content": "你是聚类质量评估助手。跨类相似判断从宽:同主题、同义改写、同一痛点/反馈的不同说法都应算相似。只输出合法 JSON。"}, {"role": "user", "content": prompt}, ], - max_tokens=1500, + max_tokens=200_000, temperature=0.0, response_format={"type": "json_object"}, # 关闭思考,避免 token 耗在 reasoning_content 导致 content 为空且无 JSON @@ -594,8 +614,8 @@ def _silhouette_decline_should_stop(scores: List[float]) -> bool: def _best_silhouette_in_window( - snapshots: List[Tuple[int, np.ndarray, float]], -) -> Tuple[int, np.ndarray, float] | None: + snapshots: List[Tuple[int, np.ndarray, float, int]], +) -> Tuple[int, np.ndarray, float, int] | None: """在最近轮廓窗口内取轮廓系数最高的一轮;无快照时返回 None。""" if not snapshots: return None @@ -603,6 +623,33 @@ def _best_silhouette_in_window( return max(pool, key=lambda x: x[2]) +def _resolve_peak_with_neighbor_noise_tiebreak( + snapshots: List[Tuple[int, np.ndarray, float, int]], +) -> Tuple[Tuple[int, np.ndarray, float, int], str | None]: + """轮廓窗口早停:以窗口内峰值为中心,±2 邻轮若轮廓 ≥ 峰值−0.03 则与峰值一起按离群数择优。""" + pool = snapshots[-SILHOUETTE_DECLINE_WINDOW:] + center = max(pool, key=lambda x: x[2]) + center_nn, _, center_sil, center_noise = center + threshold = center_sil - SILHOUETTE_NEIGHBOR_MARGIN + by_nn = {s[0]: s for s in snapshots} + candidates: Dict[int, Tuple[int, np.ndarray, float, int]] = {center_nn: center} + for delta in range(-SILHOUETTE_NEIGHBOR_RADIUS, SILHOUETTE_NEIGHBOR_RADIUS + 1): + if delta == 0: + continue + snap = by_nn.get(center_nn + delta) + if snap is not None and snap[2] >= threshold: + candidates[snap[0]] = snap + if len(candidates) == 1: + return center, None + chosen = min(candidates.values(), key=lambda s: (s[3], -s[2])) + note = ( + f"邻轮复核:峰值 n_neighbors={center_nn}(轮廓{center_sil:.4f},离群{center_noise})," + f"候选 {sorted(candidates)} 中择离群最少 → n_neighbors={chosen[0]}" + f"(轮廓{chosen[2]:.4f},离群{chosen[3]})" + ) + return chosen, note + + def _auto_tune_n_neighbors( embeddings: np.ndarray, sentences: List[str], @@ -616,8 +663,8 @@ def _auto_tune_n_neighbors( min_cs = 2 silhouette_avg: float | None = None silhouette_history: List[float] = [] - # (n_neighbors, labels, silhouette) 仅在有有效轮廓时入栈,供早停回退最优轮次 - silhouette_snapshots: List[Tuple[int, np.ndarray, float]] = [] + # (n_neighbors, labels, silhouette, n_noise) 仅在有有效轮廓时入栈,供早停回退最优轮次 + silhouette_snapshots: List[Tuple[int, np.ndarray, float, int]] = [] n = len(embeddings) while True: @@ -654,34 +701,46 @@ def _auto_tune_n_neighbors( if silhouette_avg is not None: silhouette_history.append(silhouette_avg) - silhouette_snapshots.append((n_neighbors, labels.copy(), silhouette_avg)) + silhouette_snapshots.append( + (n_neighbors, labels.copy(), silhouette_avg, n_noise) + ) if _silhouette_decline_should_stop(silhouette_history): window = silhouette_history[-SILHOUETTE_DECLINE_WINDOW:] - best = _best_silhouette_in_window(silhouette_snapshots) - assert best is not None - best_nn, best_labels, best_sil = best + best, tiebreak_note = _resolve_peak_with_neighbor_noise_tiebreak( + silhouette_snapshots + ) + best_nn, best_labels, best_sil, best_noise = best n_neighbors = best_nn labels = best_labels silhouette_avg = best_sil log_entry["silhouette_window"] = [round(s, 4) for s in window] + log_entry["n_noise"] = best_noise + stop_tail = ( + f",回退至 n_neighbors={best_nn}(轮廓{best_sil:.4f},离群{best_noise})" + ) + if tiebreak_note: + log_entry["neighbor_tiebreak"] = tiebreak_note + stop_tail = f";{tiebreak_note}" log_entry["stop_reason"] = ( - f"连续{SILHOUETTE_DECLINE_WINDOW}轮轮廓:后5个均低于窗口首值" - f"{window[0]:.4f},回退至 n_neighbors={best_nn}(轮廓{best_sil:.4f})" + f"连续{SILHOUETTE_DECLINE_WINDOW}轮轮廓:后{SILHOUETTE_DECLINE_WINDOW - 1}个" + f"均低于窗口峰值{max(window):.4f}{stop_tail}" ) tuning_log.append(log_entry) logger.info( - "[%s] 轮廓窗口 %s,早停并回退 n_neighbors=%s 轮廓=%.4f", + "[%s] 轮廓窗口 %s,早停并回退 n_neighbors=%s 轮廓=%.4f 离群=%s%s", stage, log_entry["silhouette_window"], best_nn, best_sil, + best_noise, + f";{tiebreak_note}" if tiebreak_note else "", ) break if n_neighbors > MAX_N_NEIGHBORS: best = _best_silhouette_in_window(silhouette_snapshots) if best is not None: - best_nn, best_labels, best_sil = best + best_nn, best_labels, best_sil, _ = best n_neighbors = best_nn labels = best_labels silhouette_avg = best_sil diff --git a/词频.py b/词频.py index 220a4e0..66f646a 100644 --- a/词频.py +++ b/词频.py @@ -2,7 +2,8 @@ 词频统计:从最新结构化任务读取产品与 CSV,两步连跑。 1. 随机 25 条 content → LLM 归纳「产品专有名词」与「Amazon/产品专属停用词」 - 2. spaCy 全量 content 分词 + 词频 → output/word_freq.csv(专有名词按完整短语统计,不拆词) + 2. NLTK 全量 content 分词 + stem/lemma 并族 → output/word_freq.csv + (专有名词按完整短语统计;每条评论每个词族最多计 1 次) 用法:: @@ -15,16 +16,13 @@ import argparse import csv import json import logging -import os import random import re import sqlite3 import sys -from collections import Counter +from collections import Counter, defaultdict from pathlib import Path -from typing import Any, Dict, Iterable, List, Sequence, Set, Tuple - -from spacy.lang.en.stop_words import STOP_WORDS as EN_STOP_WORDS +from typing import Dict, Iterable, List, Sequence, Set, Tuple from voc_llm import CHAT_MODEL, chat_extra_body, create_chat_client, require_chat_api_key @@ -43,9 +41,146 @@ WORD_FREQ_CSV = OUTPUT_DIR / "word_freq.csv" MODEL_NAME = CHAT_MODEL -SAMPLE_SIZE = 25 +SAMPLE_SIZE = 38 SAMPLE_SEED = 42 +# NLTK 英文停用词扩展(与匹配规则参考一致) +_EXTRA_STOP_WORDS = frozenset( + { + "www", "http", "https", "com", "amazon", "asin", "sku", + "oz", "lb", "lbs", "inch", "inches", "ft", "mm", "cm", "ml", "kg", "pcs", "pc", + } +) + +# 产品标题拆词中可视为「不重要」、应参与停用的功能词 +_PRODUCT_NAME_FILLER = frozenset( + { + "a", "an", "the", "and", "or", "but", "for", "nor", "so", "yet", + "at", "by", "in", "of", "on", "to", "up", "as", "is", "it", "be", + "with", "from", "into", "via", "per", "vs", "vs.", + } +) + + +def _ensure_nltk(): + """加载 NLTK 分词 / 词性 / 词形还原依赖(首次自动下载数据包)。""" + try: + import nltk # noqa: F401 + except ImportError as e: + raise RuntimeError( + "未安装 nltk,请执行: uv pip install --python 310py/bin/python nltk" + ) from e + import nltk + from nltk.corpus import wordnet as wn + from nltk.stem import PorterStemmer, WordNetLemmatizer + from nltk.tag import pos_tag + from nltk.tokenize import word_tokenize + + for resource, pkg in ( + ("tokenizers/punkt", "punkt"), + ("tokenizers/punkt_tab", "punkt_tab"), + ("corpora/wordnet", "wordnet"), + ("corpora/omw-1.4", "omw-1.4"), + ("taggers/averaged_perceptron_tagger", "averaged_perceptron_tagger"), + ("taggers/averaged_perceptron_tagger_eng", "averaged_perceptron_tagger_eng"), + ("corpora/stopwords", "stopwords"), + ): + try: + nltk.data.find(resource) + except LookupError: + logger.info("下载 NLTK 数据包: %s", pkg) + nltk.download(pkg, quiet=True) + + return word_tokenize, pos_tag, WordNetLemmatizer(), PorterStemmer(), wn + + +def _penn_to_wn_pos(tag: str, wn) -> str: + if tag.startswith("J"): + return wn.ADJ + if tag.startswith("V"): + return wn.VERB + if tag.startswith("N"): + return wn.NOUN + if tag.startswith("R"): + return wn.ADV + return wn.NOUN + + +def load_nltk_stop_words() -> Set[str]: + _ensure_nltk() + import nltk + from nltk.corpus import stopwords + + try: + words = set(stopwords.words("english")) + except LookupError: + nltk.download("stopwords", quiet=True) + words = set(stopwords.words("english")) + words |= _EXTRA_STOP_WORDS + return words + + +class WordVariantEngine: + """NLTK 分词 + stem / POS-lemma 词形变体(不含同义词并族)。""" + + def __init__(self) -> None: + word_tokenize, pos_tag_fn, lemmatizer, stemmer, wn = _ensure_nltk() + self._word_tokenize = word_tokenize + self._pos_tag = pos_tag_fn + self._lemmatizer = lemmatizer + self._stemmer = stemmer + self._wn = wn + + def variants_for_word(self, word: str, pos: str | None = None) -> Set[str]: + w = (word or "").lower().strip() + if not w: + return set() + out: Set[str] = {w, self._stemmer.stem(w)} + if pos is not None: + wn_pos = _penn_to_wn_pos(pos, self._wn) + out.add(self._lemmatizer.lemmatize(w, pos=wn_pos)) + for p in (self._wn.NOUN, self._wn.VERB, self._wn.ADJ, self._wn.ADV): + out.add(self._lemmatizer.lemmatize(w, pos=p)) + return {x for x in out if x} + + def variant_pool(self, word: str, pos: str | None = None) -> Set[str]: + return self.variants_for_word(word, pos=pos) + + def pos_tag_tokens(self, tokens: Sequence[str]) -> List[Tuple[str, str]]: + if not tokens: + return [] + try: + return self._pos_tag(list(tokens)) + except Exception: + return [(t, "NN") for t in tokens] + + +class _UnionFind: + def __init__(self) -> None: + self._parent: Dict[str, str] = {} + + def add(self, x: str) -> None: + if x not in self._parent: + self._parent[x] = x + + def find(self, x: str) -> str: + self.add(x) + while self._parent[x] != x: + self._parent[x] = self._parent[self._parent[x]] + x = self._parent[x] + return x + + def union(self, a: str, b: str) -> None: + ra, rb = self.find(a), self.find(b) + if ra != rb: + self._parent[rb] = ra + + def groups(self) -> Dict[str, List[str]]: + out: Dict[str, List[str]] = defaultdict(list) + for x in self._parent: + out[self.find(x)].append(x) + return dict(out) + def _strip_think(text: str) -> str: if not text: @@ -142,7 +277,8 @@ def _build_terms_prompt( def _call_llm(system: str, user: str, api_key: str) -> str: _ = api_key - client = create_chat_client() + # 术语提取只需短 JSON/列表,200k max_tokens 会导致 API 长时间生成或挂起 + client = create_chat_client(timeout=600.0) extra_body = chat_extra_body(MODEL_NAME) resp = client.chat.completions.create( model=MODEL_NAME, @@ -151,6 +287,7 @@ def _call_llm(system: str, user: str, api_key: str) -> str: {"role": "user", "content": user}, ], temperature=0.6, + max_tokens=90000, extra_body=extra_body, ) msg = resp.choices[0].message @@ -275,25 +412,15 @@ def _product_name_tokens(product_name: str) -> Set[str]: } -# 产品标题拆词中可视为「不重要」、应参与停用的功能词(含 spaCy 英文停用词交集) -_PRODUCT_NAME_FILLER = frozenset( - { - "a", "an", "the", "and", "or", "but", "for", "nor", "so", "yet", - "at", "by", "in", "of", "on", "to", "up", "as", "is", "it", "be", - "with", "from", "into", "via", "per", "vs", "vs.", - } -) - - -def _product_name_filler_tokens(product_name: str) -> Set[str]: +def _product_name_filler_tokens(product_name: str, nltk_stops: Set[str]) -> Set[str]: """产品名拆词中的虚词/功能词 → 应停用。""" tokens = _product_name_tokens(product_name) - return {t for t in tokens if t in EN_STOP_WORDS or t in _PRODUCT_NAME_FILLER} + return {t for t in tokens if t in nltk_stops or t in _PRODUCT_NAME_FILLER} def _product_name_core_tokens(product_name: str) -> Set[str]: """产品名中有分析价值的实词 → 不停用。""" - fillers = _product_name_filler_tokens(product_name) + fillers = _product_name_filler_tokens(product_name, load_nltk_stop_words()) return _product_name_tokens(product_name) - fillers @@ -306,10 +433,9 @@ def _finalize_term_lists( stopwords: List[str], product_name: str, ) -> Tuple[List[str], Set[str]]: - stop_set = set(EN_STOP_WORDS) + stop_set = load_nltk_stop_words() stop_set.update(_dedupe_terms(stopwords)) - # 产品名虚词(for/the/a 等)纳入停用;实词成分保持可统计 - stop_set.update(_product_name_filler_tokens(product_name)) + stop_set.update(_product_name_filler_tokens(product_name, stop_set)) for tok in _product_name_core_tokens(product_name): stop_set.discard(tok) pn_lower = _normalize_term(product_name) @@ -321,24 +447,10 @@ def _finalize_term_lists( if t in stop_set: continue cleaned_terms.append(t) - # 长短语优先匹配 cleaned_terms.sort(key=lambda x: (-len(x.split()), -len(x))) return cleaned_terms, stop_set -def _load_spacy(): - import spacy - - try: - return spacy.load("en_core_web_sm", disable=["ner", "parser"]) - except OSError as e: - raise RuntimeError( - "未安装 spaCy 英文模型,请执行: 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'" - ) from e - - def _phrase_pattern(phrase: str) -> re.Pattern[str]: parts = [re.escape(p) for p in phrase.split()] body = r"\s+".join(parts) @@ -366,81 +478,91 @@ def _overlaps_span(char_start: int, char_end: int, spans: Sequence[Tuple[int, in return False -def _apply_product_terms( - lower: str, - product_terms: List[str], - counter: Counter[str], - stop_set: Set[str], -) -> List[Tuple[int, int]]: - """匹配专有名词短语:只计完整短语频次,并返回需屏蔽拆词统计的字符区间。""" +def _collect_protected_spans(lower: str, product_terms: List[str], stop_set: Set[str]) -> List[Tuple[int, int]]: + """返回专有名词短语匹配区间(屏蔽拆词统计,短语单独计数)。""" spans: List[Tuple[int, int]] = [] for phrase in product_terms: if not phrase or phrase in stop_set or _is_pure_number(phrase): continue - pat = _phrase_pattern(phrase) - hits = 0 - for m in pat.finditer(lower): + for m in _phrase_pattern(phrase).finditer(lower): spans.append((m.start(), m.end())) - hits += 1 - if hits: - counter[phrase] += hits return _merge_spans(spans) -def _tokenize_doc( - nlp, text: str, protected_spans: Sequence[Tuple[int, int]] -) -> List[str]: - doc = nlp(text) - tokens: List[str] = [] - for tok in doc: - if tok.is_space or tok.is_punct: +def _phrases_hit_in_review(lower: str, product_terms: List[str], stop_set: Set[str]) -> List[str]: + """本条评论命中的专有名词(每条评论每短语最多计 1 次)。""" + hit: List[str] = [] + for phrase in product_terms: + if not phrase or phrase in stop_set or _is_pure_number(phrase): continue - char_start = tok.idx - char_end = tok.idx + len(tok.text) - if _overlaps_span(char_start, char_end, protected_spans): - continue - lemma = (tok.lemma_ or tok.text).lower().strip() - if not lemma or _is_pure_number(lemma): - continue - if not re.search(r"[a-z]", lemma, re.I): - continue - tokens.append(lemma) - return tokens + if _phrase_pattern(phrase).search(lower): + hit.append(phrase) + return hit -_SINGLE_EN_WORD = re.compile(r"^[a-z]+$") +_TOKEN_RE = re.compile(r"[a-z0-9']+") -def _should_lemma_normalize(word: str) -> bool: - """仅对单个英文词做词形还原;多词短语、连字符短语保持原样。""" - w = word.strip().lower() - if not w or " " in w or "-" in w or "'" in w: +def _tokenize_tagged_with_spans(text: str, engine: WordVariantEngine) -> List[Tuple[str, str, int, int]]: + """带字符区间的分词 + 词性标注。""" + lower = text.lower() + raw: List[Tuple[str, int, int]] = [] + for m in _TOKEN_RE.finditer(lower): + tok = m.group() + if tok: + raw.append((tok, m.start(), m.end())) + if not raw: + return [] + words = [t for t, _, _ in raw] + tagged = engine.pos_tag_tokens(words) + return [(tagged[i][0], tagged[i][1], raw[i][1], raw[i][2]) for i in range(len(raw))] + + +def _is_valid_token(tok: str, stop_set: Set[str]) -> bool: + if not tok or len(tok) < 1 or tok in stop_set or _is_pure_number(tok): return False - return bool(_SINGLE_EN_WORD.match(w)) + return bool(re.search(r"[a-z]", tok)) -def _lemma_form(word: str, nlp) -> str: - w = word.strip().lower() - if not _should_lemma_normalize(w): - return w - doc = nlp(w) - if not doc: - return w - tok = doc[0] - if tok.is_space or tok.is_punct: - return w - lemma = (tok.lemma_ or tok.text).lower().strip() - if not lemma or lemma == "-": - return w - return lemma +def _register_review_tokens_in_uf( + text: str, + protected: Sequence[Tuple[int, int]], + engine: WordVariantEngine, + uf: _UnionFind, + variant_index: Dict[str, Set[str]], + stop_set: Set[str], +) -> None: + """Pass 1:将本条评论 token 注册进 Union-Find(stem/lemma 并族)。""" + seen: Set[str] = set() + for tok, tag, start, end in _tokenize_tagged_with_spans(text, engine): + if _overlaps_span(start, end, protected): + continue + if not _is_valid_token(tok, stop_set): + continue + if tok in seen: + continue + seen.add(tok) + uf.add(tok) + variants = engine.variant_pool(tok, pos=tag) + related: Set[str] = set() + for v in variants: + related |= variant_index[v] + for other in related: + uf.union(tok, other) + for v in variants: + variant_index[v].add(tok) -def _merge_word_forms(counter: Counter[str], nlp) -> Counter[str]: - """写入 CSV 前合并单复数/时态等词形(如 dogs→dog, bought→buy)。""" - merged: Counter[str] = Counter() - for word, count in counter.items(): - merged[_lemma_form(word, nlp)] += count - return merged +def _family_labels(uf: _UnionFind, surface_doc_freq: Counter[str]) -> Dict[str, str]: + """词族代表形:族内 surface 文档频次最高者,并列取最短。""" + labels: Dict[str, str] = {} + for root, members in uf.groups().items(): + best = sorted( + members, + key=lambda m: (-surface_doc_freq.get(m, 0), len(m), m), + )[0] + labels[root] = best + return labels def _build_word_freq( @@ -448,19 +570,60 @@ def _build_word_freq( product_terms: List[str], stop_set: Set[str], ) -> Counter[str]: - nlp = _load_spacy() - counter: Counter[str] = Counter() + """ + NLTK 分词 + stem/lemma Union-Find 并族。 + 每条评论:每个词族最多 +1;专有名词短语命中也最多 +1/短语。 + 输出 word 为词族代表形(方案 1)。 + """ + engine = WordVariantEngine() + uf = _UnionFind() + variant_index: Dict[str, Set[str]] = defaultdict(set) + + # Pass 1:全库注册词形变体并族 + for _, text in rows: + if not text.strip(): + continue + protected = _collect_protected_spans(text.lower(), product_terms, stop_set) + _register_review_tokens_in_uf(text, protected, engine, uf, variant_index, stop_set) + + family_counter: Counter[str] = Counter() + phrase_counter: Counter[str] = Counter() + surface_doc_freq: Counter[str] = Counter() + + # Pass 2:按评论计数(每词族 / 每短语最多 1 次) for _, text in rows: if not text.strip(): continue lower = text.lower() - protected = _apply_product_terms(lower, product_terms, counter, stop_set) - for tok in _tokenize_doc(nlp, text, protected): - if tok in stop_set: + protected = _collect_protected_spans(lower, product_terms, stop_set) + + for phrase in _phrases_hit_in_review(lower, product_terms, stop_set): + phrase_counter[phrase] += 1 + + families_seen: Set[str] = set() + seen_tok: Set[str] = set() + for tok, tag, start, end in _tokenize_tagged_with_spans(text, engine): + if _overlaps_span(start, end, protected): continue - counter[tok] += 1 - counter = Counter({k: v for k, v in counter.items() if not _is_pure_number(k)}) - return _merge_word_forms(counter, nlp) + if not _is_valid_token(tok, stop_set): + continue + if tok in seen_tok: + continue + seen_tok.add(tok) + surface_doc_freq[tok] += 1 + root = uf.find(tok) + if root not in families_seen: + families_seen.add(root) + family_counter[root] += 1 + + labels = _family_labels(uf, surface_doc_freq) + merged: Counter[str] = Counter() + for root, count in family_counter.items(): + merged[labels.get(root, root)] += count + for phrase, count in phrase_counter.items(): + merged[phrase] += count + + return Counter({k: v for k, v in merged.items() if v > 0 and not _is_pure_number(k)}) def _save_word_freq(counter: Counter[str], path: Path) -> None: @@ -562,7 +725,7 @@ def run(*, skip_llm: bool = False) -> dict: product_name, ) - logger.info("第 2 步:spaCy 全量分词与词频统计") + logger.info("第 2 步:NLTK 分词 + stem/lemma 并族词频统计") counter = _build_word_freq(rows, product_terms, stop_set) _save_word_freq(counter, WORD_FREQ_CSV) logger.info("已写入 %s", WORD_FREQ_CSV)