包含 Persona 锚定聚类、LLM 报告生成、方法论文档及 .gitignore 更新,便于在 Gitee 独立部署。 Co-authored-by: Cursor <cursoragent@cursor.com>
756 lines
25 KiB
Python
756 lines
25 KiB
Python
"""
|
||
词频统计:从最新结构化任务读取产品与 CSV,两步连跑。
|
||
|
||
1. 随机 25 条 content → LLM 归纳「产品专有名词」与「Amazon/产品专属停用词」
|
||
2. NLTK 全量 content 分词 + stem/lemma 并族 → output/word_freq.csv
|
||
(专有名词按完整短语统计;每条评论每个词族最多计 1 次)
|
||
|
||
用法::
|
||
|
||
./310py/bin/python 词频.py
|
||
./310py/bin/python 词频.py --skip-llm # 复用 output/voc_terms.json,仅跑第 2 步
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import logging
|
||
import random
|
||
import re
|
||
import sqlite3
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from pathlib import Path
|
||
from typing import Dict, Iterable, List, Sequence, Set, Tuple
|
||
|
||
from voc_llm import CHAT_MODEL, chat_extra_body, create_chat_client, require_chat_api_key
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
stream=sys.stderr,
|
||
)
|
||
logger = logging.getLogger("voc_wordfreq")
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||
STRUCTURED_DB = PROJECT_ROOT / "voc_structured.sqlite"
|
||
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||
TERMS_JSON = OUTPUT_DIR / "voc_terms.json"
|
||
WORD_FREQ_CSV = OUTPUT_DIR / "word_freq.csv"
|
||
|
||
MODEL_NAME = CHAT_MODEL
|
||
|
||
SAMPLE_SIZE = 38
|
||
SAMPLE_SEED = 42
|
||
|
||
# NLTK 英文停用词扩展(与匹配规则参考一致)
|
||
_EXTRA_STOP_WORDS = frozenset(
|
||
{
|
||
"www", "http", "https", "com", "amazon", "asin", "sku",
|
||
"oz", "lb", "lbs", "inch", "inches", "ft", "mm", "cm", "ml", "kg", "pcs", "pc",
|
||
}
|
||
)
|
||
|
||
# 产品标题拆词中可视为「不重要」、应参与停用的功能词
|
||
_PRODUCT_NAME_FILLER = frozenset(
|
||
{
|
||
"a", "an", "the", "and", "or", "but", "for", "nor", "so", "yet",
|
||
"at", "by", "in", "of", "on", "to", "up", "as", "is", "it", "be",
|
||
"with", "from", "into", "via", "per", "vs", "vs.",
|
||
}
|
||
)
|
||
|
||
|
||
def _ensure_nltk():
|
||
"""加载 NLTK 分词 / 词性 / 词形还原依赖(首次自动下载数据包)。"""
|
||
try:
|
||
import nltk # noqa: F401
|
||
except ImportError as e:
|
||
raise RuntimeError(
|
||
"未安装 nltk,请执行: uv pip install --python 310py/bin/python nltk"
|
||
) from e
|
||
import nltk
|
||
from nltk.corpus import wordnet as wn
|
||
from nltk.stem import PorterStemmer, WordNetLemmatizer
|
||
from nltk.tag import pos_tag
|
||
from nltk.tokenize import word_tokenize
|
||
|
||
for resource, pkg in (
|
||
("tokenizers/punkt", "punkt"),
|
||
("tokenizers/punkt_tab", "punkt_tab"),
|
||
("corpora/wordnet", "wordnet"),
|
||
("corpora/omw-1.4", "omw-1.4"),
|
||
("taggers/averaged_perceptron_tagger", "averaged_perceptron_tagger"),
|
||
("taggers/averaged_perceptron_tagger_eng", "averaged_perceptron_tagger_eng"),
|
||
("corpora/stopwords", "stopwords"),
|
||
):
|
||
try:
|
||
nltk.data.find(resource)
|
||
except LookupError:
|
||
logger.info("下载 NLTK 数据包: %s", pkg)
|
||
nltk.download(pkg, quiet=True)
|
||
|
||
return word_tokenize, pos_tag, WordNetLemmatizer(), PorterStemmer(), wn
|
||
|
||
|
||
def _penn_to_wn_pos(tag: str, wn) -> str:
|
||
if tag.startswith("J"):
|
||
return wn.ADJ
|
||
if tag.startswith("V"):
|
||
return wn.VERB
|
||
if tag.startswith("N"):
|
||
return wn.NOUN
|
||
if tag.startswith("R"):
|
||
return wn.ADV
|
||
return wn.NOUN
|
||
|
||
|
||
def load_nltk_stop_words() -> Set[str]:
|
||
_ensure_nltk()
|
||
import nltk
|
||
from nltk.corpus import stopwords
|
||
|
||
try:
|
||
words = set(stopwords.words("english"))
|
||
except LookupError:
|
||
nltk.download("stopwords", quiet=True)
|
||
words = set(stopwords.words("english"))
|
||
words |= _EXTRA_STOP_WORDS
|
||
return words
|
||
|
||
|
||
class WordVariantEngine:
|
||
"""NLTK 分词 + stem / POS-lemma 词形变体(不含同义词并族)。"""
|
||
|
||
def __init__(self) -> None:
|
||
word_tokenize, pos_tag_fn, lemmatizer, stemmer, wn = _ensure_nltk()
|
||
self._word_tokenize = word_tokenize
|
||
self._pos_tag = pos_tag_fn
|
||
self._lemmatizer = lemmatizer
|
||
self._stemmer = stemmer
|
||
self._wn = wn
|
||
|
||
def variants_for_word(self, word: str, pos: str | None = None) -> Set[str]:
|
||
w = (word or "").lower().strip()
|
||
if not w:
|
||
return set()
|
||
out: Set[str] = {w, self._stemmer.stem(w)}
|
||
if pos is not None:
|
||
wn_pos = _penn_to_wn_pos(pos, self._wn)
|
||
out.add(self._lemmatizer.lemmatize(w, pos=wn_pos))
|
||
for p in (self._wn.NOUN, self._wn.VERB, self._wn.ADJ, self._wn.ADV):
|
||
out.add(self._lemmatizer.lemmatize(w, pos=p))
|
||
return {x for x in out if x}
|
||
|
||
def variant_pool(self, word: str, pos: str | None = None) -> Set[str]:
|
||
return self.variants_for_word(word, pos=pos)
|
||
|
||
def pos_tag_tokens(self, tokens: Sequence[str]) -> List[Tuple[str, str]]:
|
||
if not tokens:
|
||
return []
|
||
try:
|
||
return self._pos_tag(list(tokens))
|
||
except Exception:
|
||
return [(t, "NN") for t in tokens]
|
||
|
||
|
||
class _UnionFind:
|
||
def __init__(self) -> None:
|
||
self._parent: Dict[str, str] = {}
|
||
|
||
def add(self, x: str) -> None:
|
||
if x not in self._parent:
|
||
self._parent[x] = x
|
||
|
||
def find(self, x: str) -> str:
|
||
self.add(x)
|
||
while self._parent[x] != x:
|
||
self._parent[x] = self._parent[self._parent[x]]
|
||
x = self._parent[x]
|
||
return x
|
||
|
||
def union(self, a: str, b: str) -> None:
|
||
ra, rb = self.find(a), self.find(b)
|
||
if ra != rb:
|
||
self._parent[rb] = ra
|
||
|
||
def groups(self) -> Dict[str, List[str]]:
|
||
out: Dict[str, List[str]] = defaultdict(list)
|
||
for x in self._parent:
|
||
out[self.find(x)].append(x)
|
||
return dict(out)
|
||
|
||
|
||
def _strip_think(text: str) -> str:
|
||
if not text:
|
||
return text
|
||
text = re.sub(
|
||
r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE
|
||
)
|
||
text = re.sub(r"</?think>", "", text, flags=re.IGNORECASE)
|
||
return text.strip()
|
||
|
||
|
||
def _latest_job(conn: sqlite3.Connection) -> Tuple[int, str, str, str]:
|
||
row = conn.execute(
|
||
"""
|
||
SELECT id, industry, product_name, source_file
|
||
FROM analysis_jobs
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
"""
|
||
).fetchone()
|
||
if not row:
|
||
raise RuntimeError(f"{STRUCTURED_DB} 中无 analysis_jobs 记录,请先跑结构化")
|
||
return int(row[0]), str(row[1]), str(row[2]), str(row[3])
|
||
|
||
|
||
def _resolve_source_path(source_file: str) -> Path:
|
||
p = Path(source_file)
|
||
if p.is_file():
|
||
return p.resolve()
|
||
candidate = PROJECT_ROOT / source_file
|
||
if candidate.is_file():
|
||
return candidate.resolve()
|
||
raise FileNotFoundError(f"找不到评论 CSV: {source_file}")
|
||
|
||
|
||
def _load_contents(csv_path: Path) -> List[Tuple[int, str]]:
|
||
rows: List[Tuple[int, str]] = []
|
||
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||
reader = csv.DictReader(f)
|
||
if not reader.fieldnames or "content" not in reader.fieldnames:
|
||
raise ValueError(f"{csv_path} 缺少 content 列")
|
||
for i, row in enumerate(reader, start=1):
|
||
text = (row.get("content") or "").strip()
|
||
if text:
|
||
rows.append((i, text))
|
||
if not rows:
|
||
raise ValueError(f"{csv_path} 无有效 content")
|
||
return rows
|
||
|
||
|
||
def _sample_reviews(
|
||
rows: Sequence[Tuple[int, str]], *, n: int, seed: int
|
||
) -> List[Tuple[int, str]]:
|
||
rng = random.Random(seed)
|
||
k = min(n, len(rows))
|
||
return rng.sample(list(rows), k)
|
||
|
||
|
||
def _build_terms_prompt(
|
||
industry: str, product_name: str, samples: Sequence[Tuple[int, str]]
|
||
) -> Tuple[str, str]:
|
||
blocks = []
|
||
for row_id, text in samples:
|
||
blocks.append(f"[R{row_id}]\n{text}")
|
||
joined = "\n\n---\n\n".join(blocks)
|
||
system = (
|
||
"你是亚马逊美国站英文评论与电商文本分析专家。"
|
||
"根据样本评论,列出两类词表,供后续英文分词与词频统计使用。"
|
||
)
|
||
user = f"""行业:{industry}
|
||
产品:{product_name}
|
||
|
||
以下为该产品 {len(samples)} 条随机评论(仅正文 content):
|
||
|
||
{joined}
|
||
|
||
请输出两部分(可用 Markdown 标题、编号列表或逗号分隔,不必是 JSON):
|
||
|
||
## 产品专有名词(product_terms)
|
||
列出该产品评论中可能出现的**全部**专有表达,尽量合理扩展,包括但不限于:
|
||
成分/品类、宠物品种与病种、剂型包装、用法场景、Amazon 物流售后相关但**属于本产品语境**的词等。
|
||
多词短语请保留完整形式(如 turkey tail mushroom powder);后续词频**只统计完整短语**,不会拆成单词分别计数。
|
||
**不要**把下面「专属停用词」里的词放进本列表。
|
||
|
||
## 专属停用词(stopwords)
|
||
列出 Amazon 美国站电商通用词,以及与本产品强相关、词频高但**分析价值低**的词(如泛化评价词、过泛产品描述词等)。
|
||
多词短语可保留。
|
||
注意:不要把产品名「{product_name}」**整句**列为停用词;也不要把其中的**实词/品类成分**(如 turkey、tail、mushroom)列为停用词。
|
||
产品名拆词里**无分析价值的虚词/功能词**(如 for、the、a、of、and)可以且应列入停用词。
|
||
|
||
每条一行或逗号分隔即可。"""
|
||
return system, user
|
||
|
||
|
||
def _call_llm(system: str, user: str, api_key: str) -> str:
|
||
_ = api_key
|
||
# 术语提取只需短 JSON/列表,200k max_tokens 会导致 API 长时间生成或挂起
|
||
client = create_chat_client(timeout=600.0)
|
||
extra_body = chat_extra_body(MODEL_NAME)
|
||
resp = client.chat.completions.create(
|
||
model=MODEL_NAME,
|
||
messages=[
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": user},
|
||
],
|
||
temperature=0.6,
|
||
max_tokens=90000,
|
||
extra_body=extra_body,
|
||
)
|
||
msg = resp.choices[0].message
|
||
text = msg.content or getattr(msg, "reasoning_content", None) or ""
|
||
if not text.strip():
|
||
raise RuntimeError("LLM 返回为空")
|
||
return _strip_think(text)
|
||
|
||
|
||
def _normalize_term(s: str) -> str:
|
||
s = re.sub(r"\s+", " ", (s or "").strip().lower())
|
||
return s.strip(".,;:\"'""''`-–—()[]{}")
|
||
|
||
|
||
def _split_list_items(line: str) -> List[str]:
|
||
line = re.sub(r"^[\s\d\.\)\-•\*]+", "", line.strip())
|
||
if not line or line.startswith("#"):
|
||
return []
|
||
parts = re.split(r"[,,;;、|]", line)
|
||
return [_normalize_term(p) for p in parts if _normalize_term(p)]
|
||
|
||
|
||
def _try_parse_json_terms(text: str) -> Tuple[List[str], List[str]] | None:
|
||
text = text.replace("```json", "").replace("```", "").strip()
|
||
start, end = text.find("{"), text.rfind("}")
|
||
if start == -1 or end <= start:
|
||
return None
|
||
try:
|
||
obj = json.loads(text[start : end + 1])
|
||
except json.JSONDecodeError:
|
||
return None
|
||
if not isinstance(obj, dict):
|
||
return None
|
||
|
||
def pick(keys: Sequence[str]) -> List[str]:
|
||
for k in keys:
|
||
v = obj.get(k)
|
||
if isinstance(v, list):
|
||
return [_normalize_term(str(x)) for x in v if _normalize_term(str(x))]
|
||
if isinstance(v, str):
|
||
return _split_list_items(v)
|
||
return []
|
||
|
||
terms = pick(("product_terms", "专有名词", "terms", "keywords"))
|
||
stops = pick(("stopwords", "stop_words", "停用词", "custom_stopwords"))
|
||
if terms or stops:
|
||
return terms, stops
|
||
return None
|
||
|
||
|
||
def _parse_section_lines(text: str, mode: str) -> List[str]:
|
||
"""mode: product | stop"""
|
||
lines = text.splitlines()
|
||
collecting = False
|
||
items: List[str] = []
|
||
product_hdr = re.compile(
|
||
r"(专有名词|product[_\s-]*terms?|product\s+terms)", re.I
|
||
)
|
||
stop_hdr = re.compile(
|
||
r"(专属停用|停用词|stop[_\s-]*words?|custom\s+stop)", re.I
|
||
)
|
||
other_hdr = re.compile(r"^#{1,3}\s+")
|
||
|
||
for line in lines:
|
||
raw = line.strip()
|
||
if not raw:
|
||
if collecting and items:
|
||
collecting = False
|
||
continue
|
||
if mode == "product" and product_hdr.search(raw):
|
||
collecting = True
|
||
inline = re.split(r"[::]", raw, maxsplit=1)
|
||
if len(inline) > 1:
|
||
items.extend(_split_list_items(inline[1]))
|
||
continue
|
||
if mode == "stop" and stop_hdr.search(raw):
|
||
collecting = True
|
||
inline = re.split(r"[::]", raw, maxsplit=1)
|
||
if len(inline) > 1:
|
||
items.extend(_split_list_items(inline[1]))
|
||
continue
|
||
if collecting and (other_hdr.match(raw) or (
|
||
mode == "product" and stop_hdr.search(raw)
|
||
) or (mode == "stop" and product_hdr.search(raw))):
|
||
collecting = False
|
||
continue
|
||
if collecting:
|
||
items.extend(_split_list_items(raw))
|
||
return items
|
||
|
||
|
||
def _parse_llm_terms(text: str) -> Tuple[List[str], List[str]]:
|
||
parsed = _try_parse_json_terms(text)
|
||
if parsed:
|
||
return parsed
|
||
product = _parse_section_lines(text, "product")
|
||
stop = _parse_section_lines(text, "stop")
|
||
if not product and not stop:
|
||
# 兜底:按行提取非空短语
|
||
for line in text.splitlines():
|
||
items = _split_list_items(line)
|
||
if items and len(items) <= 8:
|
||
product.extend(items)
|
||
return _dedupe_terms(product), _dedupe_terms(stop)
|
||
|
||
|
||
def _dedupe_terms(items: Iterable[str]) -> List[str]:
|
||
seen: Set[str] = set()
|
||
out: List[str] = []
|
||
for x in items:
|
||
if x and x not in seen:
|
||
seen.add(x)
|
||
out.append(x)
|
||
return out
|
||
|
||
|
||
def _product_name_tokens(product_name: str) -> Set[str]:
|
||
return {
|
||
t
|
||
for t in re.findall(r"[a-z0-9]+", product_name.lower())
|
||
if t
|
||
}
|
||
|
||
|
||
def _product_name_filler_tokens(product_name: str, nltk_stops: Set[str]) -> Set[str]:
|
||
"""产品名拆词中的虚词/功能词 → 应停用。"""
|
||
tokens = _product_name_tokens(product_name)
|
||
return {t for t in tokens if t in nltk_stops or t in _PRODUCT_NAME_FILLER}
|
||
|
||
|
||
def _product_name_core_tokens(product_name: str) -> Set[str]:
|
||
"""产品名中有分析价值的实词 → 不停用。"""
|
||
fillers = _product_name_filler_tokens(product_name, load_nltk_stop_words())
|
||
return _product_name_tokens(product_name) - fillers
|
||
|
||
|
||
def _is_pure_number(s: str) -> bool:
|
||
return bool(s) and s.isdigit()
|
||
|
||
|
||
def _finalize_term_lists(
|
||
product_terms: List[str],
|
||
stopwords: List[str],
|
||
product_name: str,
|
||
) -> Tuple[List[str], Set[str]]:
|
||
stop_set = load_nltk_stop_words()
|
||
stop_set.update(_dedupe_terms(stopwords))
|
||
stop_set.update(_product_name_filler_tokens(product_name, stop_set))
|
||
for tok in _product_name_core_tokens(product_name):
|
||
stop_set.discard(tok)
|
||
pn_lower = _normalize_term(product_name)
|
||
if pn_lower:
|
||
stop_set.discard(pn_lower)
|
||
|
||
cleaned_terms: List[str] = []
|
||
for t in _dedupe_terms(product_terms):
|
||
if t in stop_set:
|
||
continue
|
||
cleaned_terms.append(t)
|
||
cleaned_terms.sort(key=lambda x: (-len(x.split()), -len(x)))
|
||
return cleaned_terms, stop_set
|
||
|
||
|
||
def _phrase_pattern(phrase: str) -> re.Pattern[str]:
|
||
parts = [re.escape(p) for p in phrase.split()]
|
||
body = r"\s+".join(parts)
|
||
return re.compile(rf"(?<!\w){body}(?!\w)", re.IGNORECASE)
|
||
|
||
|
||
def _merge_spans(spans: List[Tuple[int, int]]) -> List[Tuple[int, int]]:
|
||
if not spans:
|
||
return []
|
||
ordered = sorted(spans)
|
||
merged: List[Tuple[int, int]] = [ordered[0]]
|
||
for start, end in ordered[1:]:
|
||
prev_s, prev_e = merged[-1]
|
||
if start <= prev_e:
|
||
merged[-1] = (prev_s, max(prev_e, end))
|
||
else:
|
||
merged.append((start, end))
|
||
return merged
|
||
|
||
|
||
def _overlaps_span(char_start: int, char_end: int, spans: Sequence[Tuple[int, int]]) -> bool:
|
||
for s, e in spans:
|
||
if char_start < e and char_end > s:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _collect_protected_spans(lower: str, product_terms: List[str], stop_set: Set[str]) -> List[Tuple[int, int]]:
|
||
"""返回专有名词短语匹配区间(屏蔽拆词统计,短语单独计数)。"""
|
||
spans: List[Tuple[int, int]] = []
|
||
for phrase in product_terms:
|
||
if not phrase or phrase in stop_set or _is_pure_number(phrase):
|
||
continue
|
||
for m in _phrase_pattern(phrase).finditer(lower):
|
||
spans.append((m.start(), m.end()))
|
||
return _merge_spans(spans)
|
||
|
||
|
||
def _phrases_hit_in_review(lower: str, product_terms: List[str], stop_set: Set[str]) -> List[str]:
|
||
"""本条评论命中的专有名词(每条评论每短语最多计 1 次)。"""
|
||
hit: List[str] = []
|
||
for phrase in product_terms:
|
||
if not phrase or phrase in stop_set or _is_pure_number(phrase):
|
||
continue
|
||
if _phrase_pattern(phrase).search(lower):
|
||
hit.append(phrase)
|
||
return hit
|
||
|
||
|
||
_TOKEN_RE = re.compile(r"[a-z0-9']+")
|
||
|
||
|
||
def _tokenize_tagged_with_spans(text: str, engine: WordVariantEngine) -> List[Tuple[str, str, int, int]]:
|
||
"""带字符区间的分词 + 词性标注。"""
|
||
lower = text.lower()
|
||
raw: List[Tuple[str, int, int]] = []
|
||
for m in _TOKEN_RE.finditer(lower):
|
||
tok = m.group()
|
||
if tok:
|
||
raw.append((tok, m.start(), m.end()))
|
||
if not raw:
|
||
return []
|
||
words = [t for t, _, _ in raw]
|
||
tagged = engine.pos_tag_tokens(words)
|
||
return [(tagged[i][0], tagged[i][1], raw[i][1], raw[i][2]) for i in range(len(raw))]
|
||
|
||
|
||
def _is_valid_token(tok: str, stop_set: Set[str]) -> bool:
|
||
if not tok or len(tok) < 1 or tok in stop_set or _is_pure_number(tok):
|
||
return False
|
||
return bool(re.search(r"[a-z]", tok))
|
||
|
||
|
||
def _register_review_tokens_in_uf(
|
||
text: str,
|
||
protected: Sequence[Tuple[int, int]],
|
||
engine: WordVariantEngine,
|
||
uf: _UnionFind,
|
||
variant_index: Dict[str, Set[str]],
|
||
stop_set: Set[str],
|
||
) -> None:
|
||
"""Pass 1:将本条评论 token 注册进 Union-Find(stem/lemma 并族)。"""
|
||
seen: Set[str] = set()
|
||
for tok, tag, start, end in _tokenize_tagged_with_spans(text, engine):
|
||
if _overlaps_span(start, end, protected):
|
||
continue
|
||
if not _is_valid_token(tok, stop_set):
|
||
continue
|
||
if tok in seen:
|
||
continue
|
||
seen.add(tok)
|
||
uf.add(tok)
|
||
variants = engine.variant_pool(tok, pos=tag)
|
||
related: Set[str] = set()
|
||
for v in variants:
|
||
related |= variant_index[v]
|
||
for other in related:
|
||
uf.union(tok, other)
|
||
for v in variants:
|
||
variant_index[v].add(tok)
|
||
|
||
|
||
def _family_labels(uf: _UnionFind, surface_doc_freq: Counter[str]) -> Dict[str, str]:
|
||
"""词族代表形:族内 surface 文档频次最高者,并列取最短。"""
|
||
labels: Dict[str, str] = {}
|
||
for root, members in uf.groups().items():
|
||
best = sorted(
|
||
members,
|
||
key=lambda m: (-surface_doc_freq.get(m, 0), len(m), m),
|
||
)[0]
|
||
labels[root] = best
|
||
return labels
|
||
|
||
|
||
def _build_word_freq(
|
||
rows: Sequence[Tuple[int, str]],
|
||
product_terms: List[str],
|
||
stop_set: Set[str],
|
||
) -> Counter[str]:
|
||
"""
|
||
NLTK 分词 + stem/lemma Union-Find 并族。
|
||
每条评论:每个词族最多 +1;专有名词短语命中也最多 +1/短语。
|
||
输出 word 为词族代表形(方案 1)。
|
||
"""
|
||
engine = WordVariantEngine()
|
||
uf = _UnionFind()
|
||
variant_index: Dict[str, Set[str]] = defaultdict(set)
|
||
|
||
# Pass 1:全库注册词形变体并族
|
||
for _, text in rows:
|
||
if not text.strip():
|
||
continue
|
||
protected = _collect_protected_spans(text.lower(), product_terms, stop_set)
|
||
_register_review_tokens_in_uf(text, protected, engine, uf, variant_index, stop_set)
|
||
|
||
family_counter: Counter[str] = Counter()
|
||
phrase_counter: Counter[str] = Counter()
|
||
surface_doc_freq: Counter[str] = Counter()
|
||
|
||
# Pass 2:按评论计数(每词族 / 每短语最多 1 次)
|
||
for _, text in rows:
|
||
if not text.strip():
|
||
continue
|
||
lower = text.lower()
|
||
protected = _collect_protected_spans(lower, product_terms, stop_set)
|
||
|
||
for phrase in _phrases_hit_in_review(lower, product_terms, stop_set):
|
||
phrase_counter[phrase] += 1
|
||
|
||
families_seen: Set[str] = set()
|
||
seen_tok: Set[str] = set()
|
||
for tok, tag, start, end in _tokenize_tagged_with_spans(text, engine):
|
||
if _overlaps_span(start, end, protected):
|
||
continue
|
||
if not _is_valid_token(tok, stop_set):
|
||
continue
|
||
if tok in seen_tok:
|
||
continue
|
||
seen_tok.add(tok)
|
||
surface_doc_freq[tok] += 1
|
||
root = uf.find(tok)
|
||
if root not in families_seen:
|
||
families_seen.add(root)
|
||
family_counter[root] += 1
|
||
|
||
labels = _family_labels(uf, surface_doc_freq)
|
||
merged: Counter[str] = Counter()
|
||
for root, count in family_counter.items():
|
||
merged[labels.get(root, root)] += count
|
||
for phrase, count in phrase_counter.items():
|
||
merged[phrase] += count
|
||
|
||
return Counter({k: v for k, v in merged.items() if v > 0 and not _is_pure_number(k)})
|
||
|
||
|
||
def _save_word_freq(counter: Counter[str], path: Path) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8", newline="") as f:
|
||
w = csv.writer(f)
|
||
w.writerow(["word", "count"])
|
||
for word, count in counter.most_common():
|
||
w.writerow([word, count])
|
||
|
||
def _step1_extract_terms(
|
||
*,
|
||
job_id: int,
|
||
industry: str,
|
||
product_name: str,
|
||
samples: List[Tuple[int, str]],
|
||
api_key: str,
|
||
) -> dict:
|
||
system, user = _build_terms_prompt(industry, product_name, samples)
|
||
logger.info("第 1 步:调用 %s 提取专有名词与停用词(样本 %s 条)", MODEL_NAME, len(samples))
|
||
raw = _call_llm(system, user, api_key)
|
||
product_raw, stop_raw = _parse_llm_terms(raw)
|
||
product_terms, _stop_set = _finalize_term_lists(
|
||
product_raw, stop_raw, product_name
|
||
)
|
||
logger.info(
|
||
"解析得到专有名词 %s 条、专属停用词 %s 条",
|
||
len(product_terms),
|
||
len(stop_raw),
|
||
)
|
||
payload = {
|
||
"job_id": job_id,
|
||
"industry": industry,
|
||
"product_name": product_name,
|
||
"sample_size": len(samples),
|
||
"sample_seed": SAMPLE_SEED,
|
||
"sample_row_ids": [r for r, _ in samples],
|
||
"model": MODEL_NAME,
|
||
"product_terms": product_terms,
|
||
"stopwords_custom": _dedupe_terms(stop_raw),
|
||
"llm_raw": raw,
|
||
}
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
TERMS_JSON.write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
logger.info("已保存 %s", TERMS_JSON)
|
||
return payload
|
||
|
||
|
||
def _load_terms_json() -> dict:
|
||
if not TERMS_JSON.is_file():
|
||
raise FileNotFoundError(f"缺少 {TERMS_JSON},请先运行完整流程或去掉 --skip-llm")
|
||
return json.loads(TERMS_JSON.read_text(encoding="utf-8"))
|
||
|
||
|
||
def run(*, skip_llm: bool = False) -> dict:
|
||
if not STRUCTURED_DB.is_file():
|
||
raise FileNotFoundError(f"缺少 {STRUCTURED_DB}")
|
||
|
||
conn = sqlite3.connect(STRUCTURED_DB)
|
||
try:
|
||
job_id, industry, product_name, source_file = _latest_job(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
csv_path = _resolve_source_path(source_file)
|
||
rows = _load_contents(csv_path)
|
||
logger.info(
|
||
"job_id=%s product=%r 评论 %s 条,来源 %s",
|
||
job_id,
|
||
product_name,
|
||
len(rows),
|
||
csv_path.name,
|
||
)
|
||
|
||
if skip_llm:
|
||
meta = _load_terms_json()
|
||
product_terms, stop_set = _finalize_term_lists(
|
||
list(meta.get("product_terms") or []),
|
||
list(meta.get("stopwords_custom") or []),
|
||
product_name,
|
||
)
|
||
logger.info("第 1 步跳过,复用 %s", TERMS_JSON)
|
||
else:
|
||
require_chat_api_key()
|
||
api_key = ""
|
||
samples = _sample_reviews(rows, n=SAMPLE_SIZE, seed=SAMPLE_SEED)
|
||
meta = _step1_extract_terms(
|
||
job_id=job_id,
|
||
industry=industry,
|
||
product_name=product_name,
|
||
samples=samples,
|
||
api_key=api_key,
|
||
)
|
||
product_terms, stop_set = _finalize_term_lists(
|
||
meta["product_terms"],
|
||
meta.get("stopwords_custom") or [],
|
||
product_name,
|
||
)
|
||
|
||
logger.info("第 2 步:NLTK 分词 + stem/lemma 并族词频统计")
|
||
counter = _build_word_freq(rows, product_terms, stop_set)
|
||
_save_word_freq(counter, WORD_FREQ_CSV)
|
||
logger.info("已写入 %s", WORD_FREQ_CSV)
|
||
|
||
return {
|
||
"job_id": job_id,
|
||
"product_name": product_name,
|
||
"terms_json": str(TERMS_JSON),
|
||
"word_freq_csv": str(WORD_FREQ_CSV),
|
||
"unique_words": len(counter),
|
||
"total_tokens": sum(counter.values()),
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="VOC 词频统计")
|
||
parser.add_argument(
|
||
"--skip-llm",
|
||
action="store_true",
|
||
help="跳过第 1 步,使用 output/voc_terms.json",
|
||
)
|
||
args = parser.parse_args()
|
||
result = run(skip_llm=args.skip_llm)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|