移除结构化 audience 字段,强化 voc_业务_2 源评论归因匹配与 Persona 引用展示,更新 README 与流水线默认清理 SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
548 lines
22 KiB
Python
548 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
数据加载模块:从 SQLite 数据库和 CSV 中提取结构化数据。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import json
|
||
import logging
|
||
import math
|
||
import sqlite3
|
||
from collections import Counter, defaultdict
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set, 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"
|
||
|
||
# 与 report_utils._PERSONA_PHRASE_STOPWORDS 保持一致(避免循环 import)
|
||
_PERSONA_PHRASE_STOPWORDS = frozenset({
|
||
"self", "unknown", "user", "customer", "buyer", "myself", "the user",
|
||
"a user", "consumer", "reviewer", "amazon", "product", "item",
|
||
})
|
||
|
||
|
||
def _phrase_segments_local(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 _cluster_semantic_label(
|
||
cluster: "ClusterData",
|
||
phrase_doc_freq: Dict[str, int],
|
||
n_clusters_in_stage: int,
|
||
) -> str:
|
||
"""从 top_phrases 生成簇短语义标签(频次 × IDF,同类 stage 内去重)。"""
|
||
scored: List[Tuple[float, str]] = []
|
||
for rank, phrase in enumerate(cluster.top_phrases):
|
||
tf = 1.0 / (1 + rank)
|
||
for seg in _phrase_segments_local([phrase]):
|
||
if seg in _PERSONA_PHRASE_STOPWORDS or len(seg) < 4:
|
||
continue
|
||
df = phrase_doc_freq.get(seg, 1)
|
||
idf = math.log((n_clusters_in_stage + 1) / (df + 0.5))
|
||
scored.append((tf * idf, seg))
|
||
if scored:
|
||
scored.sort(key=lambda x: (-x[0], -len(x[1])))
|
||
return scored[0][1][:48]
|
||
for phrase in cluster.top_phrases:
|
||
p = (phrase or "").strip()
|
||
if len(p) >= 4:
|
||
return p[:48]
|
||
return f"cluster_{cluster.cluster_label}"
|
||
|
||
|
||
@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)
|
||
|
||
|
||
# Persona A/B/C 维度信号词(用于运行时判定簇的 suggested_dimension)
|
||
_A_DIM_SIGNALS = frozenset({
|
||
# 生理/物理特征 — 跨品类通用
|
||
"sensitive skin", "sensitive", "allergy", "allergic",
|
||
"elderly", "senior", "older",
|
||
"kid", "child", "children", "toddler", "infant", "baby", "newborn",
|
||
"pregnant", "pregnancy", "nursing", "breastfeeding",
|
||
"diabetic", "diabetes",
|
||
"my dog", "my cat", "my pet", "puppy", "kitten", "pet owner",
|
||
"professional", "beginner", "first time user",
|
||
"large breed", "small breed", "small dog", "large dog",
|
||
"oily skin", "dry skin", "acne", "eczema", "psoriasis",
|
||
"curly hair", "fine hair", "color treated",
|
||
})
|
||
_B_DIM_SIGNALS = frozenset({
|
||
# 行为/使用场景 — 跨品类通用
|
||
"travel", "on the go", "portable", "lightweight", "compact",
|
||
"daily", "everyday", "first time", "first-time", "beginner",
|
||
"outdoor", "indoor", "home", "office", "kitchen", "car",
|
||
"quick", "easy to use", "simple", "convenient",
|
||
"maintenance", "cleaning", "storage", "organization",
|
||
"camping", "hiking", "gym", "workout",
|
||
"cooking", "baking", "cleaning house",
|
||
})
|
||
_C_DIM_SIGNALS = frozenset({
|
||
# 购买动机/背景 — 跨品类通用
|
||
"gift", "present", "birthday", "christmas", "holiday",
|
||
"for my wife", "for my husband", "for my mom", "for my daughter",
|
||
"replacement", "replace", "upgrade", "switched from",
|
||
"price", "cheap", "expensive", "affordable", "worth the money",
|
||
"waste of money", "not worth", "overpriced", "good value",
|
||
"recommend", "recommended", "saw on", "social media",
|
||
"amazon", "online", "review", "reviews", "rating",
|
||
"bought", "purchased", "ordered", "arrived",
|
||
})
|
||
|
||
|
||
def _suggest_dimension(stage: str, top_phrases: List[str]) -> str:
|
||
"""根据簇的 top_phrases 内容判定建议的 Persona 维度(A/B/C)。"""
|
||
blob = " ".join(top_phrases).lower()
|
||
a_hits = sum(1 for w in _A_DIM_SIGNALS if w in blob)
|
||
b_hits = sum(1 for w in _B_DIM_SIGNALS if w in blob)
|
||
c_hits = sum(1 for w in _C_DIM_SIGNALS if w in blob)
|
||
if a_hits >= 2:
|
||
return "A"
|
||
if a_hits >= 1 and (b_hits + c_hits) == 0:
|
||
return "A"
|
||
if b_hits > c_hits and b_hits >= 2:
|
||
return "B"
|
||
if c_hits > b_hits and c_hits >= 2:
|
||
return "C"
|
||
# 默认按 stage 推理
|
||
if stage.startswith("3a"):
|
||
return "C"
|
||
return "B"
|
||
|
||
|
||
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_comment_extractions(
|
||
self,
|
||
job_id: Optional[int] = None,
|
||
) -> Dict[int, Dict[str, Any]]:
|
||
"""加载 source_row → extraction_json 映射(最新 job 或指定 job_id)。"""
|
||
if not self.structured_db.is_file():
|
||
logger.warning("未找到结构化库 %s,引用匹配将仅使用原文关键词", self.structured_db)
|
||
return {}
|
||
conn = sqlite3.connect(self.structured_db)
|
||
try:
|
||
if job_id is None:
|
||
row = conn.execute(
|
||
"SELECT id FROM analysis_jobs ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
if not row:
|
||
logger.warning("voc_structured.sqlite 中无结构化任务")
|
||
return {}
|
||
job_id = int(row[0])
|
||
cur = conn.execute(
|
||
"""
|
||
SELECT source_row, extraction_json
|
||
FROM comment_extractions
|
||
WHERE job_id = ?
|
||
ORDER BY source_row
|
||
""",
|
||
(job_id,),
|
||
)
|
||
out: Dict[int, Dict[str, Any]] = {}
|
||
for sr, js in cur.fetchall():
|
||
try:
|
||
out[int(sr)] = json.loads(js)
|
||
except json.JSONDecodeError as e:
|
||
logger.debug("跳过无效 extraction source_row=%s: %s", sr, e)
|
||
logger.info("已加载 %s 条结构化提取 (job_id=%s)", len(out), job_id)
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
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()
|
||
catalog: List[Dict[str, Any]] = []
|
||
for stage in (
|
||
"3a_pain_global",
|
||
"3b_aspect_opinion_negative",
|
||
"3b_aspect_opinion_positive",
|
||
):
|
||
stage_clusters = sorted(all_c.get(stage, []), key=lambda x: -x.review_count)
|
||
phrase_doc_freq: Dict[str, int] = defaultdict(int)
|
||
for c in stage_clusters:
|
||
segs = set(_phrase_segments_local(c.top_phrases))
|
||
for seg in segs:
|
||
if seg not in _PERSONA_PHRASE_STOPWORDS and len(seg) >= 4:
|
||
phrase_doc_freq[seg] += 1
|
||
n_stage = max(len(stage_clusters), 1)
|
||
used_labels: Set[str] = set()
|
||
for c in stage_clusters:
|
||
label = _cluster_semantic_label(c, phrase_doc_freq, n_stage)
|
||
if label in used_labels:
|
||
for rank, phrase in enumerate(c.top_phrases[1:], start=1):
|
||
alt = (phrase or "").strip().lower()[:48]
|
||
if alt and len(alt) >= 4 and alt not in used_labels:
|
||
label = alt
|
||
break
|
||
used_labels.add(label)
|
||
entry: Dict[str, Any] = {
|
||
"stage": stage,
|
||
"label": c.cluster_label,
|
||
"review_count": c.review_count,
|
||
"semantic_label": label,
|
||
"top_phrases": c.top_phrases[:12],
|
||
"suggested_dimension": _suggest_dimension(stage, c.top_phrases),
|
||
}
|
||
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]:
|
||
global_pain = self.load_global_pain_clusters()
|
||
global_fb = self.load_global_feedback_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": [],
|
||
"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": {},
|
||
"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'<a href="{url}" target="_blank" rel="noopener">{asin}</a>'
|
||
|