#!/usr/bin/env python3 # -*- coding: utf-8 -*- """PChome / MOMO 比價 AI 例外決策服務。""" from __future__ import annotations import json import os from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import inspect, text VALID_REVIEW_ACTIONS = { "accept_identity": { "label": "採用同款", "attempt_status": "manual_accepted", "message": "已採用候選商品為正式 PChome 同款配對", }, "reject_identity": { "label": "否決候選", "attempt_status": "manual_rejected", "message": "AI 決策已排除候選商品,本輪例外決策已關閉", }, "unit_price_required": { "label": "標記單位價", "attempt_status": "manual_unit_price_required", "message": "已標記為需單位價比較,不寫入正式總價差", }, "needs_research": { "label": "需補搜尋", "attempt_status": "manual_needs_research", "message": "已標記為需補搜尋詞或重新抓取", }, } def _num(value: Any) -> float | None: try: if value is None: return None return float(value) except (TypeError, ValueError): return None def _json_array_expr(conn, bind_name: str) -> str: return f"CAST(:{bind_name} AS jsonb)" if conn.dialect.name == "postgresql" else f":{bind_name}" def _json_object_expr(conn, bind_name: str) -> str: return f"CAST(:{bind_name} AS jsonb)" if conn.dialect.name == "postgresql" else f":{bind_name}" def _json_text_payload(value: Any) -> str | None: if value is None: return None if isinstance(value, (dict, list)): return json.dumps(value, ensure_ascii=False) if isinstance(value, str): stripped = value.strip() if not stripped: return None try: json.loads(stripped) return stripped except (TypeError, ValueError): return json.dumps({"raw": stripped}, ensure_ascii=False) return json.dumps(value, ensure_ascii=False) def _has_table_column(conn, table_name: str, column_name: str) -> bool: try: columns = inspect(conn).get_columns(table_name) return any(str(column.get("name")) == column_name for column in columns) except Exception: return False def _candidate_diagnostic_text(attempt: dict[str, Any]) -> str: parts: list[str] = [] error_message = str(attempt.get("error_message") or "").strip() diagnostic_payload = _json_text_payload(attempt.get("match_diagnostic_json")) if error_message: parts.append(error_message) if diagnostic_payload: parts.append(f"match_diagnostic_json={diagnostic_payload}") return "; ".join(parts)[:4000] def _ensure_competitor_match_reviews_table(conn) -> None: if conn.dialect.name == "postgresql": conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_match_reviews ( id BIGSERIAL PRIMARY KEY, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', review_action VARCHAR(40) NOT NULL, review_reason TEXT, reviewer_identity VARCHAR(120), momo_product_id INTEGER, momo_product_name TEXT, momo_price NUMERIC(10,2), candidate_product_id VARCHAR(100), candidate_product_name TEXT, candidate_price NUMERIC(10,2), candidate_match_score NUMERIC(4,3), candidate_diagnostic TEXT, resulting_attempt_status VARCHAR(40), reviewed_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) else: conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_match_reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', review_action VARCHAR(40) NOT NULL, review_reason TEXT, reviewer_identity VARCHAR(120), momo_product_id INTEGER, momo_product_name TEXT, momo_price NUMERIC(10,2), candidate_product_id VARCHAR(100), candidate_product_name TEXT, candidate_price NUMERIC(10,2), candidate_match_score NUMERIC(4,3), candidate_diagnostic TEXT, resulting_attempt_status VARCHAR(40), reviewed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_sku_source_time ON competitor_match_reviews (sku, source, reviewed_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_action_time ON competitor_match_reviews (review_action, reviewed_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_candidate ON competitor_match_reviews (candidate_product_id) """)) def _ensure_competitor_price_history_table(conn) -> None: tags_type = "JSONB" if conn.dialect.name == "postgresql" else "TEXT" pk_type = "BIGSERIAL PRIMARY KEY" if conn.dialect.name == "postgresql" else "INTEGER PRIMARY KEY AUTOINCREMENT" conn.execute(text(f""" CREATE TABLE IF NOT EXISTS competitor_price_history ( id {pk_type}, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', momo_product_id INTEGER, momo_price NUMERIC(10,2), price NUMERIC(10,2) NOT NULL, original_price NUMERIC(10,2), discount_pct INTEGER, competitor_product_id VARCHAR(100), competitor_product_name TEXT, match_score NUMERIC(4,3), tags {tags_type} DEFAULT '[]', crawled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time ON competitor_price_history (sku, source, crawled_at DESC) """)) def _fetch_latest_attempt(conn, sku: str, source: str) -> dict[str, Any] | None: latest_price_sql = """ pr.price AS current_momo_price FROM competitor_match_attempts cma LEFT JOIN products p ON p.i_code = cma.sku LEFT JOIN LATERAL ( SELECT price FROM price_records WHERE product_id = p.id ORDER BY timestamp DESC, id DESC LIMIT 1 ) pr ON TRUE """ if conn.dialect.name != "postgresql": latest_price_sql = """ ( SELECT price FROM price_records WHERE product_id = p.id ORDER BY timestamp DESC, id DESC LIMIT 1 ) AS current_momo_price FROM competitor_match_attempts cma LEFT JOIN products p ON p.i_code = cma.sku """ row = conn.execute(text(f""" SELECT cma.*, p.id AS current_momo_product_id, p.name AS current_momo_product_name, {latest_price_sql} WHERE cma.sku = :sku AND cma.source = :source ORDER BY cma.attempted_at DESC, cma.id DESC LIMIT 1 """), {"sku": sku, "source": source}).mappings().first() return dict(row) if row else None def _insert_manual_attempt(conn, attempt: dict[str, Any], action_meta: dict[str, str], source: str) -> None: columns = [ "sku", "source", "momo_product_id", "momo_product_name", "momo_price", "search_terms", "candidate_count", "attempt_status", "best_competitor_product_id", "best_competitor_product_name", "best_competitor_price", "best_match_score", "error_message", "attempted_at", ] search_terms_expr = _json_array_expr(conn, "search_terms") values = [ ":sku", ":source", ":momo_product_id", ":momo_product_name", ":momo_price", search_terms_expr, ":candidate_count", ":attempt_status", ":best_id", ":best_name", ":best_price", ":best_score", ":error_message", "CURRENT_TIMESTAMP", ] params = { "sku": attempt.get("sku"), "source": source, "momo_product_id": attempt.get("momo_product_id") or attempt.get("current_momo_product_id"), "momo_product_name": attempt.get("momo_product_name") or attempt.get("current_momo_product_name"), "momo_price": attempt.get("momo_price") or attempt.get("current_momo_price"), "search_terms": json.dumps([f"manual_review:{action_meta['attempt_status']}"], ensure_ascii=False), "candidate_count": attempt.get("candidate_count") or 0, "attempt_status": action_meta["attempt_status"], "best_id": attempt.get("best_competitor_product_id"), "best_name": attempt.get("best_competitor_product_name"), "best_price": attempt.get("best_competitor_price"), "best_score": attempt.get("best_match_score"), "error_message": action_meta["message"], } if _has_table_column(conn, "competitor_match_attempts", "match_diagnostic_json"): columns.append("match_diagnostic_json") values.append(_json_object_expr(conn, "match_diagnostic_json")) params["match_diagnostic_json"] = _json_text_payload(attempt.get("match_diagnostic_json")) if _has_table_column(conn, "competitor_match_attempts", "comparison_mode"): columns.append("comparison_mode") values.append(":comparison_mode") params["comparison_mode"] = attempt.get("comparison_mode") if _has_table_column(conn, "competitor_match_attempts", "hard_veto"): columns.append("hard_veto") values.append(":hard_veto") params["hard_veto"] = attempt.get("hard_veto") if _has_table_column(conn, "competitor_match_attempts", "diagnostic_codes"): columns.append("diagnostic_codes") values.append(_json_array_expr(conn, "diagnostic_codes")) params["diagnostic_codes"] = _json_text_payload(attempt.get("diagnostic_codes")) conn.execute(text(f""" INSERT INTO competitor_match_attempts ({", ".join(columns)}) VALUES ({", ".join(values)}) """), params) def _promote_manual_match(conn, attempt: dict[str, Any], source: str) -> None: candidate_id = str(attempt.get("best_competitor_product_id") or "").strip() candidate_name = str(attempt.get("best_competitor_product_name") or "").strip() candidate_price = _num(attempt.get("best_competitor_price")) if not candidate_id or not candidate_name or not candidate_price or candidate_price <= 0: raise ValueError("採用同款需要候選 PChome 商品 ID、名稱與有效價格") _ensure_competitor_price_history_table(conn) _taipei = timezone(timedelta(hours=8)) ttl_hours = int(os.getenv("PCHOME_FEEDER_TTL_HOURS", "48")) expires_at = (datetime.now(_taipei) + timedelta(hours=ttl_hours)).strftime("%Y-%m-%d %H:%M:%S") match_score = max(_num(attempt.get("best_match_score")) or 0, 0.76) tags = [ "identity_v2", "manual_review", "manual_accept", "comparison_exact_identity", ] tags_json = json.dumps(tags, ensure_ascii=False) tags_expr = _json_array_expr(conn, "tags") momo_product_id = attempt.get("momo_product_id") or attempt.get("current_momo_product_id") momo_price = attempt.get("momo_price") or attempt.get("current_momo_price") conn.execute(text(f""" INSERT INTO competitor_prices (sku, source, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at, expires_at) VALUES (:sku, :source, :price, NULL, NULL, :candidate_id, :candidate_name, :match_score, {tags_expr}, CURRENT_TIMESTAMP, :expires_at) ON CONFLICT (sku, source) DO UPDATE SET price = EXCLUDED.price, original_price = EXCLUDED.original_price, discount_pct = EXCLUDED.discount_pct, competitor_product_id = EXCLUDED.competitor_product_id, competitor_product_name = EXCLUDED.competitor_product_name, match_score = EXCLUDED.match_score, tags = EXCLUDED.tags, crawled_at = CURRENT_TIMESTAMP, expires_at = EXCLUDED.expires_at """), { "sku": attempt.get("sku"), "source": source, "price": candidate_price, "candidate_id": candidate_id, "candidate_name": candidate_name[:200], "match_score": match_score, "tags": tags_json, "expires_at": expires_at, }) conn.execute(text(f""" INSERT INTO competitor_price_history (sku, source, momo_product_id, momo_price, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at) VALUES (:sku, :source, :momo_product_id, :momo_price, :price, NULL, NULL, :candidate_id, :candidate_name, :match_score, {tags_expr}, CURRENT_TIMESTAMP) """), { "sku": attempt.get("sku"), "source": source, "momo_product_id": momo_product_id, "momo_price": momo_price, "price": candidate_price, "candidate_id": candidate_id, "candidate_name": candidate_name[:200], "match_score": match_score, "tags": tags_json, }) def _expire_current_manual_candidate(conn, attempt: dict[str, Any], source: str) -> None: """Expire a current official match when the operator rejects its candidate.""" candidate_id = str(attempt.get("best_competitor_product_id") or "").strip() sku = str(attempt.get("sku") or "").strip() if not sku or not candidate_id: return conn.execute(text(""" UPDATE competitor_prices SET expires_at = CURRENT_TIMESTAMP, crawled_at = CURRENT_TIMESTAMP WHERE sku = :sku AND source = :source AND competitor_product_id = :candidate_id """), { "sku": sku, "source": source, "candidate_id": candidate_id, }) def record_competitor_match_review( engine, sku: str, review_action: str, reviewer_identity: str = "dashboard", review_reason: str = "", source: str = "pchome", ) -> dict[str, Any]: """Record a human decision for the latest PChome match attempt.""" sku = str(sku or "").strip() source = str(source or "pchome").strip() or "pchome" review_action = str(review_action or "").strip() review_reason = str(review_reason or "").strip()[:1000] reviewer_identity = str(reviewer_identity or "dashboard").strip()[:120] or "dashboard" if not sku: return {"success": False, "message": "缺少 MOMO 商品 ID"} action_meta = VALID_REVIEW_ACTIONS.get(review_action) if not action_meta: return {"success": False, "message": "不支援的 AI 決策動作"} with engine.begin() as conn: _ensure_competitor_match_reviews_table(conn) attempt = _fetch_latest_attempt(conn, sku, source) if not attempt: return {"success": False, "message": "找不到可執行 AI 決策的 PChome 比對嘗試"} if review_action == "accept_identity": _promote_manual_match(conn, attempt, source) else: _expire_current_manual_candidate(conn, attempt, source) _insert_manual_attempt(conn, attempt, action_meta, source) conn.execute(text(""" INSERT INTO competitor_match_reviews (sku, source, review_action, review_reason, reviewer_identity, momo_product_id, momo_product_name, momo_price, candidate_product_id, candidate_product_name, candidate_price, candidate_match_score, candidate_diagnostic, resulting_attempt_status, reviewed_at) VALUES (:sku, :source, :review_action, :review_reason, :reviewer_identity, :momo_product_id, :momo_product_name, :momo_price, :candidate_id, :candidate_name, :candidate_price, :candidate_score, :candidate_diagnostic, :resulting_attempt_status, CURRENT_TIMESTAMP) """), { "sku": sku, "source": source, "review_action": review_action, "review_reason": review_reason, "reviewer_identity": reviewer_identity, "momo_product_id": attempt.get("momo_product_id") or attempt.get("current_momo_product_id"), "momo_product_name": attempt.get("momo_product_name") or attempt.get("current_momo_product_name"), "momo_price": attempt.get("momo_price") or attempt.get("current_momo_price"), "candidate_id": attempt.get("best_competitor_product_id"), "candidate_name": attempt.get("best_competitor_product_name"), "candidate_price": attempt.get("best_competitor_price"), "candidate_score": attempt.get("best_match_score"), "candidate_diagnostic": _candidate_diagnostic_text(attempt), "resulting_attempt_status": action_meta["attempt_status"], }) return { "success": True, "message": action_meta["message"], "sku": sku, "review_action": review_action, "attempt_status": action_meta["attempt_status"], }