diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 9d80a1e..d7df312 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.278 補 PChome 競價摘要 30 分鐘共享快取與 feeder/backfill 主動清除,並新增市場情報 `candidate_queue_review_ai_summary_preflight` 預覽 gate;API 只檢查未來摘要輸入與 Ollama-first/Gemini-backup-only policy,不呼叫 LLM、不派 Telegram、不寫 DB、不掛 scheduler。 - V10.276 修正 ElephantAlpha 價格類 Hermes prefetch timeout:`price_drop` / `market_opportunity` trigger 直接把 SQL 命中的 MOMO / PChome 價差實證轉成 HITL action lines,完整 Hermes LLM prefetch 預設關閉;無 DB 實證仍只記 suppressed telemetry / cooldown,不寫 `human_review`、不發空 Telegram。 - V10.266 強化核心 MOMO/PChome 比價鏈路:新增 `marketplace_product_matcher.py` 身份比對、只讓 `identity_v2` 且分數 ≥ 0.76 的高信心配對進 Dashboard/AI/Excel/Daily/Growth/PPT,並建立 `competitor_intel_repository.py` 統一圖表與簡報資料出口;同品牌但不同型號、不同組數、套組/單品或多品項不一致會進待審,不進正式比價。 - V10.267 專業化 ElephantAlpha `resource_optimization` 告警:不再讓 LLM 生成「48 小時預期效益 / 已執行」敘事,改由程式量測 action queue、P1/P2、pending_review、逾時項目與 CPU load;單純 backlog 不發 Telegram,只有可行動資源壓力才寫 `ai_insights(resource_pressure)` 並發送量測型告警。 diff --git a/routes/market_intel_review_post_routes.py b/routes/market_intel_review_post_routes.py index ddefec4..70e4f3d 100644 --- a/routes/market_intel_review_post_routes.py +++ b/routes/market_intel_review_post_routes.py @@ -11,6 +11,9 @@ from routes.market_intel_review_routes import ( market_intel_review_bp, ) from services.market_intel import MarketIntelService +from services.market_intel.candidate_queue_review_ai_summary_preflight import ( + build_candidate_queue_review_ai_summary_preflight, +) from services.market_intel.candidate_queue_review_archive_summary import ( build_candidate_queue_review_archive_summary, ) @@ -106,6 +109,37 @@ def _build_review_completion_archive_stack( return transaction, receipt, closeout, inventory, archive +def _build_review_archive_summary_stack( + *, + service, + sample_result, + payload_error, + operator_evidence, + writer_output, + smoke_result, + limit, + execute_requested, +): + transaction, receipt, closeout, inventory, archive = ( + _build_review_completion_archive_stack( + service=service, + sample_result=sample_result, + payload_error=payload_error, + operator_evidence=operator_evidence, + writer_output=writer_output, + smoke_result=smoke_result, + limit=limit, + execute_requested=execute_requested, + ) + ) + archive_summary = build_candidate_queue_review_archive_summary( + review_completion_archive=archive, + operator_evidence=operator_evidence, + execute_requested=execute_requested, + ) + return transaction, receipt, closeout, inventory, archive, archive_summary + + @market_intel_review_bp.route( "/api/market_intel/manual_sample_review/" "candidate_queue_review_decision_post_closeout_inventory", @@ -145,8 +179,8 @@ def market_intel_manual_sample_candidate_queue_review_archive_summary(): sample_result, operator_evidence, writer_output, smoke_result, payload_error, limit = ( _extract_run_payload() ) - transaction, receipt, closeout, inventory, archive = ( - _build_review_completion_archive_stack( + transaction, receipt, closeout, inventory, archive, archive_summary = ( + _build_review_archive_summary_stack( service=service, sample_result=sample_result, payload_error=payload_error, @@ -157,8 +191,37 @@ def market_intel_manual_sample_candidate_queue_review_archive_summary(): execute_requested=execute_requested, ) ) - data = build_candidate_queue_review_archive_summary( - review_completion_archive=archive, + data = archive_summary + data["phase"] = service.phase + return jsonify(data), 400 if payload_error else 200 + + +@market_intel_review_bp.route( + "/api/market_intel/manual_sample_review/" + "candidate_queue_review_ai_summary_preflight", + methods=["POST"], +) +@login_required +def market_intel_manual_sample_candidate_queue_review_ai_summary_preflight(): + service = MarketIntelService() + execute_requested = request.args.get("execute", "false").lower() == "true" + sample_result, operator_evidence, writer_output, smoke_result, payload_error, limit = ( + _extract_run_payload() + ) + transaction, receipt, closeout, inventory, archive, archive_summary = ( + _build_review_archive_summary_stack( + service=service, + sample_result=sample_result, + payload_error=payload_error, + operator_evidence=operator_evidence, + writer_output=writer_output, + smoke_result=smoke_result, + limit=limit, + execute_requested=execute_requested, + ) + ) + data = build_candidate_queue_review_ai_summary_preflight( + archive_summary=archive_summary, operator_evidence=operator_evidence, execute_requested=execute_requested, ) diff --git a/services/market_intel/candidate_queue_review_ai_summary_preflight.py b/services/market_intel/candidate_queue_review_ai_summary_preflight.py new file mode 100644 index 0000000..6376712 --- /dev/null +++ b/services/market_intel/candidate_queue_review_ai_summary_preflight.py @@ -0,0 +1,365 @@ +"""候選審核 queue AI summary preflight 預覽。 + +本模組只檢查 archive summary input 是否足夠進入未來的 Ollama-first 摘要流程; +不呼叫 LLM、不派送 Telegram、不讀 approval token、不執行 CLI、不寫檔、 +不寫 DB、不更新 review_state、不 commit、不掛 scheduler。 +""" + + +FORBIDDEN_TOKEN_KEYWORDS = ( + "approval_token", + "approval-token", + "market_intel_queue_write_approval", +) +SAFE_TOKEN_METADATA_KEYS = { + "approval_token_present", + "approval_token_valid", + "approval_token_secret_configured", +} +SAFE_APPROVAL_ENV_VAR = "MARKET_INTEL_QUEUE_WRITE_APPROVAL" +TARGET_TABLE = "market_alert_review_queue" +OLLAMA_CASCADE = ( + {"key": "gcp_a", "label": "GCP-A", "host": "34.143.170.20:11434"}, + {"key": "gcp_b", "label": "GCP-B", "host": "34.21.145.224:11434"}, + {"key": "lan_111", "label": "111", "host": "192.168.0.111:11434"}, +) + + +def _as_dict(value): + return value if isinstance(value, dict) else {} + + +def _as_list(value): + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + +def _contains_forbidden_token_key(value): + if isinstance(value, dict): + for key, nested in value.items(): + normalized_key = str(key).lower() + if normalized_key in SAFE_TOKEN_METADATA_KEYS and isinstance(nested, bool): + continue + if normalized_key == "approval_env_var" and nested == SAFE_APPROVAL_ENV_VAR: + continue + if any(token_key in normalized_key for token_key in FORBIDDEN_TOKEN_KEYWORDS): + return True + if _contains_forbidden_token_key(nested): + return True + elif isinstance(value, list): + return any(_contains_forbidden_token_key(item) for item in value) + return False + + +def _safe_text(value, limit=300): + if value is None: + return None + text = str(value).strip() + return text[:limit] if text else None + + +def _operator_summary(operator_evidence): + operator_evidence = _as_dict(operator_evidence) + return { + "provided_keys": sorted(operator_evidence.keys()), + "operator_confirmed_ai_summary_preflight": bool( + operator_evidence.get("operator_confirmed_ai_summary_preflight") + or operator_evidence.get("operator_confirmed_ai_summary_review") + ), + "operator_confirmed_ollama_first": bool( + operator_evidence.get("operator_confirmed_ollama_first") + or operator_evidence.get("operator_confirmed_ollama_cascade") + ), + "operator_confirmed_gemini_backup_only": bool( + operator_evidence.get("operator_confirmed_gemini_backup_only") + or operator_evidence.get("operator_confirmed_gemini_fallback_only") + ), + "operator_confirmed_no_llm_call": bool( + operator_evidence.get("operator_confirmed_no_llm_call") + ), + "operator_confirmed_no_telegram_dispatch": bool( + operator_evidence.get("operator_confirmed_no_telegram_dispatch") + ), + "operator_confirmed_no_scheduler_attach": bool( + operator_evidence.get("operator_confirmed_no_scheduler_attach") + ), + "operator_confirmed_no_api_db_write": bool( + operator_evidence.get("operator_confirmed_no_api_db_write") + ), + "ai_summary_preflight_artifact_path": _safe_text( + operator_evidence.get("ai_summary_preflight_artifact_path") + or operator_evidence.get("summary_preflight_artifact_path") + ), + "approval_token_submitted_to_api": _contains_forbidden_token_key( + operator_evidence + ), + } + + +def _summary_payload(archive_summary): + summary = _as_dict(archive_summary) + sections = _as_list(summary.get("summary_sections")) + artifact_paths = _as_dict(summary.get("artifact_paths")) + expected_keys = sorted(str(key) for key in _as_list(summary.get("expected_dedupe_keys"))) + found_keys = sorted(str(key) for key in _as_list(summary.get("found_dedupe_keys"))) + missing_keys = sorted(str(key) for key in _as_list(summary.get("missing_dedupe_keys"))) + return { + "provided": bool(summary), + "mode": summary.get("mode"), + "archive_summary_ready": bool(summary.get("archive_summary_ready")), + "summary_input_ready": bool(summary.get("summary_input_ready")), + "ready_for_ai_summary_review": bool(summary.get("ready_for_ai_summary_review")), + "expected_dedupe_keys": expected_keys, + "found_dedupe_keys": found_keys, + "missing_dedupe_keys": missing_keys, + "state_mismatches": _as_list(summary.get("state_mismatches")), + "row_summary_count": int(summary.get("row_summary_count") or 0), + "summary_sections": sections, + "summary_section_keys": [ + _as_dict(section).get("key") + for section in sections + if _as_dict(section).get("key") + ], + "artifact_paths": artifact_paths, + "read_only_query_executed": bool(summary.get("read_only_query_executed")), + "database_connection_opened": bool(summary.get("database_connection_opened")), + "database_write_executed": bool(summary.get("database_write_executed")), + "database_commit_executed": bool(summary.get("database_commit_executed")), + "review_state_update_executed": bool(summary.get("review_state_update_executed")), + "scheduler_attached": bool(summary.get("scheduler_attached")), + "llm_call_executed": bool(summary.get("llm_call_executed")), + "telegram_dispatched": bool(summary.get("telegram_dispatched")), + "ai_summary_generated": bool(summary.get("ai_summary_generated")), + "blocked_reasons": _as_list(summary.get("blocked_reasons")), + } + + +def _side_effects_clear(summary): + blocked_keys = ( + "api_executes_cli", + "api_writes_file", + "api_writes_database", + "api_updates_review_state", + "summary_file_written", + "summary_record_written", + "summary_manifest_written", + "database_write_executed", + "database_commit_executed", + "review_state_update_executed", + "llm_call_executed", + "ollama_call_executed", + "gemini_call_executed", + "telegram_dispatched", + "ai_summary_generated", + "external_network_executed", + "scheduler_attached", + "writes_executed", + "would_write_database", + ) + return all(not _as_dict(summary).get(key) for key in blocked_keys) + + +def _model_route_policy(): + return { + "primary_policy": "ollama_first", + "primary_cascade": list(OLLAMA_CASCADE), + "fallback_policy": "gemini_backup_only_after_ollama_cascade_failure", + "app_host_forbidden_as_ollama_node": "192.168.0.188", + "api_executes_llm": False, + "api_dispatches_telegram": False, + "expected_future_caller": "market_intel_review_ai_summary", + } + + +def _preflight_gates(*, summary, operator, route_policy): + required_sections = { + "review_scope", + "review_state_result", + "evidence_artifacts", + "execution_safety", + } + section_keys = set(summary["summary_section_keys"]) + artifact_paths = summary["artifact_paths"] + return [ + { + "key": "archive_summary_preview_provided", + "label": "必須提供 archive summary preview", + "passed": bool( + summary["provided"] + and summary["mode"] == "candidate_queue_review_archive_summary_preview" + ), + }, + { + "key": "archive_summary_ready", + "label": "archive summary input 必須已通過", + "passed": bool( + summary["archive_summary_ready"] + and summary["summary_input_ready"] + and summary["ready_for_ai_summary_review"] + ), + }, + { + "key": "summary_sections_complete", + "label": "摘要輸入必須包含必要 section", + "passed": required_sections.issubset(section_keys), + }, + { + "key": "summary_dedupe_scope_complete", + "label": "summary dedupe key 必須完整且無缺漏", + "passed": bool( + summary["expected_dedupe_keys"] + and summary["expected_dedupe_keys"] == summary["found_dedupe_keys"] + and not summary["missing_dedupe_keys"] + ), + }, + { + "key": "summary_state_clean", + "label": "AI 摘要前不可存在 review_state mismatch", + "passed": bool(summary["row_summary_count"] > 0 and not summary["state_mismatches"]), + }, + { + "key": "summary_artifact_paths_present", + "label": "摘要前必須保留上游 artifact path", + "passed": all(artifact_paths.values()), + }, + { + "key": "ollama_first_policy_locked", + "label": "未來摘要路由必須 Ollama-first 且 Gemini 只能備援", + "passed": bool( + route_policy["primary_policy"] == "ollama_first" + and len(route_policy["primary_cascade"]) == 3 + and route_policy["fallback_policy"] + == "gemini_backup_only_after_ollama_cascade_failure" + and route_policy["app_host_forbidden_as_ollama_node"] + == "192.168.0.188" + ), + }, + { + "key": "operator_confirmed_ai_summary_preflight", + "label": "操作員確認本步不呼叫模型且後續須 Ollama-first", + "passed": bool( + operator["operator_confirmed_ai_summary_preflight"] + and operator["operator_confirmed_ollama_first"] + and operator["operator_confirmed_gemini_backup_only"] + and operator["operator_confirmed_no_llm_call"] + and operator["operator_confirmed_no_telegram_dispatch"] + and operator["operator_confirmed_no_api_db_write"] + and operator["operator_confirmed_no_scheduler_attach"] + ), + }, + { + "key": "ai_summary_preflight_artifact_path_recorded", + "label": "preflight artifact path 必須由操作員提供", + "passed": bool(operator["ai_summary_preflight_artifact_path"]), + }, + { + "key": "ai_summary_preflight_no_approval_token_submitted_to_api", + "label": "payload 不得包含一次性 approval token key", + "passed": not operator["approval_token_submitted_to_api"], + }, + { + "key": "ai_summary_preflight_has_no_side_effects", + "label": "preflight 不得呼叫 LLM、派送 Telegram、寫檔、寫 DB 或掛 scheduler", + "passed": _side_effects_clear(summary), + }, + ] + + +def build_candidate_queue_review_ai_summary_preflight( + *, + archive_summary, + operator_evidence=None, + execute_requested=False, +): + """建立 AI summary preflight 預覽;不執行 LLM、檔案、網路或 DB 副作用。""" + summary = _summary_payload(archive_summary) + operator = _operator_summary(operator_evidence) + route_policy = _model_route_policy() + gates = _preflight_gates( + summary=summary, + operator=operator, + route_policy=route_policy, + ) + blocked_reasons = [gate["key"] for gate in gates if not gate["passed"]] + preflight_ready = bool(not blocked_reasons) + + return { + "mode": "candidate_queue_review_ai_summary_preflight_preview", + "target_table": TARGET_TABLE, + "target_operation": "preflight_ollama_first_ai_summary", + "execute_requested": bool(execute_requested), + "ai_summary_preflight_ready": preflight_ready, + "ready_for_manual_ollama_summary_run": preflight_ready, + "ready_for_next_manual_phase": preflight_ready, + "ready_for_ai_summary_generation": False, + "ready_for_llm_call": False, + "ready_for_telegram_dispatch": False, + "ready_for_api_review_state_update": False, + "ready_for_api_database_write": False, + "ready_for_scheduler_attach": False, + "api_executes_cli": False, + "api_reads_approval_token": False, + "api_writes_file": False, + "api_writes_database": False, + "api_updates_review_state": False, + "approval_record_written": False, + "decision_record_written": False, + "ai_summary_generated": False, + "llm_call_executed": False, + "ollama_call_executed": False, + "gemini_call_executed": False, + "telegram_dispatched": False, + "summary_file_written": False, + "summary_record_written": False, + "summary_manifest_written": False, + "review_state_update_executed": False, + "read_only_query_executed": summary["read_only_query_executed"], + "database_connection_opened": summary["database_connection_opened"], + "database_session_created": False, + "explicit_transaction_opened": False, + "transaction_opened": False, + "transaction_committed": False, + "database_write_executed": False, + "database_commit_executed": False, + "database_rollback_executed": False, + "external_network_executed": False, + "scheduler_attached": False, + "writes_executed": False, + "would_write_database": False, + "expected_dedupe_keys": summary["expected_dedupe_keys"], + "found_dedupe_keys": summary["found_dedupe_keys"], + "missing_dedupe_keys": summary["missing_dedupe_keys"], + "state_mismatches": summary["state_mismatches"], + "row_summary_count": summary["row_summary_count"], + "summary_section_keys": summary["summary_section_keys"], + "model_route_policy": route_policy, + "operator_ai_summary_preflight": operator, + "blocked_reasons": blocked_reasons, + "gates": gates, + "next_operator_steps": [ + "確認 archive summary input 已通過且 artifact path 完整", + "後續若要產生 AI 摘要,只能另以 OllamaService 三主機級聯執行", + "Gemini 只能在 Ollama cascade 全失敗後作為備援或 ADR 鎖定場景", + "preflight 本身不得呼叫模型、不得派送 Telegram、不得寫 DB", + ], + "safe_boundaries": [ + "do_not_call_llm_from_ai_summary_preflight", + "do_not_dispatch_telegram_from_ai_summary_preflight", + "do_not_write_summary_file_from_ai_summary_preflight", + "do_not_insert_summary_record_from_ai_summary_preflight", + "do_not_update_review_state_from_ai_summary_preflight", + "do_not_read_approval_token_from_ai_summary_preflight", + "do_not_execute_cli_from_ai_summary_preflight", + "do_not_attach_scheduler_from_ai_summary_preflight", + "ollama_first_required_for_future_summary", + "ollama_cascade_must_use_gcp_a_then_gcp_b_then_111", + "gemini_backup_only_after_ollama_cascade_failure", + "host_188_forbidden_as_ollama_node", + "ai_summary_preflight_preview_only", + "no_remove_orphans", + "no_momo_db_lifecycle_change", + ], + }