串接 PChome 單位價覆核隊列
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-05-20 00:29:40 +08:00
parent 36c177762f
commit cd13044849
11 changed files with 338 additions and 34 deletions

View File

@@ -4,7 +4,7 @@
================================================================================
【已完成】
- V10.295 補核心 MOMO/PChome 比價第三層語意:同核心商品但買送、套組、件數不同且只有單一基礎規格時標記 `unit_comparable`,只寫入 `competitor_match_attempts`;商品看板顯示「需單位價比較」、候選商品、候選 PChome 價格與單位價換算證據;多容量/多品項套組仍保持不可比較,避免把不同販售組合直接寫進正式總價差。
- V10.296 補核心 MOMO/PChome 比價第三層語意與覆核閉環:同核心商品但買送、套組、件數不同且只有單一基礎規格時標記 `unit_comparable`,只寫入 `competitor_match_attempts`;商品看板、daily/growth 報表、OpenClaw/PPT 摘要共用 `competitor_intel_repository` 的覆核隊列,顯示「需單位價比較」、候選商品、候選 PChome 價格與單位價換算證據;多容量/多品項套組仍保持不可比較,避免把不同販售組合直接寫進正式總價差。
- V10.289 重排 Elephant Alpha L3 HITL `ea_escalation` Telegram 告警:改成專業 incident brief 格式分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格行動會拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID避免長 bullet 難讀。
- V10.284 關閉 Code Review Hermes LLM scan 預設路徑Step 2 改 deterministic fast static scan不再讓部署後先卡三段 Ollama timeout若需要 LLM scan 可用 `CODE_REVIEW_HERMES_LLM_SCAN_ENABLED=true` 顯式開啟,仍只走本地矩陣、不走 Gemini。
- V10.283 將 Code Review Hermes scan 收斂為 fast compact prompt預設 2 檔 × 900 字、輸出 384 tokens仍走 GCP-A → GCP-B → 111 本地矩陣,避免部署後 code_review_hermes 先卡三段 timeout。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.295"
SYSTEM_VERSION = "V10.296"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-20 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 僅備援 / 鎖定場景
> **適用版本**: V10.295
> **適用版本**: V10.296
---
@@ -345,7 +345,7 @@ LEFT JOIN competitor_prices cp
- `services/competitor_identity_revalidator.py` 可對既有 `competitor_prices` legacy row 離線重跑 `identity_v2`:只有新版 matcher 分數 `>= 0.76` 且無 hard veto 才補 `identity_v2` / `legacy_revalidated` tags預設不刷新 `expires_at`,避免過期價格進入決策。
- `CompetitorPriceFeeder.run_expired_identity_refresh()` 會優先刷新已通過 `identity_v2` 但 TTL 過期的 PChome row直接用既有 `competitor_product_id` 批次呼叫 PChome 商品 API再用新版 matcher 重新驗證名稱/規格/價格 sanity通過後寫回 `competitor_prices``competitor_price_history`。這條路徑提升新鮮價格覆蓋率,但不降低 match threshold也不讓過期價格直接進入決策。
- `marketplace_product_matcher.py` 的擴充只能走「正向證據 + 反向 veto」品牌一致、商品線/型號訊號強、價格合理且無 hard veto 時才允許 `strong_product_line_match` 加分;補充瓶/補充包/refill 與一般正裝不互相配對,分享組/加量組/明星組等組合包不得誤配單品。
- 套組/買送/件數不同但品牌、核心商品線與單一基礎規格一致時matcher 必須回傳 `comparison_mode='unit_comparable'``unit_comparable` reasonFeeder 只能寫入 `competitor_match_attempts.attempt_status='unit_comparable'``refresh_unit_comparable`,不得寫入 `competitor_prices`。Dashboard 與 `competitor_intel_repository` 必須用 `build_unit_price_comparison()` 產生每 ml / 每 g / 每入單位價證據,讓 PPT / AI 報表可說明「需單位價比較」而不是把總價當同款價差。商品看板在正式配對尚未成立時,仍必須顯示最佳候選 PChome 商品名稱、候選價與「候選價,需單位換算」說明,讓人工覆核可直接看見下一步;若任一側含多個不同容量/重量規格,視為多品項套組,不可進 `unit_comparable`
- 套組/買送/件數不同但品牌、核心商品線與單一基礎規格一致時matcher 必須回傳 `comparison_mode='unit_comparable'``unit_comparable` reasonFeeder 只能寫入 `competitor_match_attempts.attempt_status='unit_comparable'``refresh_unit_comparable`,不得寫入 `competitor_prices`。Dashboard 與 `competitor_intel_repository` 必須用 `build_unit_price_comparison()` 產生每 ml / 每 g / 每入單位價證據,讓 PPT / AI 報表可說明「需單位價比較」而不是把總價當同款價差。商品看板在正式配對尚未成立時,仍必須顯示最佳候選 PChome 商品名稱、候選價與「候選價,需單位換算」說明,讓人工覆核可直接看見下一步;daily/growth、PPT 與 OpenClaw 摘要不得自建查詢,需消費 `fetch_competitor_review_queue()` 與 coverage 的 `unit_comparable_count`若任一側含多個不同容量/重量規格,視為多品項套組,不可進 `unit_comparable`
- PChome feeder 的外部 request timeout 由 `PCHOME_FEEDER_TIMEOUT` 控制,預設 12 秒;排程不得因單一 PChome 搜尋 API timeout 被拖到數分鐘。
- 商品看板的 PChome 狀態必須把 matcher 診斷原因翻成可行動語意:品牌衝突、規格衝突、補充包差異、組合差異、商品線不符等,不可只顯示籠統「待比對」或「身份否決」。
- Dashboard 必須把「待比對」拆成可診斷狀態:`價格過期待刷新``舊版配對待重驗``低分配對待審``身份否決``需單位價比較``找不到同款``抓取異常``尚未搜尋`。不可再用單一「待比對」掩蓋資料品質原因。

View File

@@ -3334,14 +3334,18 @@ 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')]
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')]
unit_comparable_c = [
r for r in results
if not r.get('found') and r.get('match_status') in ('unit_comparable', 'refresh_unit_comparable')
]
not_found_c = [r for r in results if not r.get('found') and r not in unit_comparable_c]
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)} 件 | 待補身份/價格:{len(not_found_c)}\n"
f"掃描商品:{len(results)} 件 | 高信心比對:{len(found_c)} 件 | 需單位價比較:{len(unit_comparable_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(
@@ -3350,6 +3354,9 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str,
f"MOMO價格優勢 TOP3可加強曝光" + " / ".join(
f"{r['momo_name'][:15]}MOMO便宜NT${r['price_diff']:,.0f}"
for r in momo_low_price_c[:3]) + "\n"
f"單位價覆核樣本:" + " / ".join(
f"{r['momo_name'][:12]}{(r.get('unit_comparison') or {}).get('summary') or '候選價需換算'}"
for r in unit_comparable_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"

View File

@@ -22,6 +22,36 @@ from sqlalchemy import inspect, text
PCHOME_MATCH_SCORE_FLOOR = 0.76
UNIT_COMPARABLE_STATUSES = {"unit_comparable", "refresh_unit_comparable"}
ACTIONABLE_ATTEMPT_STATUSES = {
"unit_comparable",
"refresh_unit_comparable",
"identity_veto",
"low_score",
"expired_match",
"refresh_no_result",
"no_result",
}
ATTEMPT_STATUS_LABELS = {
"unit_comparable": "需單位價比較",
"refresh_unit_comparable": "需單位價比較",
"identity_veto": "身份否決",
"low_score": "低信心待審",
"expired_match": "價格過期待刷新",
"refresh_no_result": "刷新找不到商品",
"no_result": "找不到同款",
"never_attempted": "尚未搜尋",
}
ATTEMPT_ACTION_LABELS = {
"unit_comparable": "人工確認檔期、贈品與單位價",
"refresh_unit_comparable": "人工確認檔期、贈品與單位價",
"identity_veto": "確認是否為不同商品線或規格",
"low_score": "人工審核候選商品身份",
"expired_match": "重新刷新 PChome 價格",
"refresh_no_result": "調整搜尋詞後重抓",
"no_result": "補充搜尋詞或品牌關鍵字",
"never_attempted": "排入 PChome 補抓",
}
COMPETITOR_INTEL_CACHE_TTL_SECONDS = int(os.getenv("COMPETITOR_INTEL_CACHE_TTL_SECONDS", "1800"))
_BASE_DIR = Path(__file__).resolve().parents[1]
_CACHE_FILE = _BASE_DIR / "data" / "competitor_intel_cache.pkl"
@@ -48,6 +78,30 @@ def _month_label(value: Any) -> str:
return str(value or "")[:7]
def _attempt_status_label(status: Any) -> str:
return ATTEMPT_STATUS_LABELS.get(str(status or ""), str(status or "待比對"))
def _attempt_action_label(status: Any) -> str:
return ATTEMPT_ACTION_LABELS.get(str(status or ""), "人工確認比對證據")
def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str, Any]]:
status = str(row.get("attempt_status") or "")
if status not in UNIT_COMPARABLE_STATUSES:
return None
try:
from services.marketplace_product_matcher import build_unit_price_comparison
return build_unit_price_comparison(
row.get("name") or row.get("momo_product_name") or "",
row.get("best_competitor_product_name") or "",
row.get("momo_price"),
row.get("best_competitor_price"),
)
except Exception:
return {"comparable": False, "reason": "build_error"}
def clear_competitor_intel_cache() -> None:
"""Clear cached PChome/MOMO intelligence after crawler/import updates."""
with _CACHE_LOCK:
@@ -124,15 +178,39 @@ def fetch_competitor_coverage(engine) -> dict:
def _fetch_competitor_coverage_uncached(engine) -> dict:
"""讀取目前 PChome 比價覆蓋率與待審分類。"""
if not inspect(engine).has_table("competitor_prices"):
inspector = inspect(engine)
if not inspector.has_table("competitor_prices"):
return {
"active_with_price": 0,
"valid_matches": 0,
"pending": 0,
"match_rate": 0,
"attempt_status": {},
"unit_comparable_count": 0,
"actionable_review_count": 0,
}
has_match_attempts = inspector.has_table("competitor_match_attempts")
attempt_cte = """
latest_attempt AS (
SELECT
NULL AS sku,
NULL AS attempt_status
WHERE FALSE
)
"""
if has_match_attempts:
attempt_cte = """
latest_attempt AS (
SELECT DISTINCT ON (sku)
sku,
attempt_status
FROM competitor_match_attempts
WHERE source = 'pchome'
ORDER BY sku, attempted_at DESC NULLS LAST
)
"""
sql = text(f"""
WITH latest_momo AS (
SELECT
@@ -156,14 +234,7 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
),
latest_attempt AS (
SELECT DISTINCT ON (sku)
sku,
attempt_status
FROM competitor_match_attempts
WHERE source = 'pchome'
ORDER BY sku, attempted_at DESC NULLS LAST
)
{attempt_cte}
SELECT
(SELECT COUNT(*) FROM latest_momo WHERE rn = 1) AS active_with_price,
(SELECT COUNT(*) FROM valid_competitor) AS valid_matches,
@@ -190,12 +261,16 @@ def _fetch_competitor_coverage_uncached(engine) -> dict:
str(row.get("attempt_status")): int(row.get("status_count") or 0)
for row in rows
}
unit_count = sum(statuses.get(status, 0) for status in UNIT_COMPARABLE_STATUSES)
actionable_count = sum(statuses.get(status, 0) for status in ACTIONABLE_ATTEMPT_STATUSES)
return {
"active_with_price": active,
"valid_matches": valid,
"pending": pending,
"match_rate": round(valid / max(active, 1) * 100, 1),
"attempt_status": statuses,
"unit_comparable_count": unit_count,
"actionable_review_count": actionable_count,
"match_score_floor": PCHOME_MATCH_SCORE_FLOOR,
}
@@ -394,6 +469,133 @@ def _fetch_top_competitor_risks_uncached(engine, limit: int = 10) -> list[dict]:
return result
def fetch_competitor_review_queue(engine, limit: int = 12) -> list[dict]:
"""可行動的 PChome 比對覆核隊列,供 Dashboard / AI / PPT 共用。"""
limit = max(1, min(int(limit or 12), 50))
return _cached_payload(
f"review_queue:v1:limit={limit}:floor={PCHOME_MATCH_SCORE_FLOOR}",
lambda: _fetch_competitor_review_queue_uncached(engine, limit=limit),
)
def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dict]:
inspector = inspect(engine)
if not (
inspector.has_table("products")
and inspector.has_table("price_records")
and inspector.has_table("competitor_prices")
and inspector.has_table("competitor_match_attempts")
):
return []
limit = max(1, min(int(limit or 12), 50))
sql = text(f"""
WITH latest_momo AS (
SELECT
p.id AS product_id,
p.i_code AS sku,
p.name,
p.category,
pr.price AS momo_price,
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
),
valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
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
),
latest_attempt AS (
SELECT DISTINCT ON (cma.sku)
cma.sku,
cma.attempt_status,
cma.candidate_count,
cma.best_competitor_product_id,
cma.best_competitor_product_name,
cma.best_competitor_price,
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
)
SELECT
lm.sku,
lm.name,
lm.category,
lm.momo_price,
la.attempt_status,
la.candidate_count,
la.best_competitor_product_id,
la.best_competitor_product_name,
la.best_competitor_price,
la.best_match_score,
la.error_message,
la.attempted_at
FROM latest_momo lm
JOIN latest_attempt la ON la.sku = lm.sku
LEFT JOIN valid_competitor vc ON vc.sku = lm.sku
WHERE lm.rn = 1
AND vc.sku IS NULL
AND la.attempt_status IN (
'unit_comparable',
'refresh_unit_comparable',
'identity_veto',
'low_score',
'expired_match',
'refresh_no_result',
'no_result'
)
ORDER BY
CASE
WHEN la.attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 0
WHEN la.attempt_status = 'identity_veto' THEN 1
WHEN la.attempt_status = 'low_score' THEN 2
WHEN la.attempt_status = 'expired_match' THEN 3
ELSE 4
END,
lm.momo_price DESC NULLS LAST,
la.best_match_score DESC NULLS LAST,
la.attempted_at DESC NULLS LAST
LIMIT :limit
""")
with engine.connect() as conn:
rows = conn.execute(sql, {"limit": limit}).mappings().all()
queue = []
for row in rows:
item = dict(row)
unit_comparison = _build_unit_comparison_for_attempt(item)
queue.append({
"sku": str(item.get("sku") or ""),
"name": item.get("name") or "",
"category": item.get("category") or "",
"momo_price": _num(item.get("momo_price")),
"attempt_status": item.get("attempt_status") or "",
"status_label": _attempt_status_label(item.get("attempt_status")),
"action_label": _attempt_action_label(item.get("attempt_status")),
"candidate_count": int(item.get("candidate_count") or 0),
"candidate_pc_id": item.get("best_competitor_product_id"),
"candidate_pc_name": item.get("best_competitor_product_name") or "",
"candidate_pc_price": _num(item.get("best_competitor_price")),
"best_match_score": _num(item.get("best_match_score")),
"match_diagnostic": item.get("error_message") or "",
"attempted_at": _date_label(item.get("attempted_at")),
"unit_comparison": unit_comparison,
})
return queue
def fetch_competitor_comparison_results(
engine,
start_date: Optional[Union[date, datetime, str]] = None,
@@ -549,18 +751,13 @@ def fetch_competitor_comparison_results(
pchome_id = row.get("competitor_product_id")
found = bool(row.get("pchome_price"))
match_status = "matched" if found else (row.get("attempt_status") or "no_valid_match")
unit_comparison = None
if match_status in {"unit_comparable", "refresh_unit_comparable"}:
try:
from services.marketplace_product_matcher import build_unit_price_comparison
unit_comparison = build_unit_price_comparison(
row.get("name") or "",
row.get("best_competitor_product_name") or "",
row.get("momo_price"),
row.get("best_competitor_price"),
)
except Exception:
unit_comparison = {"comparable": False, "reason": "build_error"}
unit_comparison = _build_unit_comparison_for_attempt({
"attempt_status": match_status,
"name": row.get("name") or "",
"best_competitor_product_name": row.get("best_competitor_product_name") or "",
"momo_price": row.get("momo_price"),
"best_competitor_price": row.get("best_competitor_price"),
})
results.append({
"found": found,
"momo_icode": str(row.get("sku") or ""),
@@ -577,6 +774,8 @@ def fetch_competitor_comparison_results(
"match_score": _num(row.get("match_score")),
"momo_revenue": _num(row.get("momo_revenue")),
"match_status": match_status,
"match_status_label": _attempt_status_label(match_status),
"action_label": _attempt_action_label(match_status),
"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 "",
@@ -591,5 +790,6 @@ def build_competitor_intel_payload(engine, days: int = 30) -> dict:
"coverage": fetch_competitor_coverage(engine),
"trend": fetch_competitor_gap_trend(engine, days=days),
"top_risks": fetch_top_competitor_risks(engine, limit=10),
"review_queue": fetch_competitor_review_queue(engine, limit=12),
"match_score_floor": PCHOME_MATCH_SCORE_FLOOR,
}

View File

@@ -30,7 +30,7 @@ from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from database.manager import get_session
from sqlalchemy import bindparam, text
from sqlalchemy import bindparam, inspect, text
from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1
from services.action_plan_dedupe import (
@@ -563,11 +563,29 @@ def _fetch_competitor_summary() -> Dict[str, Any]:
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
""")).fetchone()
if row and row[0]:
attempt_row = None
if session.bind is not None and inspect(session.bind).has_table("competitor_match_attempts"):
attempt_row = session.execute(text("""
WITH latest_attempt AS (
SELECT DISTINCT ON (sku)
sku,
attempt_status
FROM competitor_match_attempts
WHERE source = 'pchome'
ORDER BY sku, attempted_at DESC NULLS LAST
)
SELECT
SUM(CASE WHEN attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 1 ELSE 0 END) AS unit_comparable_count,
SUM(CASE WHEN attempt_status IN ('unit_comparable', 'refresh_unit_comparable', 'identity_veto', 'low_score', 'expired_match', 'no_result', 'refresh_no_result') THEN 1 ELSE 0 END) AS review_queue_count
FROM latest_attempt
""")).fetchone()
return {
"total_skus": int(row[0]),
"avg_gap_pct": round(float(row[1] or 0), 1),
"undercut_count": int(row[2] or 0),
"premium_count": int(row[3] or 0),
"unit_comparable_count": int((attempt_row[0] if attempt_row else 0) or 0),
"review_queue_count": int((attempt_row[1] if attempt_row else 0) or 0),
}
return {}
except Exception as e:
@@ -1342,6 +1360,7 @@ def generate_weekly_strategy_report(
平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}%
被競品削價數:{competitor_summary.get('undercut_count', 0)}
我方具優勢數:{competitor_summary.get('premium_count', 0)}
需單位價覆核:{competitor_summary.get('unit_comparable_count', 0)}
TOP 威脅品項近48h Hermes 偵測):
{_format_threats(threats)}
@@ -1615,6 +1634,7 @@ def _legacy_full_gemini_daily_report() -> dict:
監控SKU{competitor_summary.get('total_skus', 0)}
被削價風險:{competitor_summary.get('undercut_count', 0)}價差超過10%
平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}%
單位價/身份覆核隊列:{competitor_summary.get('review_queue_count', 0)}
請按以下結構輸出(使用 HTML <b> 標題):
@@ -1785,6 +1805,7 @@ def generate_monthly_report() -> dict:
監控SKU{competitor_summary.get('total_skus', 0)}
月均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}%
被削價風險SKU{competitor_summary.get('undercut_count', 0)}
需單位價覆核SKU{competitor_summary.get('unit_comparable_count', 0)}
【價格變動概況】
本月調價次數:{price_trend_data.get('price_changes', 0)}

View File

@@ -355,8 +355,24 @@
<span>待審/待補</span>
<strong class="momo-mono">{{ comp_coverage.pending | default(0) | number_format }}</strong>
</div>
<div>
<span>需單位價覆核</span>
<strong class="momo-mono">{{ comp_coverage.unit_comparable_count | default(0) | number_format }}</strong>
</div>
</div>
{% if competitor_intel.top_risks %}
{% if competitor_intel.review_queue %}
<ol class="daily-competitor-risk-list daily-competitor-risk-list--review">
{% for item in competitor_intel.review_queue[:3] %}
<li>
<span>{{ item.name[:22] }} · {{ item.status_label }}</span>
<strong class="momo-mono">{{ item.action_label }}</strong>
{% if item.unit_comparison and item.unit_comparison.summary %}
<em>{{ item.unit_comparison.summary }}</em>
{% endif %}
</li>
{% endfor %}
</ol>
{% elif competitor_intel.top_risks %}
<ol class="daily-competitor-risk-list">
{% for item in competitor_intel.top_risks[:5] %}
<li>

View File

@@ -147,6 +147,8 @@
<strong class="momo-mono">{{ coverage.match_rate | default(0) }}%</strong>
<span>待審/待補</span>
<strong class="momo-mono">{{ coverage.pending | default(0) | number_format }}</strong>
<span>需單位價覆核</span>
<strong class="momo-mono">{{ coverage.unit_comparable_count | default(0) | number_format }}</strong>
</div>
</div>
</article>

View File

@@ -51,6 +51,22 @@ def test_competitor_ppt_results_keep_pending_diagnostics_in_export():
assert "(vc.pchome_price IS NULL)" in source
def test_competitor_review_queue_is_canonical_unit_price_handoff():
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
daily_template = (ROOT / "templates" / "daily_sales.html").read_text(encoding="utf-8")
growth_template = (ROOT / "templates" / "growth_analysis.html").read_text(encoding="utf-8")
assert "def fetch_competitor_review_queue" in source
assert "\"review_queue\": fetch_competitor_review_queue" in source
assert "\"unit_comparable_count\"" in source
assert "\"status_label\"" in source
assert "\"action_label\"" in source
assert "build_unit_price_comparison" in source
assert "需單位價覆核" in daily_template
assert "competitor_intel.review_queue" in daily_template
assert "coverage.unit_comparable_count" in growth_template
def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint():
source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
@@ -58,14 +74,17 @@ def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint():
assert "待補資料不可當成成功配對" in source
assert "高信心比對" in source
assert "待補身份/價格" in source
assert "需單位價比較" in source
assert "單位價覆核樣本" in source
assert "我方 = PChome" not in source
assert "請以 PChome 視角" not in source
def test_top_competitor_risks_reads_latest_momo_price_after_valid_competitor_filter():
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
risk_source = source.split("def _fetch_top_competitor_risks_uncached", 1)[1].split("def fetch_competitor_review_queue", 1)[0]
assert "FROM valid_competitor vc" in source
assert "JOIN LATERAL" in source
assert "WHERE pr.product_id = p.id" in source
assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in source.split("def _fetch_top_competitor_risks_uncached", 1)[1].split("def fetch_competitor_comparison_results", 1)[0]
assert "FROM valid_competitor vc" in risk_source
assert "JOIN LATERAL" in risk_source
assert "WHERE pr.product_id = p.id" in risk_source
assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in risk_source

View File

@@ -815,7 +815,7 @@
.daily-competitor-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 14px;
}
@@ -863,6 +863,25 @@
color: var(--momo-danger-text);
}
.daily-competitor-risk-list--review {
padding-left: 0;
list-style: none;
}
.daily-competitor-risk-list--review strong {
color: var(--momo-page-accent-dark);
font-size: 0.78rem;
}
.daily-competitor-risk-list--review em {
display: block;
margin-top: 3px;
color: var(--momo-text-muted);
font-family: var(--momo-font-mono, ui-monospace, monospace);
font-size: 0.72rem;
font-style: normal;
}
.card-header--split {
display: flex;
justify-content: space-between;

View File

@@ -76,3 +76,23 @@
.growth-analysis-page .trend-down {
color: var(--momo-danger-text) !important;
}
.ga-competitor-quality {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px 14px;
align-items: baseline;
}
.ga-competitor-quality span {
min-width: 0;
color: var(--momo-text-secondary);
font-size: var(--momo-text-body-sm);
font-weight: var(--momo-font-weight-semibold);
}
.ga-competitor-quality strong {
color: var(--momo-text-primary);
font-weight: var(--momo-font-weight-black);
text-align: right;
}