# -*- coding: utf-8 -*- """ 数据加载模块:从 SQLite 数据库和 CSV 中提取结构化数据。 """ from __future__ import annotations import csv import json import logging import sqlite3 from collections import Counter, defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger("voc.data_loader") STRUCTURED_DB_NAME = "voc_structured.sqlite" CLUSTER_DB_NAME = "voc_clustering.sqlite" EMBED_DB_NAME = "voc_embeddings.sqlite" CLEANED_CSV_NAME = "merged_reviews_cleaned.csv" WORD_FREQ_CSV_NAME = "output/word_freq.csv" TERMS_JSON_NAME = "output/voc_terms.json" @dataclass class ReviewRecord: asin: str rating: float title: str content: str verified: bool vine: bool review_date: str source_row: int @dataclass class ASINStats: asin: str total: int avg_rating: float pos_rate: float neg_rate: float star_dist: Dict[int, int] = field(default_factory=dict) @dataclass class ClusterData: stage: str cluster_label: int review_count: int phrase_count: int top_phrases: List[str] = field(default_factory=list) source_rows: List[int] = field(default_factory=list) source_asins: List[str] = field(default_factory=list) sentiment: str = "" audience_label: int = -1 @dataclass class MarketStats: total_reviews: int weighted_avg_rating: float pos_rate: float neg_rate: float neg_review_count: int pos_review_count: int asins: List[ASINStats] = field(default_factory=list) class DataLoader: def __init__(self, project_root: Path, product_name: str = "", industry: str = ""): self.project_root = Path(project_root).resolve() self.product_name = product_name self.industry = industry self.structured_db = self.project_root / STRUCTURED_DB_NAME self.cluster_db = self.project_root / CLUSTER_DB_NAME self.embed_db = self.project_root / EMBED_DB_NAME self.cleaned_csv = self.project_root / CLEANED_CSV_NAME self.word_freq_csv = self.project_root / WORD_FREQ_CSV_NAME self.terms_json = self.project_root / TERMS_JSON_NAME @staticmethod def load_raw_review_samples(input_dir: Path, max_samples: int = 50): """从原始 CSV 目录中加载样本评论内容(仅 content 字段),供 LLM 识别产品/行业。 Returns: (samples: list[str], dir_name: str)""" import csv as _csv samples = [] dir_path = Path(input_dir) dir_name = dir_path.name if dir_path.is_dir() else "" if not dir_path.is_dir(): logger.warning("原始数据目录不存在: %s", dir_path) return samples, dir_name for fname in sorted(dir_path.iterdir()): if not fname.suffix.lower() == ".csv": continue try: with fname.open(encoding="utf-8-sig", newline="") as f: reader = _csv.DictReader(f) if not reader.fieldnames or "content" not in reader.fieldnames: continue for row in reader: text = (row.get("content") or "").strip() if text and len(text) >= 20: samples.append(text) if len(samples) >= max_samples: break if len(samples) >= max_samples: break except Exception as e: logger.debug("跳过文件 %s: %s", fname.name, e) return samples, dir_name def load_reviews(self) -> List[ReviewRecord]: if not self.cleaned_csv.is_file(): raise FileNotFoundError(f"清洗后评论文件不存在: {self.cleaned_csv}") reviews: List[ReviewRecord] = [] with self.cleaned_csv.open(encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for i, row in enumerate(reader, start=1): text = (row.get("content") or "").strip() if not text: continue reviews.append(ReviewRecord( asin=(row.get("asin") or "").strip(), rating=float(row.get("rating") or 0), title=(row.get("title") or "").strip(), content=text, verified=(row.get("verified") or "").strip().lower() == "true", vine=(row.get("vine") or "").strip().lower() == "true", review_date=(row.get("review_date") or "").strip(), source_row=i, )) logger.info("已加载 %s 条有效评论", len(reviews)) return reviews def get_review_by_source_row(self, source_row: int) -> Optional[ReviewRecord]: reviews = self.load_reviews() for r in reviews: if r.source_row == source_row: return r return None def load_basic_stats(self) -> MarketStats: reviews = self.load_reviews() if not reviews: raise ValueError("无有效评论") asin_groups: Dict[str, List[ReviewRecord]] = defaultdict(list) for r in reviews: asin_groups[r.asin].append(r) asin_stats_list: List[ASINStats] = [] weighted_sum = 0.0 total_count = 0 for asin in sorted(asin_groups.keys()): grp = asin_groups[asin] ratings = [r.rating for r in grp if r.rating > 0] n = len(ratings) if n == 0: continue avg = sum(ratings) / n pos = sum(1 for r in ratings if r >= 4) / n neg = sum(1 for r in ratings if r <= 2) / n dist = Counter(int(r) for r in ratings) asin_stats_list.append(ASINStats( asin=asin, total=n, avg_rating=round(avg, 2), pos_rate=round(pos, 3), neg_rate=round(neg, 3), star_dist={i: dist.get(i, 0) for i in range(1, 6)}, )) weighted_sum += avg * n total_count += n all_ratings = [r.rating for r in reviews if r.rating > 0] wavg = round(weighted_sum / total_count, 2) if total_count else 0.0 pos_total = sum(1 for r in all_ratings if r >= 4) neg_total = sum(1 for r in all_ratings if r <= 2) return MarketStats( total_reviews=total_count, weighted_avg_rating=wavg, pos_rate=round(pos_total / total_count, 3) if total_count else 0, neg_rate=round(neg_total / total_count, 3) if total_count else 0, neg_review_count=neg_total, pos_review_count=pos_total, asins=asin_stats_list, ) def _latest_cluster_run_id(self) -> int: if not self.cluster_db.is_file(): raise FileNotFoundError(f"聚类库不存在: {self.cluster_db}") conn = sqlite3.connect(self.cluster_db) try: row = conn.execute("SELECT id FROM cluster_runs ORDER BY id DESC LIMIT 1").fetchone() if not row: raise RuntimeError("voc_clustering.sqlite 中无聚类记录") return int(row[0]) finally: conn.close() def load_cluster_data(self) -> Dict[str, List[ClusterData]]: run_id = self._latest_cluster_run_id() conn = sqlite3.connect(self.cluster_db) try: cur = conn.execute( """SELECT stage, cluster_label, source_row, embed_text, entity_type, audience, sentiment, content FROM cluster_assignments WHERE run_id = ? ORDER BY stage, cluster_label, source_row""", (run_id,), ) rows = cur.fetchall() finally: conn.close() groups: Dict[Tuple[str, int], List[Tuple[int, str, str, str, str]]] = defaultdict(list) for stage, label, src_row, embed_text, etype, audience, sentiment, content in rows: groups[(stage, int(label))].append(( int(src_row), embed_text, etype or "", audience or "", sentiment or "", )) reviews = self.load_reviews() row_to_asin: Dict[int, str] = {r.source_row: r.asin for r in reviews} result: Dict[str, List[ClusterData]] = defaultdict(list) for (stage, label), items in sorted(groups.items()): unique_src_rows = sorted(set(sr for sr, _, _, _, _ in items)) asins = sorted(set(row_to_asin.get(sr, "?") for sr in unique_src_rows)) phrases = [et for _, et, _, _, _ in items if et.strip()] phrase_counter = Counter(phrases) top_phrases = [p for p, _ in phrase_counter.most_common(15)] sentiment = items[0][3] if items else "" audience_str = items[0][2] if items else "" aud_label = -1 if audience_str: try: aud_label = int(audience_str) except ValueError: aud_label = -1 cd = ClusterData( stage=stage, cluster_label=label, review_count=len(unique_src_rows), phrase_count=len(phrases), top_phrases=top_phrases, source_rows=unique_src_rows, source_asins=asins, sentiment=sentiment, audience_label=aud_label, ) result[stage].append(cd) logger.info("已加载聚类: %s stages, %s 簇", len(result), sum(len(v) for v in result.values())) return dict(result) def build_persona_cluster_catalog( self, sample_reviews_per_cluster: int = 5, max_review_chars: int = 320, ) -> List[Dict[str, Any]]: """供 Persona LLM 绑定的聚类簇目录(含每簇代表性评论)。""" all_c = self.load_cluster_data() dim_hint = { "1_audience": "A", "3a_pain_global": "C", "3b_aspect_opinion_negative": "B", "3b_aspect_opinion_positive": "B", } catalog: List[Dict[str, Any]] = [] for stage in ( "1_audience", "3a_pain_global", "3b_aspect_opinion_negative", "3b_aspect_opinion_positive", ): for c in sorted(all_c.get(stage, []), key=lambda x: -x.review_count): entry: Dict[str, Any] = { "stage": stage, "label": c.cluster_label, "review_count": c.review_count, "top_phrases": c.top_phrases[:12], "suggested_dimension": dim_hint.get(stage, "B"), } if sample_reviews_per_cluster > 0: quotes = self.get_representative_quotes( c.source_rows, max_quotes=sample_reviews_per_cluster, ) entry["sample_reviews"] = [ { "rating": int(q.get("rating") or 0), "content": (q.get("content") or "")[:max_review_chars], } for q in quotes ] catalog.append(entry) return catalog def load_audience_clusters(self) -> List[ClusterData]: return self.load_cluster_data().get("1_audience", []) def load_global_pain_clusters(self) -> List[ClusterData]: return self.load_cluster_data().get("3a_pain_global", []) def load_global_feedback_clusters(self) -> Dict[str, List[ClusterData]]: all_c = self.load_cluster_data() return { "negative": all_c.get("3b_aspect_opinion_negative", []), "positive": all_c.get("3b_aspect_opinion_positive", []), "neutral": all_c.get("3b_aspect_opinion_neutral", []), } def load_per_audience_clusters(self) -> Dict[int, Dict[str, List[ClusterData]]]: all_c = self.load_cluster_data() aud_c: Dict[int, Dict[str, List[ClusterData]]] = defaultdict( lambda: {"pain": [], "negative": [], "positive": [], "neutral": []} ) for stage, clusters in all_c.items(): if stage.startswith("2a_pain_audience_c"): al = int(stage.split("_c")[-1]) aud_c[al]["pain"].extend(clusters) elif stage.startswith("2b_aspect_opinion_"): parts = stage.replace("2b_aspect_opinion_", "").split("_audience_c") sentiment = parts[0] al = int(parts[1]) if len(parts) > 1 else -1 if sentiment in ("positive", "negative", "neutral"): aud_c[al][sentiment].extend(clusters) return dict(aud_c) def build_cluster_prompt_data( self, persona_sample_reviews: int = 5, ) -> Dict[str, Any]: audience = self.load_audience_clusters() global_pain = self.load_global_pain_clusters() global_fb = self.load_global_feedback_clusters() per_aud = self.load_per_audience_clusters() stats = self.load_basic_stats() def _sum(clusters: List[ClusterData]) -> List[Dict[str, Any]]: return [ {"label": c.cluster_label, "phrase_count": c.phrase_count, "review_count": c.review_count, "asins": c.source_asins, "top_phrases": c.top_phrases} for c in sorted(clusters, key=lambda x: -x.phrase_count) ] return { "audience_clusters": _sum(audience), "global_pains": _sum(global_pain), "global_negative": _sum(global_fb["negative"]), "global_positive": _sum(global_fb["positive"]), "persona_cluster_catalog": self.build_persona_cluster_catalog( sample_reviews_per_cluster=persona_sample_reviews, ), "per_audience": { al: {"pains": _sum(d["pain"]), "negative": _sum(d["negative"]), "positive": _sum(d["positive"])} for al, d in sorted(per_aud.items()) }, "total_reviews": stats.total_reviews, "neg_review_count": stats.neg_review_count, "pos_review_count": stats.pos_review_count, "asins": [a.asin for a in stats.asins], } def load_word_freq(self, top_n: int = 200) -> List[Tuple[str, int]]: if not self.word_freq_csv.is_file(): logger.warning("词频文件不存在: %s", self.word_freq_csv) return [] rows: List[Tuple[str, int]] = [] with self.word_freq_csv.open(encoding="utf-8", newline="") as f: reader = csv.DictReader(f) for row in reader: w = (row.get("word") or "").strip() if not w: continue try: c = int(row.get("count") or 0) except ValueError: c = 0 rows.append((w, c)) rows.sort(key=lambda x: x[1], reverse=True) return rows[:top_n] def get_representative_quotes(self, source_rows: List[int], max_quotes: int = 5) -> List[Dict[str, str]]: reviews = self.load_reviews() row_map = {r.source_row: r for r in reviews} candidates = [] for sr in source_rows: r = row_map.get(sr) if r and len(r.content) >= 30: candidates.append(r) candidates.sort(key=lambda r: abs(len(r.content) - 200)) selected = candidates[:max_quotes] return [ {"content": r.content, "asin": r.asin, "rating": str(int(r.rating)), "amazon_url": f"https://www.amazon.com/dp/{r.asin}"} for r in selected ] def get_quotes_for_cluster(self, cluster: ClusterData, max_quotes: int = 5) -> List[Dict[str, str]]: return self.get_representative_quotes(cluster.source_rows, max_quotes) def build_amazon_url(asin: str) -> str: return f"https://www.amazon.com/dp/{asin}" def asin_link_html(asin: str) -> str: url = build_amazon_url(asin) return f'{asin}'