This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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` reason;Feeder 只能寫入 `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` reason;Feeder 只能寫入 `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 必須把「待比對」拆成可診斷狀態:`價格過期待刷新`、`舊版配對待重驗`、`低分配對待審`、`身份否決`、`需單位價比較`、`找不到同款`、`抓取異常`、`尚未搜尋`。不可再用單一「待比對」掩蓋資料品質原因。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)} 次
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user