修正競品簡報覆蓋率與營運視角
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s

This commit is contained in:
OoO
2026-05-19 22:33:01 +08:00
parent 840cb0acdb
commit 880d15b055
3 changed files with 111 additions and 30 deletions

View File

@@ -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}')

View File

@@ -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

View File

@@ -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