包含 Persona 锚定聚类、LLM 报告生成、方法论文档及 .gitignore 更新,便于在 Gitee 独立部署。 Co-authored-by: Cursor <cursoragent@cursor.com>
2303 lines
87 KiB
Python
2303 lines
87 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""报告数据后处理:主题 enrichment、Persona 统计、根因引用回填、决策摘要等。"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||
|
||
from data_loader import ASINStats, ClusterData, DataLoader, MarketStats, ReviewRecord
|
||
from llm_analyzer import market_competition_judgment
|
||
|
||
logger = logging.getLogger("voc.report_utils")
|
||
|
||
# 通用英文停用词(不含品类词,品类词由 build_product_stopwords 动态追加)
|
||
STOPWORDS_BASE = frozenset({
|
||
"de", "la", "el", "en", "un", "una", "the", "and", "for", "with", "that", "this",
|
||
"have", "has", "had", "was", "were", "are", "but", "not", "you", "your", "its",
|
||
"it's", "i", "me", "my", "we", "our", "they", "them", "their", "be", "been",
|
||
"product", "item", "one", "get", "got", "buy", "bought", "amazon", "review",
|
||
})
|
||
STOPWORDS = STOPWORDS_BASE # 兼容旧 import
|
||
|
||
VALID_ASIN_RE = re.compile(r"^B[A-Z0-9]{9}$")
|
||
|
||
# 差评主题名常见后缀(用于自动配对好评/差评不对称洞察)
|
||
_NEG_THEME_SUFFIXES = ("差", "低", "故障", "问题", "不足", "缺陷", "损坏", "失效", "难", "慢", "弱")
|
||
|
||
EMOTIONAL_HINTS = (
|
||
"自信", "焦虑", "恐惧", "感受", "情绪", "安心", "掌控", "体面", "尊严",
|
||
"社交", "不安全感", "压力", "满足", "惊喜", "期待", "好奇", "骄傲",
|
||
"confidence", "anxiety", "fear", "feel", "emotion",
|
||
)
|
||
|
||
_STRONG_HINTS = (
|
||
"waste", "charge", "broken", "stopped", "doesn't", "does not", "not worth",
|
||
"terrible", "horrible", "useless", "defect", "return", "refund", "pull",
|
||
"nick", "burn", "bleed", "cut", "irritat", "bump", "overheat", "loud",
|
||
"durable", "quality", "shave", "trim", "waterproof", "battery",
|
||
)
|
||
|
||
# 情感词频中应降权的中性噪声词
|
||
_NEUTRAL_NOISE = frozenset({
|
||
"cut", "long", "feel", "leave", "try", "look", "hold", "end", "want", "turn",
|
||
"different", "little", "leg", "electric", "get", "got", "use", "used", "work",
|
||
"works", "good", "bad", "nice", "time", "day", "way", "thing", "things",
|
||
})
|
||
|
||
def build_product_stopwords(product_name: str = "") -> frozenset:
|
||
"""从产品名提取品类高频词,合并通用停用词。"""
|
||
extra: Set[str] = set()
|
||
if product_name:
|
||
for w in re.findall(r"[a-zA-Z]{3,}", product_name.lower()):
|
||
extra.add(w)
|
||
for w in re.findall(r"[\u4e00-\u9fff]{2,}", product_name):
|
||
extra.add(w)
|
||
return frozenset(STOPWORDS_BASE | extra)
|
||
|
||
|
||
def _theme_name_similarity(pos: str, neg: str) -> int:
|
||
"""估算好评/差评主题名相似度(用于自动配对)。"""
|
||
neg_core = neg
|
||
for suf in _NEG_THEME_SUFFIXES:
|
||
if neg_core.endswith(suf) and len(neg_core) > len(suf) + 1:
|
||
neg_core = neg_core[: -len(suf)]
|
||
break
|
||
if len(neg_core) >= 2 and neg_core in pos:
|
||
return len(neg_core) + 5
|
||
if len(pos) >= 2 and pos in neg:
|
||
return len(pos) + 3
|
||
return sum(1 for c in set(neg_core) if c in pos and c not in "与及和")
|
||
|
||
|
||
def infer_pos_neg_theme_pairs(
|
||
pos_themes: List[Dict[str, Any]],
|
||
neg_themes: List[Dict[str, Any]],
|
||
) -> List[Tuple[str, str]]:
|
||
"""从 LLM 归纳的主题名自动推断好评↔差评配对(pos_name, neg_name)。"""
|
||
pairs: List[Tuple[str, str]] = []
|
||
used_pos: Set[str] = set()
|
||
pos_names = [t.get("name", "") for t in pos_themes if t.get("name")]
|
||
neg_names = [t.get("name", "") for t in neg_themes if t.get("name")]
|
||
|
||
for neg in neg_names:
|
||
best_pos = ""
|
||
best_score = 0
|
||
for pos in pos_names:
|
||
if pos in used_pos:
|
||
continue
|
||
score = _theme_name_similarity(pos, neg)
|
||
if score > best_score:
|
||
best_score = score
|
||
best_pos = pos
|
||
if best_pos and best_score >= 3:
|
||
pairs.append((best_pos, neg))
|
||
used_pos.add(best_pos)
|
||
return pairs
|
||
|
||
_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"), "称赞防水/易清洁"),
|
||
(("love", "perfect", "great", "recommend", "amazing"), "整体满意推荐"),
|
||
(("travel", "portable", "compact", "lightweight"), "称赞便携"),
|
||
(("attachment", "head", "guard"), "提及配件/刀头"),
|
||
)
|
||
|
||
|
||
def _is_strong_keyword(kw: str) -> bool:
|
||
if len(kw) >= 12:
|
||
return True
|
||
return any(h in kw for h in _STRONG_HINTS)
|
||
|
||
|
||
def phrase_segments(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 expand_keywords(keywords: List[str]) -> List[str]:
|
||
"""展开主题 keywords(含逗号分隔的聚类短语)。"""
|
||
expanded: List[str] = []
|
||
for kw in keywords:
|
||
expanded.extend(phrase_segments([kw]))
|
||
return list(dict.fromkeys(expanded))
|
||
|
||
|
||
def match_text(keywords: List[str], text: str) -> bool:
|
||
text = text.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 text:
|
||
return True
|
||
if not strong:
|
||
for kw in sorted(weak, key=len, reverse=True):
|
||
if len(kw) >= 5 and kw in text:
|
||
return True
|
||
return False
|
||
|
||
|
||
def filter_stopwords(
|
||
word_freq: List[Tuple[str, int]],
|
||
product_name: str = "",
|
||
) -> List[Tuple[str, int]]:
|
||
sw = build_product_stopwords(product_name)
|
||
return [(w, c) for w, c in word_freq if w.lower() not in sw and len(w) >= 2]
|
||
|
||
|
||
def _score_cluster_theme(cluster: Dict[str, Any], theme: Dict[str, Any]) -> int:
|
||
theme_kws = expand_keywords(theme.get("keywords", []))
|
||
cluster_segs = phrase_segments(cluster.get("top_phrases", []))
|
||
score = 0
|
||
for cs in cluster_segs:
|
||
for tk in theme_kws:
|
||
if len(tk) >= 3 and (tk in cs or cs in tk):
|
||
score += len(tk)
|
||
return score
|
||
|
||
|
||
def enrich_theme_keywords(
|
||
themes: List[Dict[str, Any]],
|
||
global_clusters: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""从聚类 top_phrases 补充可匹配的英文 keywords。"""
|
||
if not themes:
|
||
return themes
|
||
assigned: Dict[str, Set[str]] = {t["name"]: set() for t in themes}
|
||
unassigned = list(global_clusters)
|
||
|
||
for cluster in global_clusters:
|
||
best = max(themes, key=lambda t: _score_cluster_theme(cluster, t))
|
||
if _score_cluster_theme(cluster, best) <= 0:
|
||
continue
|
||
for seg in phrase_segments(cluster.get("top_phrases", [])):
|
||
assigned[best["name"]].add(seg)
|
||
|
||
for theme in themes:
|
||
base = [k.lower().strip() for k in theme.get("keywords", []) if k.strip()]
|
||
merged = list(dict.fromkeys(base + list(assigned.get(theme["name"], set()))))
|
||
if not merged and global_clusters:
|
||
# 兜底:取频次最高的聚类短语
|
||
top = sorted(global_clusters, key=lambda c: -c.get("phrase_count", 0))[:3]
|
||
for c in top:
|
||
merged.extend(phrase_segments(c.get("top_phrases", [])))
|
||
merged = list(dict.fromkeys(merged))
|
||
theme["keywords"] = merged[:40]
|
||
return themes
|
||
|
||
|
||
def recalc_neg_priorities(
|
||
neg_themes: List[Dict[str, Any]],
|
||
neg_freq: Dict[str, int],
|
||
neg_total: int,
|
||
asin_count: int,
|
||
per_asin_neg: Dict[str, Dict[str, int]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""根据实际频次重算 P0/P1/P2,并附加 freq_rank。"""
|
||
p0_threshold = neg_total * 0.2
|
||
p1_low = neg_total * 0.1
|
||
for theme in neg_themes:
|
||
name = theme["name"]
|
||
count = neg_freq.get(name, 0)
|
||
affected = sum(1 for asin_counts in per_asin_neg.values() if asin_counts.get(name, 0) > 0)
|
||
asin_ratio = affected / max(asin_count, 1)
|
||
if count >= p0_threshold and asin_ratio >= 0.8:
|
||
theme["priority"] = "P0"
|
||
elif count >= p1_low:
|
||
theme["priority"] = "P1"
|
||
elif count >= neg_total * 0.03:
|
||
theme["priority"] = "P2"
|
||
else:
|
||
theme["priority"] = "observe"
|
||
theme["freq_count"] = count
|
||
theme["affected_asins"] = affected
|
||
|
||
ranked = sorted(
|
||
[t for t in neg_themes if neg_freq.get(t["name"], 0) > 0],
|
||
key=lambda t: neg_freq.get(t["name"], 0),
|
||
reverse=True,
|
||
)
|
||
for i, theme in enumerate(ranked, start=1):
|
||
theme["freq_rank"] = i
|
||
pri = theme.get("priority", "P2")
|
||
if pri == "observe":
|
||
theme["display_priority"] = "待观察"
|
||
elif pri == "P1" and not any(t.get("priority") == "P0" for t in neg_themes):
|
||
theme["display_priority"] = f"#{i}"
|
||
else:
|
||
theme["display_priority"] = pri
|
||
for theme in neg_themes:
|
||
theme.setdefault("freq_rank", 99)
|
||
if theme.get("priority") == "observe":
|
||
theme.setdefault("display_priority", "待观察")
|
||
else:
|
||
theme.setdefault("display_priority", theme.get("priority", "P2"))
|
||
return neg_themes
|
||
|
||
|
||
def normalize_persona_dimension(persona: Dict[str, Any]) -> Dict[str, Any]:
|
||
dim = str(persona.get("dimension", "")).strip().upper()
|
||
if dim in ("A", "B", "C"):
|
||
persona["dimension"] = dim
|
||
elif "A" in dim and "B" not in dim:
|
||
persona["dimension"] = "A"
|
||
elif "B" in dim:
|
||
persona["dimension"] = "B"
|
||
elif "C" in dim:
|
||
persona["dimension"] = "C"
|
||
else:
|
||
persona["dimension"] = "B"
|
||
dim_labels = {"A": "生理特征", "B": "行为场景", "C": "购买动机"}
|
||
persona["dimension_label"] = dim_labels.get(persona["dimension"], "行为场景")
|
||
return persona
|
||
|
||
|
||
# ── Persona ↔ 聚类簇锚定(通用,适用于任意品类) ──
|
||
|
||
_PERSONA_PHRASE_STOPWORDS = frozenset({
|
||
"self", "unknown", "user", "customer", "buyer", "myself", "the user",
|
||
"a user", "consumer", "reviewer", "amazon", "product", "item",
|
||
})
|
||
|
||
_PERSONA_BINDABLE_STAGES: Dict[str, Tuple[str, ...]] = {
|
||
"A": ("1_audience",),
|
||
"B": (
|
||
"3b_aspect_opinion_positive",
|
||
"3b_aspect_opinion_negative",
|
||
"3a_pain_global",
|
||
),
|
||
"C": (
|
||
"3a_pain_global",
|
||
"3b_aspect_opinion_negative",
|
||
"3b_aspect_opinion_positive",
|
||
),
|
||
}
|
||
|
||
_VALID_CLUSTER_STAGES = frozenset(
|
||
stage for stages in _PERSONA_BINDABLE_STAGES.values() for stage in stages
|
||
)
|
||
|
||
|
||
def build_cluster_registry(
|
||
all_clusters: Dict[str, List[ClusterData]],
|
||
) -> Dict[Tuple[str, int], ClusterData]:
|
||
"""stage+label → ClusterData 索引。"""
|
||
registry: Dict[Tuple[str, int], ClusterData] = {}
|
||
for stage, clusters in all_clusters.items():
|
||
if stage not in _VALID_CLUSTER_STAGES:
|
||
continue
|
||
for c in clusters:
|
||
registry[(stage, int(c.cluster_label))] = c
|
||
return registry
|
||
|
||
|
||
def _persona_text_signals(persona: Dict[str, Any]) -> List[str]:
|
||
parts = list(persona.get("keywords") or [])
|
||
for field in ("core_pain", "core_need", "purchase_motivation", "name"):
|
||
val = persona.get(field)
|
||
if val:
|
||
parts.append(str(val))
|
||
return expand_keywords(parts)
|
||
|
||
|
||
def _score_persona_cluster(persona: Dict[str, Any], cluster: ClusterData) -> int:
|
||
signals = _persona_text_signals(persona)
|
||
cluster_segs = [
|
||
s for s in phrase_segments(cluster.top_phrases)
|
||
if s not in _PERSONA_PHRASE_STOPWORDS
|
||
]
|
||
score = 0
|
||
for cs in cluster_segs:
|
||
for tk in signals:
|
||
if len(tk) >= 3 and (tk in cs or cs in tk):
|
||
score += len(tk)
|
||
return score
|
||
|
||
|
||
def _normalize_cluster_ref(ref: Any) -> Optional[Dict[str, Any]]:
|
||
if not isinstance(ref, dict):
|
||
return None
|
||
stage = str(ref.get("stage") or "").strip()
|
||
label = ref.get("label")
|
||
if stage not in _VALID_CLUSTER_STAGES or label is None:
|
||
return None
|
||
try:
|
||
return {"stage": stage, "label": int(label)}
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
# Persona 名称 / core_pain 中的生理标签 → 绑定簇内 ≥N 条评论含对应英文词方可保留
|
||
PERSONA_PHYSIO_MIN_REVIEWS_DEFAULT = 5
|
||
|
||
_PERSONA_PHYSIO_RULES: List[Tuple[str, Tuple[str, ...], Tuple[str, ...]]] = [
|
||
("pregnancy", ("孕妇", "孕期", "怀胎", "产后"), ("pregnant", "pregnancy", "pregnan", "postpartum", "maternity", "weeks pregnant")),
|
||
("elderly", ("老年", "老人", "长者"), ("elderly", "senior", "older adult", "retired")),
|
||
("children", ("儿童", "小孩", "幼儿", "婴儿"), ("kid", "child", "children", "toddler", "infant", "baby")),
|
||
("diabetes", ("糖尿病",), ("diabetic", "diabetes")),
|
||
("scar", ("疤痕", "妊娠纹"), ("stretch mark", "scar", "c-section", "cesarean")),
|
||
("coarse_hair", ("粗硬发", "粗毛", "硬毛"), ("coarse hair", "thick hair", "coarse", "thick")),
|
||
("sensitive_skin", ("敏感肌", "敏感皮", "易敏"), ("sensitive skin", "sensitive", "delicate skin", "irritat")),
|
||
]
|
||
|
||
|
||
def _review_text(r: ReviewRecord) -> str:
|
||
return f"{r.title} {r.content}".lower()
|
||
|
||
|
||
def count_physio_reviews_in_cluster(
|
||
source_rows: List[int],
|
||
reviews: List[ReviewRecord],
|
||
en_tokens: Tuple[str, ...],
|
||
) -> int:
|
||
"""统计簇内至少命中一个英文 token 的评论条数(按 source_row 去重)。"""
|
||
row_map = {r.source_row: r for r in reviews}
|
||
count = 0
|
||
for sr in source_rows:
|
||
r = row_map.get(sr)
|
||
if not r:
|
||
continue
|
||
text = _review_text(r)
|
||
if any(t in text for t in en_tokens):
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def compute_physio_review_counts(
|
||
source_rows: List[int],
|
||
reviews: List[ReviewRecord],
|
||
) -> Dict[str, int]:
|
||
"""各生理标签维度在簇内的评论命中条数(供 catalog 展示与校验)。"""
|
||
counts: Dict[str, int] = {}
|
||
for rule_key, _, en_tokens in _PERSONA_PHYSIO_RULES:
|
||
n = count_physio_reviews_in_cluster(source_rows, reviews, en_tokens)
|
||
if n > 0:
|
||
counts[rule_key] = n
|
||
return counts
|
||
|
||
|
||
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。"""
|
||
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
|
||
entry["physio_review_counts"] = compute_physio_review_counts(
|
||
cluster.source_rows, reviews,
|
||
)
|
||
|
||
|
||
def _physio_label_has_review_evidence(
|
||
en_tokens: Tuple[str, ...],
|
||
source_rows: List[int],
|
||
reviews: List[ReviewRecord],
|
||
min_review_count: int,
|
||
) -> bool:
|
||
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 _strip_physio_from_core_pain(core_pain: str, unsupported_zh: List[str]) -> str:
|
||
if not core_pain:
|
||
return core_pain
|
||
parts = re.split(r"[;;]", core_pain)
|
||
kept = [p.strip() for p in parts if p.strip() and not any(z in p for z in unsupported_zh)]
|
||
if kept:
|
||
return ";".join(kept[:3])
|
||
# 全部含无佐证标签时,去掉标签词保留其余
|
||
cleaned = core_pain
|
||
for zh in unsupported_zh:
|
||
cleaned = cleaned.replace(zh, "")
|
||
cleaned = re.sub(r"[;;]+", ";", cleaned).strip("; ")
|
||
return cleaned or core_pain
|
||
|
||
|
||
def validate_persona_physiological_labels(
|
||
personas: List[Dict[str, Any]],
|
||
cluster_data: Dict[str, Any],
|
||
reviews: List[ReviewRecord],
|
||
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 条评论含对应英文词。"""
|
||
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
|
||
source_rows = list(cluster.source_rows) if cluster else []
|
||
|
||
unsupported: List[str] = []
|
||
text_blob = f"{persona.get('name', '')} {persona.get('core_pain', '')}"
|
||
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 not _physio_label_has_review_evidence(
|
||
en_tokens, source_rows, reviews, min_review_count,
|
||
):
|
||
unsupported.extend(hit_zh)
|
||
|
||
if not unsupported:
|
||
continue
|
||
|
||
old_name = persona.get("name", "")
|
||
persona["name"] = _strip_physio_from_name(old_name, unsupported)
|
||
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)",
|
||
min_review_count, old_name, persona["name"],
|
||
persona["physio_removed_labels"], counts_hint,
|
||
)
|
||
|
||
return personas
|
||
|
||
|
||
def assign_persona_clusters(
|
||
personas: List[Dict[str, Any]],
|
||
all_clusters: Dict[str, List[ClusterData]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""为每个 Persona 绑定聚类簇;LLM 已给 cluster_ref 则校验,否则自动映射。"""
|
||
registry = build_cluster_registry(all_clusters)
|
||
used_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
|
||
|
||
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)
|
||
|
||
if not candidates:
|
||
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
|
||
|
||
persona["cluster_ref"] = {"stage": best.stage, "label": int(best.cluster_label)}
|
||
persona.pop("cluster_unassigned", None)
|
||
if best_score <= 0:
|
||
persona["cluster_auto_mapped"] = True
|
||
used_keys.add((best.stage, int(best.cluster_label)))
|
||
|
||
return personas
|
||
|
||
|
||
def enrich_persona_keywords(
|
||
persona: Dict[str, Any],
|
||
cluster: Optional[ClusterData],
|
||
) -> Dict[str, Any]:
|
||
"""从绑定簇 top_phrases 补充 keywords(过滤无信息词)。"""
|
||
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):
|
||
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:
|
||
merged = [
|
||
s for s in phrase_segments(cluster.top_phrases)
|
||
if s not in _PERSONA_PHRASE_STOPWORDS and len(s) >= 4
|
||
][:20]
|
||
persona["keywords"] = merged[:40]
|
||
return persona
|
||
|
||
|
||
def match_persona_text(keywords: List[str], text: str) -> bool:
|
||
"""Persona 簇内二次过滤:比主题 match_text 更严格,避免泛词虚高。"""
|
||
text = text.lower()
|
||
expanded = [
|
||
k for k in expand_keywords(keywords)
|
||
if k not in _PERSONA_PHRASE_STOPWORDS and len(k) >= 3
|
||
]
|
||
if not expanded:
|
||
return False
|
||
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
|
||
return False
|
||
|
||
|
||
def resolve_persona_audience_label(
|
||
persona: Dict[str, Any],
|
||
all_clusters: Dict[str, List[ClusterData]],
|
||
) -> int:
|
||
"""推断 Persona 对应的 audience 簇标签(供根因 per_audience 使用)。"""
|
||
ref = persona.get("cluster_ref") or {}
|
||
stage = ref.get("stage", "")
|
||
if stage == "1_audience" and ref.get("label") is not None:
|
||
return int(ref["label"])
|
||
|
||
if "_audience_c" in stage:
|
||
try:
|
||
return int(stage.rsplit("_audience_c", 1)[-1])
|
||
except ValueError:
|
||
pass
|
||
|
||
persona_rows = set(persona.get("source_rows") or [])
|
||
aud_clusters = all_clusters.get("1_audience", [])
|
||
if persona_rows and aud_clusters:
|
||
best_label, best_overlap = -1, 0
|
||
for ac in aud_clusters:
|
||
overlap = len(persona_rows & set(ac.source_rows))
|
||
if overlap > best_overlap:
|
||
best_overlap = overlap
|
||
best_label = int(ac.cluster_label)
|
||
if best_label >= 0:
|
||
return best_label
|
||
|
||
if aud_clusters:
|
||
return int(max(aud_clusters, key=lambda c: c.review_count).cluster_label)
|
||
return -1
|
||
|
||
|
||
def _apply_persona_pct_display(persona: Dict[str, Any], total: int) -> None:
|
||
hit = persona.get("hit_count", 0)
|
||
if hit <= 0:
|
||
persona["pct_estimate"] = None
|
||
persona["pct_verified"] = False
|
||
persona["pct_display"] = "待验证"
|
||
return
|
||
raw_pct = hit / max(total, 1) * 100
|
||
persona["pct_estimate"] = round(raw_pct, 1)
|
||
persona["pct_verified"] = True
|
||
if raw_pct < 0.5:
|
||
persona["pct_display"] = "<1%"
|
||
elif raw_pct < 10:
|
||
persona["pct_display"] = f"{persona['pct_estimate']:.1f}%"
|
||
else:
|
||
persona["pct_display"] = f"{round(raw_pct)}%"
|
||
|
||
|
||
def compute_persona_pcts(
|
||
personas: List[Dict[str, Any]],
|
||
reviews: List[ReviewRecord],
|
||
all_clusters: Optional[Dict[str, List[ClusterData]]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Persona 命中数/占比:以绑定聚类簇 source_rows 为主,簇内 keyword 为辅。"""
|
||
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
|
||
|
||
enrich_persona_keywords(persona, cluster)
|
||
|
||
if not cluster:
|
||
persona["source_rows"] = []
|
||
persona["hit_count"] = 0
|
||
persona["stat_method"] = "unassigned"
|
||
persona["audience_label"] = -1
|
||
_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")
|
||
|
||
if shared_cluster or (dim == "A" and len(base_rows) > 80):
|
||
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"
|
||
else:
|
||
hit_rows = base_rows
|
||
persona["stat_method"] = "cluster"
|
||
|
||
persona["source_rows"] = sorted(hit_rows)
|
||
persona["hit_count"] = len(hit_rows)
|
||
persona["cluster_review_count"] = len(base_rows)
|
||
persona["audience_label"] = resolve_persona_audience_label(persona, all_clusters or {})
|
||
_apply_persona_pct_display(persona, total)
|
||
|
||
return personas
|
||
|
||
|
||
def _truncate_quote(text: str, max_len: int = 220) -> str:
|
||
text = re.sub(r"\s+", " ", text.strip())
|
||
if len(text) <= max_len:
|
||
return text
|
||
return text[: max_len - 1] + "…"
|
||
|
||
|
||
def _persona_quote_relevant(persona: Dict[str, Any], text: str, is_neg: bool) -> bool:
|
||
"""过滤与 Persona 定义明显矛盾的引用。"""
|
||
t = text.lower()
|
||
name = (persona.get("name") or "").lower()
|
||
pain = (persona.get("core_pain") or "").lower()
|
||
if ("敏感" in name or "sensitive" in " ".join(persona.get("keywords") or [])) and (
|
||
"don't have sensitive" in t or "do not have sensitive" in t or "not sensitive skin" in t
|
||
):
|
||
return False
|
||
if ("粗硬" in name or "coarse" in t or "thick hair" in t) and is_neg is False:
|
||
if not any(k in t for k in ("coarse", "thick", "coarse hair", "thick hair", "coarse/dark")):
|
||
if "粗硬" in pain and "bikini" in t and "pull" not in t:
|
||
pass # 可能仍相关
|
||
if is_neg and _quote_sentiment(text, []) == "pos":
|
||
return False
|
||
return True
|
||
|
||
|
||
def quote_cn_summary(text: str) -> str:
|
||
"""英文引用的一句话中文摘要(规则匹配,无需额外 LLM)。"""
|
||
t = text.lower()
|
||
for patterns, hint in _QUOTE_CN_HINTS:
|
||
if any(p in t for p in patterns):
|
||
return hint
|
||
return "用户反馈摘要"
|
||
|
||
|
||
def pick_persona_quotes(
|
||
personas: List[Dict[str, Any]],
|
||
reviews: List[ReviewRecord],
|
||
) -> List[Dict[str, Any]]:
|
||
"""为每个 Persona 选取代表性引用,优先差评;无匹配时不强行填充。"""
|
||
used_rows: Set[int] = set()
|
||
row_map = {r.source_row: r for r in reviews}
|
||
results = []
|
||
for persona in personas:
|
||
bound_rows = set(persona.get("source_rows") or [])
|
||
if bound_rows:
|
||
matched = [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({
|
||
"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),
|
||
})
|
||
return results
|
||
|
||
|
||
_JTBD_FIELD_KEYS = (
|
||
"core_job",
|
||
"functional_motivation",
|
||
"emotional_motivation",
|
||
"social_motivation",
|
||
"trigger",
|
||
)
|
||
|
||
_JTBD_EMPTY_MARKERS = frozenset({"", "-", "—", "–", "n/a", "none", "null", "无", "暂无", "留空"})
|
||
|
||
_SOCIAL_MOTIVATION_HINTS = (
|
||
"社交", "他人", "朋友", "伴侣", "老公", "老婆", "party", "gift", "recommend",
|
||
"embarrass", "体面", "social", "husband", "wife", "partner", "date", "public",
|
||
"boyfriend", "girlfriend", "family", "mom", "daughter", "son", "people",
|
||
)
|
||
|
||
|
||
def _normalize_jtbd_cell(val: Any) -> str:
|
||
text = str(val or "").strip()
|
||
if text.lower() in _JTBD_EMPTY_MARKERS:
|
||
return "-"
|
||
return text
|
||
|
||
|
||
def _persona_evidence_blob(persona: Dict[str, Any]) -> str:
|
||
parts: List[str] = []
|
||
for key in ("keywords", "core_pain", "core_need", "purchase_motivation", "name"):
|
||
val = persona.get(key)
|
||
if isinstance(val, list):
|
||
parts.extend(str(x) for x in val if x)
|
||
elif val:
|
||
parts.append(str(val))
|
||
return " ".join(parts)
|
||
|
||
|
||
def _jtbd_evidence_tokens(blob: str) -> Tuple[Set[str], str]:
|
||
lower = blob.lower()
|
||
tokens: Set[str] = set()
|
||
for seg in re.findall(r"[a-z]{3,}", lower):
|
||
tokens.add(seg)
|
||
for seg in re.findall(r"[\u4e00-\u9fff]{2,}", blob):
|
||
tokens.add(seg)
|
||
return tokens, lower
|
||
|
||
|
||
def _jtbd_field_has_evidence(text: str, tokens: Set[str], blob: str) -> bool:
|
||
text = _normalize_jtbd_cell(text)
|
||
if text == "-":
|
||
return False
|
||
tl = text.lower()
|
||
blob_lower = blob.lower()
|
||
for token in tokens:
|
||
if len(token) >= 3 and token.isascii() and token in tl:
|
||
return True
|
||
if not token.isascii() and token in text:
|
||
return True
|
||
if len(text) >= 2:
|
||
for i in range(len(text) - 1):
|
||
seg = text[i : i + 2]
|
||
if seg in blob or seg in blob_lower:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _jtbd_social_has_evidence(text: str, tokens: Set[str], blob: str) -> bool:
|
||
if not _jtbd_field_has_evidence(text, tokens, blob):
|
||
return False
|
||
combined = f"{blob} {text}".lower()
|
||
return any(h in combined for h in _SOCIAL_MOTIVATION_HINTS)
|
||
|
||
|
||
def fix_jtbd_fields(
|
||
jtbd: List[Dict[str, Any]],
|
||
personas: Optional[List[Dict[str, Any]]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""修正 functional / emotional 错位,校验各动机字段是否有 Persona 数据佐证。"""
|
||
persona_map = {p.get("name"): p for p in (personas or []) if p.get("name")}
|
||
|
||
for item in jtbd:
|
||
func = _normalize_jtbd_cell(item.get("functional_motivation"))
|
||
emo = _normalize_jtbd_cell(item.get("emotional_motivation"))
|
||
func_is_emotional = func != "-" and any(h in func for h in EMOTIONAL_HINTS)
|
||
emo_is_functional = emo != "-" and not any(h in emo for h in EMOTIONAL_HINTS)
|
||
if emo == "-" and func_is_emotional:
|
||
item["emotional_motivation"] = func
|
||
item["functional_motivation"] = "-"
|
||
elif emo_is_functional and func == "-":
|
||
item["functional_motivation"] = emo
|
||
item["emotional_motivation"] = "-"
|
||
|
||
persona = persona_map.get(item.get("persona", ""), {})
|
||
blob = _persona_evidence_blob(persona)
|
||
tokens, _ = _jtbd_evidence_tokens(blob)
|
||
|
||
for key in _JTBD_FIELD_KEYS:
|
||
raw = _normalize_jtbd_cell(item.get(key))
|
||
if not persona or not blob.strip():
|
||
item[key] = raw if raw != "-" else "-"
|
||
continue
|
||
if key == "social_motivation":
|
||
item[key] = raw if _jtbd_social_has_evidence(raw, tokens, blob) else "-"
|
||
else:
|
||
item[key] = raw if _jtbd_field_has_evidence(raw, tokens, blob) else "-"
|
||
|
||
return jtbd
|
||
|
||
|
||
def filter_matrix_rows(
|
||
matrix: List[Dict[str, Any]],
|
||
market_avg: Optional[float] = None,
|
||
max_personas: int = 4,
|
||
max_scenes_per_persona: int = 2,
|
||
) -> List[Dict[str, Any]]:
|
||
"""过滤空场景行,每人群最多保留 N 个有效场景;全市场低分时校准「高满意度」。"""
|
||
filtered: List[Dict[str, Any]] = []
|
||
per_persona: Dict[str, int] = {}
|
||
seen_personas: List[str] = []
|
||
market_low = market_avg is not None and market_avg < 3.5
|
||
for row in matrix:
|
||
scene = (row.get("scene") or "").strip()
|
||
if not scene or scene in ("—", "-", "N/A"):
|
||
continue
|
||
persona = row.get("persona", "?")
|
||
if persona not in seen_personas:
|
||
if len(seen_personas) >= max_personas:
|
||
continue
|
||
seen_personas.append(persona)
|
||
if per_persona.get(persona, 0) >= max_scenes_per_persona:
|
||
continue
|
||
for key in ("must_be_needs", "performance_needs", "attractive_needs"):
|
||
val = (row.get(key) or "").strip()
|
||
if len(val) > 80:
|
||
row[key] = val[:79] + "…"
|
||
note_val = (row.get("satisfaction_note") or "").strip()
|
||
if len(note_val) > 60:
|
||
row["satisfaction_note"] = note_val[:59] + "…"
|
||
note = row.get("satisfaction_note", "")
|
||
if row.get("satisfaction") == "高":
|
||
if market_low:
|
||
row["satisfaction"] = "中等"
|
||
note = f"全市场均分 {market_avg} 偏低,该场景相对竞品尚可但非绝对满意。{note}"
|
||
elif "场景" not in note:
|
||
note = f"(细分场景相对满意度,非全市场){note}"
|
||
row["satisfaction_note"] = note
|
||
filtered.append(row)
|
||
per_persona[persona] = per_persona.get(persona, 0) + 1
|
||
return filtered
|
||
|
||
|
||
_KANO_NO_REVERSE_ITEM = "本品类暂无明确反向型需求"
|
||
|
||
_KANO_REVERSE_META_HINTS = (
|
||
"暂无明确反向", "候选搜索", "重新判定", "反向型候选", "无明确反向", "其他反向",
|
||
)
|
||
|
||
_KANO_REVERSE_CONCLUSION_HINTS = (
|
||
"暂无明确反向型需求", "暂无明确反向需求", "无明确反向", "低于阈值",
|
||
"未发现明确反向", "判定本品类暂无",
|
||
)
|
||
|
||
|
||
def _is_kano_reverse_meta_item(item: Dict[str, Any]) -> bool:
|
||
"""占位/搜索结论类反向条目,非真实反向需求。"""
|
||
item_text = (item.get("item") or "").strip()
|
||
blob = " ".join(
|
||
str(item.get(k) or "") for k in ("item", "reason", "evidence", "competitor_status")
|
||
)
|
||
if item_text == _KANO_NO_REVERSE_ITEM:
|
||
return True
|
||
if any(h in item_text for h in _KANO_REVERSE_META_HINTS):
|
||
return True
|
||
return any(h in blob for h in _KANO_REVERSE_CONCLUSION_HINTS)
|
||
|
||
|
||
def normalize_kano_reverse_items(kano: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""无明确反向型需求时,统一输出标准占位条目标题。"""
|
||
non_reverse = [it for it in kano if it.get("type") != "reverse"]
|
||
reverse_items = [it for it in kano if it.get("type") == "reverse"]
|
||
real_reverse = [it for it in reverse_items if not _is_kano_reverse_meta_item(it)]
|
||
if real_reverse:
|
||
return non_reverse + real_reverse
|
||
|
||
meta = reverse_items[0] if reverse_items else {}
|
||
placeholder = {
|
||
"type": "reverse",
|
||
"item": _KANO_NO_REVERSE_ITEM,
|
||
"evidence": (meta.get("evidence") or "").strip()
|
||
or "经主动搜索 too many parts / too complicated / too loud / unnecessary / takes too long 等词组,均未达反向型阈值。",
|
||
"affected_persona": (meta.get("affected_persona") or "").strip() or "—",
|
||
"reason": (meta.get("reason") or "").strip() or _KANO_NO_REVERSE_ITEM,
|
||
"competitor_status": meta.get("competitor_status") or "",
|
||
}
|
||
return non_reverse + [placeholder]
|
||
|
||
|
||
def fix_kano_items(kano: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""拆分合并条目,修正反向型误分类。"""
|
||
fixed: List[Dict[str, Any]] = []
|
||
quality_hints = ("缺陷", "故障", "损坏", "脱落", "断裂", "失效", "不工作", "质量")
|
||
reverse_hints = ("too many", "too complicated", "too loud", "功能过多", "太复杂", "太吵")
|
||
for item in kano:
|
||
item_text = item.get("item", "")
|
||
if "·" in item_text or "、" in item_text or "," in item_text:
|
||
parts = re.split(r"[·、,]", item_text)
|
||
for p in parts:
|
||
p = p.strip()
|
||
if p:
|
||
sub = dict(item)
|
||
sub["item"] = p
|
||
fixed.append(sub)
|
||
continue
|
||
t = item.get("type", "performance")
|
||
item_lower = item_text.lower()
|
||
if t == "reverse" and any(h in item_text for h in quality_hints):
|
||
if not any(h in item_lower for h in reverse_hints):
|
||
item["type"] = "must-be"
|
||
fixed.append(item)
|
||
return fixed
|
||
|
||
|
||
_KANO_QUADRANTS: Dict[str, Dict[str, str]] = {
|
||
"must-be": {
|
||
"css": "kano-basic",
|
||
"label": "基本型 Must-be",
|
||
"title": "不满足 → 强烈差评;满足 → 用户不会特别提及",
|
||
"icon": "✕",
|
||
"empty": "暂无基本型需求条目",
|
||
},
|
||
"performance": {
|
||
"css": "kano-perf",
|
||
"label": "期望型 Performance",
|
||
"title": "越好 → 越高分;越差 → 越低分(线性关系)",
|
||
"icon": "↑",
|
||
"empty": "暂无期望型需求条目",
|
||
},
|
||
"attractive": {
|
||
"css": "kano-excite",
|
||
"label": "魅力型 Attractive",
|
||
"title": "有 → 惊喜好评;没有 → 用户不会差评",
|
||
"icon": "★",
|
||
"empty": "暂无魅力型需求条目",
|
||
},
|
||
"reverse": {
|
||
"css": "kano-rev",
|
||
"label": "反向型 Reverse",
|
||
"title": "经扩展搜索验证:用户主动排斥的功能负担;低于门槛时输出搜索过程与结论",
|
||
"icon": "⊘",
|
||
"empty": _KANO_NO_REVERSE_ITEM,
|
||
},
|
||
}
|
||
|
||
|
||
def _html_esc(text: str) -> str:
|
||
import html as html_mod
|
||
return html_mod.escape(str(text or ""), quote=False)
|
||
|
||
|
||
def _render_kano_item(item: Dict[str, Any], icon: str) -> str:
|
||
"""单条 KANO 需求五字段卡片行。"""
|
||
item_name = _html_esc(item.get("item") or "—")
|
||
evidence = _html_esc(item.get("evidence") or "—")
|
||
persona = _html_esc(item.get("affected_persona") or "—")
|
||
reason = _html_esc(item.get("reason") or "—")
|
||
competitor = _html_esc(item.get("competitor_status") or "—")
|
||
is_reverse_meta = _is_kano_reverse_meta_item(item)
|
||
if is_reverse_meta and not item.get("competitor_status"):
|
||
rows = (
|
||
f'<div class="kf-row"><span class="kf-key">搜索/证据:</span>{evidence}</div>'
|
||
f'<div class="kf-row"><span class="kf-key">结论:</span>{reason}</div>'
|
||
)
|
||
if competitor != "—":
|
||
rows += f'<div class="kf-row"><span class="kf-key">补充:</span>{competitor}</div>'
|
||
else:
|
||
rows = (
|
||
f'<div class="kf-row"><span class="kf-key">频次证据:</span>{evidence}</div>'
|
||
f'<div class="kf-row"><span class="kf-key">影响 Persona:</span>{persona}</div>'
|
||
f'<div class="kf-row"><span class="kf-key">分类原因:</span>{reason}</div>'
|
||
f'<div class="kf-row"><span class="kf-key">竞品现状:</span>{competitor}</div>'
|
||
)
|
||
return (
|
||
f'<div class="kano-item">'
|
||
f'<div class="kano-icon">{icon}</div>'
|
||
f'<div class="kano-fields">'
|
||
f'<div class="kf-name">{item_name}</div>'
|
||
f"{rows}"
|
||
f"</div></div>"
|
||
)
|
||
|
||
|
||
def build_kano_grid_html(kano: List[Dict[str, Any]]) -> str:
|
||
"""KANO 四象限 2×2 卡片布局(基本型/期望型/魅力型/反向型)。"""
|
||
by_type: Dict[str, List[Dict[str, Any]]] = {
|
||
"must-be": [], "performance": [], "attractive": [], "reverse": [],
|
||
}
|
||
for item in kano:
|
||
t = item.get("type", "performance")
|
||
if t in by_type:
|
||
by_type[t].append(item)
|
||
|
||
cards: List[str] = []
|
||
for ktype in ("must-be", "performance", "attractive", "reverse"):
|
||
meta = _KANO_QUADRANTS[ktype]
|
||
items = by_type[ktype]
|
||
if items:
|
||
body = "".join(_render_kano_item(it, meta["icon"]) for it in items)
|
||
else:
|
||
body = f'<p class="kano-empty">{meta["empty"]}</p>'
|
||
cards.append(
|
||
f'<div class="kano-card {meta["css"]}">'
|
||
f'<div class="kano-label">{meta["label"]}</div>'
|
||
f'<div class="kano-title">{meta["title"]}</div>'
|
||
f"{body}"
|
||
f"</div>"
|
||
)
|
||
return f'<div class="kano-grid">{"".join(cards)}</div>'
|
||
|
||
|
||
def _valid_asins(reviews: List[ReviewRecord]) -> Set[str]:
|
||
return {r.asin for r in reviews if VALID_ASIN_RE.match(r.asin)}
|
||
|
||
|
||
def _quote_sentiment(text: str, keywords: List[str]) -> str:
|
||
"""判断引用与根因方向是否一致(negative root cause should not use praise)。"""
|
||
t = text.lower()
|
||
neg_markers = (
|
||
"not recommend", "do not", "don't", "never", "bad", "terrible", "broken",
|
||
"waste", "zero star", "not waterproof", "isn't waterproof", "not water proof",
|
||
"stopped working", "doesn't work", "does not work", "disappointed", "return",
|
||
)
|
||
if any(p in t for p in neg_markers):
|
||
return "neg"
|
||
conditional_praise = ("waterproof", "easy to clean", "works well")
|
||
if any(p in t for p in conditional_praise):
|
||
if any(k in t for k in ("not", "never", "bad", "terrible", "broken", "fail", "but", "however", " although", "stopped", "after")):
|
||
return "neg"
|
||
praise = ("love", "great", "perfect", "amazing")
|
||
if any(p in t for p in praise) and not any(k in t for k in ("not", "never", "bad", "terrible", "broken")):
|
||
return "pos"
|
||
return "neg"
|
||
|
||
|
||
def _rootcause_relaxed_pool(
|
||
neg_reviews: List[ReviewRecord],
|
||
bound_rows: Set[int],
|
||
used_rows: Set[int],
|
||
persona: Dict[str, Any],
|
||
valid_asins: Set[str],
|
||
) -> List[ReviewRecord]:
|
||
"""匹配失败时:从 Persona source_rows 内任取 ≤2★ 差评(不要求关键词命中)。"""
|
||
if bound_rows:
|
||
pool = [r for r in neg_reviews if r.source_row in bound_rows]
|
||
else:
|
||
pool = list(neg_reviews)
|
||
return [
|
||
r for r in pool
|
||
if r.source_row not in used_rows
|
||
and r.asin in valid_asins
|
||
and _persona_quote_relevant(persona, r.content, True)
|
||
]
|
||
|
||
|
||
def _append_rootcause_quote(
|
||
cause: Dict[str, Any],
|
||
review: ReviewRecord,
|
||
used_rows: Set[int],
|
||
*,
|
||
reused_from_persona: bool = False,
|
||
) -> None:
|
||
if not reused_from_persona:
|
||
used_rows.add(review.source_row)
|
||
entry: Dict[str, Any] = {
|
||
"text": _truncate_quote(review.content),
|
||
"asin": review.asin,
|
||
"rating": review.rating,
|
||
"cn_summary": quote_cn_summary(review.content),
|
||
}
|
||
if reused_from_persona:
|
||
entry["reused_from_persona"] = True
|
||
cause["quotes"].append(entry)
|
||
|
||
|
||
def _persona_card_quote_entry(
|
||
persona_quotes: Optional[List[Dict[str, Any]]],
|
||
persona_name: str,
|
||
reviews: List[ReviewRecord],
|
||
) -> Optional[ReviewRecord]:
|
||
"""画像区已选引用 → ReviewRecord(用于根因空 quotes 降级复用)。"""
|
||
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"):
|
||
return None
|
||
asin = pq.get("asin") or ""
|
||
text = pq.get("quote") or ""
|
||
rating = pq.get("rating")
|
||
row_map = {r.source_row: r for r in reviews}
|
||
for r in row_map.values():
|
||
if r.asin == asin and text[:80] in r.content:
|
||
return r
|
||
if asin and rating is not None:
|
||
return ReviewRecord(
|
||
asin=asin,
|
||
rating=float(rating),
|
||
title="",
|
||
content=text,
|
||
verified=False,
|
||
vine=False,
|
||
review_date="",
|
||
source_row=-1,
|
||
)
|
||
return None
|
||
|
||
|
||
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,
|
||
) -> List[Dict[str, Any]]:
|
||
"""从真实评论回填根因引用;匹配失败时放宽至 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}
|
||
|
||
for rc in rootcauses:
|
||
pname = rc.get("persona_name", "")
|
||
persona = persona_map.get(pname, {})
|
||
kws = expand_keywords(persona.get("keywords") or [])
|
||
bound_rows = set(persona.get("source_rows") or [])
|
||
themes = rc.get("affected_themes") or []
|
||
theme_kws = expand_keywords(themes)
|
||
used_rows: Set[int] = set()
|
||
persona_card_review = _persona_card_quote_entry(persona_quotes, pname, reviews)
|
||
|
||
for cause in rc.get("root_causes", []):
|
||
cause["quotes"] = []
|
||
cause_kws = expand_keywords(cause.get("quote_keywords") or [])
|
||
search_kws = cause_kws or kws + theme_kws
|
||
pool = neg_reviews if not bound_rows else [r for r in neg_reviews if r.source_row in bound_rows]
|
||
|
||
def _filter_candidates(cands: List[ReviewRecord]) -> List[ReviewRecord]:
|
||
return [
|
||
r for r in cands
|
||
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
|
||
])
|
||
if not candidates:
|
||
candidates = _filter_candidates([
|
||
r for r in pool
|
||
if r.source_row not in used_rows
|
||
and match_text(kws + theme_kws, r.title + " " + r.content)
|
||
and r.asin in valid_asins
|
||
])
|
||
if not candidates:
|
||
candidates = _rootcause_relaxed_pool(
|
||
neg_reviews, bound_rows, used_rows, persona, valid_asins,
|
||
)
|
||
|
||
candidates.sort(key=lambda r: abs(len(r.content) - 160))
|
||
for r in candidates[:2]:
|
||
_append_rootcause_quote(cause, r, used_rows)
|
||
|
||
if not cause["quotes"] and persona_card_review is not None:
|
||
_append_rootcause_quote(
|
||
cause, persona_card_review, used_rows, reused_from_persona=True,
|
||
)
|
||
|
||
return rootcauses
|
||
|
||
|
||
def enhanced_market_judgment(stats: MarketStats) -> Tuple[str, str]:
|
||
title, desc = market_competition_judgment(stats.weighted_avg_rating)
|
||
if stats.asins:
|
||
worst = min(stats.asins, key=lambda a: a.avg_rating)
|
||
best = max(stats.asins, key=lambda a: a.avg_rating)
|
||
desc += (
|
||
f" 最差竞品 {worst.asin}(均分 {worst.avg_rating},差评率 {int(worst.neg_rate * 100)}%);"
|
||
f"最佳 {best.asin}(均分 {best.avg_rating},正评率 {int(best.pos_rate * 100)}%)。"
|
||
)
|
||
if stats.weighted_avg_rating < 3.5:
|
||
desc += (
|
||
" 建议对标最佳竞品提炼可复制的 Must-be 能力(刀头/续航/结构),"
|
||
"并在其薄弱维度(如耐用性或性价比感知)建立差异化。"
|
||
)
|
||
return title, desc
|
||
|
||
|
||
def build_theme_asymmetry_insights(
|
||
neg_freq: Dict[str, int],
|
||
pos_freq: Dict[str, int],
|
||
pos_themes: Optional[List[Dict[str, Any]]] = None,
|
||
neg_themes: Optional[List[Dict[str, Any]]] = None,
|
||
) -> List[str]:
|
||
"""好评/差评不对称洞察(自动配对主题名)。"""
|
||
insights: List[str] = []
|
||
pairs = infer_pos_neg_theme_pairs(pos_themes or [], neg_themes or [])
|
||
for pos_name, neg_name in pairs:
|
||
pc = pos_freq.get(pos_name, 0)
|
||
nc = neg_freq.get(neg_name, 0)
|
||
if nc >= 10 and pc <= nc * 0.15:
|
||
insights.append(
|
||
f"「{neg_name}」差评 {nc} 条,但「{pos_name}」好评仅 {pc} 条——"
|
||
f"用户很少主动表扬该维度,却是显性痛点,应作为产品定义底线。"
|
||
)
|
||
# 通用:任意含「性价比/价格/value/price」的差评 vs 对应好评
|
||
for neg_name, nc in neg_freq.items():
|
||
if nc < 15:
|
||
continue
|
||
if not any(k in neg_name for k in ("性价比", "价格", "price", "value", "贵")):
|
||
continue
|
||
pos_match = next(
|
||
(p for p in pos_freq if any(k in p for k in ("性价比", "价格", "value", "price")) and pos_freq[p] >= 0),
|
||
None,
|
||
)
|
||
if pos_match is not None and pos_freq.get(pos_match, 0) <= nc * 0.1:
|
||
insights.append(
|
||
f"「{neg_name}」差评 {nc} 条,对应好评维度「{pos_match}」仅 {pos_freq.get(pos_match, 0)} 条——"
|
||
"价格/价值感知是购买决策主要障碍,需在成本与感知价值间重新平衡。"
|
||
)
|
||
break
|
||
return insights[:5]
|
||
|
||
|
||
def build_asin_theme_insights(
|
||
stats: MarketStats,
|
||
neg_themes: List[Dict[str, Any]],
|
||
per_asin_neg: Dict[str, Dict[str, int]],
|
||
asin_labels: Dict[str, str],
|
||
) -> List[str]:
|
||
"""各 ASIN 主题对比的文字结论。"""
|
||
if not stats.asins or not neg_themes:
|
||
return []
|
||
insights = []
|
||
top_themes = sorted(
|
||
[(t["name"], t.get("freq_count", 0)) for t in neg_themes],
|
||
key=lambda x: -x[1],
|
||
)[:3]
|
||
for theme_name, _ in top_themes:
|
||
counts = [(a.asin, per_asin_neg.get(a.asin, {}).get(theme_name, 0)) for a in stats.asins]
|
||
counts = [(a, c) for a, c in counts if c > 0]
|
||
if not counts:
|
||
continue
|
||
counts.sort(key=lambda x: -x[1])
|
||
worst_asin, worst_cnt = counts[0]
|
||
label = asin_labels.get(worst_asin, worst_asin)
|
||
insights.append(f"「{theme_name}」集中出现在 {label}({worst_cnt} 条),可作为对标突破口。")
|
||
return insights[:4]
|
||
|
||
|
||
def build_product_conclusion(
|
||
stats: MarketStats,
|
||
neg_freq: Dict[str, int],
|
||
pos_freq: Dict[str, int],
|
||
neg_themes: List[Dict[str, Any]],
|
||
) -> str:
|
||
"""一句话产品定义结论。"""
|
||
top_neg = sorted(neg_freq.items(), key=lambda x: -x[1])[:3]
|
||
top_pos = sorted(pos_freq.items(), key=lambda x: -x[1])[:2]
|
||
must_fix = "、".join(n for n, _ in top_neg) if top_neg else "核心质量缺陷"
|
||
must_have = "、".join(n for n, _ in top_pos) if top_pos else "基础修剪体验"
|
||
if stats.weighted_avg_rating < 3.5:
|
||
return (
|
||
f"新品进入建议:Must-be 优先解决 {must_fix};"
|
||
f"Performance 对齐 {must_have};"
|
||
f"Attractive 可在好评高频但差评未覆盖的维度建立差异化感知。"
|
||
)
|
||
return f"改进建议:优先修复 {must_fix},保留并强化 {must_have} 卖点。"
|
||
|
||
|
||
def build_executive_summary(
|
||
stats: MarketStats,
|
||
neg_freq: Dict[str, int],
|
||
pos_freq: Dict[str, int],
|
||
neg_themes: List[Dict[str, Any]],
|
||
asin_labels: Optional[Dict[str, str]] = None,
|
||
pos_themes: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Dict[str, Any]:
|
||
asin_labels = asin_labels or {}
|
||
bullets = [
|
||
f"样本:{stats.total_reviews:,} 条有效评论 · {len(stats.asins)} 个竞品 · 加权均分 {stats.weighted_avg_rating} · 差评率 {int(stats.neg_rate * 100)}%",
|
||
]
|
||
if stats.asins:
|
||
worst = min(stats.asins, key=lambda a: a.avg_rating)
|
||
best = max(stats.asins, key=lambda a: a.avg_rating)
|
||
wlabel = asin_labels.get(worst.asin, worst.asin)
|
||
blabel = asin_labels.get(best.asin, best.asin)
|
||
bullets.append(
|
||
f"最弱竞品 {wlabel}({worst.total} 条评论,差评率 {int(worst.neg_rate * 100)}%)— 重点对标其短板"
|
||
)
|
||
bullets.append(
|
||
f"最佳竞品 {blabel}(均分 {best.avg_rating},正评率 {int(best.pos_rate * 100)}%)— 提炼可复制卖点"
|
||
)
|
||
top_neg = [(n, c) for n, c in sorted(neg_freq.items(), key=lambda x: -x[1]) if c > 0][:3]
|
||
if top_neg:
|
||
bullets.append("核心差评主题:" + ";".join(f"{n}({c}条)" for n, c in top_neg))
|
||
else:
|
||
bullets.append("差评主题频次待确认(请检查主题关键词匹配)")
|
||
top_pos = sorted(pos_freq.items(), key=lambda x: -x[1])[:3]
|
||
if top_pos:
|
||
bullets.append("核心好评卖点:" + ";".join(f"{n}({c}条)" for n, c in top_pos))
|
||
|
||
conclusion = build_product_conclusion(stats, neg_freq, pos_freq, neg_themes)
|
||
insights = build_theme_asymmetry_insights(
|
||
neg_freq, pos_freq,
|
||
pos_themes=pos_themes or [{"name": n} for n in pos_freq],
|
||
neg_themes=neg_themes,
|
||
)
|
||
|
||
opps = []
|
||
ranked_themes = sorted(
|
||
[t for t in neg_themes if neg_freq.get(t["name"], 0) > 0],
|
||
key=lambda t: (neg_freq.get(t["name"], 0), -t.get("freq_rank", 99)),
|
||
reverse=True,
|
||
)
|
||
for t in ranked_themes[:5]:
|
||
cnt = neg_freq.get(t["name"], 0)
|
||
disp = t.get("display_priority", t.get("priority", "P2"))
|
||
opps.append(f"[{disp}] {t['name']}:{cnt} 条差评(占差评比 {round(cnt / max(stats.neg_review_count, 1) * 100)}%)")
|
||
if not opps and top_neg:
|
||
for n, c in top_neg:
|
||
opps.append(f"改进「{n}」— 覆盖 {c} 条差评")
|
||
|
||
return {
|
||
"bullets": bullets,
|
||
"opportunities": opps,
|
||
"conclusion": conclusion,
|
||
"insights": insights,
|
||
}
|
||
|
||
|
||
def rank_keywords_for_display(keywords: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""情感词频展示排序:负面 > 正面 > 中性(中性噪声词靠后)。"""
|
||
def _sort_key(kw: Dict[str, Any]) -> Tuple[int, int, str]:
|
||
sent = kw.get("sentiment", "中性")
|
||
word = (kw.get("word") or "").lower()
|
||
sent_order = {"负面": 0, "正面": 1, "中性": 2}.get(sent, 3)
|
||
noise = 1 if word in _NEUTRAL_NOISE else 0
|
||
return (sent_order, noise, word)
|
||
|
||
return sorted(keywords, key=_sort_key)
|
||
|
||
|
||
def filter_display_keywords(
|
||
keywords: List[Dict[str, Any]],
|
||
limit: int = 30,
|
||
product_name: str = "",
|
||
) -> List[Dict[str, Any]]:
|
||
"""过滤中性噪声词,按决策价值排序后取前 N 条。"""
|
||
sw = build_product_stopwords(product_name)
|
||
kept: List[Dict[str, Any]] = []
|
||
for kw in keywords:
|
||
word = (kw.get("word") or "").lower()
|
||
sent = kw.get("sentiment", "中性")
|
||
if word in _NEUTRAL_NOISE and sent == "中性":
|
||
continue
|
||
if word in sw:
|
||
continue
|
||
kept.append(kw)
|
||
return rank_keywords_for_display(kept)[:limit]
|
||
|
||
|
||
# ── 情感关键词(双表:差评词 / 好评词)──
|
||
|
||
_STRONG_NEG_HINTS = (
|
||
"nick", "bleed", "cut me", "cut my", "pull", "tug", "snag", "burn", "razor burn", "irritat", "bleeding",
|
||
)
|
||
_ATTRACTIVE_KW_HINTS = ("amazing", "cute", "pretty", "bonus", "comes with", "didn't expect", "gift")
|
||
_STRONG_POS_EMOTION_HINTS = ("love", "obsessed", "perfect")
|
||
_SCENE_POS_HINTS = ("shower", "travel", "waterproof", "bathroom", "portable")
|
||
_STRONG_POS_HINTS = ("smooth", "painless", "gentle")
|
||
|
||
_POLARITY_PILL = {
|
||
"强负面": "pill-danger",
|
||
"负面": "pill-warn",
|
||
"强正面": "pill-success",
|
||
"强正面情感": "pill-success",
|
||
"魅力型信号": "pill-success",
|
||
"正面场景": "pill-success",
|
||
"正面": "pill-success",
|
||
}
|
||
|
||
|
||
def words_display_label(keywords: List[str], max_parts: int = 4) -> str:
|
||
"""从主题 keywords 归纳英文词组展示标签(如 cut / nick / bleeding)。"""
|
||
segs: List[str] = []
|
||
for kw in keywords or []:
|
||
for seg in phrase_segments([kw]):
|
||
if len(seg) < 3:
|
||
continue
|
||
if seg not in segs:
|
||
segs.append(seg)
|
||
if not segs:
|
||
return (keywords[0] if keywords else "?")[:48]
|
||
segs.sort(key=lambda s: (len(s) > 12, len(s)))
|
||
return " / ".join(segs[:max_parts])
|
||
|
||
|
||
def build_sentiment_keyword_groups(
|
||
themes: List[Dict[str, Any]],
|
||
reviews: List[Any],
|
||
limit: int = 6,
|
||
is_neg: bool = True,
|
||
id_prefix: str = "g",
|
||
) -> List[Dict[str, Any]]:
|
||
"""从主题 keywords 构建词组;count 按评论去重(组内任一词命中计 1)。"""
|
||
from echarts_builder import calc_keyword_group_freq
|
||
|
||
scored: List[Tuple[int, Dict[str, Any]]] = []
|
||
for theme in themes:
|
||
kws = theme.get("keywords") or []
|
||
count = calc_keyword_group_freq(reviews, kws, is_neg=is_neg)
|
||
if count > 0:
|
||
scored.append((count, theme))
|
||
scored.sort(key=lambda x: x[0], reverse=True)
|
||
|
||
groups: List[Dict[str, Any]] = []
|
||
for i, (count, theme) in enumerate(scored[:limit]):
|
||
name = theme.get("name", "")
|
||
kws = theme.get("keywords") or []
|
||
groups.append({
|
||
"id": f"{id_prefix}{i}",
|
||
"words": words_display_label(kws),
|
||
"count": count,
|
||
"theme_name": name,
|
||
"match_keywords": kws,
|
||
})
|
||
return groups
|
||
|
||
|
||
def infer_polarity_label(words: str, is_negative: bool) -> str:
|
||
w = (words or "").lower()
|
||
if is_negative:
|
||
if any(h in w for h in _STRONG_NEG_HINTS):
|
||
return "强负面"
|
||
return "负面"
|
||
if any(h in w for h in _ATTRACTIVE_KW_HINTS):
|
||
return "魅力型信号"
|
||
if any(h in w for h in _STRONG_POS_EMOTION_HINTS):
|
||
return "强正面情感"
|
||
if any(h in w for h in _SCENE_POS_HINTS):
|
||
return "正面场景"
|
||
if any(h in w for h in _STRONG_POS_HINTS):
|
||
return "强正面"
|
||
return "正面"
|
||
|
||
|
||
def _match_personas_for_keywords(
|
||
keywords: List[str],
|
||
personas: List[Dict[str, Any]],
|
||
max_n: int = 2,
|
||
) -> List[str]:
|
||
"""按 Persona keywords 与词组匹配度推断关联人群。"""
|
||
blob = " ".join(expand_keywords(keywords)).lower()
|
||
if not blob.strip():
|
||
return ["全部群体"]
|
||
scored: List[Tuple[int, str]] = []
|
||
for p in personas:
|
||
name = p.get("name", "")
|
||
if not name:
|
||
continue
|
||
score = 0
|
||
for sig in _persona_text_signals(p):
|
||
if len(sig) >= 3 and sig.lower() in blob:
|
||
score += len(sig)
|
||
elif len(sig) >= 4 and sig.lower() in " ".join(keywords).lower():
|
||
score += 3
|
||
if score > 0:
|
||
scored.append((score, name))
|
||
scored.sort(reverse=True)
|
||
if scored:
|
||
return [n for _, n in scored[:max_n]]
|
||
return ["全部群体"]
|
||
|
||
|
||
def finalize_sentiment_keywords(
|
||
llm_data: Dict[str, Any],
|
||
neg_groups: List[Dict[str, Any]],
|
||
pos_groups: List[Dict[str, Any]],
|
||
personas: List[Dict[str, Any]],
|
||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||
"""合并 LLM 标注与系统统计 count(count 以主题频次为准,LLM 不可改)。"""
|
||
valid_personas = {p.get("name") for p in personas if p.get("name")}
|
||
|
||
def _merge(groups: List[Dict[str, Any]], side: List[Dict[str, Any]], is_neg: bool) -> List[Dict[str, Any]]:
|
||
by_id = {item.get("id"): item for item in side if item.get("id")}
|
||
out: List[Dict[str, Any]] = []
|
||
for i, g in enumerate(groups):
|
||
ann = by_id.get(g["id"]) or (side[i] if i < len(side) else {})
|
||
words = (ann.get("words") or g.get("words") or "?").strip()
|
||
polarity = (
|
||
ann.get("polarity_label") or ann.get("polarity") or ""
|
||
).strip() or infer_polarity_label(words, is_neg)
|
||
meaning = (ann.get("meaning") or "").strip()
|
||
if not meaning:
|
||
meaning = g.get("theme_name", "")
|
||
if len(meaning) > 80:
|
||
meaning = meaning[:79] + "…"
|
||
related = [n for n in (ann.get("related_personas") or []) if n in valid_personas]
|
||
if not related:
|
||
related = _match_personas_for_keywords(g.get("match_keywords") or [], personas)
|
||
out.append({
|
||
"words": words,
|
||
"count": g.get("count", 0),
|
||
"polarity_label": polarity,
|
||
"meaning": meaning,
|
||
"related_personas": related,
|
||
"row_class": "row-r" if polarity == "强负面" else ("row-y" if is_neg else "row-g"),
|
||
})
|
||
return out
|
||
|
||
neg_side = llm_data.get("negative") if isinstance(llm_data.get("negative"), list) else []
|
||
pos_side = llm_data.get("positive") if isinstance(llm_data.get("positive"), list) else []
|
||
if not neg_side and isinstance(llm_data.get("keywords"), list):
|
||
neg_side = [k for k in llm_data["keywords"] if k.get("sentiment") == "负面"]
|
||
pos_side = [k for k in llm_data["keywords"] if k.get("sentiment") == "正面"]
|
||
|
||
return _merge(neg_groups, neg_side, True), _merge(pos_groups, pos_side, False)
|
||
|
||
|
||
def _keyword_table_rows(items: List[Dict[str, Any]], default_row: str) -> str:
|
||
rows: List[str] = []
|
||
for item in items:
|
||
pill = _POLARITY_PILL.get(item.get("polarity_label", ""), "pill-gray")
|
||
related = "、".join(item.get("related_personas") or [])
|
||
row_cls = item.get("row_class", default_row)
|
||
rows.append(
|
||
f'<tr class="{row_cls}"><td>{item.get("words", "")}</td>'
|
||
f'<td class="num">{item.get("count", 0)}</td>'
|
||
f'<td><span class="pill {pill}">{item.get("polarity_label", "")}</span></td>'
|
||
f'<td>{item.get("meaning", "")}</td>'
|
||
f"<td>{related}</td></tr>"
|
||
)
|
||
return "\n".join(rows)
|
||
|
||
|
||
def build_keyword_tables_html(
|
||
neg_items: List[Dict[str, Any]],
|
||
pos_items: List[Dict[str, Any]],
|
||
) -> Tuple[str, str]:
|
||
"""返回 (差评表 tbody 行, 好评表 tbody 行)。"""
|
||
return _keyword_table_rows(neg_items, "row-y"), _keyword_table_rows(pos_items, "row-g")
|
||
|
||
|
||
def prepare_keyword_display(
|
||
neg_themes: List[Dict[str, Any]],
|
||
pos_themes: List[Dict[str, Any]],
|
||
reviews: List[Any],
|
||
personas: List[Dict[str, Any]],
|
||
llm_raw: Any,
|
||
limit: int = 6,
|
||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||
"""构建词组 → 合并 LLM 标注 → 返回 (neg_groups, pos_groups, neg_display, pos_display)。"""
|
||
neg_groups = build_sentiment_keyword_groups(
|
||
neg_themes, reviews, limit=limit, is_neg=True, id_prefix="g",
|
||
)
|
||
pos_groups = build_sentiment_keyword_groups(
|
||
pos_themes, reviews, limit=limit, is_neg=False, id_prefix="g",
|
||
)
|
||
llm_data = llm_raw if isinstance(llm_raw, dict) else {"negative": [], "positive": []}
|
||
neg_disp, pos_disp = finalize_sentiment_keywords(llm_data, neg_groups, pos_groups, personas)
|
||
return neg_groups, pos_groups, neg_disp, pos_disp
|
||
|
||
|
||
def sort_personas_by_evidence(personas: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""按命中评论数降序排列,无命中排后。"""
|
||
return sorted(
|
||
personas,
|
||
key=lambda p: (p.get("hit_count", 0) > 0, p.get("hit_count", 0)),
|
||
reverse=True,
|
||
)
|
||
|
||
|
||
def sort_rootcauses_by_evidence(
|
||
rootcauses: List[Dict[str, Any]],
|
||
personas: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""根因卡片按 Persona 命中数排序。"""
|
||
pmap = {p.get("name"): p.get("hit_count", 0) for p in personas}
|
||
return sorted(rootcauses, key=lambda rc: pmap.get(rc.get("persona_name", ""), 0), reverse=True)
|
||
|
||
|
||
def truncate_cell(text: str, max_len: int = 80) -> str:
|
||
"""表格单元格截断,保留完整文本于 title 属性。"""
|
||
text = (text or "").strip()
|
||
if len(text) <= max_len:
|
||
return text
|
||
return text[: max_len - 1] + "…"
|
||
|
||
|
||
def jtbd_cell_html(text: str, max_len: int = 72) -> str:
|
||
"""JTBD 表格单元格:截断 + hover 全文。"""
|
||
full = _normalize_jtbd_cell(text)
|
||
if full == "-":
|
||
return "-"
|
||
display = truncate_cell(full, max_len)
|
||
if display == full:
|
||
return full
|
||
esc = full.replace('"', """)
|
||
return f'<span title="{esc}">{display}</span>'
|
||
|
||
|
||
def asin_link_with_label(asin: str, labels: Dict[str, str]) -> str:
|
||
"""ASIN 链接 + 可读标签。"""
|
||
label = labels.get(asin, asin)
|
||
url = f"https://www.amazon.com/dp/{asin}"
|
||
return f'<a href="{url}" target="_blank" rel="noopener" title="{asin}">{label}</a>'
|
||
|
||
|
||
def get_layout_config(cfg: Optional[dict] = None) -> Dict[str, int]:
|
||
"""大品类报告布局阈值(通用,不限定品类)。"""
|
||
cfg = cfg or {}
|
||
return {
|
||
"large_asin_threshold": int(cfg.get("large_asin_threshold", 12)),
|
||
"heatmap_asin_threshold": int(cfg.get("heatmap_asin_threshold", 30)),
|
||
"asin_table_top_n": int(cfg.get("asin_table_top_n", 10)),
|
||
"star_summary_top_n": int(cfg.get("star_summary_top_n", 15)),
|
||
"theme_mini_top_n": int(cfg.get("theme_mini_top_n", 8)),
|
||
}
|
||
|
||
|
||
def is_large_market(asin_count: int, threshold: int = 12) -> bool:
|
||
return asin_count > threshold
|
||
|
||
|
||
def sorted_asin_stats(stats: MarketStats, *, by_reviews: bool = True) -> List[ASINStats]:
|
||
if by_reviews:
|
||
return sorted(stats.asins, key=lambda a: (-a.total, -a.avg_rating))
|
||
return sorted(stats.asins, key=lambda a: (-a.avg_rating, -a.total))
|
||
|
||
|
||
def build_asin_labels(stats: MarketStats) -> Dict[str, str]:
|
||
"""ASIN → 可读标签(按均分从高到低编号竞品A/B/…/Z/#27)。"""
|
||
sorted_asins = sorted(stats.asins, key=lambda a: (-a.avg_rating, -a.total))
|
||
labels: Dict[str, str] = {}
|
||
for i, a in enumerate(sorted_asins):
|
||
code = chr(ord("A") + i) if i < 26 else str(i + 1)
|
||
labels[a.asin] = f"竞品{code}·{a.avg_rating}分"
|
||
return labels
|
||
|
||
|
||
def build_asin_short_codes(stats: MarketStats) -> Dict[str, str]:
|
||
"""ASIN → 图表短码(A/B/…/Z/#27),与 build_asin_labels 同排序。"""
|
||
sorted_asins = sorted(stats.asins, key=lambda a: (-a.avg_rating, -a.total))
|
||
return {
|
||
a.asin: (chr(ord("A") + i) if i < 26 else f"#{i + 1}")
|
||
for i, a in enumerate(sorted_asins)
|
||
}
|
||
|
||
|
||
def _asin_row_class(a: ASINStats) -> Tuple[str, str, str]:
|
||
avg_class = "danger" if a.avg_rating < 3.0 else ("warn" if a.avg_rating < 3.5 else "")
|
||
row_class = "row-r" if a.avg_rating < 3.0 else ("row-y" if a.avg_rating < 3.5 else "row-g")
|
||
avg_display = f"{a.avg_rating}" + (" ⚠" if a.avg_rating < 3.0 else "")
|
||
return avg_class, row_class, avg_display
|
||
|
||
|
||
def format_star_dist_cell(star_dist: Dict[int, int]) -> str:
|
||
parts = [f"{s}★:{star_dist.get(s, 0)}" for s in (5, 4, 3, 2, 1)]
|
||
return " · ".join(parts)
|
||
|
||
|
||
def build_asin_table_row(
|
||
a: ASINStats,
|
||
asin_labels: Dict[str, str],
|
||
*,
|
||
asin_link: str,
|
||
compact: bool = False,
|
||
) -> str:
|
||
avg_class, row_class, avg_display = _asin_row_class(a)
|
||
label = asin_labels.get(a.asin, a.asin)
|
||
sd = a.star_dist
|
||
if compact:
|
||
star_cell = f'<td class="num star-dist" title="{format_star_dist_cell(sd)}">详情</td>'
|
||
return (
|
||
f'<tr class="{row_class}"><td>{label}</td><td>{asin_link}</td>'
|
||
f'<td class="num">{a.total}</td>'
|
||
f'<td class="num {avg_class}" style="font-weight:700">{avg_display}</td>'
|
||
f'<td class="num">{int(a.pos_rate * 100)}%</td>'
|
||
f'<td class="num">{int(a.neg_rate * 100)}%</td>{star_cell}</tr>'
|
||
)
|
||
return (
|
||
f'<tr class="{row_class}"><td>{label}</td><td>{asin_link}</td>'
|
||
f'<td class="num">{a.total}</td>'
|
||
f'<td class="num {avg_class}" style="font-weight:700">{avg_display}</td>'
|
||
f'<td class="num">{int(a.pos_rate * 100)}%</td>'
|
||
f'<td class="num">{int(a.neg_rate * 100)}%</td>'
|
||
f'<td class="num">{sd.get(5, 0)}</td><td class="num">{sd.get(4, 0)}</td>'
|
||
f'<td class="num">{sd.get(3, 0)}</td><td class="num">{sd.get(2, 0)}</td>'
|
||
f'<td class="num">{sd.get(1, 0)}</td></tr>'
|
||
)
|
||
|
||
|
||
def build_asin_tables_html(
|
||
stats: MarketStats,
|
||
asin_labels: Dict[str, str],
|
||
asin_link_fn,
|
||
*,
|
||
top_n: int = 10,
|
||
large_threshold: int = 12,
|
||
) -> Tuple[str, str, str]:
|
||
"""返回 (摘要表 HTML, 附录 HTML, 说明 note)。"""
|
||
n = len(stats.asins)
|
||
if n <= large_threshold:
|
||
thead = (
|
||
"<thead><tr><th>竞品</th><th>ASIN</th><th class=\"num\">有效评论数</th>"
|
||
"<th class=\"num\">均分</th><th class=\"num\">正评率(≥4★)</th><th class=\"num\">差评率(≤2★)</th>"
|
||
"<th class=\"num\">5★</th><th class=\"num\">4★</th><th class=\"num\">3★</th><th class=\"num\">2★</th><th class=\"num\">1★</th></tr></thead>"
|
||
)
|
||
rows = [
|
||
build_asin_table_row(a, asin_labels, asin_link=asin_link_fn(a.asin))
|
||
for a in sorted_asin_stats(stats)
|
||
]
|
||
table = f"<table>{thead}<tbody>{''.join(rows)}</tbody></table>"
|
||
return table, "", ""
|
||
|
||
ranked = sorted_asin_stats(stats)
|
||
top = ranked[:top_n]
|
||
rest = ranked[top_n:]
|
||
compact_head = (
|
||
"<thead><tr><th>竞品</th><th>ASIN</th><th class=\"num\">有效评论数</th>"
|
||
"<th class=\"num\">均分</th><th class=\"num\">正评率(≥4★)</th><th class=\"num\">差评率(≤2★)</th>"
|
||
"<th class=\"num\">星分布</th></tr></thead>"
|
||
)
|
||
summary_rows = [
|
||
build_asin_table_row(a, asin_labels, asin_link=asin_link_fn(a.asin), compact=True)
|
||
for a in top
|
||
]
|
||
if rest:
|
||
rt = sum(a.total for a in rest)
|
||
ra = round(sum(a.avg_rating * a.total for a in rest) / max(rt, 1), 2)
|
||
summary_rows.append(
|
||
f'<tr class="row-b"><td colspan="2"><strong>其余 {len(rest)} 款合计</strong></td>'
|
||
f'<td class="num">{rt}</td><td class="num">{ra}</td><td class="num">—</td>'
|
||
f'<td class="num">—</td><td class="num">—</td></tr>'
|
||
)
|
||
summary = f"<table>{compact_head}<tbody>{''.join(summary_rows)}</tbody></table>"
|
||
|
||
full_head = (
|
||
"<thead><tr><th>竞品</th><th>ASIN</th><th class=\"num\">有效评论数</th>"
|
||
"<th class=\"num\">均分</th><th class=\"num\">正评率(≥4★)</th><th class=\"num\">差评率(≤2★)</th>"
|
||
"<th class=\"num\">5★</th><th class=\"num\">4★</th><th class=\"num\">3★</th><th class=\"num\">2★</th><th class=\"num\">1★</th></tr></thead>"
|
||
)
|
||
appendix_rows = [
|
||
build_asin_table_row(a, asin_labels, asin_link=asin_link_fn(a.asin))
|
||
for a in ranked
|
||
]
|
||
appendix = (
|
||
f'<details class="asin-appendix"><summary>展开全部 {n} 个 ASIN 明细表</summary>'
|
||
f'<div class="tbl-wrap" style="margin-top:10px"><table>{full_head}'
|
||
f"<tbody>{''.join(appendix_rows)}</tbody></table></div></details>"
|
||
)
|
||
note = (
|
||
f'<p class="note">共 {n} 个竞品:摘要表默认展示评论量 Top {top_n};'
|
||
f'「星分布」列悬停查看 5★–1★ 明细。</p>'
|
||
)
|
||
return summary, appendix, note
|
||
|
||
|
||
def _need_pills(text: str) -> List[str]:
|
||
text = (text or "").strip()
|
||
if not text or text in ("—", "-", "N/A"):
|
||
return []
|
||
parts = re.split(r"[、,;/]+", text)
|
||
return [p.strip() for p in parts if p.strip()]
|
||
|
||
|
||
def _format_matrix_need(text: str) -> str:
|
||
"""矩阵需求列:顿号/逗号统一为分号展示。"""
|
||
text = (text or "").strip()
|
||
if not text or text in ("—", "-", "N/A"):
|
||
return "—"
|
||
return re.sub(r"[、,;/]+", ";", text).strip(";")
|
||
|
||
|
||
def _matrix_sat_class(satisfaction: str) -> str:
|
||
sat = (satisfaction or "中等").strip()
|
||
if sat in ("高", "较高"):
|
||
return "sat-hi"
|
||
if sat in ("低",):
|
||
return "sat-lo"
|
||
return "sat-mid"
|
||
|
||
|
||
def enrich_matrix_scene_evidence(
|
||
matrix: List[Dict[str, Any]],
|
||
personas: List[Dict[str, Any]],
|
||
reviews: List[ReviewRecord],
|
||
) -> List[Dict[str, Any]]:
|
||
"""为矩阵场景列补充英文原词频次佐证(≥5 条显示 ✓)。"""
|
||
row_map = {r.source_row: r for r in reviews}
|
||
pmap = {p.get("name"): p for p in personas if p.get("name")}
|
||
for row in matrix:
|
||
if row.get("scene_evidence"):
|
||
continue
|
||
persona = pmap.get(row.get("persona"), {})
|
||
src_rows = persona.get("source_rows") or []
|
||
if not src_rows:
|
||
continue
|
||
best_kw, best_cnt = "", 0
|
||
for kw in (persona.get("keywords") or [])[:20]:
|
||
kw = (kw or "").strip()
|
||
if len(kw) < 4 or not re.search(r"[a-zA-Z]", kw):
|
||
continue
|
||
kw_l = kw.lower()
|
||
cnt = sum(
|
||
1 for sr in src_rows
|
||
if sr in row_map
|
||
and kw_l in (row_map[sr].title + " " + row_map[sr].content).lower()
|
||
)
|
||
if cnt > best_cnt:
|
||
best_kw, best_cnt = kw, cnt
|
||
if best_cnt >= 5:
|
||
row["scene_evidence"] = f"{best_kw} {best_cnt}条 ✓"
|
||
return matrix
|
||
|
||
|
||
def build_matrix_table_rows_html(
|
||
matrix: List[Dict[str, Any]],
|
||
persona_map: Dict[str, Dict[str, Any]],
|
||
) -> str:
|
||
"""人群×场景×需求矩阵表格行(六列:用户群/场景/基本型/期望型/魅力型/满意度)。"""
|
||
if not matrix:
|
||
return '<tr><td colspan="6" class="note">暂无矩阵数据。</td></tr>'
|
||
rows_html: List[str] = []
|
||
for row in matrix:
|
||
pname = row.get("persona", "?")
|
||
p_obj = persona_map.get(pname, {})
|
||
pct = p_obj.get("pct_display", row.get("pct", "?"))
|
||
hit = p_obj.get("hit_count", 0)
|
||
hit_suffix = f"({hit}条)" if hit else ""
|
||
persona_cell = (
|
||
f'<strong>{pname}</strong>'
|
||
f'<br><small style="color:#888">~{pct}{hit_suffix}</small>'
|
||
)
|
||
scene = (row.get("scene") or "").strip() or "—"
|
||
ev = (row.get("scene_evidence") or "").strip()
|
||
if ev and ev not in scene:
|
||
scene_cell = f'{scene}<br><small style="color:#888">{ev}</small>'
|
||
else:
|
||
scene_cell = scene
|
||
sat = row.get("satisfaction", "中等")
|
||
sat_class = _matrix_sat_class(sat)
|
||
note = (row.get("satisfaction_note") or "").strip()
|
||
sat_cell = sat
|
||
if note:
|
||
sat_cell += f'<br><small style="color:#888;font-weight:400">{note}</small>'
|
||
rows_html.append(
|
||
f'<tr class="row-b">'
|
||
f"<td>{persona_cell}</td>"
|
||
f"<td>{scene_cell}</td>"
|
||
f"<td>{_format_matrix_need(row.get('must_be_needs', ''))}</td>"
|
||
f"<td>{_format_matrix_need(row.get('performance_needs', ''))}</td>"
|
||
f"<td>{_format_matrix_need(row.get('attractive_needs', ''))}</td>"
|
||
f'<td class="{sat_class}">{sat_cell}</td>'
|
||
f"</tr>"
|
||
)
|
||
return "\n".join(rows_html)
|
||
|
||
|
||
def build_matrix_cards_html(
|
||
matrix: List[Dict[str, Any]],
|
||
persona_map: Dict[str, Dict[str, Any]],
|
||
) -> str:
|
||
"""兼容旧调用:返回完整矩阵表格 HTML。"""
|
||
inner = build_matrix_table_rows_html(matrix, persona_map)
|
||
if matrix:
|
||
return (
|
||
'<div class="tbl-wrap"><table class="matrix-table">'
|
||
"<thead><tr>"
|
||
'<th style="min-width:130px">用户群</th>'
|
||
'<th style="min-width:150px">使用场景(When/Where)'
|
||
'<br><small style="font-weight:normal;color:#aaa">仅评论中佐证≥5条的场景</small></th>'
|
||
'<th style="min-width:150px">基本型需求</th>'
|
||
'<th style="min-width:150px">期望型需求</th>'
|
||
'<th style="min-width:120px">魅力型需求</th>'
|
||
'<th style="min-width:100px">当前满意度</th>'
|
||
"</tr></thead>"
|
||
f"<tbody>{inner}</tbody></table></div>"
|
||
)
|
||
return '<p class="note">暂无矩阵数据。</p>'
|
||
|
||
|
||
def build_footer_asin_links(
|
||
stats: MarketStats,
|
||
asin_labels: Dict[str, str],
|
||
*,
|
||
collapse_threshold: int = 20,
|
||
) -> str:
|
||
"""Footer ASIN 链接;过多时折叠。"""
|
||
n = len(stats.asins)
|
||
if n <= collapse_threshold:
|
||
return " / ".join(asin_link_with_label(a.asin, asin_labels) for a in stats.asins)
|
||
top = sorted_asin_stats(stats)[:8]
|
||
links = " / ".join(asin_link_with_label(a.asin, asin_labels) for a in top)
|
||
return (
|
||
f'{links} … '
|
||
f'<details class="footer-asin-details" style="display:inline">'
|
||
f"<summary>查看全部 {n} 个 ASIN</summary>"
|
||
f'<div style="margin-top:6px;line-height:1.8">'
|
||
f'{" / ".join(asin_link_with_label(a.asin, asin_labels) for a in sorted_asin_stats(stats))}'
|
||
f"</div></details>"
|
||
)
|
||
|
||
|
||
_KANO_TYPE_CN = {
|
||
"must-be": "基本型",
|
||
"performance": "期望型",
|
||
"attractive": "魅力型",
|
||
"reverse": "反向型",
|
||
}
|
||
|
||
_KANO_TYPE_EN = {
|
||
"must-be": "Must-be",
|
||
"performance": "Performance",
|
||
"attractive": "Attractive",
|
||
"reverse": "Reverse",
|
||
}
|
||
|
||
_ATTRACTIVE_KW = ("love", "amazing", "bonus", "didn't expect", "surprise", "obsessed", "perfect gift")
|
||
|
||
|
||
def _theme_by_name(themes: List[Dict[str, Any]], name: str) -> Dict[str, Any]:
|
||
for t in themes:
|
||
if t.get("name") == name:
|
||
return t
|
||
return {}
|
||
|
||
|
||
def neg_theme_display_priority(
|
||
count: int,
|
||
neg_total: int,
|
||
affected_asins: int,
|
||
asin_count: int,
|
||
) -> Tuple[str, str, str]:
|
||
"""返回 (显示标签, pill_class, row_class)。"""
|
||
p0_th = neg_total * 0.2
|
||
p1_low = neg_total * 0.1
|
||
p2_low = neg_total * 0.03
|
||
asin_ratio = affected_asins / max(asin_count, 1)
|
||
if count >= p0_th and asin_ratio >= 0.8:
|
||
return "P0", "pill-danger", "row-r"
|
||
if count >= p1_low:
|
||
return "P1", "pill-warn", "row-y"
|
||
if count >= p2_low:
|
||
return "P2", "pill-info", "row-b"
|
||
return "待观察", "pill-gray", "row-b"
|
||
|
||
|
||
def format_neg_theme_cell(name: str, description: str = "") -> str:
|
||
"""差评主题列:名称 + 根因拆分说明。"""
|
||
desc = (description or "").strip()
|
||
if desc:
|
||
if not desc.startswith("("):
|
||
desc = f"({desc})"
|
||
return f"{name}<br><small style=\"color:#888\">{desc}</small>"
|
||
return name
|
||
|
||
|
||
def format_theme_asin_scope(
|
||
theme_name: str,
|
||
per_asin: Dict[str, Dict[str, int]],
|
||
asin_labels: Dict[str, str],
|
||
asin_count: int,
|
||
top_n: int = 2,
|
||
) -> str:
|
||
"""涉及范围 & 最痛 ASIN。"""
|
||
hits = [(asin, per_asin.get(asin, {}).get(theme_name, 0)) for asin in per_asin]
|
||
hits = [(a, c) for a, c in hits if c > 0]
|
||
affected = len(hits)
|
||
if affected == 0:
|
||
return f"0/{asin_count} ASIN"
|
||
hits.sort(key=lambda x: x[1], reverse=True)
|
||
top_parts = [f"{asin_labels.get(a, a)}({c}条)" for a, c in hits[:top_n]]
|
||
return f"{affected}/{asin_count} ASIN · 最痛:{'、'.join(top_parts)}"
|
||
|
||
|
||
def _score_kano_theme_match(theme_name: str, keywords: List[str], kano_item: Dict[str, Any]) -> int:
|
||
blob = " ".join(
|
||
str(kano_item.get(k) or "")
|
||
for k in ("item", "reason", "evidence", "affected_persona")
|
||
).lower()
|
||
score = 0
|
||
if theme_name and theme_name in blob:
|
||
score += 8
|
||
for seg in expand_keywords(keywords):
|
||
if len(seg) >= 4 and seg.lower() in blob:
|
||
score += len(seg)
|
||
for part in re.findall(r"[\u4e00-\u9fff]{2,}", theme_name):
|
||
if part in blob:
|
||
score += len(part) + 2
|
||
return score
|
||
|
||
|
||
def _kano_hint_text(
|
||
ktype: str,
|
||
pos_name: str,
|
||
paired_neg: str,
|
||
neg_freq: Dict[str, int],
|
||
neg_total: int,
|
||
) -> str:
|
||
neg_count = neg_freq.get(paired_neg, 0) if paired_neg else 0
|
||
if ktype == "must-be":
|
||
return "做不好易强烈差评,做好用户少见专门表扬"
|
||
if ktype == "attractive":
|
||
return "好评惊喜提及,缺失一般不引发差评"
|
||
if paired_neg and neg_count > 0:
|
||
pct = round(neg_count / max(neg_total, 1) * 100)
|
||
return f"差评也有「{paired_neg}」对应期望({neg_count}条/{pct}%)"
|
||
return "做得越好评分越高,是线性竞争点"
|
||
|
||
|
||
def infer_pos_theme_kano_preview(
|
||
pos_theme: Dict[str, Any],
|
||
neg_themes: List[Dict[str, Any]],
|
||
neg_freq: Dict[str, int],
|
||
neg_total: int,
|
||
kano_items: List[Dict[str, Any]],
|
||
paired_neg: str = "",
|
||
) -> str:
|
||
"""好评主题 KANO 预判列(类型 + 一句理由)。"""
|
||
name = pos_theme.get("name", "")
|
||
keywords = pos_theme.get("keywords") or []
|
||
kw_blob = " ".join(keywords).lower()
|
||
|
||
best_item: Optional[Dict[str, Any]] = None
|
||
best_score = 0
|
||
for item in kano_items or []:
|
||
t = item.get("type", "")
|
||
if t == "reverse":
|
||
continue
|
||
sc = _score_kano_theme_match(name, keywords, item)
|
||
if sc > best_score:
|
||
best_score = sc
|
||
best_item = item
|
||
|
||
if best_item and best_score >= 3:
|
||
ktype = best_item.get("type", "performance")
|
||
hint = (best_item.get("reason") or "").strip()
|
||
if len(hint) > 48:
|
||
hint = hint[:47] + "…"
|
||
if not hint:
|
||
hint = _kano_hint_text(ktype, name, paired_neg, neg_freq, neg_total)
|
||
else:
|
||
neg_meta = _theme_by_name(neg_themes, paired_neg) if paired_neg else {}
|
||
neg_pri = neg_meta.get("priority", "")
|
||
neg_count = neg_freq.get(paired_neg, 0) if paired_neg else 0
|
||
p0_th = neg_total * 0.2
|
||
if any(k in kw_blob for k in _ATTRACTIVE_KW) and neg_count == 0:
|
||
ktype = "attractive"
|
||
elif paired_neg and neg_count >= p0_th and neg_pri == "P0":
|
||
ktype = "must-be"
|
||
elif paired_neg and neg_count > 0:
|
||
ktype = "performance"
|
||
elif any(k in kw_blob for k in _ATTRACTIVE_KW):
|
||
ktype = "attractive"
|
||
else:
|
||
ktype = "performance"
|
||
hint = _kano_hint_text(ktype, name, paired_neg, neg_freq, neg_total)
|
||
|
||
cn = _KANO_TYPE_CN.get(ktype, "期望型")
|
||
en = _KANO_TYPE_EN.get(ktype, "Performance")
|
||
return f"{cn}({en})<br><small style=\"color:#888\">({hint})</small>"
|
||
|
||
|
||
def build_neg_theme_table_rows(
|
||
neg_freq: Dict[str, int],
|
||
neg_themes: List[Dict[str, Any]],
|
||
neg_total: int,
|
||
asin_count: int,
|
||
per_asin_neg: Dict[str, Dict[str, int]],
|
||
asin_labels: Dict[str, str],
|
||
) -> str:
|
||
"""差评主题明细表 HTML 行。"""
|
||
theme_map = {t["name"]: t for t in neg_themes if t.get("name")}
|
||
rows: List[str] = []
|
||
for name, count in sorted(neg_freq.items(), key=lambda x: x[1], reverse=True):
|
||
if count <= 0:
|
||
continue
|
||
meta = theme_map.get(name, {})
|
||
pct = f"{round(count / max(neg_total, 1) * 100)}%"
|
||
affected = sum(
|
||
1 for asin_counts in per_asin_neg.values() if asin_counts.get(name, 0) > 0
|
||
)
|
||
label, pill_cls, row_cls = neg_theme_display_priority(
|
||
count, neg_total, affected, asin_count,
|
||
)
|
||
cell_name = format_neg_theme_cell(name, meta.get("description", ""))
|
||
scope = format_theme_asin_scope(name, per_asin_neg, asin_labels, asin_count)
|
||
rows.append(
|
||
f'<tr class="{row_cls}"><td>{cell_name}</td><td class="num">{count}</td>'
|
||
f'<td class="num">{pct}</td><td><span class="pill {pill_cls}">{label}</span></td>'
|
||
f'<td>{scope}</td></tr>'
|
||
)
|
||
return "\n".join(rows)
|
||
|
||
|
||
def build_pos_theme_table_rows(
|
||
pos_freq: Dict[str, int],
|
||
pos_themes: List[Dict[str, Any]],
|
||
pos_total: int,
|
||
neg_themes: List[Dict[str, Any]],
|
||
neg_freq: Dict[str, int],
|
||
neg_total: int,
|
||
kano_items: List[Dict[str, Any]],
|
||
) -> str:
|
||
"""好评主题明细表 HTML 行(含 KANO 预判)。"""
|
||
theme_map = {t["name"]: t for t in pos_themes if t.get("name")}
|
||
pairs = infer_pos_neg_theme_pairs(pos_themes, neg_themes)
|
||
pair_map = {pos: neg for pos, neg in pairs}
|
||
rows: List[str] = []
|
||
for name, count in sorted(pos_freq.items(), key=lambda x: x[1], reverse=True):
|
||
if count <= 0:
|
||
continue
|
||
meta = theme_map.get(name, {})
|
||
pct = f"{round(count / max(pos_total, 1) * 100)}%"
|
||
kano_cell = infer_pos_theme_kano_preview(
|
||
meta or {"name": name},
|
||
neg_themes,
|
||
neg_freq,
|
||
neg_total,
|
||
kano_items,
|
||
paired_neg=pair_map.get(name, ""),
|
||
)
|
||
rows.append(
|
||
f'<tr class="row-g"><td>{name}</td><td class="num">{count}</td>'
|
||
f'<td class="num">{pct}</td><td>{kano_cell}</td></tr>'
|
||
)
|
||
return "\n".join(rows)
|
||
|
||
|
||
def build_neg_theme_summary_note(
|
||
stats: MarketStats,
|
||
neg_freq: Dict[str, int],
|
||
neg_themes: List[Dict[str, Any]],
|
||
per_asin_neg: Optional[Dict[str, Dict[str, int]]] = None,
|
||
) -> str:
|
||
"""差评主题区块说明:总数、门槛与 P0/P1/P2 结论。"""
|
||
per_asin_neg = per_asin_neg or {}
|
||
neg_total = stats.neg_review_count
|
||
total = stats.total_reviews
|
||
neg_pct = round(neg_total / max(total, 1) * 100)
|
||
p0_threshold = int(neg_total * 0.2)
|
||
p1_low = int(neg_total * 0.1)
|
||
p2_low = max(1, round(neg_total * 0.03))
|
||
|
||
line1 = (
|
||
f"差评总数:{neg_total}条(占有效评论 {neg_pct}%)"
|
||
f" · P0 门槛:{p0_threshold}条({neg_total}×20%)"
|
||
f" · P1 范围:{p1_low}–{p0_threshold}条"
|
||
f" · P2 范围:{p2_low}–{p1_low}条"
|
||
)
|
||
|
||
p0_themes = [
|
||
t for t in neg_themes
|
||
if t.get("priority") == "P0" and neg_freq.get(t.get("name", ""), 0) >= p0_threshold
|
||
]
|
||
sorted_neg = sorted(neg_freq.items(), key=lambda x: x[1], reverse=True)
|
||
if p0_themes:
|
||
names = "、".join(t["name"] for t in p0_themes[:3])
|
||
line2 = f"本次数据有 {len(p0_themes)} 个 P0 级差评主题:{names}。"
|
||
elif sorted_neg:
|
||
top_name, top_count = sorted_neg[0]
|
||
top_pct = round(top_count / max(neg_total, 1) * 100)
|
||
affected = sum(
|
||
1 for asin_counts in per_asin_neg.values()
|
||
if asin_counts.get(top_name, 0) > 0
|
||
)
|
||
top_label, _, _ = neg_theme_display_priority(
|
||
top_count, neg_total, affected, len(stats.asins),
|
||
)
|
||
line2 = (
|
||
f"本次数据无 P0 级差评主题(无单一问题达到≥20%的差评集中度)。"
|
||
f"最高频为{top_name} {top_count}条({top_pct}%),属 {top_label} 级别。"
|
||
)
|
||
else:
|
||
line2 = "本次数据无 P0 级差评主题(无单一问题达到≥20%的差评集中度)。"
|
||
|
||
return f"{line1}<br>{line2}"
|
||
|
||
|
||
def persona_display_meta(persona: Dict[str, Any], total_reviews: int) -> 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 "待验证")
|
||
return (
|
||
f"占比 ~<span class=\"p-pct\">{pct}</span>(命中 {hit} 条 / {total_reviews})"
|
||
f" · 置信度 {conf} · 维度{dim} {dim_label}"
|
||
)
|
||
|
||
|
||
def normalize_rootcauses(
|
||
rootcauses: List[Dict[str, Any]],
|
||
neg_themes: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""截断过长文本,过滤 affected_themes 为合法差评主题名。"""
|
||
valid_names = [t["name"] for t in neg_themes if t.get("name")]
|
||
valid_set = set(valid_names)
|
||
for rc in rootcauses:
|
||
themes = [t for t in (rc.get("affected_themes") or []) if t in valid_set]
|
||
rc["affected_themes"] = themes or valid_names[:3]
|
||
cleaned_causes = []
|
||
for cause in rc.get("root_causes", [])[:3]:
|
||
cause["title"] = (cause.get("title") or "根因")[:24]
|
||
mech = (cause.get("mechanism") or "").strip()
|
||
cause["mechanism"] = (mech[:150] + "…") if len(mech) > 150 else mech
|
||
dev = (cause.get("dev_direction") or "").strip()
|
||
cause["dev_direction"] = (dev[:100] + "…") if len(dev) > 100 else dev
|
||
cleaned_causes.append(cause)
|
||
rc["root_causes"] = cleaned_causes
|
||
return rootcauses
|