This commit is contained in:
@@ -2424,24 +2424,20 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str:
|
||||
"台灣消費者行為與美妝/保健品類購買決策模式。\n\n"
|
||||
"═══════════════════════════════\n"
|
||||
"角色設定(絕對不可違反)\n"
|
||||
" 我方 = PChome 競品 = momo\n"
|
||||
" price_diff = momo售價 − PChome售價\n"
|
||||
" 正值 → momo偏貴 → PChome定價優勢 → 加強曝光與轉換\n"
|
||||
" 負值 → momo較便宜 → 競品威脅 → 研擬差異化因應策略\n"
|
||||
" 視角 = EwoooC 商品營運決策,不預設單一平台永遠正確\n"
|
||||
" price_diff = PChome售價 − MOMO售價\n"
|
||||
" 正值 → PChome較貴 / MOMO具價格優勢 → 可放大 MOMO 價格賣點\n"
|
||||
" 負值 → PChome較便宜 / PChome低價壓力 → 需評估促銷、組合或服務差異化\n"
|
||||
" 待補資料不可當成成功配對;必須明確列為資料品質風險\n"
|
||||
"═══════════════════════════════\n\n"
|
||||
f"請以 PChome 視角,針對以下{report_type}輸出一份專業競品分析報告:\n\n"
|
||||
f"請以 EwoooC 商品營運視角,針對以下{report_type}輸出一份專業競品分析報告:\n\n"
|
||||
"【整體競爭態勢】(3-4句)\n"
|
||||
"從 PChome 角度評估本期整體定價競爭力,引用平均價差百分比,"
|
||||
"指出最關鍵亮點與警訊,並結合美妝/保健/母嬰市場脈動說明意涵。\n\n"
|
||||
"【PChome 定價優勢商品深度解析】(4-5句)\n"
|
||||
"點名具體優勢商品與品類(如施巴、ESTEE LAUDER 等),"
|
||||
"推測 PChome 具優勢的可能原因(進貨量優勢、活動定價、品牌授權),"
|
||||
"給出具體的曝光放大策略:首頁置頂、搜尋關鍵字投放、會員專案推廣。\n\n"
|
||||
"【momo 威脅商品應對方案】(4-5句)\n"
|
||||
"點名具體威脅商品,判斷 momo 低價策略來源(平台補貼/獨家代理/批量進貨),"
|
||||
"提出 PChome 差異化應對:加值服務(快速到貨/禮盒包裝)、"
|
||||
"組合促銷、PChome 幣回饋、VIP 會員專屬折扣,"
|
||||
"避免純粹降價而犧牲毛利。\n\n"
|
||||
"引用平均價差、比對成功件數、待補資料件數,指出價格壓力、MOMO 優勢與資料覆蓋風險。\n\n"
|
||||
"【PChome 低價壓力商品深度解析】(4-5句)\n"
|
||||
"點名 PChome 較便宜的具體商品與品類,判斷可能原因(活動定價、組合包、會員回饋或清庫存),"
|
||||
"提出 MOMO 端可採取的促銷、組合、內容曝光或服務差異化做法,避免只用降價犧牲毛利。\n\n"
|
||||
"【MOMO 價格優勢商品放大策略】(4-5句)\n"
|
||||
"點名 MOMO 較便宜的商品,提出可放大的搜尋關鍵字、站內陳列、檔期素材與推薦理由。\n\n"
|
||||
"【美妝/保健/母嬰品類專項洞察】(3-4句)\n"
|
||||
"針對本期出現的具體商品,結合台灣市場趨勢深度分析:\n"
|
||||
"美妝:成分透明化趨勢、敏感肌/無添加需求、社群口碑行銷;\n"
|
||||
@@ -3336,23 +3332,27 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str,
|
||||
)
|
||||
|
||||
found_c = [r for r in results if r.get('found')]
|
||||
pc_wins_c = [r for r in found_c if r.get('price_diff', 0) < -10]
|
||||
mo_wins_c = [r for r in found_c if r.get('price_diff', 0) > 10]
|
||||
pchome_low_price_c = [r for r in found_c if r.get('price_diff', 0) < -10]
|
||||
momo_low_price_c = [r for r in found_c if r.get('price_diff', 0) > 10]
|
||||
not_found_c = [r for r in results if not r.get('found')]
|
||||
avg_diff_c = (sum(r.get('price_diff_pct', 0) for r in found_c) / len(found_c)
|
||||
if found_c else 0)
|
||||
|
||||
data_summary = (
|
||||
f"【可信資料源=competitor_prices 高信心配對,MOMO vs PChome】\n"
|
||||
f"分析週期:{period_label}\n"
|
||||
f"掃描商品:{len(results)} 件 | 比對成功:{len(found_c)} 件\n"
|
||||
f"PChome定價優勢(比momo便宜):{len(pc_wins_c)} 件 | momo威脅(比PChome便宜):{len(mo_wins_c)} 件\n"
|
||||
f"掃描商品:{len(results)} 件 | 高信心比對:{len(found_c)} 件 | 待補身份/價格:{len(not_found_c)} 件\n"
|
||||
f"PChome 低價壓力(PChome 比 MOMO 便宜):{len(pchome_low_price_c)} 件 | MOMO 價格優勢(MOMO 比 PChome 便宜):{len(momo_low_price_c)} 件\n"
|
||||
f"平均價差:{avg_diff_c:+.1f}%(正值=PChome較貴、MOMO具價格優勢;負值=PChome較便宜)\n\n"
|
||||
f"PChome優勢 TOP3(應加強曝光宣傳):" + " / ".join(
|
||||
f"PChome低價壓力 TOP3(需研擬因應):" + " / ".join(
|
||||
f"{r['momo_name'][:15]}(PChome便宜NT${abs(r['price_diff']):,.0f})"
|
||||
for r in pc_wins_c[:3]) + "\n"
|
||||
f"momo威脅 TOP3(需研擬因應策略):" + " / ".join(
|
||||
for r in pchome_low_price_c[:3]) + "\n"
|
||||
f"MOMO價格優勢 TOP3(可加強曝光):" + " / ".join(
|
||||
f"{r['momo_name'][:15]}(MOMO便宜NT${r['price_diff']:,.0f})"
|
||||
for r in mo_wins_c[:3]) + "\n\n"
|
||||
for r in momo_low_price_c[:3]) + "\n"
|
||||
f"待補資料樣本:" + " / ".join(
|
||||
f"{r['momo_name'][:12]}({r.get('match_status', 'no_valid_match')})"
|
||||
for r in not_found_c[:3]) + "\n\n"
|
||||
f"外部情報:{mcp_text_c[:400]}"
|
||||
)
|
||||
ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報({period_label})')
|
||||
|
||||
@@ -406,11 +406,36 @@ def fetch_competitor_comparison_results(
|
||||
) -> list[dict]:
|
||||
"""輸出與 legacy competitor PPT 相容的比價結果,不再 live crawl。"""
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
has_daily_sales = inspect(engine).has_table("daily_sales")
|
||||
inspector = inspect(engine)
|
||||
if not (
|
||||
inspector.has_table("products")
|
||||
and inspector.has_table("price_records")
|
||||
and inspector.has_table("competitor_prices")
|
||||
):
|
||||
return []
|
||||
|
||||
has_daily_sales = inspector.has_table("daily_sales")
|
||||
has_match_attempts = inspector.has_table("competitor_match_attempts")
|
||||
sales_cte = ""
|
||||
sales_join = ""
|
||||
sales_select = "0 AS momo_revenue,"
|
||||
order_expr = "ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST"
|
||||
attempt_cte = """
|
||||
latest_attempt AS (
|
||||
SELECT
|
||||
NULL AS sku,
|
||||
NULL AS attempt_status,
|
||||
NULL AS candidate_count,
|
||||
NULL AS best_match_score,
|
||||
NULL AS error_message,
|
||||
NULL AS attempted_at
|
||||
WHERE FALSE
|
||||
)
|
||||
"""
|
||||
order_expr = (
|
||||
"lm.momo_price DESC NULLS LAST, "
|
||||
"(vc.pchome_price IS NULL), "
|
||||
"ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST"
|
||||
)
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
|
||||
if has_daily_sales:
|
||||
@@ -434,7 +459,27 @@ def fetch_competitor_comparison_results(
|
||||
"""
|
||||
sales_join = "LEFT JOIN sales_rank sr ON sr.product_id = lm.product_id"
|
||||
sales_select = "COALESCE(sr.momo_revenue, 0) AS momo_revenue,"
|
||||
order_expr = "COALESCE(sr.momo_revenue, 0) DESC, " + order_expr
|
||||
order_expr = (
|
||||
"COALESCE(sr.momo_revenue, 0) DESC, "
|
||||
"(vc.pchome_price IS NULL), "
|
||||
"ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST"
|
||||
)
|
||||
|
||||
if has_match_attempts:
|
||||
attempt_cte = """
|
||||
latest_attempt AS (
|
||||
SELECT DISTINCT ON (cma.sku)
|
||||
cma.sku,
|
||||
cma.attempt_status,
|
||||
cma.candidate_count,
|
||||
cma.best_match_score,
|
||||
cma.error_message,
|
||||
cma.attempted_at
|
||||
FROM competitor_match_attempts cma
|
||||
WHERE cma.source = 'pchome'
|
||||
ORDER BY cma.sku, cma.attempted_at DESC NULLS LAST
|
||||
)
|
||||
"""
|
||||
|
||||
sql = text(f"""
|
||||
WITH latest_momo AS (
|
||||
@@ -463,7 +508,8 @@ def fetch_competitor_comparison_results(
|
||||
AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR}
|
||||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||||
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
|
||||
)
|
||||
),
|
||||
{attempt_cte}
|
||||
{sales_cte}
|
||||
SELECT
|
||||
lm.sku,
|
||||
@@ -473,11 +519,17 @@ def fetch_competitor_comparison_results(
|
||||
vc.competitor_product_id,
|
||||
vc.competitor_product_name,
|
||||
vc.match_score,
|
||||
la.attempt_status,
|
||||
la.candidate_count,
|
||||
la.best_match_score,
|
||||
la.error_message,
|
||||
la.attempted_at,
|
||||
{sales_select}
|
||||
(vc.pchome_price - lm.momo_price) AS price_diff,
|
||||
((vc.pchome_price - lm.momo_price) / lm.momo_price * 100) AS price_diff_pct
|
||||
FROM latest_momo lm
|
||||
JOIN valid_competitor vc ON vc.sku = lm.sku
|
||||
LEFT JOIN valid_competitor vc ON vc.sku = lm.sku
|
||||
LEFT JOIN latest_attempt la ON la.sku = lm.sku
|
||||
{sales_join}
|
||||
WHERE lm.rn = 1
|
||||
AND lm.momo_price > 0
|
||||
@@ -490,8 +542,9 @@ def fetch_competitor_comparison_results(
|
||||
results = []
|
||||
for row in rows:
|
||||
pchome_id = row.get("competitor_product_id")
|
||||
found = bool(row.get("pchome_price"))
|
||||
results.append({
|
||||
"found": True,
|
||||
"found": found,
|
||||
"momo_icode": str(row.get("sku") or ""),
|
||||
"momo_name": row.get("name") or "",
|
||||
"momo_price": _num(row.get("momo_price")),
|
||||
@@ -502,6 +555,10 @@ def fetch_competitor_comparison_results(
|
||||
"price_diff_pct": _num(row.get("price_diff_pct")),
|
||||
"match_score": _num(row.get("match_score")),
|
||||
"momo_revenue": _num(row.get("momo_revenue")),
|
||||
"match_status": "matched" if found else (row.get("attempt_status") or "no_valid_match"),
|
||||
"candidate_count": int(row.get("candidate_count") or 0),
|
||||
"best_match_score": _num(row.get("best_match_score")),
|
||||
"match_diagnostic": row.get("error_message") or "",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_competitor_intel_cache_reuses_memory_and_shared_file(tmp_path, monkeypatch):
|
||||
from services import competitor_intel_repository as repo
|
||||
|
||||
@@ -34,3 +37,24 @@ def test_clear_competitor_intel_cache_removes_shared_file(tmp_path, monkeypatch)
|
||||
|
||||
assert repo._MEM_CACHE == {}
|
||||
assert not cache_file.exists()
|
||||
|
||||
|
||||
def test_competitor_ppt_results_keep_pending_diagnostics_in_export():
|
||||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "LEFT JOIN valid_competitor" in source
|
||||
assert "\"found\": found" in source
|
||||
assert "\"match_status\"" in source
|
||||
assert "\"candidate_count\"" in source
|
||||
assert "(vc.pchome_price IS NULL)" in source
|
||||
|
||||
|
||||
def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint():
|
||||
source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "EwoooC 商品營運視角" in source
|
||||
assert "待補資料不可當成成功配對" in source
|
||||
assert "高信心比對" in source
|
||||
assert "待補身份/價格" in source
|
||||
assert "我方 = PChome" not in source
|
||||
assert "請以 PChome 視角" not in source
|
||||
|
||||
Reference in New Issue
Block a user