feat(report): §8.2 先按评分分桶再统计口语短语

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-16 14:57:58 +08:00
parent 9d4881a01c
commit 0e353318e1
6 changed files with 297 additions and 78 deletions

View File

@ -103,6 +103,11 @@ _COMMENT_FUZZ_KEYS: tuple[str, ...] = (
_COMMENT_CSV_SKU = COMMENT_CSV_COLUMNS[0]
_COMMENT_CSV_BODY = COMMENT_CSV_COLUMNS[3]
_COMMENT_CSV_SCORE = COMMENT_CSV_COLUMNS[7] # 「评分」→ commentScore
# 评价星级与 §8.2 分桶:先按评分筛正负,再在对应子集内统计口语短语(无评分时回退关键词)
_COMMENT_SCORE_NEG_MAX = 2 # 12 星 → 偏负向
_COMMENT_SCORE_POS_MIN = 4 # 45 星 → 偏正向3 星为中评,归入中性)
# ---------------------------------------------------------------------------
# 运行配置(按需改这里)
@ -561,18 +566,38 @@ def _merge_comment_previews(merged_rows: list[dict[str, str]]) -> str:
return "\n".join(parts)
def _iter_comment_text_units(
def _parse_comment_score(val: Any) -> int | None:
"""解析 ``commentScore`` /「评分」列;期望京东 15 星,非法或空返回 None。"""
s = str(val or "").strip()
if not s:
return None
m = re.match(r"^\s*(\d+(?:\.\d+)?)", s)
if not m:
return None
try:
x = float(m.group(1))
except ValueError:
return None
if x < 1 or x > 5:
return None
return int(round(x))
def _iter_comment_text_units_and_scores(
comment_rows: list[dict[str, str]],
merged_rows: list[dict[str, str]],
) -> list[str]:
"""逐条评价正文;无 flat 评论时用合并表 comment_preview 按行兜底。"""
out: list[str] = []
) -> tuple[list[str], list[int | None]]:
"""逐条评价正文与同序评分(无则为 None无 flat 评论时用合并表 comment_preview 按行兜底(评分均为 None"""
texts: list[str] = []
scores: list[int | None] = []
for row in comment_rows:
t = _cell(row, _COMMENT_CSV_BODY, "tagCommentContent")
if t:
out.append(t)
if out:
return out
if not t:
continue
texts.append(t)
scores.append(_parse_comment_score(_cell(row, _COMMENT_CSV_SCORE, "commentScore")))
if texts:
return texts, scores
for row in merged_rows:
p = _cell(
row,
@ -580,8 +605,18 @@ def _iter_comment_text_units(
"comment_preview",
)
if p:
out.append(p)
return out
texts.append(p)
scores.append(None)
return texts, scores
def _iter_comment_text_units(
comment_rows: list[dict[str, str]],
merged_rows: list[dict[str, str]],
) -> list[str]:
"""逐条评价正文;无 flat 评论时用合并表 comment_preview 按行兜底。"""
texts, _ = _iter_comment_text_units_and_scores(comment_rows, merged_rows)
return texts
_POS_LEX = (
@ -732,38 +767,133 @@ def _lexeme_hits_in_texts(
return [{"word": w, "texts_matched": n} for w, n in c.most_common(18)]
def _comment_sentiment_lexicon(texts: list[str]) -> dict[str, Any]:
def _keyword_sentiment_quadrant(stripped: str) -> str:
"""``pos_only`` | ``neg_only`` | ``mixed`` | ``neutral``(空文本为 neutral"""
if not stripped:
return "neutral"
hp = any(k in stripped for k in _POS_CLASS)
hn = any(k in stripped for k in _NEG_CLASS)
if hp and hn:
return "mixed"
if hp:
return "pos_only"
if hn:
return "neg_only"
return "neutral"
def _sentiment_quadrant_for_row(
stripped: str,
score: int | None,
*,
use_score_column: bool,
) -> str:
if not stripped:
return "neutral"
if use_score_column and score is not None:
if score <= _COMMENT_SCORE_NEG_MAX:
return "neg_only"
if score >= _COMMENT_SCORE_POS_MIN:
return "pos_only"
return "neutral"
return _keyword_sentiment_quadrant(stripped)
def _include_in_positive_lexeme_corpus(
stripped: str,
score: int | None,
*,
use_score_column: bool,
) -> bool:
"""口语短语正向统计语境:评分模式下为 45 星;否则为命中正向词(含原「混合」条)。"""
if not stripped:
return False
if use_score_column and score is not None:
return score >= _COMMENT_SCORE_POS_MIN
hp = any(k in stripped for k in _POS_CLASS)
return hp
def _include_in_negative_lexeme_corpus(
stripped: str,
score: int | None,
*,
use_score_column: bool,
) -> bool:
"""口语短语负向统计语境:评分模式下为 12 星;否则为命中负向词(含原「混合」条)。"""
if not stripped:
return False
if use_score_column and score is not None:
return score <= _COMMENT_SCORE_NEG_MAX
hn = any(k in stripped for k in _NEG_CLASS)
return hn
def _comment_sentiment_lexicon(
texts: list[str],
scores: list[int | None] | None = None,
) -> dict[str, Any]:
"""
基于预设词表对每条文本做正/负向粗判非深度学习同条同时含正负词时计为混合
短语级词频仅在对应语境下统计条形图用 ``_POS_LEX_HITS`` / ``_NEG_LEX_HITS``
/负向粗判非深度学习
- ``scores`` ``texts`` 等长且**至少有一条非空评分****先按 15 星分桶**再在对应子集内统计
正向/负向口语短语条形图无评分或非法评分的行仍按**关键词子串**粗判
- 否则与旧版一致**仅关键词**划分四象限与短语语境
"""
use_score_column = bool(
scores is not None
and len(scores) == len(texts)
and any(s is not None for s in scores)
)
pos_only = neg_only = mixed = neutral = 0
corpus_pos_mixed: list[str] = []
corpus_neg_mixed: list[str] = []
for t in texts:
for i, t in enumerate(texts):
s = (t or "").strip()
sc: int | None = None
if use_score_column:
sc = scores[i] if scores is not None and i < len(scores) else None
if not s:
neutral += 1
continue
hp = any(k in s for k in _POS_CLASS)
hn = any(k in s for k in _NEG_CLASS)
if hp and hn:
mixed += 1
corpus_pos_mixed.append(s)
corpus_neg_mixed.append(s)
elif hp:
quad = _sentiment_quadrant_for_row(s, sc, use_score_column=use_score_column)
if quad == "pos_only":
pos_only += 1
corpus_pos_mixed.append(s)
elif hn:
elif quad == "neg_only":
neg_only += 1
corpus_neg_mixed.append(s)
elif quad == "mixed":
mixed += 1
else:
neutral += 1
if _include_in_positive_lexeme_corpus(s, sc, use_score_column=use_score_column):
corpus_pos_mixed.append(s)
if _include_in_negative_lexeme_corpus(s, sc, use_score_column=use_score_column):
corpus_neg_mixed.append(s)
total = len(texts)
pos_lex = _lexeme_hits_in_texts(corpus_pos_mixed, _POS_LEX_HITS)
neg_lex = _lexeme_hits_in_texts(corpus_neg_mixed, _NEG_LEX_HITS)
method = "score_then_lexeme" if use_score_column else "keyword_lexicon"
base_note = (
"「正向短语」与「负向短语」条形图统计的是预设口语片段在**对应语境**下的命中条数,"
"每条每短语最多计 1 次;非分词模型。"
)
if use_score_column:
scope_extra = (
"当前批次启用了**评分列**四象限以星级为主12 星偏负、45 星偏正、3 星为中评、空文本为中性);"
"「正向口语短语」仅在 **45 星** 评价条内统计;「负向口语短语」仅在 **12 星** 评价条内统计;"
"无评分行仍按关键词子串归入四象限并参与短语语境。"
"条形图不是全文情感或某维度的完整满意度;未收录说法仍可能出现在关注词与语义池。"
)
else:
scope_extra = (
"「正向短语」仅在命中正向词表的评价条内统计(含关键词混合条);"
"「负向短语」仅在命中负向词表的评价条内统计(含关键词混合条)。"
"条形图表示的是「预设短语命中条数」,不是全文情感或某维度(如包装、物流)的完整满意度;"
"若用户用「盒子不错」「没压坏」等未收录说法,仍可能落在关注词「包装」子串与语义池原文中。"
"预设表无法覆盖全部说法(如「一袋就一点点」),须结合语义池原文。"
)
return {
"method": "keyword_lexicon",
"method": method,
"text_units": total,
"positive_only": pos_only,
"negative_only": neg_only,
@ -773,14 +903,7 @@ def _comment_sentiment_lexicon(texts: list[str]) -> dict[str, Any]:
"negative_lexicon_sample": list(_NEG_LEX[:10]) + list(_NEG_LEXEME_DETAIL[:5]),
"positive_tone_lexeme_hits": pos_lex,
"negative_tone_lexeme_hits": neg_lex,
"lexeme_scope_note": (
"「正向短语」仅在命中正向词表的评价条内统计(含混合条);"
"「负向短语」仅在命中负向词表的评价条内统计(含混合条);每条每短语最多计 1 次;"
"统计的是预设口语片段,非分词模型。"
"条形图表示的是「预设短语命中条数」,不是全文情感或某维度(如包装、物流)的完整满意度;"
"若用户用「盒子不错」「没压坏」等未收录说法,仍可能落在关注词「包装」子串与语义池原文中,条形图偏低不代表该维度无人提及。"
"预设表无法覆盖全部说法(如「一袋就一点点」),条形图条数低不代表用户未在别处表述同类不满,须结合语义池原文。"
),
"lexeme_scope_note": base_note + scope_extra,
}
@ -1144,6 +1267,7 @@ def build_scenario_groups_llm_payload(
def build_comment_sentiment_llm_payload(
texts: list[str],
*,
scores: list[int | None] | None = None,
attributed_texts: list[str] | None = None,
max_samples_positive: int = 16,
max_samples_negative: int = 30,
@ -1153,12 +1277,17 @@ def build_comment_sentiment_llm_payload(
shuffle_seed: str = "",
) -> dict[str, Any]:
"""
供大模型做正/负向语义归纳附规则统计关键词规则**归类**后的抽样以及 **sample_reviews_semantic_pool**
全量去重后的评价句确定性洗牌抽样供模型结合语境自行判断褒贬
供大模型做正/负向语义归纳附规则统计**评分优先或关键词**归类后的抽样以及 **sample_reviews_semantic_pool**
全量去重后的评价句确定性洗牌抽样供模型结合语境自行判断褒贬
``sentiment_bucket_method`` 标明归类依据为子串词表条形图与 lexicon 计数方式与之一致
但正文归纳应以模型对 ``sample_reviews_semantic_pool`` 的整句理解为准
``sentiment_bucket_method``有有效评分列时为 ``score_then_lexeme``否则为 ``keyword_substring_heuristic``
条形图与 ``comment_sentiment_lexicon`` 计数方式与之一致正文归纳仍以整句语义为准
"""
use_score_column = bool(
scores is not None
and len(scores) == len(texts)
and any(s is not None for s in scores)
)
pos_only_texts: list[str] = []
neg_only_texts: list[str] = []
mixed_texts: list[str] = []
@ -1180,13 +1309,13 @@ def build_comment_sentiment_llm_payload(
if disp and disp not in seen_unique:
seen_unique.add(disp)
all_unique_disp.append(disp)
hp = any(k in s for k in _POS_CLASS)
hn = any(k in s for k in _NEG_CLASS)
if hp and hn:
sc = scores[i] if use_score_column and scores is not None else None
quad = _sentiment_quadrant_for_row(s, sc, use_score_column=use_score_column)
if quad == "mixed":
mixed_texts.append(disp)
elif hp:
elif quad == "pos_only":
pos_only_texts.append(disp)
elif hn:
elif quad == "neg_only":
neg_only_texts.append(disp)
def _semantic_pool(seq: list[str], cap: int) -> list[str]:
@ -1225,16 +1354,19 @@ def build_comment_sentiment_llm_payload(
break
return out
lex = _comment_sentiment_lexicon(texts)
lex = _comment_sentiment_lexicon(texts, scores)
pos_h = lex.get("positive_tone_lexeme_hits") or []
neg_h = lex.get("negative_tone_lexeme_hits") or []
pos_h_top = [x for x in pos_h[:12] if isinstance(x, dict)]
neg_h_top = [x for x in neg_h[:12] if isinstance(x, dict)]
bucket_method = (
"score_then_lexeme" if use_score_column else "keyword_substring_heuristic"
)
return {
"comment_sentiment_lexicon": lex,
"positive_lexeme_hits_top": pos_h_top,
"negative_lexeme_hits_top": neg_h_top,
"sentiment_bucket_method": "keyword_substring_heuristic",
"sentiment_bucket_method": bucket_method,
"sample_reviews_semantic_pool": semantic_pool,
"sample_reviews_positive_biased": _sample(pos_only_texts, max_samples_positive),
"sample_reviews_negative_biased": _sample(neg_only_texts, max_samples_negative),
@ -2071,8 +2203,10 @@ def build_competitor_markdown(
if n:
hits[w] += n
comment_texts = _iter_comment_text_units(comment_rows, merged_rows)
sentiment_lex = _comment_sentiment_lexicon(comment_texts)
comment_texts, comment_scores = _iter_comment_text_units_and_scores(
comment_rows, merged_rows
)
sentiment_lex = _comment_sentiment_lexicon(comment_texts, comment_scores)
scen_counts, scen_n_texts = _comment_scenario_counts(
comment_texts, scenario_groups
)
@ -2576,29 +2710,56 @@ def build_competitor_markdown(
]
)
lines.extend(
_sm_score = sentiment_lex.get("method") == "score_then_lexeme"
_sec82_title = (
"### 8.2 评价正负面粗判(评分优先 + 关键词回退)"
if _sm_score
else "### 8.2 评价正负面粗判(关键词规则)"
)
_sec82_block: list[str] = [
"---",
"",
"## 八、消费者反馈与用户画像(按细分类目)",
"",
"### 8.1 方法",
"",
"- **细类划分**:与 **§5 竞品矩阵** 相同,**仅**依据 ``detail_category_path`` 解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。",
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺 ``detail_category_path`` 或路径无法解析为可读细类的,该评价不进入按细类统计**(与 §5 **同一条排除规则**)。",
"- **正负面粗判§8.2**:若评价含有效「评分」列则**先按星级**粗分正负与中评,再在对应子集内统计口语短语;无评分时仍按关键词子串;若任务开启 **llm_comment_sentiment**,可附**大模型对抽样原文的主题归因**,与条形图互补。",
"- **关注词与使用场景§8.3**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与 §5 矩阵细类一一对应。",
"",
_sec82_title,
"",
f"- **有效文本条数**{sentiment_lex.get('text_units', 0)}(与 §8.1 **归因规则**一致)。",
]
if _sm_score:
_sec82_block.append(
"- **四象限口径**:本批存在有效「评分」时——**12 星**计为偏负向,**45 星**计为偏正向,**3 星**计为中评,**空文本**计为中性;"
"无评分的条仍按关键词子串划分;「混合」仅在**无评分**且同条兼含正/负关键词时出现。"
)
_sec82_block.extend(
[
"---",
"",
"## 八、消费者反馈与用户画像(按细分类目)",
"",
"### 8.1 方法",
"",
"- **细类划分**:与 **§5 竞品矩阵** 相同,**仅**依据 ``detail_category_path`` 解析为「饼干 / 西式糕点 / …」等(规则见 §5 章首说明)。",
"- **归因**:每条评价按其 SKU 对应到深入样本,再映射到该 SKU 所属细类SKU 不在合并表中的评价单独归入说明性分组;**在合并表中但该 SKU 缺 ``detail_category_path`` 或路径无法解析为可读细类的,该评价不进入按细类统计**(与 §5 **同一条排除规则**)。",
"- **正负面粗判§8.2**:先以关键词规则与图表做粗分;若任务开启 **llm_comment_sentiment**,可附**大模型对抽样原文的主题归因**(尤其负向「用户在抱怨什么」),与词频条形图互补。",
"- **关注词与使用场景§8.3**:对组内评价正文做关注词子串计数(左栏条形图);对每条有效文本独立扫描**本次任务生效的场景词组**(来自报告调参或系统默认),一条可属多场景,右栏为**占该细类有效文本比例 %**(多标签下可相加 **>** 100%)。二者在 **同一张图左右并列**,与 §5 矩阵细类一一对应。",
"",
"### 8.2 评价正负面粗判(关键词规则)",
"",
f"- **有效文本条数**{sentiment_lex.get('text_units', 0)}(与 §8.1 **归因规则**一致)。",
f"- **偏正向(仅命中正向词表)**{sentiment_lex.get('positive_only', 0)} 条;"
f"**偏负向(仅命中负向词表)**{sentiment_lex.get('negative_only', 0)} 条;"
f"**混合(同条兼含正/负词)**{sentiment_lex.get('mixed_positive_and_negative', 0)} 条;"
f"**中性或空文本**{sentiment_lex.get('neutral_or_empty', 0)} 条。",
"- **说明**:词表为方向性粗判,讽刺、省略与错别字会导致误判;正式结论请**人工抽样**阅读原文。",
f"- **偏正向**{sentiment_lex.get('positive_only', 0)}"
+ ("(主要为 45 星)" if _sm_score else "(仅命中正向词表)")
+ ""
f"**偏负向**{sentiment_lex.get('negative_only', 0)}"
+ ("(主要为 12 星)" if _sm_score else "(仅命中负向词表)")
+ ""
f"**混合**{sentiment_lex.get('mixed_positive_and_negative', 0)}"
+ ("(无评分且同条兼含正/负关键词)" if _sm_score else "(同条兼含正/负词)")
+ ""
f"**中性或空文本**{sentiment_lex.get('neutral_or_empty', 0)}"
+ ("(含 3 星中评及无关键词命中)" if _sm_score else "")
+ "",
"- **说明**"
+ (
"星级与正文可能不一致(如五星长文吐槽);口语短语条形图仅在对应星级子集内统计;正式结论请**人工抽样**阅读原文。"
if _sm_score
else "词表为方向性粗判,讽刺、省略与错别字会导致误判;正式结论请**人工抽样**阅读原文。"
),
]
)
lines.extend(_sec82_block)
_scope = (sentiment_lex.get("lexeme_scope_note") or "").strip()
if _scope:
lines.append(f"- **词根统计说明**{_scope}")
@ -2614,14 +2775,22 @@ def build_competitor_markdown(
_embed_chart(
run_dir,
"chart_positive_lexemes_bar.png",
"正向评价里**最常出现的口语短语**(在偏正向或混合评价条内统计;条形图)",
(
"正向评价里**最常出现的口语短语**(在 **45 星** 评价条内统计;条形图)"
if _sm_score
else "正向评价里**最常出现的口语短语**(在偏正向或混合评价条内统计;条形图)"
),
)
)
lines.extend(
_embed_chart(
run_dir,
"chart_negative_lexemes_bar.png",
"负向评价里**最常出现的口语短语**(在偏负向或混合评价条内统计;条形图)",
(
"负向评价里**最常出现的口语短语**(在 **12 星** 评价条内统计;条形图)"
if _sm_score
else "负向评价里**最常出现的口语短语**(在偏负向或混合评价条内统计;条形图)"
),
)
)
pos_h = sentiment_lex.get("positive_tone_lexeme_hits") or []
@ -2647,7 +2816,7 @@ def build_competitor_markdown(
"",
"#### 大模型深入解读(主题归因,与词频统计互补)",
"",
"> **说明**:基于与上节**同一套关键词归类规则**抽样的评价原文,由大模型归纳**用户在说什么**(尤其是负向的具体事由),与上列条数、条形图**互补**;引文以原评论为准。",
"> **说明**:基于与上节**同一套评分优先或关键词归类规则**抽样的评价原文,由大模型归纳**用户在说什么**(尤其是负向的具体事由),与上列条数、条形图**互补**;引文以原评论为准。",
"",
_llm_s,
]
@ -2863,8 +3032,12 @@ def build_competitor_brief(
if n:
hits[w] += n
comment_texts = _iter_comment_text_units(comment_rows, merged_rows)
comment_sentiment_lexicon = _comment_sentiment_lexicon(comment_texts)
comment_texts, comment_scores = _iter_comment_text_units_and_scores(
comment_rows, merged_rows
)
comment_sentiment_lexicon = _comment_sentiment_lexicon(
comment_texts, comment_scores
)
scen_counts, scen_n_texts = _comment_scenario_counts(
comment_texts, scenario_groups
)

View File

@ -186,7 +186,9 @@ def main() -> None:
chunk_gr = use_chunked_group_summaries_llm(eff_rc)
if "sentiment" in only:
comment_units = jcr._iter_comment_text_units(comment_rows, merged)
comment_units, comment_scores = jcr._iter_comment_text_units_and_scores(
comment_rows, merged
)
attr_units = jcr._comment_lines_with_product_context(
comment_rows,
merged,
@ -199,6 +201,7 @@ def main() -> None:
def _sent() -> str:
pl = jcr.build_comment_sentiment_llm_payload(
comment_units,
scores=comment_scores,
attributed_texts=attr_units,
semantic_pool_max=40,
shuffle_seed=keyword,

View File

@ -369,7 +369,9 @@ def write_competitor_analysis_for_run_dir(
)
want_sent = bool(eff_rc.get("llm_comment_sentiment")) or env_on
if want_sent and not skip_sent:
comment_units = jcr._iter_comment_text_units(comment_rows, merged_rows)
comment_units, comment_scores = jcr._iter_comment_text_units_and_scores(
comment_rows, merged_rows
)
if len(comment_units) >= 2:
sentiment_llm_record["attempted"] = True
try:
@ -385,6 +387,7 @@ def write_competitor_analysis_for_run_dir(
attr_units = list(comment_units)
pl = jcr.build_comment_sentiment_llm_payload(
comment_units,
scores=comment_scores,
attributed_texts=attr_units,
max_samples_positive=16,
max_samples_negative=30,

View File

@ -116,8 +116,8 @@ SENTIMENT_LLM_SYSTEM = """你是电商/食品类用户研究助手。输入 JSON
- ``comment_sentiment_lexicon``子串词表统计与报告条形图**同一计数方式****仅作定量参考**子串命中说话人态度
- ``positive_lexeme_hits_top`` / ``negative_lexeme_hits_top``短语级命中摘要同源
- ``sentiment_bucket_method``恒为 ``keyword_substring_heuristic````sample_reviews_positive_biased`` / ``negative`` / ``mixed_tone`` 是按该词表**机械归类**的抽样**可能与整句真实褒贬不一致**例如软硬适中曾被误归负向
- **``sample_reviews_semantic_pool``**若有本批评价经去重后的**随机/洗牌抽样**覆盖未命中任一关键词的句子**归纳正/负向体验引用短引文时优先以此池与上述各列表中的原文为准自行结合语境理解**转折对比没那么甜软硬适中先抑后扬/先扬后抑整句态度**不得以子串是否命中负面词来断言该句为抱怨**
- ``sentiment_bucket_method````score_then_lexeme`` 表示**先按 15 星分桶**无评分行再按关键词``keyword_substring_heuristic`` 表示**仅关键词**分桶与条形图一致``sample_reviews_positive_biased`` / ``negative`` / ``mixed_tone`` 按该规则**机械归类**的抽样**可能与整句真实褒贬不一致**例如软硬适中曾被误归负向
- **``sample_reviews_semantic_pool``**若有本批评价经去重后的**随机/洗牌抽样**来自全部有效条不限于某一象限**归纳正/负向体验引用短引文时优先以此池与上述各列表中的原文为准自行结合语境理解**转折对比没那么甜软硬适中先抑后扬/先扬后抑整句态度**不得以子串是否命中负面词来断言该句为抱怨**
每条样本通常以 ``细类SKU品名店铺`` 开头表示 **§5 细类SKU品名店铺**写归纳与引文时须能还原哪家店哪条 SKU哪款品名或保留前缀**禁止**无指代地写用户普遍

View File

@ -616,7 +616,12 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
sent = brief.get("comment_sentiment_lexicon") or {}
if isinstance(sent, dict):
pie_labs = ["偏正向", "偏负向", "正负混合", "中性/空"]
_score_mode = (sent.get("method") or "") == "score_then_lexeme"
pie_labs = (
["偏正向(45星)", "偏负向(12星)", "关键词混合", "中性/空"]
if _score_mode
else ["偏正向", "偏负向", "正负混合", "中性/空"]
)
pie_vals = [
float(sent.get("positive_only") or 0),
float(sent.get("negative_only") or 0),
@ -642,7 +647,11 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
save_bar_h(
plx,
pvx,
"正向/混合语境 · 正向口语短语命中条数",
(
"45星语境 · 正向口语短语命中条数"
if _score_mode
else "正向/混合语境 · 正向口语短语命中条数"
),
"chart_positive_lexemes_bar.png",
"条数",
)
@ -652,7 +661,11 @@ def generate_report_charts(run_dir: Path, brief: dict[str, Any]) -> list[str]:
save_bar_h(
nlx,
nvx,
"负向/混合语境 · 负向口语短语命中条数",
(
"12星语境 · 负向口语短语命中条数"
if _score_mode
else "负向/混合语境 · 负向口语短语命中条数"
),
"chart_negative_lexemes_bar.png",
"条数",
)

View File

@ -60,6 +60,33 @@ class BuildCompetitorBriefTests(SimpleTestCase):
self.assertEqual(pl.get("sentiment_bucket_method"), "keyword_substring_heuristic")
self.assertGreaterEqual(len(pl["sample_reviews_semantic_pool"]), 1)
def test_comment_sentiment_score_then_lexeme(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
texts = ["很好吃", "太差了", "一般般"]
scores = [5, 1, 3]
lex = jcr._comment_sentiment_lexicon(texts, scores)
self.assertEqual(lex.get("method"), "score_then_lexeme")
self.assertEqual(lex.get("positive_only"), 1)
self.assertEqual(lex.get("negative_only"), 1)
self.assertEqual(lex.get("neutral_or_empty"), 1)
pl = jcr.build_comment_sentiment_llm_payload(texts, scores=scores)
self.assertEqual(pl.get("sentiment_bucket_method"), "score_then_lexeme")
def test_comment_sentiment_all_scores_missing_falls_back_keyword(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path:
sys.path.insert(0, str(root))
import jd_competitor_report as jcr # noqa: WPS433
texts = ["好吃推荐", "差评"]
scores = [None, None]
lex = jcr._comment_sentiment_lexicon(texts, scores)
self.assertEqual(lex.get("method"), "keyword_lexicon")
def test_custom_focus_words_in_report_config(self) -> None:
root = Path(settings.CRAWLER_JD_ROOT).resolve()
if str(root) not in sys.path: