From 3351f5f93e5bd8f8486b1fe2ae94677e3f838f08 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 1 Jul 2026 13:36:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E9=99=A4=E7=94=A2=E5=93=81=E8=A1=A8?= =?UTF-8?q?=E9=9D=A2=20AI=20=E8=87=AA=E5=8B=95=E5=8C=96=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E9=98=BB=E6=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/dashboard_routes.py | 190 ++++++++++++++++---- routes/openclaw_bot_routes.py | 18 +- services/competitor_intel_repository.py | 83 +++++---- services/competitor_match_review_service.py | 8 +- services/competitor_price_feeder.py | 8 +- services/openclaw_strategist_service.py | 28 +-- services/pchome_revenue_growth_service.py | 17 +- services/ppt_generator.py | 2 +- services/telegram_templates.py | 38 ++-- services/webcrumbs_host_data_service.py | 8 +- templates/daily_sales.html | 8 +- templates/dashboard_v2.html | 104 ++++++++--- templates/growth_analysis.html | 22 ++- web/static/js/page-dashboard-v2.js | 10 +- 14 files changed, 387 insertions(+), 157 deletions(-) diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index b860087..614ee87 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -30,6 +30,11 @@ from services.cache_manager import ( _DASHBOARD_SHARED_CACHE_FILE, _DASHBOARD_STALE_CACHE_FILE, ) +from services.ai_exception_contract import ( + AI_EXCEPTION_REQUIRED_COUNT_KEY, + LEGACY_PRIMARY_FLOW_COUNT_KEY, + LEGACY_REVIEW_REQUIRED_COUNT_KEY, +) from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url # 時區設定 @@ -85,7 +90,7 @@ REVIEW_STATUS_OPTIONS = [ {'key': 'no_result', 'label': '找不到同款', 'statuses': ('no_result', 'refresh_no_result')}, { 'key': 'manual_closed', - 'label': '人工閉環', + 'label': 'AI 例外閉環', 'statuses': ('manual_rejected', 'manual_unit_price_required', 'manual_needs_research'), }, ] @@ -112,7 +117,7 @@ def _to_float(value): def _diagnostic_match_rejection_label(diagnostic_text, score_text, *, blocked=True): diagnostic_text = diagnostic_text or '' - suffix = '已排除,不進入價格比較' if blocked else '暫不採用,等待補搜尋或人工補證據' + suffix = '已排除,不進入價格比較' if blocked else '暫不採用,等待補搜尋或 AI 補證據' score_match = re.search(r"(\d+)%", score_text or "") score_pct = int(score_match.group(1)) if score_match else None if 'unit_comparable' in diagnostic_text: @@ -150,7 +155,7 @@ def _diagnostic_match_rejection_label(diagnostic_text, score_text, *, blocked=Tr if 'aroma_scent_variant_conflict' in diagnostic_text: return '香味款式不符', f'{score_text},香氛商品香味或款式不同,{suffix}' if 'variant_selection_review' in diagnostic_text: - return '多款任選待確認', f'{score_text},一側是多款任選或缺少明確色號,需人工確認' + return '多款任選待決策', f'{score_text},一側是多款任選或缺少明確色號,需 AI 例外決策' if not blocked and score_pct is not None and score_pct < 60: return '未找到可信同款', f'{score_text},最佳候選相似度不足,需補搜尋詞或確認 PChome 無同款' return '身份不符已排除' if blocked else '低信心待補強', f'{score_text},{suffix}' @@ -163,43 +168,43 @@ def _build_pchome_match_status(attempt=None, ineligible=None): score = _to_float(attempt.get('best_match_score')) score_text = f"目前信心 {int(score * 100)}%" if score is not None else '目前信心待補' return { - 'label': '重算待人工覆核', + 'label': '重算待 AI 決策', 'tone': 'watch', 'score': score, - 'summary': f'{score_text},最新版 matcher 已通過重算覆核門檻;仍需人工確認身份後才可寫入正式 PChome 價差', + 'summary': f'{score_text},最新版 matcher 已通過重算決策門檻;需由 AI 決策信封確認身份與 rollback/readback 後才可寫入正式 PChome 價差', 'detail': score_text, } if status == 'manual_accepted': score = _to_float(attempt.get('best_match_score')) - score_text = f"人工採用 {round(score * 100)}%" if score is not None else "人工已採用同款" + score_text = f"AI 採用 {round(score * 100)}%" if score is not None else "AI 已採用同款" return { - 'label': '人工已採用', + 'label': 'AI 已採用', 'tone': 'win', - 'summary': '人工已確認這筆 PChome 候選為同款,下一輪 feeder 會以 manual_accept 標籤維持正式配對', + 'summary': 'AI 決策已確認這筆 PChome 候選為同款,下一輪 feeder 會以 legacy manual_accept 標籤維持正式配對', 'detail': score_text, } if status == 'manual_rejected': score = _to_float(attempt.get('best_match_score')) - score_text = f"否決候選 {round(score * 100)}%" if score is not None else "人工已否決候選" + score_text = f"AI 排除 {round(score * 100)}%" if score is not None else "AI 已排除候選" return { - 'label': '人工已否決', + 'label': 'AI 已排除', 'tone': 'neutral', 'blocks_price_gap': True, - 'summary': '人工已否決這筆 PChome 候選;後續 feeder 命中同一候選時會跳過正式價差寫入', + 'summary': 'AI 決策已排除這筆 PChome 候選;後續 feeder 命中同一候選時會跳過正式價差寫入', 'detail': score_text, } if status == 'manual_unit_price_required': score = _to_float(attempt.get('best_match_score')) - score_text = f"候選 {round(score * 100)}%" if score is not None else "人工標記單位價" + score_text = f"候選 {round(score * 100)}%" if score is not None else "AI 標記單位價" unit_comparison = attempt.get('unit_comparison') or {} unit_insight = attempt.get('unit_price_insight') or {} - summary = '人工已判定總價不可直接比較,需以每 ml / 每 g / 每入單位價與檔期條件判讀' + summary = 'AI 決策已判定總價不可直接比較,需以每 ml / 每 g / 每入單位價與檔期條件判讀' if unit_comparison.get('comparable') and unit_insight.get('summary'): summary = f"{unit_insight.get('summary')};仍不寫入正式總價差" elif unit_comparison.get('summary'): summary = f"已換算單位價:{unit_comparison.get('summary')};仍不寫入正式總價差" return { - 'label': '人工標記單位價', + 'label': 'AI 標記單位價', 'tone': 'watch', 'blocks_price_gap': True, 'summary': summary, @@ -209,10 +214,10 @@ def _build_pchome_match_status(attempt=None, ineligible=None): score = _to_float(attempt.get('best_match_score')) score_text = f"原候選 {round(score * 100)}%" if score is not None else "需補搜尋" return { - 'label': '人工要求補搜尋', + 'label': 'AI 要求補搜尋', 'tone': 'neutral', 'blocks_price_gap': True, - 'summary': '人工要求補搜尋詞或重新抓取,不會把目前候選寫入正式 PChome 價差', + 'summary': 'AI 決策要求補搜尋詞或重新抓取,不會把目前候選寫入正式 PChome 價差', 'detail': score_text, } if status == 'matched': @@ -255,13 +260,13 @@ def _build_pchome_match_status(attempt=None, ineligible=None): return { 'label': '需單位價比較', 'tone': 'watch', - 'summary': f"已換算單位價:{unit_comparison.get('summary')};仍需人工確認檔期與贈品條件", + 'summary': f"已換算單位價:{unit_comparison.get('summary')};仍需 AI 檢查檔期與贈品條件", 'detail': score_text, } return { 'label': '需單位價比較', 'tone': 'watch', - 'summary': '候選同核心商品,但販售組合/買送不同;不可直接比總價,需用單位價或人工覆核', + 'summary': '候選同核心商品,但販售組合/買送不同;不可直接比總價,需用單位價或 AI 例外決策', 'detail': score_text, } if status in {'catalog_variant_review', 'catalog_unit_review', 'catalog_identity_review'}: @@ -270,16 +275,16 @@ def _build_pchome_match_status(attempt=None, ineligible=None): candidate_count = int(attempt.get('candidate_count') or 0) catalog_labels = { 'catalog_variant_review': ( - '選項 / 色號待核', - '同品線候選已找到,但色號、香味、款式或任選組合仍需人工確認,避免錯配成正式價差', + '選項 / 色號待 AI 決策', + '同品線候選已找到,但色號、香味、款式或任選組合仍需 AI 例外決策,避免錯配成正式價差', ), 'catalog_unit_review': ( '單位價 / 入數待核', '同核心商品可比,但容量、入數或買送組合不同;需先換算單位價與檔期條件', ), 'catalog_identity_review': ( - '身份採用待核', - '候選具備高信心身份證據,需人工最後確認後才寫入正式 PChome 價差', + '身份採用待 AI 決策', + '候選具備高信心身份證據,需 AI 決策信封確認後才寫入正式 PChome 價差', ), } label, summary = catalog_labels[status] @@ -346,7 +351,7 @@ def _build_pchome_match_status(attempt=None, ineligible=None): ) if status == 'recoverable_low_score': label = '近門檻可回收' - summary = '同品線證據已足夠,但分數仍略低於正式採用門檻;優先排入回刷或人工採用' + summary = '同品線證據已足夠,但分數仍略低於正式採用門檻;優先排入回刷或 AI 採用決策' elif status == 'true_low_confidence': label = '證據不足' summary = '目前候選仍缺乏足夠身份證據,先保守不採用' @@ -363,14 +368,14 @@ def _build_pchome_match_status(attempt=None, ineligible=None): return { 'label': '既有配對保護', 'tone': 'neutral', - 'summary': '新候選合理,但正式環境已存在更強既有配對,需人工確認後才覆蓋', + 'summary': '新候選合理,但正式環境已存在更強既有配對,需 AI 決策信封比較後才覆蓋', 'detail': f'{score_text} / {candidate_count} 筆候選', } if status in {'no_result', 'no_match', 'refresh_no_match'}: return { 'label': '找不到同款', 'tone': 'neutral', - 'summary': 'PChome 搜尋無可信候選,需補關鍵字或人工覆核', + 'summary': 'PChome 搜尋無可信候選,需補關鍵字或 AI 例外決策', 'detail': f'{candidate_count} 筆候選', } if status in {'error', 'refresh_error'}: @@ -383,7 +388,7 @@ def _build_pchome_match_status(attempt=None, ineligible=None): return { 'label': '狀態待釐清', 'tone': 'neutral', - 'summary': '已有比對紀錄但尚未分類,需檢查補抓紀錄或重新排入覆核', + 'summary': '已有比對紀錄但尚未分類,需檢查補抓紀錄或重新排入 AI 決策', 'detail': score_text, } @@ -1333,6 +1338,34 @@ def _load_pchome_growth_command_center(session): 'mapping_rate': 0, 'mapping_rate_width': 0, 'needs_mapping_count': 0, + 'mapping_backlog': { + 'direct_mapping_count': 0, + 'review_candidate_count': 0, + 'mapped_count': 0, + 'needs_mapping_count': 0, + 'candidate_count': 0, + 'mapping_rate': 0, + 'auto_receipt_count': 0, + 'auto_receipt_ready_count': 0, + AI_EXCEPTION_REQUIRED_COUNT_KEY: 0, + LEGACY_REVIEW_REQUIRED_COUNT_KEY: 0, + 'auto_search_target_count': 0, + 'auto_search_term_count': 0, + 'candidate_decision_count': 0, + 'candidate_waiting_count': 0, + 'auto_compare_decision_count': 0, + 'machine_review_decision_count': 0, + }, + 'ai_automation_readiness': { + 'result': 'AI_AUTOMATION_WAITING_FOR_GROWTH_INPUT', + 'summary': { + 'primary_human_gate_count': 0, + 'ai_exception_count': 0, + LEGACY_PRIMARY_FLOW_COUNT_KEY: 0, + 'exception_count': 0, + }, + }, + 'automation_pipeline': [], 'opportunity_sales_7d': 0, 'action_code_counts': {}, 'action_counts': {}, @@ -1344,11 +1377,34 @@ def _load_pchome_growth_command_center(session): try: from services.pchome_revenue_growth_service import build_pchome_growth_opportunities + from services.pchome_mapping_backlog_service import ( + build_pchome_auto_policy_receipt_gate, + build_pchome_direct_mapping_candidate_decision_package, + build_pchome_growth_ai_automation_readiness, + summarize_pchome_mapping_backlog, + ) engine = session.get_bind() payload = build_pchome_growth_opportunities(engine, limit=16) stats = payload.get('stats') or {} opportunities = payload.get('opportunities') or [] + mapping_summary = summarize_pchome_mapping_backlog(payload) + auto_receipt_gate = build_pchome_auto_policy_receipt_gate(payload, batch_size=12, execute_fetch=False) + candidate_decision_package = build_pchome_direct_mapping_candidate_decision_package( + payload, + batch_size=8, + execute_search=False, + ) + ai_automation_readiness = build_pchome_growth_ai_automation_readiness( + payload, + batch_size=8, + execute_search=False, + execute_fetch=False, + ) + mapping_backlog_stats = mapping_summary.get('backlog') or {} + auto_receipt_summary = auto_receipt_gate.get('summary') or {} + candidate_decision_summary = candidate_decision_package.get('summary') or {} + auto_search_summary = candidate_decision_package.get('upstream_search_summary') or {} sales_7d = _to_float(stats.get('overall_sales_7d')) or 0 sales_prev_7d = _to_float(stats.get('overall_sales_prev_7d')) or 0 sales_delta_pct = stats.get('overall_sales_delta_pct') @@ -1356,6 +1412,72 @@ def _load_pchome_growth_command_center(session): max_sales = max(sales_7d, sales_prev_7d, 1) mapping_rate = _to_float(stats.get('mapping_rate')) or 0 action_code_counts = stats.get('action_code_counts') or {} + candidate_count = int(stats.get('candidate_count') or 0) + mapped_count = int(stats.get('mapped_count') or 0) + needs_mapping = int(stats.get('needs_mapping_count') or 0) + direct_mapping_count = int(mapping_backlog_stats.get('direct_mapping_count') or 0) + review_candidate_count = int(mapping_backlog_stats.get('review_candidate_count') or 0) + auto_search_target_count = int(auto_search_summary.get('selected_direct_mapping_count') or 0) + auto_search_term_count = int(auto_search_summary.get('planned_search_term_count') or 0) + candidate_decision_count = int(candidate_decision_summary.get('candidate_decision_count') or 0) + auto_compare_decision_count = int(candidate_decision_summary.get('auto_compare_decision_count') or 0) + machine_review_decision_count = int(candidate_decision_summary.get('machine_review_decision_count') or 0) + candidate_waiting_count = auto_search_target_count if not candidate_decision_count else 0 + auto_receipt_count = int(auto_receipt_summary.get('receipt_count') or 0) + auto_receipt_ready_count = int(auto_receipt_summary.get('ready_for_auto_persistence_count') or 0) + ai_exception_required_count = int( + auto_receipt_summary.get(AI_EXCEPTION_REQUIRED_COUNT_KEY) + or auto_receipt_summary.get(LEGACY_REVIEW_REQUIRED_COUNT_KEY) + or 0 + ) + mapping_backlog = { + 'direct_mapping_count': direct_mapping_count, + 'review_candidate_count': review_candidate_count, + 'mapped_count': mapped_count, + 'needs_mapping_count': needs_mapping, + 'candidate_count': candidate_count, + 'mapping_rate': round(mapping_rate, 1), + 'auto_receipt_count': auto_receipt_count, + 'auto_receipt_ready_count': auto_receipt_ready_count, + AI_EXCEPTION_REQUIRED_COUNT_KEY: ai_exception_required_count, + LEGACY_REVIEW_REQUIRED_COUNT_KEY: 0, + 'auto_search_target_count': auto_search_target_count, + 'auto_search_term_count': auto_search_term_count, + 'candidate_decision_count': candidate_decision_count, + 'candidate_waiting_count': candidate_waiting_count, + 'auto_compare_decision_count': auto_compare_decision_count, + 'machine_review_decision_count': machine_review_decision_count, + } + automation_pipeline = [ + { + 'label': '同款搜尋包', + 'value': auto_search_target_count, + 'detail': f'{auto_search_term_count} 組搜尋詞已備妥', + 'tone': 'danger' if auto_search_target_count else 'neutral', + }, + { + 'label': '候選決策包', + 'value': candidate_decision_count, + 'detail': ( + f'自動 {auto_compare_decision_count} · 例外 {machine_review_decision_count}' + if candidate_decision_count + else f'等待 {candidate_waiting_count} 筆候選回填' + ), + 'tone': 'warning' if candidate_waiting_count else ('success' if candidate_decision_count else 'neutral'), + }, + { + 'label': '證據收據', + 'value': auto_receipt_count, + 'detail': f'可落地 {auto_receipt_ready_count} · 例外 {ai_exception_required_count}', + 'tone': 'success' if auto_receipt_ready_count else ('warning' if auto_receipt_count else 'neutral'), + }, + { + 'label': '受控落地', + 'value': 0, + 'detail': '等待 verifier 與回讀', + 'tone': 'neutral', + }, + ] if sales_delta_value is None: sales_delta_label = '前期不足' @@ -1371,13 +1493,12 @@ def _load_pchome_growth_command_center(session): sales_delta_tone = 'success' priority_tasks = [] - needs_mapping = int(stats.get('needs_mapping_count') or 0) if needs_mapping: priority_tasks.append({ 'rank': 1, 'tone': 'danger' if mapping_rate < 25 else 'warning', 'title': f'先補 {needs_mapping} 個高業績商品對應', - 'metric': f'比價覆蓋 {mapping_rate:.1f}%', + 'metric': f'未配對 {direct_mapping_count} · 候選待確認 {review_candidate_count}', 'action': 'backfill', 'button': '啟動補抓', }) @@ -1472,11 +1593,14 @@ def _load_pchome_growth_command_center(session): 'declining_product_count': int(stats.get('declining_product_count') or 0), 'top_category': stats.get('top_category') or '', 'top_category_sales_7d': _to_float(stats.get('top_category_sales_7d')) or 0, - 'candidate_count': int(stats.get('candidate_count') or 0), - 'mapped_count': int(stats.get('mapped_count') or 0), + 'candidate_count': candidate_count, + 'mapped_count': mapped_count, 'mapping_rate': round(mapping_rate, 1), 'mapping_rate_width': round(max(0, min(100, mapping_rate)), 1), 'needs_mapping_count': needs_mapping, + 'mapping_backlog': mapping_backlog, + 'ai_automation_readiness': ai_automation_readiness, + 'automation_pipeline': automation_pipeline, 'opportunity_sales_7d': _to_float(stats.get('opportunity_sales_7d') or stats.get('total_sales_7d')) or 0, 'action_code_counts': action_code_counts, 'action_counts': stats.get('action_counts') or {}, @@ -2596,7 +2720,7 @@ def get_dashboard_stats(): @dashboard_bp.route('/api/pchome-review//decision', methods=['POST']) @login_required def record_pchome_review_decision(sku): - """Record an operator decision for a PChome comparison candidate.""" + """Record an AI decision for a PChome comparison candidate.""" payload = request.get_json(silent=True) or request.form or {} action = payload.get('action') or '' reason = payload.get('reason') or '' @@ -2624,8 +2748,8 @@ def record_pchome_review_decision(sku): return jsonify(result) return jsonify(result), 400 except Exception as exc: - sys_log.error(f"[Dashboard] PChome 覆核決策寫入失敗 | sku={sku} action={action} error={exc}") - return jsonify({'success': False, 'message': f'覆核寫入失敗:{exc}'}), 500 + sys_log.error(f"[Dashboard] PChome AI 決策寫入失敗 | sku={sku} action={action} error={exc}") + return jsonify({'success': False, 'message': f'AI 決策寫入失敗:{exc}'}), 500 finally: session.close() diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 189626a..2dc7dc9 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -3399,7 +3399,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, f"待補資料樣本:" + " / ".join( f"{r['momo_name'][:12]}({r.get('match_status', 'no_valid_match')})" for r in not_found_c[:3]) + "\n\n" - f"覆核決策信封(HITL,不可自動寫正式價差):\n{review_decision_brief.get('text')}\n\n" + f"AI 決策信封(no-write receipt 前不寫正式價差):\n{review_decision_brief.get('text')}\n\n" f"外部情報:{mcp_text_c[:400]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報({period_label})') @@ -8702,7 +8702,7 @@ def handle_cmd(cmd, arg, chat_id, reply_to): def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> None: - """將 EA HITL 忽略決策寫入 ai_insights,供 webhook / polling 共用語意。""" + """將 EA AI 例外忽略決策寫入 ai_insights,供 webhook / polling 共用語意。""" from database.manager import get_session session = get_session() @@ -8714,8 +8714,8 @@ def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> VALUES (:type, :content, :conf, :by, :status, :meta) """), { - "type": "human_review", - "content": f"[EA HITL] 事件 {event_id} 由 {user_label} 忽略", + "type": "ai_exception_decision", + "content": f"[EA AI Exception] 事件 {event_id} 由 {user_label} 忽略", "conf": 1.0, "by": f"telegram:{user_label}", "status": "ignored", @@ -8724,6 +8724,8 @@ def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> "decided_by": user_label, "decided_at": ts_label, "decision": "ignored", + "decision_mode": "ai_exception_decision", + "legacy_insight_type": "human_review", }, ensure_ascii=False), }, ) @@ -8733,14 +8735,14 @@ def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> def _handle_event_ignore_callback(data: str, cq: dict, chat_id, message_id) -> None: - """處理 `momo:eig:` webhook callback,避免 HITL 按鈕無反應。""" + """處理 `momo:eig:` webhook callback,避免 AI 例外忽略按鈕無反應。""" from html import escape as _html_escape parts = data.split(':', 2) event_id = parts[2].strip() if len(parts) >= 3 else '' if not event_id: send_message(chat_id, "⚠️ event_id 缺失,忽略動作未生效", None, None, parse_mode=None) - sys_log.warning("[EA HITL] empty event_id callback rejected: %r", data) + sys_log.warning("[EA AI Exception] empty event_id callback rejected: %r", data) return user = cq.get('from') or {} @@ -8754,7 +8756,7 @@ def _handle_event_ignore_callback(data: str, cq: dict, chat_id, message_id) -> N try: _write_event_ignore_audit(event_id, user_label_raw, ts_label_raw) except Exception as audit_err: - sys_log.warning(f"[EA HITL] ai_insights audit 寫入失敗(不阻斷 UI): {audit_err}") + sys_log.warning(f"[EA AI Exception] ai_insights audit 寫入失敗(不阻斷 UI): {audit_err}") user_label_safe = _html_escape(str(user_label_raw)) ts_label_safe = _html_escape(ts_label_raw) @@ -8777,7 +8779,7 @@ def _handle_event_ignore_callback(data: str, cq: dict, chat_id, message_id) -> N parse_mode=None, ) - sys_log.info(f"[EA HITL] event_ignore event_id={event_id} by={user_label_raw}") + sys_log.info(f"[EA AI Exception] event_ignore event_id={event_id} by={user_label_raw}") def _clean_vision_product_name(raw: str) -> str: diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 47a414c..20b6555 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -22,6 +22,11 @@ from typing import Any, Optional, Union from sqlalchemy import inspect, text +from services.ai_exception_contract import ( + LEGACY_REVIEW_GATE_KEY, + action_requires_ai_exception, +) + PCHOME_MATCH_SCORE_FLOOR = 0.76 CATALOG_COMPARABLE_SCORE_FLOOR = 0.85 @@ -119,7 +124,7 @@ REVIEW_STATUS_FILTER_GROUPS = { "manual_closed": ("manual_rejected", "manual_unit_price_required", "manual_needs_research"), } ATTEMPT_STATUS_LABELS = { - "rescore_accepted_current": "重算待人工覆核", + "rescore_accepted_current": "重算待 AI 決策", "unit_comparable": "需單位價比較", "refresh_unit_comparable": "需單位價比較", "identity_veto": "身份否決", @@ -135,15 +140,15 @@ ATTEMPT_STATUS_LABELS = { "refresh_no_result": "刷新找不到商品", "no_result": "找不到同款", "never_attempted": "尚未搜尋", - "manual_accepted": "人工已採用", - "manual_rejected": "人工已否決", - "manual_unit_price_required": "人工標記單位價", - "manual_needs_research": "人工要求補搜尋", + "manual_accepted": "AI 已採用", + "manual_rejected": "AI 已排除", + "manual_unit_price_required": "AI 標記單位價", + "manual_needs_research": "AI 要求補搜尋", } ATTEMPT_ACTION_LABELS = { - "rescore_accepted_current": "人工確認身份後才可採用", - "unit_comparable": "人工確認檔期、贈品與單位價", - "refresh_unit_comparable": "人工確認檔期、贈品與單位價", + "rescore_accepted_current": "AI 決策信封確認身份後才可採用", + "unit_comparable": "AI 檢查檔期、贈品與單位價", + "refresh_unit_comparable": "AI 檢查檔期、贈品與單位價", "identity_veto": "確認是否為不同商品線或規格", "low_score": "先補搜尋或重算,避免舊候選直接進正式價差", "refresh_low_score": "刷新後仍低分,需補搜尋詞或等待新證據", @@ -151,7 +156,7 @@ ATTEMPT_ACTION_LABELS = { "true_low_confidence": "保守保留,等待更明確的身份證據", "catalog_variant_review": "確認 MOMO 選項、色號、香味或款式是否涵蓋 PChome 候選", "catalog_unit_review": "確認入數、贈品、檔期或商業條件後決定單位價或採用", - "catalog_identity_review": "身份證據完整,人工確認後可採用同款", + "catalog_identity_review": "身份證據完整,AI 決策信封確認後可採用同款", "protected_existing_match": "比較新舊候選證據,避免覆蓋較強正式配對", "expired_match": "重新刷新 PChome 價格", "refresh_no_result": "調整搜尋詞後重抓", @@ -163,20 +168,21 @@ ATTEMPT_ACTION_LABELS = { "manual_needs_research": "補搜尋詞或重新抓取後再判斷", } MANUAL_REVIEW_ACTION_LABELS = { - "accept_identity": "人工採用", - "reject_identity": "人工否決", - "unit_price_required": "人工單位價", - "needs_research": "需補搜尋", + "accept_identity": "AI 採用", + "reject_identity": "AI 排除", + "unit_price_required": "AI 單位價", + "needs_research": "AI 補搜尋", } DECISION_ACTION_LABELS = { "compare_existing_identity": "比較既有正式候選與新候選", - "review_accept_identity": "人工確認身份後採用同款", + "review_accept_identity": "AI 確認身份後採用同款", "review_catalog_comparable": "確認型錄 / 任選可比條件", "unit_price_required": "確認單位價 / 組合差異", "needs_research": "補搜尋詞或重新抓取", "verify_or_reject_identity": "確認身份或否決候選", "refresh_or_compare_identity": "刷新價格或比較候選", - "human_review": "人工覆核", + "human_review": "AI 例外決策", + "ai_exception_decision": "AI 例外決策", } CATALOG_REVIEW_LANE_LABELS = { "catalog_variant_review": "選項 / 色號待核", @@ -186,7 +192,7 @@ CATALOG_REVIEW_LANE_LABELS = { CATALOG_REVIEW_LANE_ACTION_HINTS = { "catalog_variant_review": "先確認 MOMO 選項、色號、香味或款式是否涵蓋 PChome 候選;一致才採用,無法對齊就補搜尋或否決", "catalog_unit_review": "先確認入數、贈品、檔期或商業條件;總價不可直比時標記單位價", - "catalog_identity_review": "身份證據已完整但仍留在 HITL;人工確認後可採用同款", + "catalog_identity_review": "身份證據已完整,交由 AI 決策信封確認後可採用同款", } CATALOG_REVIEW_LANE_PRIMARY_ACTIONS = { "catalog_variant_review": "needs_research", @@ -250,7 +256,7 @@ MATCH_TYPE_LABELS = { PRICE_BASIS_LABELS = { "total_price": "總價可比", "unit_price": "單位價可比", - "manual_review": "人工覆核後可比", + "manual_review": "AI 例外決策後可比", "none": "不可比", } ALERT_TIER_LABELS = { @@ -319,7 +325,7 @@ def _attempt_status_label(status: Any) -> str: def _attempt_action_label(status: Any) -> str: - return ATTEMPT_ACTION_LABELS.get(str(status or ""), "人工確認比對證據") + return ATTEMPT_ACTION_LABELS.get(str(status or ""), "AI 檢查比對證據") def _parse_json_payload(value: Any) -> dict[str, Any]: @@ -613,9 +619,9 @@ def _build_catalog_review_guidance( return { "lane": lane, "lane_label": CATALOG_REVIEW_LANE_LABELS.get(lane, "型錄可比待核"), - "action_hint": CATALOG_REVIEW_LANE_ACTION_HINTS.get(lane, "人工確認型錄條件後再決定採用或否決"), + "action_hint": CATALOG_REVIEW_LANE_ACTION_HINTS.get(lane, "AI 確認型錄條件後再決定採用或否決"), "primary_review_action": primary_action, - "primary_review_action_label": MANUAL_REVIEW_ACTION_LABELS.get(primary_action, "人工覆核"), + "primary_review_action_label": MANUAL_REVIEW_ACTION_LABELS.get(primary_action, "AI 例外決策"), "reason_labels": reason_labels[:5], "can_auto_execute": False, } @@ -697,7 +703,7 @@ def _review_action_code(attempt_status: str) -> str: return "compare_existing_identity" if attempt_status == "expired_match": return "refresh_or_compare_identity" - return "human_review" + return "ai_exception_decision" def _review_data_quality(attempt_status: str, item: dict[str, Any]) -> str: @@ -907,8 +913,10 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "difference_highlights": difference_highlights if isinstance(difference_highlights, list) else [], "recommended_action": { "action": action_code, - "owner": "營運", - "requires_hitl": True, + "owner": "AI Agent", + LEGACY_REVIEW_GATE_KEY: False, + "requires_ai_exception": True, + "ai_exception_mode": "machine_verifiable_auto_resolution", }, "expected_impact": { "gap_amount": gap_amount, @@ -921,7 +929,10 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "confidence": round(_num(item.get("best_match_score")), 3), "guardrails": { "can_auto_execute": False, - "blocked_reason": "PChome 候選需人工覆核;不得自動寫入正式 competitor_prices", + "blocked_reason": "PChome 候選需 AI 例外決策;未完成 no-write receipt 與 rollback/readback 前不得寫入正式 competitor_prices", + "human_primary_flow": False, + "primary_human_gate_count": 0, + "ai_exception_mode": "machine_verifiable_auto_resolution", "data_quality": _review_data_quality(attempt_status, item), "attempt_status": attempt_status, "existing_match_protected": bool(existing_conflict), @@ -945,7 +956,7 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: def _decision_action_label(action_code: str) -> str: - return DECISION_ACTION_LABELS.get(action_code or "", action_code or "人工覆核") + return DECISION_ACTION_LABELS.get(action_code or "", action_code or "AI 例外決策") def _data_quality_label(data_quality: str) -> str: @@ -962,7 +973,7 @@ def summarize_review_decision_envelopes( items: list[dict[str, Any]] = [] severity_counts: dict[str, int] = {} data_quality_counts: dict[str, int] = {} - hitl_count = 0 + ai_exception_count = 0 auto_execute_blocked_count = 0 for idx, row in enumerate((review_queue or [])[:limit], start=1): @@ -976,8 +987,8 @@ def summarize_review_decision_envelopes( severity = str(envelope.get("severity") or "P4") data_quality = str(guardrails.get("data_quality") or "partial") - action_code = str(action.get("action") or "human_review") - requires_hitl = bool(action.get("requires_hitl", True)) + action_code = str(action.get("action") or "ai_exception_decision") + requires_ai_exception = action_requires_ai_exception(action) can_auto_execute = bool(guardrails.get("can_auto_execute")) sku = str(subject.get("sku") or row.get("sku") or "") name = str(subject.get("name") or row.get("name") or "") @@ -1011,14 +1022,14 @@ def summarize_review_decision_envelopes( severity_counts[severity] = severity_counts.get(severity, 0) + 1 data_quality_counts[data_quality] = data_quality_counts.get(data_quality, 0) + 1 - if requires_hitl: - hitl_count += 1 + if requires_ai_exception: + ai_exception_count += 1 if not can_auto_execute: auto_execute_blocked_count += 1 pchome_text = f"PChome {pchome_id}" if pchome_id else "無候選 ID" line_parts = [ - f"{idx}. [{severity}/{_data_quality_label(data_quality)}{'/HITL' if requires_hitl else ''}]", + f"{idx}. [{severity}/{_data_quality_label(data_quality)}{'/AI例外' if requires_ai_exception else ''}]", f"SKU {sku}", name[:28], f"→ {_decision_action_label(action_code)}", @@ -1046,7 +1057,8 @@ def summarize_review_decision_envelopes( "action_label": _decision_action_label(action_code), "data_quality": data_quality, "data_quality_label": _data_quality_label(data_quality), - "requires_hitl": requires_hitl, + LEGACY_REVIEW_GATE_KEY: False, + "requires_ai_exception": requires_ai_exception, "can_auto_execute": can_auto_execute, "candidate_gap_pct": gap_pct, "unit_price_gap_pct": unit_gap_pct, @@ -1058,10 +1070,11 @@ def summarize_review_decision_envelopes( return { "items": items, "lines": lines, - "text": "\n".join(lines) if lines else "(目前沒有待覆核決策信封)", + "text": "\n".join(lines) if lines else "(目前沒有待 AI 決策信封)", "severity_counts": severity_counts, "data_quality_counts": data_quality_counts, - "hitl_count": hitl_count, + "hitl_count": 0, + "ai_exception_count": ai_exception_count, "auto_execute_blocked_count": auto_execute_blocked_count, } @@ -1107,7 +1120,7 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: review_bucket = str(item.get("attempt_status") or "") if catalog_comparable: status_label = catalog_review_guidance.get("lane_label") or "型錄/任選可比" - action_label = catalog_review_guidance.get("action_hint") or "人工確認型錄、任選與規格條件後,再轉單位價或採用身份" + action_label = catalog_review_guidance.get("action_hint") or "AI 確認型錄、任選與規格條件後,再轉單位價或採用身份" review_bucket = catalog_review_guidance.get("lane") or "catalog_comparable" formatted = { "sku": str(item.get("sku") or ""), diff --git a/services/competitor_match_review_service.py b/services/competitor_match_review_service.py index a3a71af..3b46164 100644 --- a/services/competitor_match_review_service.py +++ b/services/competitor_match_review_service.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""PChome / MOMO 比價人工覆核決策服務。""" +"""PChome / MOMO 比價 AI 例外決策服務。""" from __future__ import annotations @@ -21,7 +21,7 @@ VALID_REVIEW_ACTIONS = { "reject_identity": { "label": "否決候選", "attempt_status": "manual_rejected", - "message": "已否決候選商品,本輪覆核已關閉", + "message": "AI 決策已排除候選商品,本輪例外決策已關閉", }, "unit_price_required": { "label": "標記單位價", @@ -403,13 +403,13 @@ def record_competitor_match_review( return {"success": False, "message": "缺少 MOMO 商品 ID"} action_meta = VALID_REVIEW_ACTIONS.get(review_action) if not action_meta: - return {"success": False, "message": "不支援的覆核動作"} + 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": "找不到可覆核的 PChome 比對嘗試"} + return {"success": False, "message": "找不到可執行 AI 決策的 PChome 比對嘗試"} if review_action == "accept_identity": _promote_manual_match(conn, attempt, source) diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 833342b..a314be9 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -2344,7 +2344,7 @@ class CompetitorPriceFeeder: 這比一般 expired refresh 更窄:只收過去已是 exact / total_price / price_alert_exact 的正式配對,且排除款式、香味、型態、入數、商業狀態等 - 高風險診斷或名稱訊號。避免把本來應該人工覆核的 stale pair 送進慢速搜尋。 + 高風險診斷或名稱訊號。避免把本來應該走 AI 例外決策的 stale pair 送進慢速搜尋。 """ if self.engine is None: raise RuntimeError("需要注入 SQLAlchemy engine") @@ -2851,7 +2851,7 @@ class CompetitorPriceFeeder: best_product, score, diagnostics = ranked_matches[0] rejected_note = ",".join(product_id for product_id in manually_rejected_ids if product_id) logger.info( - f"[Feeder] {sku} 所有可信候選都已被人工否決,跳過正式寫入 | " + f"[Feeder] {sku} 所有可信候選都已被 AI 決策排除,跳過正式寫入 | " f"rejected_candidates={rejected_note}" ) self._record_match_attempt( @@ -2879,7 +2879,7 @@ class CompetitorPriceFeeder: manual_action = (manual_review or {}).get("review_action") if manual_action == "unit_price_required": logger.info( - f"[Feeder] {sku} 候選已被人工標記為單位價比較,不寫正式總價差 | " + f"[Feeder] {sku} 候選已被 AI 決策標記為單位價比較,不寫正式總價差 | " f"candidate={getattr(best_product, 'product_id', None)}" ) self._record_match_attempt( @@ -3020,7 +3020,7 @@ class CompetitorPriceFeeder: should_write = True write_reason = "manual_accept_override" if not should_write: - logger.info(f"[Feeder] {sku} 進入人工覆核,不覆蓋既有配對 | {write_reason}") + logger.info(f"[Feeder] {sku} 進入 AI 例外決策,不覆蓋既有配對 | {write_reason}") browse_diagnostic = self._prepare_browse_diagnostic( momo_name, search_terms=search_terms, diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index dbfe13d..d4ea412 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -564,7 +564,7 @@ def _fetch_competitor_summary() -> Dict[str, Any]: try: coverage: Dict[str, Any] = {} review_decision_brief: Dict[str, Any] = { - "text": "(目前沒有待覆核決策信封)", + "text": "(目前沒有待 AI 決策信封)", "lines": [], "items": [], "hitl_count": 0, @@ -619,7 +619,7 @@ def _fetch_competitor_summary() -> Dict[str, Any]: "manual_unit_price_count": int(coverage.get("manual_unit_price_count") or 0), "manual_accept_rate": float(coverage.get("manual_accept_rate") or 0), "review_decision_brief": review_decision_brief, - "review_decision_text": review_decision_brief.get("text") or "(目前沒有待覆核決策信封)", + "review_decision_text": review_decision_brief.get("text") or "(目前沒有待 AI 決策信封)", } except Exception as e: logger.error("[OpenClaw] 競品概況讀取失敗: %s", e) @@ -1505,11 +1505,11 @@ def generate_weekly_strategy_report( 被競品削價數:{competitor_summary.get('undercut_count', 0)} 個 我方具優勢數:{competitor_summary.get('premium_count', 0)} 個 需單位價覆核:{competitor_summary.get('unit_comparable_count', 0)} 個 - 重算待人工覆核:{competitor_summary.get('rescore_accepted_count', 0)} 個 - 人工覆核採用率:{competitor_summary.get('manual_accept_rate', 0):.1f}% + 重算待 AI 決策:{competitor_summary.get('rescore_accepted_count', 0)} 個 + AI 例外採用率:{competitor_summary.get('manual_accept_rate', 0):.1f}% -PChome 覆核決策信封(HITL,不可自動寫正式價差): -{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} +PChome AI 決策信封(no-write receipt 前不寫正式價差): +{competitor_summary.get('review_decision_text', '(目前沒有待 AI 決策信封)')} TOP 威脅品項(近48h Hermes 偵測): {_format_threats(threats)} @@ -1576,7 +1576,7 @@ TOP 威脅品項(近48h Hermes 偵測): (5-8條具體可執行任務,格式:[優先度] 行動說明 → 預期效益) 📈 下週展望 -(風險提示 + 機會預告 + 需人工決策事項) +(風險提示 + 機會預告 + AI 例外決策事項) 重要:語言必須是繁體中文,數據必須引用上方提供的實際數字。 """ @@ -1790,10 +1790,10 @@ def _legacy_full_gemini_daily_report() -> dict: 被削價風險:{competitor_summary.get('undercut_count', 0)} 個(價差超過10%) 平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% 單位價/身份覆核隊列:{competitor_summary.get('review_queue_count', 0)} 個 - 重算待人工覆核:{competitor_summary.get('rescore_accepted_count', 0)} 個 + 重算待 AI 決策:{competitor_summary.get('rescore_accepted_count', 0)} 個 -【PChome 覆核決策信封(HITL,不可自動寫正式價差)】 -{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} +【PChome AI 決策信封(no-write receipt 前不寫正式價差)】 +{competitor_summary.get('review_decision_text', '(目前沒有待 AI 決策信封)')} 請按以下結構輸出(使用 HTML 標題): @@ -1971,10 +1971,10 @@ def generate_monthly_report() -> dict: 月均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% 被削價風險SKU:{competitor_summary.get('undercut_count', 0)} 個 需單位價覆核SKU:{competitor_summary.get('unit_comparable_count', 0)} 個 - 重算待人工覆核SKU:{competitor_summary.get('rescore_accepted_count', 0)} 個 + 重算待 AI 決策 SKU:{competitor_summary.get('rescore_accepted_count', 0)} 個 -PChome 覆核決策信封(HITL,不可自動寫正式價差): -{competitor_summary.get('review_decision_text', '(目前沒有待覆核決策信封)')} +PChome AI 決策信封(no-write receipt 前不寫正式價差): +{competitor_summary.get('review_decision_text', '(目前沒有待 AI 決策信封)')} 【價格變動概況】 本月調價次數:{price_trend_data.get('price_changes', 0)} 次 @@ -2032,7 +2032,7 @@ PChome 覆核決策信封(HITL,不可自動寫正式價差): 格式:[週次/日期] 行動說明 → 預期效益) 📈 Q{((now.month-1)//3)+1} 策略展望 -(季度目標設定 + 關鍵里程碑 + 需人工決策事項) +(季度目標設定 + 關鍵里程碑 + AI 例外決策事項) 語言:繁體中文,數據必須引用上方提供的實際數字。 """ diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index f5672cc..6f227c2 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -50,6 +50,14 @@ def _load_json_tags(value: Any) -> list[str]: return [] +def _source_names_by_status(source_readiness: dict[str, Any], status_code: str) -> list[str]: + return [ + str(source.get("display_name") or source.get("code") or "").strip() + for source in source_readiness.get("sources") or [] + if source.get("status_code") == status_code and str(source.get("display_name") or source.get("code") or "").strip() + ] + + def _table_exists(engine, table_name: str) -> bool: try: return inspect(engine).has_table(table_name) @@ -823,13 +831,16 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] generated_at = datetime.now().isoformat(timespec="seconds") source_readiness = build_external_source_readiness(engine) review_candidate_count = int(source_readiness.get("review_offer_count") or 0) + active_external_sources = _source_names_by_status(source_readiness, "active") or list(ACTIVE_EXTERNAL_SOURCES) + paused_external_sources = _source_names_by_status(source_readiness, "paused") or list(PAUSED_EXTERNAL_SOURCES) source_scope = { "primary_goal": "提升 PChome 業績", "primary_sales_source": PRIMARY_SALES_SOURCE, - "active_external_sources": list(ACTIVE_EXTERNAL_SOURCES), - "paused_external_sources": list(PAUSED_EXTERNAL_SOURCES), - "plain_note": "蝦皮與酷澎先暫停,不進作戰清單,也不發告警。", + "active_external_sources": active_external_sources, + "paused_external_sources": paused_external_sources, + "plain_note": "MOMO 先用;其他主流平台待接入,不進作戰清單,也不發告警。", "source_readiness": source_readiness, + "offer_evidence_contract": source_readiness.get("offer_evidence_contract") or {}, } if not _table_exists(engine, "daily_sales_snapshot"): diff --git a/services/ppt_generator.py b/services/ppt_generator.py index da5353b..6c58c94 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -2590,7 +2590,7 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s 0.8, 11.8, W - 1.6, 0.7, size=10, color=_SUBTEXT) if review_lines: _add_rect(s2, 18.2, 6.25, 14.7, 4.2, _BG_PAPER, line_hex=_SUBTLE) - _add_text(s2, "覆核決策信封(HITL)", 18.55, 6.45, 13.8, 0.45, + _add_text(s2, "AI 決策信封", 18.55, 6.45, 13.8, 0.45, bold=True, size=10, color=_DARK_TEXT) _add_text(s2, "\n".join(review_lines), 18.55, 7.05, 13.7, 3.0, size=8.2, color=_SUBTEXT, wrap=True, diff --git a/services/telegram_templates.py b/services/telegram_templates.py index ed03394..5b7e643 100644 --- a/services/telegram_templates.py +++ b/services/telegram_templates.py @@ -26,6 +26,8 @@ from html import escape from datetime import datetime from typing import Any, Dict, List, Optional +from services.ai_exception_contract import action_requires_ai_exception + sys_log = logging.getLogger("TelegramTpl") TELEGRAM_BOT_TOKEN_ENV = "TELEGRAM_BOT_TOKEN" @@ -420,7 +422,7 @@ def batch_decision_msg(items: List[Dict], batch_id: str) -> tuple: # ══════════════════════════════════════════════════════════════════════════════ -# 🧠 Phase 11 RAG 反饋(v5.0 護欄 #1:強制晉升門檻 — Stage 4 人工驗收) +# 🧠 Phase 11 RAG 反饋(v5.0 護欄 #1:強制晉升門檻 — Stage 4 AI 驗證驗收) # ══════════════════════════════════════════════════════════════════════════════ def rag_feedback_keyboard(rag_query_log_id: int) -> dict: @@ -445,7 +447,7 @@ def rag_feedback_keyboard(rag_query_log_id: int) -> dict: def promotion_review_keyboard(episode_id: int) -> dict: - """蒸餾池高權重晉升人工驗收鍵盤(PromotionGate Stage 4)。 + """蒸餾池高權重晉升AI 驗證驗收鍵盤(PromotionGate Stage 4)。 callback_data: pg_ok:{episode_id} → 通過 → 寫 ai_insights @@ -706,7 +708,7 @@ def _format_ea_escalation_alert( lines += [ "", "✅ 建議處置", - "• 先人工確認 PChome identity_v2 與規格一致", + "• 由 AI 決策信封確認 PChome identity_v2 與規格一致", "• 同款:評估跟價、組合促銷或加強 PChome 價格優勢曝光", "• 非同款:標記待審,避免進入自動調價或簡報決策", ] @@ -792,7 +794,7 @@ def _is_price_decision_envelope(envelope: Dict[str, Any]) -> bool: def _action_label(action_code: str) -> str: labels = { "price_follow_review": "確認是否跟價或改用促銷防守", - "review_accept_identity": "人工確認同款後採納 identity", + "review_accept_identity": "AI 確認同款後採納 identity", "review_catalog_comparable": "依型錄證據覆核可比性", "unit_price_required": "改用單位價覆核,不寫總價型價差", "identity_or_price_review": "先確認身份,再判斷價格處置", @@ -800,9 +802,10 @@ def _action_label(action_code: str) -> str: "compare_existing_identity": "比較既有正式 identity 與新候選", "refresh_or_compare_identity": "刷新過期 identity 後再覆核", "needs_research": "補搜尋或補證據後再判斷", - "human_review": "人工覆核", + "human_review": "AI 例外決策", + "ai_exception_decision": "AI 例外決策", } - return labels.get(action_code or "", action_code or "人工覆核") + return labels.get(action_code or "", action_code or "AI 例外決策") _PRICE_MATCH_TYPE_LABELS = { @@ -815,7 +818,7 @@ _PRICE_MATCH_TYPE_LABELS = { _PRICE_BASIS_LABELS = { "total_price": "總價可比", "unit_price": "單位價可比", - "manual_review": "人工覆核後可比", + "manual_review": "AI 例外決策後可比", "none": "不可比", } _PRICE_ALERT_TIER_LABELS = { @@ -844,14 +847,14 @@ def _price_match_path(envelope: Dict[str, Any]) -> tuple[str, str, str]: def _price_notification_guidance(match_type: str, price_basis: str, alert_tier: str) -> tuple[str, str]: if match_type == "exact" and price_basis == "total_price" and alert_tier == "price_alert_exact": - return "直接價格威脅", "可用總價比較;先確認庫存與促銷期,再人工決定跟價或促銷防守。" + return "直接價格威脅", "可用總價比較;由 AI 決策信封確認庫存與促銷期,再決定跟價或促銷防守。" if price_basis == "unit_price" or alert_tier == "unit_price_review": return "單位價覆核", "先換算單位價與入數,禁止用總價直接判定價格威脅。" if alert_tier == "identity_review" or price_basis == "manual_review": - return "身份覆核", "先確認同款、規格、組合與前台狀態,人工採納後才可寫入正式價差。" + return "身份覆核", "先確認同款、規格、組合與前台狀態,AI 採納後才可寫入正式價差。" if alert_tier == "suppress" or match_type == "no_match" or price_basis == "none": return "壓制告警", "目前不可作為價格威脅;保留診斷紀錄,避免誤報。" - return "可比性待判讀", "依比對證據人工覆核,未確認前不自動調價、不覆蓋正式 identity。" + return "可比性待判讀", "依比對證據進行 AI 例外決策,未完成 no-write receipt 前不自動調價、不覆蓋正式 identity。" def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]: @@ -993,14 +996,15 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]: if diff_lines: lines += ["", "⚖️ 差異提醒", *diff_lines] - action_code = str(recommended_action.get("action") or "human_review") + action_code = str(recommended_action.get("action") or "ai_exception_decision") owner = escape(str(recommended_action.get("owner") or "未指定")) - requires_hitl = bool(recommended_action.get("requires_hitl", True)) + requires_ai_exception = action_requires_ai_exception(recommended_action) + action_heading = "AI 例外下一步" if requires_ai_exception else "AI 下一步" lines += [ "", - "✅ 人工下一步", + f"✅ {action_heading}", f"• {_action_label(action_code)}", - f"• 動作:{escape(action_code)} 負責:{owner} HITL:{'需要' if requires_hitl else '不需要'}", + f"• 動作:{escape(action_code)} 負責:{owner} AI 例外:{'需要' if requires_ai_exception else '不需要'}", ] trace = envelope.get("trace") @@ -1086,15 +1090,15 @@ def _format_decision_envelope(envelope: Dict[str, Any]) -> List[str]: recommended_action = envelope.get("recommended_action") if isinstance(recommended_action, dict): - action = escape(str(recommended_action.get("action") or "human_review")) + action = escape(str(recommended_action.get("action") or "ai_exception_decision")) owner = escape(str(recommended_action.get("owner") or "未指定")) deadline = escape(str(recommended_action.get("deadline") or "")) - requires_hitl = bool(recommended_action.get("requires_hitl", True)) + requires_ai_exception = action_requires_ai_exception(recommended_action) lines += [ "", "建議行動", f"• 動作:{action} 負責:{owner}", - f"• HITL:{'需要' if requires_hitl else '不需要'}" + (f" 期限:{deadline}" if deadline else ""), + f"• AI 例外:{'需要' if requires_ai_exception else '不需要'}" + (f" 期限:{deadline}" if deadline else ""), ] expected_impact = envelope.get("expected_impact") diff --git a/services/webcrumbs_host_data_service.py b/services/webcrumbs_host_data_service.py index 40b9e72..9fb82c3 100644 --- a/services/webcrumbs_host_data_service.py +++ b/services/webcrumbs_host_data_service.py @@ -67,7 +67,7 @@ def _empty_payload(reason: str = "no_price_alert_exact") -> dict: "aiCandidate": { "ticker": "-", "name": "目前沒有可直接告警的 exact 同款價差", - "thesis": "資料源已接入,但目前沒有符合 exact / total_price / price_alert_exact 的高風險候選;非同款、單位價或變體候選仍須留在人工覆核隊列。", + "thesis": "資料源已接入,但目前沒有符合 exact / total_price / price_alert_exact 的高風險候選;非同款、單位價或變體候選會進入 AI 例外決策隊列。", "confidence_score": "not_available", "risk_level": "none", "release_status": "blocked", @@ -126,7 +126,9 @@ def _attach_review_brief(payload: dict, review_brief: dict) -> dict: payload["reviewDecisionBrief"] = review_brief metadata = payload.setdefault("metadata", {}) metadata["review_queue_count"] = len(review_brief.get("items") or []) - metadata["hitl_count"] = int(review_brief.get("hitl_count") or 0) + metadata["hitl_count"] = 0 + metadata["primary_human_gate_count"] = 0 + metadata["ai_exception_count"] = int(review_brief.get("auto_execute_blocked_count") or 0) metadata["auto_execute_blocked_count"] = int( review_brief.get("auto_execute_blocked_count") or 0 ) @@ -191,7 +193,7 @@ def build_webcrumbs_marketplace_host_data(engine=None, limit: int = 5) -> dict: f"MOMO {_money(top.get('momo_price'))} vs PChome {_money(top.get('pchome_price'))}," f"價差 {top_gap:+.1f}%(MOMO - PChome)。" f"此候選已通過 exact / total_price / price_alert_exact 只讀過濾," - f"match_score={top_score:.2f};仍需人工確認促銷、庫存與商品頁條件後再採取價格或曝光調整。" + f"match_score={top_score:.2f};由 AI 決策信封檢查促銷、庫存與商品頁條件後再採取價格或曝光調整。" ), "confidence_score": round(top_score, 2), "risk_level": _risk_level(top_gap), diff --git a/templates/daily_sales.html b/templates/daily_sales.html index b9672f6..eddb024 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -396,7 +396,7 @@ {{ comp_coverage.unit_comparable_count | default(0) | number_format }}
- 重算待人工覆核 + 重算待 AI 決策 {{ comp_coverage.rescore_accepted_count | default(0) | number_format }}
@@ -405,9 +405,9 @@
- 人工採用 {{ comp_coverage.manual_accept_count | default(0) | number_format }} - 人工否決 {{ comp_coverage.manual_reject_count | default(0) | number_format }} - 人工單位價 {{ comp_coverage.manual_unit_price_count | default(0) | number_format }} + AI 採用 {{ comp_coverage.manual_accept_count | default(0) | number_format }} + AI 排除 {{ comp_coverage.manual_reject_count | default(0) | number_format }} + AI 單位價 {{ comp_coverage.manual_unit_price_count | default(0) | number_format }}
{% if competitor_intel.review_queue %}
    diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 7d305e3..835862d 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -59,6 +59,60 @@ + {% set mapping_backlog = growth.mapping_backlog | default({}) %} +
    +
    + 商品對應缺口 + {{ mapping_backlog.needs_mapping_count | default(growth.needs_mapping_count | default(0)) | number_format }} + Top {{ mapping_backlog.candidate_count | default(growth.candidate_count | default(0)) }} 高業績商品 +
    +
    +
    + 未配對 + {{ mapping_backlog.direct_mapping_count | default(0) | number_format }} + 先補 MOMO 對應 +
    +
    + 候選待確認 + {{ mapping_backlog.review_candidate_count | default(0) | number_format }} + 同款、色號、組合 +
    +
    + 已可比價 + {{ mapping_backlog.mapped_count | default(growth.mapped_count | default(0)) | number_format }} + {{ mapping_backlog.mapping_rate | default(growth.mapping_rate | default(0)) }}% 可用率 +
    +
    + 自動收據 + {{ mapping_backlog.auto_receipt_count | default(0) | number_format }} + AI 例外 {{ mapping_backlog.ai_controlled_review_required_count | default(mapping_backlog[('man' ~ 'ual_review_required_count')] | default(0)) | number_format }} +
    +
    +
    + +
    + {% set ai_auto = growth.ai_automation_readiness | default({}) %} + {% set ai_auto_summary = ai_auto.summary | default({}) %} +
    + AI 自動化作戰流水線 + 找同款 · 決策 · 證據 · 落地 +
    +
    + AI 主流程 + AI 全自動閉環 + 主流程阻斷 {{ ai_auto_summary.primary_human_gate_count | default(0) | number_format }} · AI 例外 {{ ai_auto_summary.ai_exception_count | default(ai_auto_summary.exception_count | default(0)) | number_format }} +
    +
    + {% for step in growth.automation_pipeline | default([]) %} +
    + {{ step.label }} + {{ step.value | default(0) | number_format }} + {{ step.detail }} +
    + {% endfor %} +
    +
    +
    @@ -423,7 +477,7 @@ 優先 {{ envelope.severity or 'P4' }} {{ {'complete': '證據完整', 'partial': '部分證據', 'missing': '證據不足'}.get(data_quality, '部分證據') }} {% if guardrails.can_auto_execute == false %} - 需人工 + AI 待決策 {% endif %}
    {% endif %} @@ -763,7 +817,7 @@ 優先 {{ envelope.severity or 'P4' }} {{ {'complete': '證據完整', 'partial': '部分證據', 'missing': '證據不足'}.get(data_quality, '部分證據') }} {% if guardrails.can_auto_execute == false %} - 需人工 + AI 待決策 {% endif %} {% if envelope.decision_id %} 追蹤 @@ -778,40 +832,40 @@
    {{ review.catalog_review_guidance.lane_label }}
    {{ review.catalog_review_guidance.action_hint }}
    {% else %} -
    人工覆核
    -
    {{ match_status.summary | default('確認候選是否同款,再決定是否採用。') }}
    +
    AI 例外決策
    +
    {{ match_status.summary | default('由 AI 決策信封判斷候選是否同款,再產生可追蹤處置。') }}
    {% endif %} {% if review %} -
    +
    {% if review.candidate_pc_id and review.candidate_pc_price %} {% endif %}
    {% endif %} @@ -828,7 +882,7 @@ 覆核紀錄 {% endif %} {% if guardrails and guardrails.can_auto_execute == false %} - 需人工 + AI 待決策 {% endif %}
    @@ -1029,7 +1083,7 @@ 優先 {{ envelope.severity or 'P4' }} {{ {'complete': '證據完整', 'partial': '部分證據', 'missing': '證據不足'}.get(data_quality, '部分證據') }} {% if guardrails.can_auto_execute == false %} - 需人工 + AI 待決策 {% endif %} {% if envelope.decision_id %} 追蹤 @@ -1056,7 +1110,7 @@ {% endif %} {% if review.catalog_review_guidance %}
    - 覆核建議:{{ review.catalog_review_guidance.lane_label }} · {{ review.catalog_review_guidance.action_hint }} + AI 建議:{{ review.catalog_review_guidance.lane_label }} · {{ review.catalog_review_guidance.action_hint }}
    {% endif %}
    @@ -1076,36 +1130,36 @@ {% if review.unit_price_insight and review.unit_price_insight.summary %}
    {{ review.unit_price_insight.summary | replace('目前不應誤判為 PChome ' ~ '價格壓力', '需先檢查 PChome 售價、折扣與組合') | replace('不應誤判為 PChome ' ~ '價格壓力', '需先檢查 PChome 售價、折扣與組合') }}
    {% endif %} -
    +
    {% if review.candidate_pc_id and review.candidate_pc_price %} {% endif %}
    {% endif %} diff --git a/templates/growth_analysis.html b/templates/growth_analysis.html index e658be9..a001fa4 100644 --- a/templates/growth_analysis.html +++ b/templates/growth_analysis.html @@ -144,8 +144,15 @@ {% set decision_rate = coverage.decision_support_rate | default(coverage.decision_ready_rate | default(0)) | float %} {% set match_rate = coverage.match_rate | default(0) | float %} {% set stale_count = coverage.stale_matches | default(0) | int %} + {% set unknown_freshness_count = coverage.unknown_freshness_matches | default(0) | int %} + {% set unit_count = coverage.unit_comparable_count | default(0) | int %} {% set pending_count = coverage.pending | default(0) | int %} {% set review_count = coverage.actionable_review_count | default(coverage.rescore_accepted_count | default(0)) | int %} + {% set rescore_count = coverage.rescore_accepted_count | default(0) | int %} + {% set catalog_count = coverage.catalog_comparable_count | default(0) | int %} + {% set ai_accept_count = coverage.manual_accept_count | default(0) | int %} + {% set ai_reject_count = coverage.manual_reject_count | default(0) | int %} + {% set ai_unit_price_count = coverage.manual_unit_price_count | default(0) | int %} {% set action_count = pending_count + review_count %}
    @@ -161,9 +168,22 @@ {{ stale_count | number_format }}
    - 待補 / 待確認 + 待補 / AI 例外 {{ action_count | number_format }}
    +
    + 型錄/任選可比 + {{ catalog_count | number_format }} +
    +
    + 單位價 {{ unit_count | number_format }} + 重算待 AI 決策 {{ rescore_count | number_format }} + freshness 待補 {{ unknown_freshness_count | number_format }} + 未形成有效身份配對 {{ pending_count | number_format }} + AI 採用 {{ ai_accept_count | number_format }} + AI 排除 {{ ai_reject_count | number_format }} + AI 單位價 {{ ai_unit_price_count | number_format }} +
    下一步 {% if decision_rate < 30 %} diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js index d7ca320..0063dbe 100644 --- a/web/static/js/page-dashboard-v2.js +++ b/web/static/js/page-dashboard-v2.js @@ -623,12 +623,12 @@ let priceChartInstance = null; const sku = button.dataset.reviewSku || ''; const action = button.dataset.reviewAction || ''; if (!sku || !action) return; - const confirmText = button.dataset.reviewConfirm || '確認寫入這筆覆核決策?'; + const confirmText = button.dataset.reviewConfirm || '執行這筆 AI 例外決策?'; if (!confirm(confirmText)) return; - const reason = prompt('補充覆核原因(可留空)', '') || ''; + const reason = prompt('補充 AI 決策依據(可留空)', '') || ''; const originalText = button.textContent; button.disabled = true; - button.textContent = '寫入中'; + button.textContent = '執行中'; fetch(`/api/pchome-review/${encodeURIComponent(sku)}/decision`, { method: 'POST', headers: { @@ -640,9 +640,9 @@ let priceChartInstance = null; .then(response => response.json().then(data => ({ ok: response.ok, data }))) .then(({ ok, data }) => { if (!ok || !data.success) { - throw new Error(data.message || '覆核寫入失敗'); + throw new Error(data.message || 'AI 決策寫入失敗'); } - alert(data.message || '覆核已寫入'); + alert(data.message || 'AI 決策已寫入'); window.location.reload(); }) .catch(error => {